diff --git a/.agents/skills/codequest-game-designer/SKILL.md b/.agents/skills/codequest-game-designer/SKILL.md index 0050d06..2933770 100644 --- a/.agents/skills/codequest-game-designer/SKILL.md +++ b/.agents/skills/codequest-game-designer/SKILL.md @@ -222,11 +222,75 @@ behaviors; the manifest can reorder or reuse them but cannot define arbitrary code or create a new renderer. Typed art templates select only renderers shipped with CODE QUEST; mechanics and template-less art remain validated design metadata. Schema v1 remains compatible metadata and uses the built-in flow. +Sound follows the trusted handler rather than the manifest: classify an +implemented cue as Implemented with its audio test as evidence, and its +`art.kind = "audio"` entry as Configured/metadata. When the desired game type is unsupported, finish the design brief but do not put an invalid type in `CODEQUEST.toml`. Explain the runtime gap and propose the smallest schema/engine increment needed before authoring that manifest change. +### 8. Build on the runtime learning mechanisms + +In the CODE QUEST engine repository, quiz cartridges already provide these +mechanisms. Reuse them before proposing new ones, and cite them as evidence in +the pedagogy map and runtime traceability: + +- Concept lenses: every generated question names one of five lenses + (`learning::Concept`: purpose, responsibility, interaction, invariant, + tradeoff) and carries a rationale for every choice (payload v2 in + `questions.rs`). A question without a known lens, with a missing or overlong + rationale, or with other mechanical failures (length, non-ASCII text) gets + one bounded repair call through the same provider when the batch falls + short, and is dropped only if that repair fails. Location-citing, trivia, + and malformed questions are dropped immediately. +- Lesson card: after commitment the `concept-quiz` handler replaces the + choices with the committed pick's rationale (the misconception) and then the + answer's rationale, holds input for 45 ticks, and waits for A or Start. +- Shuffled choices: `learning::presentation_order` gives each question a stable + order and moves every choice on each retry, so answer position is never a + cue. +- Spaced retry: a survivable miss inserts a review copy after exactly three + other questions (`RETRY_GAP`), carrying into the next batch when the current + one ends sooner. A correct same-launch retry is relearning + (`learning::Review::InSession`); only a correct answer when the miss returns + in a later launch (`Review::Spaced`) is a redemption. +- Lens mastery: evidence (first-try successes plus redemptions) wakes three + runes per lens at 1, 3, and 5 (`learning::MASTERY_THRESHOLDS`); rune II also + needs 60% and rune III 80% of the newest five graded outcomes correct, and + rune III no pending review (`LensRecord::stage_with`). Gated runes draw + cracked. The save retires first-try and spaced successes, keeps misses and + relearned questions queued across launches, and stores mastery per cartridge. +- Oracle Codex: the `codex` handler, reached through the quiz menu's + `open-codex` signal and drawn by the `oracle-codex` template, shows lens + mastery with pending-review counts and rereads every lesson. Pending lessons + are self-tests: the player's wrong pick and its misconception show, the + answer stays sealed until A, and a redemption after a reveal counts as + relearning rather than evidence. +- Difficulty as concept depth: `Concept::focus_for_level` sets each level's + focus lenses, level 4+ requests PREDICT transfer questions, and each request + names the player's weakest lens and earlier stems. These are prompt-level + requirements; acceptance does not enforce the focus share or the PREDICT + count. +- Reduced motion: the engine follows the system's reduced-motion preference by + freezing decorative motion while keeping scene timing, input, and data. +- Screen transcript: `engine/transcript.rs` derives plain-language sentences + from the same state the renderers use and publishes them through + `engine_transcript` into a visually hidden, polite `aria-live` region + (`src/index.html`). It is republished only when its words change: a new + screen is read whole, then only in-screen changes such as focus moves, and + decorative motion is never announced. A new scene or handler needs + transcript sentences, which is proposed engine work that the manifest cannot + configure. +- Oracle line: while generation waits, the Oracle's second header line names + a failed request's category with a retry countdown, or recalls journal + lessons (outstanding misses first) as retrieval practice that records no + evidence. + +When a design needs different thresholds, gaps, or feedback, name the constant +or handler that would change and classify it as proposed engine work; the +manifest cannot configure these values. + ## Produce the artifacts Create or revise: @@ -241,10 +305,15 @@ production status, and implementation gaps; the manifest is the stable graph the engine can validate. Preserve unrelated existing design decisions and show material changes clearly. -When the runtime contract has no dedicated sound schema, retain sound needs as -referenced production entries (for example `art.kind = "audio"`) instead of -inventing fields. State plainly that metadata does not play audio. Do not use a -visual `template` name to imply sound playback. +The runtime contract has no sound schema and no audio `template` value, so +retain sound needs as referenced production entries (for example +`art.kind = "audio"`) instead of inventing fields. In the CODE QUEST engine, +sound is engine-owned: the audio director in `src-tauri/src/audio.rs` diffs a +per-tick state snapshot and derives cues and scene loops for each trusted +handler, so an audio entry is the requirement the director implements, not a +switch that plays anything. State which ledger cues the director already +implements and which need engine work. Do not use a visual `template` name to +imply sound playback. ## Validate before handoff @@ -270,6 +339,28 @@ CQA_VISUAL_PREVIEW_DIR=/tmp/codequest-previews \ oracle_templates_produce_nine_distinct_native_scene_frames --lib ``` +That test writes every reachable Oracle scene, including the Codex mastery, +sealed-lesson, and lesson layouts, despite its historical name. Lesson-card states come from +`lesson_cards_render_the_misconception_and_the_answer`. The menu-to-Codex route +and the remaining Codex states come from +`dogfood_manifest_routes_its_menu_into_the_templated_codex`, +`empty_codex_says_no_lessons_yet_and_cannot_page`, and +`codex_lesson_pages_show_the_answer_and_mark_outstanding_reviews`. The quiz +header's batch progress, `RETRY` label, and `BACK IN` note come from +`the_header_counts_the_batch_and_labels_the_returning_retry`; the lens-rune +wake banner from `waking_a_lens_rune_banners_blinks_and_sounds_above_insight` +and the legacy footer under it from +`the_legacy_lesson_footer_carries_a_woken_rune_iii_banner_readably`; cracked +mastery runes from +`codex_mastery_runes_crack_at_the_exact_gate_breakpoints_on_the_rendered_frame` +and `the_lesson_footer_meter_shows_a_cracked_rune_for_an_open_miss`; +the Oracle recall and failure line from +`the_oracle_line_stays_contained_disjoint_and_readable` and +`recalled_lessons_are_written_in_but_stay_static_under_reduced_motion`; and the +Ascension and Aftermath learning debriefs from +`the_result_prompt_stays_lit_on_every_frame`. Run them with the same variable +when those surfaces change. + When the repository includes `scripts/compile-oracle-assets.sh`, run it before the preview test so inspectable PNG sources and embedded runtime buffers cannot drift. @@ -285,7 +376,9 @@ Then perform a traceability pass: easiest successful strategy cannot bypass the intended knowledge through guessing, superficial pattern matching, or unrelated dexterity. - Failure feedback identifies the consequence of the player's misconception - and enables an informed retry. + and enables an informed retry. In a question-based design, every choice + carries a rationale and a miss returns for a spaced retry, or the brief + explains why not. - Progression increases conceptual independence or complexity, not only speed, punishment, health, score, or visual intensity. - Every start/transition target resolves and every scene is reachable. diff --git a/.agents/skills/codequest-game-designer/assets/game-design-brief.md b/.agents/skills/codequest-game-designer/assets/game-design-brief.md index 455e75e..e89d94a 100644 --- a/.agents/skills/codequest-game-designer/assets/game-design-brief.md +++ b/.agents/skills/codequest-game-designer/assets/game-design-brief.md @@ -51,6 +51,12 @@ without copying the original solution?] | Assessment | | | | | | Transfer | | | | | +[For a quiz cartridge, cite the runtime mechanisms each stage relies on—concept +lenses and per-choice rationales, the lesson card, shuffled choices, spaced +retry and redemption, lens mastery runes, the Oracle Codex, level focus +lenses, and PREDICT transfer questions—and mark any stage that needs new +engine work as proposed.] + ## Scene storyboard | ID | Purpose | Learning role | Entry state | Player actions | State/feedback | Exit and next scenes | Mechanics | Art | @@ -80,6 +86,12 @@ without copying the original solution?] ## Sound requirement ledger +[Sound needs are `art.kind = "audio"` entries; the manifest has no sound schema +or audio template. In the CODE QUEST engine the audio director derives cues and +scene loops from game state per trusted handler. Describe each cue by the state +change that triggers it, and record whether the director implements it +(Implemented, with its audio test) or needs engine work (Needed).] + | ID | Used by scenes | Player-facing purpose | Cues/loops and variants | Constraints and acceptance | Status | |---|---|---|---|---|---| | `sound-id` | | | | | Needed | @@ -90,6 +102,9 @@ without copying the original solution?] |---|---|---|---|---|---|---| | `scene-id` | | | | | | Proposed | +[State the reduced-motion resting composition for any scene whose motion +carries meaning; the engine freezes decorative motion but not scene timing.] + ## Runtime traceability | Element | Status | Evidence or required work | diff --git a/.agents/skills/codequest-game-designer/evals/evals.json b/.agents/skills/codequest-game-designer/evals/evals.json index 5331135..f86c994 100644 --- a/.agents/skills/codequest-game-designer/evals/evals.json +++ b/.agents/skills/codequest-game-designer/evals/evals.json @@ -22,7 +22,14 @@ { "id": 3, "prompt": "Our existing quiz storyboard needs an accessibility pass. Add a reduced-motion Oracle state, non-color-only answer feedback, and clearer retry behavior. Preserve existing IDs wherever possible and update both the brief and manifest.", - "expected_output": "An incremental, traceable revision that preserves stable IDs, updates mechanics and art requirements, and keeps all manifest references valid.", + "expected_output": "An incremental, traceable revision that preserves stable IDs, updates mechanics and art requirements, and keeps all manifest references valid, while recognizing what the engine already provides instead of proposing it again.", + "expectations": [ + "Preserves existing scene, mechanic, and art IDs and keeps the manifest valid under the bundled validator.", + "Recognizes that the engine already follows the system reduced-motion preference by freezing decorative motion without changing timing, and specifies the Oracle's resting composition rather than proposing a new setting.", + "Recognizes the existing lesson card with -/+ verdict markers and per-choice rationales, and the spaced retry with redemption, and builds on them.", + "Recognizes the existing engine screen transcript (a polite live region that reads each screen whole, then its changes) instead of proposing screen-reader support as new work, and names the transcript sentences any new state needs.", + "Classifies any remaining gap, such as an in-game reduced-motion toggle, as proposed engine work rather than a manifest field." + ], "files": [] }, { @@ -34,6 +41,7 @@ "Finds possible loading, failure, held-input, back, replay, and terminal open states rather than checking reachability alone.", "Defines perceptible beginning, middle, and end progression through at least two feedback channels beyond a level number.", "Inventories real template assets and does not claim that manifest metadata dynamically loads them unless the repository contract supports it.", + "Treats sound as engine-derived from state: audio art entries remain production requirements, implemented cues cite the audio director and its tests, and no sound field or audio template is invented.", "Keeps the brief and CODEQUEST.toml valid, linked, and honest about implemented versus proposed work." ], "files": [] @@ -63,6 +71,19 @@ "Adds executable evidence at values immediately below, at, and above the declared breakpoints and inspects the final 240x160 composites." ], "files": [] + }, + { + "id": 7, + "prompt": "Players say the quiz feels like guessing and they forget what they got wrong. Make the Oracle quiz teach better: I want mistakes to explain themselves, missed ideas to come back, and some way to see what I have actually learned. Update the brief and the manifest.", + "expected_output": "A pedagogy pass that names the learner, objective, mental model, misconception, evidence, mastery criterion, and transfer task, maps them through a pedagogy map, and builds on the engine's existing learning mechanisms instead of re-proposing them.", + "expectations": [ + "Fills every pedagogy field in the experience frame and a five-stage pedagogy map with evidence and scaffolding per stage.", + "Cites the existing lesson card, per-choice rationales, shuffled choices, spaced retry and redemption, lens mastery thresholds, and the Oracle Codex as implemented runtime evidence.", + "Routes the menu to the Codex with the codex handler, the open-codex signal, and the oracle-codex template when the manifest lacks that route, and keeps the manifest valid.", + "Distinguishes prompt-level requirements such as level focus lenses and PREDICT transfer questions from rules the acceptance policy enforces.", + "Proposes any new threshold, retry gap, or mastery state as engine work rather than inventing manifest fields." + ], + "files": [] } ] } diff --git a/.agents/skills/codequest-game-designer/references/polish.md b/.agents/skills/codequest-game-designer/references/polish.md index e54b732..6ca77c6 100644 --- a/.agents/skills/codequest-game-designer/references/polish.md +++ b/.agents/skills/codequest-game-designer/references/polish.md @@ -60,6 +60,19 @@ or motion only when the silence/stillness has a purpose and a defined handoff. - Ensure entering and exiting frames share a motif or deliberately clear it. - Provide a reduced-motion version with equivalent information and duration; avoid full-frame flashes as a substitute for impact. +- In the CODE QUEST engine, reduced motion follows the system preference: the + shell forwards `prefers-reduced-motion`, and renderers draw decorative motion + from a frozen clock (`motion_ticks`), show settling motions at their end state + (`settled_ticks`), and keep blinking prompts lit (`blink_lit`), while scene + timing, input, and data use the real clock. Specify the resting composition + each scene shows under that freeze, and keep state changes out of the + decorative clock so they survive it. +- In the CODE QUEST engine, screen readers already get every screen in words: + `engine/transcript.rs` derives plain-language sentences from renderer state + and the shell places them in a polite live region (`engine_transcript`). The + transcript is republished only when its words change, a new screen is read + whole, and in-screen changes such as focus moves are read on their own. + Specify the sentences a new scene or state needs; adding them is engine work. ### Sound design @@ -73,6 +86,15 @@ or motion only when the silence/stillness has a purpose and a defined handoff. fullest arrangement for earned peaks. - Specify muted and reduced-audio behavior. Never make sound the only carrier of required information. +- In the CODE QUEST engine, sound is derived from state, like rendering. The + audio director (`src-tauri/src/audio.rs`) compares each tick's state snapshot + with the previous one and chooses at most one cue per tick by priority, owns + each scene's loop and cuts it on the exit tick, and grows the Datafall and + reward arrangements by presentation tier; the shell speaker only plays the + notes, behind a MUTE/LOW/MID/HIGH volume wheel. Specify each cue as the state + change that triggers it, not as a button handler, and record audio tests as + evidence. The manifest has no sound schema: audio art entries remain the + production requirements, and a new cue or loop is engine work. ### Mechanical closure @@ -88,6 +110,10 @@ animation whose skip path bypasses required initialization. - Give loading, empty, offline/disabled, invalid-data, timeout, and retry states honest behavior when they are possible. - Make success, failure, back, replay, and abandon paths explicit. +- Give feedback holds an owned exit. The quiz lesson card ignores input for its + 45-tick hold and then waits for a fresh A or Start; a reward hold is set by + the manifest's `after_ticks` gate, and its prompt appears only when the scene + graph would accept the continuation. - Verify input-edge behavior across transitions and deterministic state reset on new runs. - Test graph reachability and event closure; reachability alone is insufficient. @@ -107,7 +133,9 @@ animation whose skip path bypasses required initialization. counts. For each, name the desirable direction, reachable range, breakpoints, staged reward or consequence, crossing feedback, cap, and reset. If changing the number has no meaningful threshold response, remove it from the HUD or - turn it into a real system. + turn it into a real system. Include learning metrics that outlive a run: in + CODE QUEST quizzes, per-lens mastery runes (1/3/5 evidence) and pending + reviews persist per cartridge and never reset with a new run. - Test the values immediately below, at, and above every threshold. Include decreasing metrics so danger, recovery, and low-value mastery receive staged feedback rather than only a terminal zero state. diff --git a/.agents/skills/codequest-game-designer/references/visual-floor.md b/.agents/skills/codequest-game-designer/references/visual-floor.md index 4aa58e6..5229669 100644 --- a/.agents/skills/codequest-game-designer/references/visual-floor.md +++ b/.agents/skills/codequest-game-designer/references/visual-floor.md @@ -62,7 +62,10 @@ palette to otherwise sparse geometry. High-resolution concept art is not evidence until the 240×160 frame passes. 8. Compile the PNGs into the runtime format, render every reachable scene, and inspect credits, opening stages, title, menus, gameplay, rewards, failure, - and replay. + and replay. Include the states layered over a shared plate: the quiz lesson + card (missed, correct, legacy, and worst-case rationale copy) and both Oracle + Codex layouts (mastery over the archive plate, lessons over the trial + plate) with empty-journal and missing-rationale states. 9. Run layout checks on the final native composite with worst-case live strings and counters. Fail the build when text or focus exceeds its usable interior, any pair of live/static foreground siblings overlaps, a plate ornament enters @@ -99,6 +102,8 @@ number. - Is the brightest/densest region meaningful? - Can focus, correctness, and failure change without repainting the source? - Does the scene remain legible in grayscale and when audio is muted? +- Does the resting composition carry every state when reduced motion freezes + decorative motion and keeps blinking prompts lit? - Do all live bounds remain inside their containers without intersecting other foreground content? - Is every centered or aligned element positioned relative to its measured art diff --git a/.agents/skills/codequest-game-designer/scripts/validate_codequest.py b/.agents/skills/codequest-game-designer/scripts/validate_codequest.py index 064efb2..2d343af 100755 --- a/.agents/skills/codequest-game-designer/scripts/validate_codequest.py +++ b/.agents/skills/codequest-game-designer/scripts/validate_codequest.py @@ -41,17 +41,19 @@ "oracle-ascension", "oracle-aftermath", "oracle-progression", + "oracle-codex", } HANDLER_SIGNALS = { "repository-credits": {"continue", "elapsed"}, "opening-fanfare": {"continue", "elapsed"}, "title": {"continue"}, - "quiz-menu": {"new-run", "back"}, + "quiz-menu": {"new-run", "open-codex", "back"}, "character-creation": {"hero-ready", "back"}, "oracle": {"questions-ready", "back"}, "concept-quiz": {"needs-question", "batch-complete", "hearts-empty", "back"}, "level-up": {"questions-ready", "needs-question"}, "game-over": {"replay"}, + "codex": {"back"}, "quest-select": {"quest-selected", "back"}, "battle": {"victory", "defeat"}, "victory": {"continue"}, @@ -66,6 +68,7 @@ "concept-quiz", "level-up", "game-over", + "codex", }, "quest": {"quest-select", "battle", "victory", "defeat"}, } diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6033d0b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +# Source contracts (Node and Rust tests) parse files with line-sensitive +# patterns, so every platform checks out text with LF endings. +* text=auto eol=lf + +# Raw framebuffer plates and sprites are embedded byte-for-byte with +# include_bytes!; never let content sniffing treat them as text. +*.rgb binary +*.rgba binary +*.png binary +*.ico binary +*.icns binary +*.woff2 binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ffdb93c..ce288da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,3 +48,36 @@ jobs: - name: Build Tauri application run: npm run tauri -- build --no-bundle + + # Tool discovery, process-tree shutdown, and shell-path handling have + # Windows- and macOS-only code that the Linux job never compiles. Lint and + # test it on each host. tauri-build embeds ../src directly, so neither Node + # nor a frontend build is needed, and the runner's preinstalled Git (Git for + # Windows on Windows) supplies git and the quest shell. + verify-native: + name: Rust on ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + defaults: + run: + # Git Bash on Windows, so `-- -D warnings` reaches Cargo unchanged. + shell: bash + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri -> target + + - name: Lint Rust + run: cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings + + - name: Test Rust + run: cargo test --manifest-path src-tauri/Cargo.toml diff --git a/CODEQUEST.toml b/CODEQUEST.toml index 015ceb5..2f8fb3d 100644 --- a/CODEQUEST.toml +++ b/CODEQUEST.toml @@ -35,6 +35,13 @@ inputs = ["d-pad", "a", "b", "start"] rules = ["The current choice is always visible.", "Confirm advances to the selected destination."] feedback = ["Highlight the current choice by pointer, shape, and color.", "Use distinct navigation, confirmation, cancellation, and unavailable responses.", "Emit at most one response per input edge and do not stack cues across scene transitions."] +[[mechanics]] +id = "review-lessons" +summary = "Open the Oracle Codex to see per-lens mastery and reread every lesson the Oracle has taught, so a missed concept can be studied before it returns." +inputs = ["d-pad", "l", "r", "a", "start", "b"] +rules = ["The Codex reads the cartridge's lesson journal and mastery evidence and never changes mastery or the question deck.", "Page 0 shows every lens (purpose, roles, flows, invariants, tradeoffs) with three mastery runes that wake at exactly 1, 3, and 5 pieces of evidence; evidence is first-try successes plus misses redeemed in a later launch. Rune II also needs 60% of the lens's newest five graded answers correct and rune III 80% plus no pending review; a rune those gates hold back shows cracked, and the totals row then reads CRACKED = REVIEW DUE while a cracked lens has a pending review, or CRACKED = LOW ACCURACY while only recent accuracy holds its runes back.", "Every later page shows one lesson, oldest first: its lens and the question, then for a learned lesson the correct answer and the rationale explaining why it holds; a sealed lesson shows its self-test instead until A reveals them.", "Left, Up, and L turn back one page; Right, Down, and R turn forward one page; paging wraps between the mastery page and the newest lesson.", "B returns to the quiz menu on its input edge; A and Start reveal a sealed lesson's answer and are inactive on every other page.", "A lesson whose latest attempt was wrong stays marked for review until the player answers it correctly when it returns.", "A pending lesson, or a relearned lesson whose spaced check is still due, is a self-test: it shows the wrong choice the player picked and the misconception it reveals, and keeps the answer sealed; every page turn and every visit seals it again.", "A reveals a sealed lesson's answer; revealing it before the review or spaced check counts the next correct answer as relearning, which lights no rune and brings the question back once more as a spaced check.", "An empty journal says so honestly and explains that answering trials writes lessons."] +feedback = ["Mark each lens with pending reviews by an amber count beside its runes, not by color alone.", "Show the page counter, lens, and mastery runes for the lesson's lens in the header of every lesson page.", "Mark outstanding lessons REVIEW PENDING in amber (PENDING PEEKED once revealed), relearned lessons awaiting their spaced check CHECK PENDING in amber (CHECK PEEKED once revealed), and learned lessons LEARNED in cyan.", "Show the pick in red with a - marker, the revealed or learned answer in green, and legacy lessons without a rationale as an explicit gap rather than blank space.", "Summarize learned lessons and pending reviews on the quiz menu, and on the mastery page while no rune is cracked; while one is, the per-lens amber counts carry the pending reviews and the totals row explains the crack."] + [[mechanics]] id = "customize-hero" summary = "Choose the hero name, path, and aura before beginning a run." @@ -53,8 +60,8 @@ feedback = ["Use distinct authored crystal and corruption silhouettes in additio id = "answer-question" summary = "Choose one of four answers to a conceptual project question." inputs = ["d-pad", "a", "b"] -rules = ["Exactly one answer is correct.", "A wrong answer costs one ward and resets flow; three, two, one, and zero wards are full, strained, fractured, and broken stages.", "Correct answers build flow: streaks 0-2 award x1 score, 3-5 award x2, and 6 or more award x3.", "Cumulative score awakens Insight Runes I, II, and III at 300, 900, and 1800 points.", "Committing an answer records that question in the cartridge save; future runs and launches skip every recorded question.", "After commitment, hold the revealed answer for 45 ticks; all inputs are intentionally inactive until the automatic transition completes."] -feedback = ["Reveal whether the answer was correct through text, shape, animation, and color.", "Show the correct answer after a mistake.", "Use three authored Oracle ward runes for health instead of generic pips; communicate full, strained, fractured, and broken states through fill, count, color, and a concise review banner.", "Show flow multiplier, raw score, and three awakening Insight Rune marks together; announce exact threshold crossings before establishing the new baseline.", "Replace active answer/back prompts with the owned review, ward, flow, or rune response during the 45-tick result hold."] +rules = ["Exactly one answer is correct.", "A wrong answer costs one ward and resets flow; three, two, one, and zero wards are full, strained, fractured, and broken stages.", "Correct answers build flow: streaks 0-2 award x1 score, 3-5 award x2, and 6 or more award x3.", "Cumulative score awakens Insight Runes I, II, and III at 300, 900, and 1800 points.", "Choices appear in a stable per-question shuffle, and every retry moves every choice, so answer position never predicts the answer.", "Committing an answer records evidence for its concept lens; a correct first try retires the question for later runs and launches, while a miss keeps it queued for review until a later launch redeems it.", "A survivable miss returns after exactly three other questions, carrying into the next batch when the current one ends sooner, so the batch still closes on time.", "A correct retry in the same launch clears the pending review but counts as relearning; only a correct answer when the miss returns in a later launch is a redemption and mastery evidence.", "After commitment, hold the lesson card for 45 ticks with every input intentionally inactive, then continue only on a fresh A or Start press.", "B arms a 90-tick leave confirmation; a second B inside the window leaves the run."] +feedback = ["Reveal whether the answer was correct through text, shape, animation, and color.", "Show the correct answer after a mistake.", "Use three authored Oracle ward runes for health instead of generic pips; communicate full, strained, fractured, and broken states through fill, count, color, and a concise review banner.", "Show flow multiplier, raw score, and three awakening Insight Rune marks together; announce exact threshold crossings before establishing the new baseline.", "Replace the choices with a lesson card: a miss shows the chosen answer with the misconception its rationale names, then the correct answer with why it holds; a success shows the answer and why it holds.", "Replace active answer/back prompts with the owned review, ward, flow, rune, or REDEEMED response during the lesson hold, then show A:CONTINUE."] [[mechanics]] id = "continue-after-reward" @@ -126,6 +133,13 @@ summary = "A rune-and-circuit menu frame that carries the Oracle world into navi template = "oracle-menu" requirements = ["Fits 240x160 without empty placeholder regions.", "Shows focus through pointer, shape, and color.", "Supports idle, focused, confirmed, and cancelled states."] +[[art]] +id = "codex-frame" +kind = "ui" +summary = "The Oracle Codex: an archive of lens mastery and a readable journal of every lesson." +template = "oracle-codex" +requirements = ["Composites the mastery page inside the dormant archive frame so earned runes are the only emissive marks.", "Composites lesson pages on the trial chamber so a lesson rereads in the same place it was first learned.", "Fits five lens rows with three mastery runes and a pending-review count, plus learned and pending totals.", "Fits a page counter, lens, four 31-character question lines, a two-line answer, and three 34-character rationale lines at native scale.", "Keeps every live foreground inside the measured usable interior of its panel, disjoint from siblings, and readable against the panel fill.", "Has explicit empty-journal and missing-rationale states."] + [[art]] id = "character-frame" kind = "ui-scene" @@ -333,7 +347,7 @@ id = "quiz-menu" title = "Quiz Menu" kind = "menu" handler = "quiz-menu" -summary = "Explain the run at a glance and offer start or return." +summary = "Summarize the lesson journal, then offer a new run, the Codex, or a return to the title." mechanics = ["navigate-menu"] art = ["menu-frame", "ui-soundscape"] @@ -341,10 +355,27 @@ art = ["menu-frame", "ui-soundscape"] signal = "new-run" target = "character-creation" +[[scenes.transitions]] +signal = "open-codex" +target = "codex" + [[scenes.transitions]] signal = "back" target = "title" +[[scenes]] +id = "codex" +title = "Oracle Codex" +kind = "review" +handler = "codex" +summary = "Make learning evidence visible: per-lens mastery runes and every lesson with its rationale, so a missed concept can be studied before its retry." +mechanics = ["review-lessons"] +art = ["codex-frame", "ui-soundscape"] + +[[scenes.transitions]] +signal = "back" +target = "quiz-menu" + [[scenes]] id = "character-creation" title = "Create Hero" diff --git a/README.md b/README.md index 0967939..272ae49 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,12 @@ and runs the game in a native Tauri app backed by a headless Bevy engine. |---|---| | ![The Oracle Datafall scene with charged data runes and broken corruption seals](docs/screenshots/oracle-datafall.png) | ![A concept question with wards, flow, Insight Runes, and score](docs/screenshots/oracle-trial.png) | +A live session against this repository, with questions generated by Claude: + +| The lesson after a miss | The Codex remembers it | +|---|---| +| ![A lesson card showing the misconception behind the chosen answer, then the correct answer and why it holds](docs/screenshots/oracle-lesson-shell.png) | ![The Oracle Codex lesson page marking the missed question as review pending](docs/screenshots/oracle-codex-shell.png) | + ## What is playable now The repository's own [`CODEQUEST.toml`](CODEQUEST.toml) is the reference @@ -22,15 +28,79 @@ cartridge. It defines a manifest-driven Oracle quiz with this complete loop: 3. Create a hero by choosing a name, path, and aura. 4. Play **Oracle Datafall** while the next valid question is loading: move left or right, collect data shards, and avoid corruption glyphs. -5. Answer conceptual questions about the project's purpose, architecture, - responsibilities, interactions, invariants, and tradeoffs. -6. Complete an accepted question batch to level up and increase the next - batch's difficulty. Lose all three wards to end the run. +5. Answer conceptual questions about the project's purpose, responsibilities, + interactions, invariants, and tradeoffs, then read the lesson card that + explains the answer. +6. Survive a six-question batch to level up and deepen the next batch's + concepts. Lose all three wards to end the run. +7. Open the **Oracle Codex** from the menu to see per-lens mastery and reread + every lesson you have earned. + +## How the game teaches + +CODE QUEST is built to leave you with a durable model of the project, not a +score. Every question assesses one of five concept lenses: **purpose**, +**roles** (responsibilities), **flows** (interactions), **invariants**, and +**tradeoffs**. + +- **Every choice explains itself.** Generated questions carry a short + rationale for each choice. After you commit, a lesson card replaces the + choices: a miss shows *why your pick was wrong* (the misconception it + represents) and then why the answer holds; a success reinforces the + reasoning. The card waits for you to press A. +- **Position never gives the answer away.** Language models put the correct + answer first most of the time (11 of 12 in a live run against this + repository), and the cursor starts on the first choice. Choices are shown in + a stable per-question shuffle, and every retry moves every choice. +- **Misses come back.** A missed question returns after three other + questions, in the next batch if needed, so the retry never comes straight + after its lesson card. A correct retry in the same launch clears the pending + review but counts as *relearning*, not mastery, because you just read the + answer. Every miss returns once more in a later launch; answering it then is + a *redemption* that earns mastery evidence and retires the question. + First-try correct answers retire their questions for good. The rule is on + screen: the header shows batch progress (`TRIAL 2/7`, the batch growing with + each miss), a returning question is labeled `RETRY` before you answer it, + and the miss's lesson card says when it comes back (`BACK IN 3`, `LATER` + while the deck waits for the Oracle's next questions, or `NEXT RUN` when + the ward breaks). +- **Difficulty deepens the concepts, not the pressure.** Initiate batches + (level 1) focus on purpose and roles, Adept batches (2–3) on flows and + tradeoffs, and Oracle-bound batches (4+) on invariants, including **PREDICT** + questions that describe an undocumented change or failure and ask what the + design implies. There are no timers. +- **Mastery is visible.** Each lens has three mastery runes that wake at 1, 3, + and 5 pieces of evidence (first-try successes plus later-launch + redemptions). Rune II also needs 60% of your last five graded answers on the + lens right (same-launch retry successes are not graded), and rune III needs + 80% and no pending review, so volume alone cannot hide a current + misconception. A rune held back by those gates shows *cracked* (a review is + due, or recent answers slipped) instead of going dark. Waking one is the loudest moment of a run: its own + banner (`ROLES RUNE I`) outranks every score banner, the new rune blinks on + the lesson card, and it has its own chime. The Codex shows the runes with + pending reviews, and pages through the lesson journal: question, answer, and + rationale. +- **Every ending is a debrief.** A level-up recaps the batch's first-try + successes and names the lenses the next batch focuses on. When the run ends, + the result screen adds a learning ledger to the score: first-try successes, + corrected misses (`REDEEMED`), reviews still open, and the lens whose runes + woke this run (or a pointer to the Codex while reviews remain). +- **Pending reviews are self-tests.** A Codex page for a missed question (or a + relearned one whose spaced check is still due) shows + the wrong choice you picked and the misconception behind it, and keeps the + answer sealed until you press A. Reading it first is allowed, but it is not + evidence: the next correct answer to that question counts as relearning and + lights no rune. +- **Generation adapts to you.** Each request names your weakest lens and the + questions you have already been asked, so new batches target your gaps + without repeating themselves. The current progression model is visible as well as numeric: - Correct-answer flow awards x1 at streaks 0–2, x2 at 3–5, and x3 at 6+. -- Cumulative scores of 300, 900, and 1800 awaken Insight Runes I, II, and III. +- Cumulative scores of 300, 900, and 1800 awaken Insight Runes I, II, and III, + shown on screen as `INSIGHT I`, `II`, and `III` so the word RUNE on a banner + always means a lens. - Datafall charge lights at 3, 6, and 9 collected shards; corruption breaks containment seals at 1, 3, and 5 hits. These are expressive Datafall goals and do not alter quiz score, wards, or question generation. @@ -39,8 +109,33 @@ The current progression model is visible as well as numeric: The game has no filler question deck. If the installed AI provider returns an invalid batch, the Oracle keeps Datafall playable and retries; press B to leave -the wait safely. Runtime audio is not implemented yet. Audio entries in the -manifest are production requirements, not playable sound. +the wait safely. The Oracle's second header line says why generation failed +and when it will retry (`TIMED OUT - RETRY IN 5S`), and otherwise recalls +earlier lessons from the journal, outstanding misses first. + +Sound is engine-owned, like the framebuffer. Each tick the engine compares the +observable game state with the previous tick and emits tick-stamped notes for a +four-voice chip (two pulses, a triangle wave, and noise); the shell's speaker +only plays them. Scene loops stop on the tick their scene exits, one input +produces at most one cue, and the Datafall and level-up arrangements gain a +voice with each Oracle bond tier. The audio context starts on your first key +press or device press, and the right-edge volume wheel (or V) steps through +MUTE, LOW, MID, and HIGH; the setting is remembered. Every answer, threshold, +and status stays visible on screen when muted. + +When your system asks for reduced motion, the engine freezes decorative motion +inside the framebuffer (blinking prompts stay lit, heroes stop bobbing, +starfields stop scrolling) without changing any scene timing, input, or data. + +Screen readers get the game in words. Beside the framebuffer and the notes, +the engine writes a plain-language transcript of every screen: the question, +its choices in display order and which one is focused, the lesson card's +reasoning, the Codex pages, the Oracle's status, and the controls that work. +The shell places it in a visually hidden, polite live region. The engine +republishes the transcript only when its words change, so blinking prompts +and falling data are never announced. A new screen is read whole; a small +change on the same screen, such as focus moving to another choice, is read +on its own. ## Install @@ -77,7 +172,11 @@ line tools: consume the Windows repository paths used by the quest runner. On Windows, standard Git for Windows, Codex, and Claude installation locations -are discovered automatically. Custom or portable installs can set `CQA_GIT`, +are discovered automatically: `.exe` files on `PATH`, `%USERPROFILE%\.local\bin`, +the standalone Codex installer's `%LOCALAPPDATA%\Programs\OpenAI\Codex\bin`, +and npm global installs (`codex.cmd` or `claude.cmd` on `PATH` or in +`%APPDATA%\npm`). Prompts reach either CLI on stdin, so their size is not +limited by the command line. Custom or portable installs can set `CQA_GIT`, `CQA_CODEX`, `CQA_CLAUDE`, and `CQA_SHELL` to an executable name on `PATH` or an absolute path. macOS GUI launches repair their restricted `PATH` before tool discovery. @@ -96,7 +195,12 @@ each application session. On the first power-on, CODE QUEST makes one minimal non-interactive request to the selected CLI. The engine and boot animation do not start until that request succeeds. If the pack is missing or the CLI is unavailable, unauthenticated, or unhealthy, the switch and power LED flash red -and return to the off position. Battery changes are locked while power is on. +and return to the off position. The reason the CLI reported, such as +`CODEX CLI UNAVAILABLE - ENTITY NOT FOUND` or the first line the CLI wrote to +stderr, appears in the device's status strip and stays on the +CHECK BATTERIES guide and the rear battery tab until power is tried again or +the batteries are changed. +Battery changes are locked while power is on. ## Load a cartridge @@ -113,14 +217,21 @@ The rack caches at most three repositories. Labels show the current branch and refresh whenever the rack opens. Drag a cartridge down, press Delete, or use its recycle action to remove only the rack entry; repository files and saves remain on disk. A loaded cartridge must be ejected before another can be inserted. +If a cartridge fails to load, for example because its `CODEQUEST.toml` is +invalid or its drive is not mounted, the rack keeps it and shows the error; fix +the problem and load it again. Only a folder that is no longer a Git repository +leaves the rack on its own. Loading a repository creates a versioned, namespaced save beside it, never inside it: `/games/demo` uses `/games/demo.sav`. Quiz saves retain validated AI -question batches and answered-question history. Legacy Claude batch saves load -without conversion. Committing an answer records it -immediately, and later runs and launches filter every recorded question so the -cartridge continues with unseen material. Ejecting or recycling a cartridge -does not delete its save. +question batches, retired (correctly answered) questions, questions awaiting +review, and per-lens mastery. Legacy saves, including Claude-era batches and +questions without rationales, load without conversion. Committing an answer +records it immediately on a background writer: a first-try correct answer +retires the question for later runs and launches, while a miss keeps it queued +for review until you redeem it in a later launch (a same-launch correct retry +queues it once more for that check). Ejecting or recycling a cartridge does not +delete its save. Loading alone does not modify the selected repository. Quest mode can run the repository's own lint, build, or test scripts, so those commands have whatever @@ -138,18 +249,40 @@ Cartridge selection follows this order: ### Quiz -Quiz cartridges request six questions from the verified battery provider. -Generated questions must have four distinct -choices, exactly one correct answer, no repository trivia, no more than four -31-character lines for the prompt, and no more than 31 characters per choice. -Valid questions survive a mixed batch, so an accepted batch can contain fewer -than six. Accepted batches are cached in the sibling save and prefetched while -the player continues. +Quiz cartridges request six questions from the verified battery provider. The +request carries an anonymized project brief: the README, design documents, the +manifest summary, and doc comments plus top-level signatures from up to 30 +components spread across the tree, labelled `COMPONENT n` with every path and +file name withheld so questions stay conceptual. + +Each generated question names its lens and gives four distinct choices, exactly +one correct answer, and a rationale for every choice. A question is accepted +only if it fits the display (four 31-character lines, 31 characters per choice, +three 34-character rationale lines), uses a known lens, and asks no repository +trivia: no file locations, counts, dates, versions, commits, branches, or +authors. Ordinary nouns such as "file" or "path" are fine in a conceptual +question. Valid questions survive a mixed batch. When a delivery falls short, +questions that failed only mechanical checks (length, a missing or overlong +rationale, an unknown lens, or non-ASCII text) get one bounded repair call +through the same CLI within the request's deadline, skipped when less than 10 +seconds remain, so a request costs at most two CLI calls. Trivia, malformed, +and unrepaired questions are dropped, and a short delivery is topped up until +the batch holds six. Accepted batches are cached in the sibling save and +prefetched while the player continues. Set `CQA_CODEX_MODEL` or `CQA_CLAUDE_MODEL` to choose the model used by the corresponding CLI. Set `CQA_NO_AI=1` to disable generation for diagnostics; `0`, `false`, `no`, and `off` leave it enabled. +Each generation request, including its repair pass, has a time budget that +depends on the provider: 150 seconds for Claude and 300 seconds for Codex, +which is slower at high reasoning effort. Set `CQA_AI_TIMEOUT_SECS` to a whole +number of seconds to use one budget for both providers. Values are clamped to +30–900 seconds, and a value that is not a whole number is logged and ignored. +The readiness probe keeps its own 60-second limit. While a request is pending, +the Oracle keeps Datafall playable. A request that runs out of time shows +`TIMED OUT` and is retried. + ### Quest battle Quest cartridges turn repository operations into streamed command battles. @@ -191,33 +324,47 @@ the running CODE QUEST ADVANCE build. | Keyboard | Handheld input | Current use | |---|---|---| -| Arrow keys | D-pad | Navigate; move during Datafall | -| D | A | Confirm, answer, or start a quest | -| S | B | Back, leave the Oracle, or abort a quest | +| Arrow keys | D-pad | Navigate; move during Datafall; page the Codex | +| D | A | Confirm, answer, continue past a lesson, reveal a sealed Codex answer, or start a quest | +| S | B | Back, leave the Oracle or Codex, leave a run (press twice), or abort a quest | | Enter | START | Start or confirm | | Shift | SELECT | Reserved | -| A / F | L / R | Page the quest list | +| A / F | L / R | Page the quest list or the Codex | | P | Power switch | Turn the device on or off | | C | Cartridge slot | Open or close the rack while powered off | | F1 | FRONT/BACK switch | Turn the device over | +| V | Volume wheel | Cycle MUTE, LOW, MID, HIGH | The engine consumes input edges, not browser key-repeat events. During the -Datafall-to-question handoff and the 45-tick answer review, inactive controls are +Datafall-to-question handoff and the 45-tick lesson hold, inactive controls are explicitly ignored so a held key cannot answer the next question accidentally. +After the hold, the lesson card waits for a fresh A or Start press, and leaving +an active question takes two B presses within 90 ticks. ## Architecture +Maintainers: the [architecture guide](docs/architecture.md) walks through the +runtime, the learning model, and the scene machine, and the +[save-format reference](docs/reference/save-format.md) documents every +cartridge save key and its compatibility rules. + Gameplay and device presentation have a hard boundary: | Path | Responsibility | |---|---| -| `src/` | JavaScript/CSS physical shell, cartridge rack, native-dialog bridge, boot overlay, input forwarding, window fitting, and framebuffer canvas | -| `src-tauri/src/engine.rs` | Headless Bevy game state, fixed-step timing, input edges, Oracle/quiz/quest behavior, command effects, and CPU rendering | +| `src/` | JavaScript/CSS physical shell, cartridge rack, native-dialog bridge, boot overlay, input forwarding, window fitting, framebuffer canvas, device status strip, and the WebAudio speaker (`speaker.js`) that plays engine notes | +| `src-tauri/src/engine.rs` | Headless Bevy game state, fixed-step timing, input edges, Oracle/quiz/Codex/quest behavior, lesson cards and spaced retry, command effects, and CPU rendering | +| `src-tauri/src/engine/transcript.rs` | The screen transcript for screen readers, derived from the same state and helpers the renderers use, and its change-only publication | +| `src-tauri/src/audio.rs` | Engine-owned four-voice chip sound: per-tick state snapshots, the cue/loop director, and tick-stamped notes | +| `src-tauri/src/learning.rs` | The learning model: concept lenses, answer evidence, mastery thresholds, the lesson journal, and choice-presentation order | | `src-tauri/src/scene_machine.rs` | Executable finite-state machine and built-in quiz/quest templates | | `src-tauri/src/codequest.rs` | `CODEQUEST.toml` parser, validation, and runtime compilation | -| `src-tauri/src/lib.rs` | Tauri boundary, verified provider state, Git cartridge inspection, provenance, quest construction, AI prompting, and question persistence | +| `src-tauri/src/lib.rs` | Tauri boundary, verified provider state, Git cartridge inspection, quest construction, and provider calls | +| `src-tauri/src/questions.rs` | Question payloads, acceptance policy, prompt construction, response parsing, and learner persistence | +| `src-tauri/src/repo_context.rs` | The anonymized, budgeted project brief sent with each generation request | +| `src-tauri/src/provenance.rs` | Author credits and explicit copyright notices for the chronicle card | | `src-tauri/src/external_tools.rs` | Cross-platform Git, Codex, Claude, and shell discovery | -| `src-tauri/src/save.rs` | Versioned namespaced saves with atomic file replacement | +| `src-tauri/src/save.rs` | Versioned namespaced saves with atomic file replacement and serialized read-modify-write updates | | `src-tauri/assets/oracle/` | Authored 240×160 Oracle plates, hero sprites, portraits, and Datafall sprites embedded into the engine | Bevy always produces one 240×160 RGBA framebuffer: 153,600 bytes at 8 bits per @@ -248,6 +395,27 @@ npm run tauri -- build --no-bundle python3 .agents/skills/codequest-game-designer/scripts/validate_codequest.py CODEQUEST.toml ``` +CI also runs the Rust lint and tests on Windows and macOS runners, the only +hosts that compile the platform-specific tool discovery, process-tree +shutdown, and shell-path code. + +Question quality can be measured against any repository with the real +provider. The ignored `question_eval` test runs the app's exact generation +path (anonymized brief, bounded prompt, provider CLI on stdin, acceptance +policy) and prints every candidate as ACCEPT or REJECT with the limits it +violated, then answer-position, lens, focus-lens, and PREDICT tallies and the +acceptance rate. Each level costs one real provider request: + +```bash +./scripts/eval-questions.sh claude 1,4 +CQA_CODEX_MODEL=gpt-5.5 ./scripts/eval-questions.sh codex 1,2,4 ~/src/other-repo +``` + +`CQ_EVAL_ROUNDS` repeats each level, `CQ_EVAL_TIMEOUT_SECS` sets the limit per +request (120 seconds by default, shorter than the app's Codex budget), and `CQA_CLAUDE`, `CQA_CODEX`, `CQA_CLAUDE_MODEL`, and +`CQA_CODEX_MODEL` select the CLI and model exactly as in the app. The module +documentation in `src-tauri/src/question_eval.rs` lists every option. + Oracle render changes can emit every native scene without launching the shell: ```bash diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..0d60215 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,895 @@ +# CODE QUEST ADVANCE architecture + +This guide is for people and agents who change the code. It describes how the +pieces fit, which module owns each decision, and the rules a change must keep. +Function and type names are the ones in the source, so every claim can be +checked with a search. Line numbers are left out on purpose: `engine.rs` moves +often. + +The README covers installation, controls, and the player-facing rules. The +[Oracle quiz design](game-design/oracle-quiz.md) covers the pedagogy and the +per-scene design contract. This document covers the runtime. + +## 1. The hard boundary + +The engine owns all game state. The shell presents exactly three engine +outputs and sends back input edges and device events. + +| Engine output | Produced by | Tauri command | Shell consumer | +|---|---|---|---| +| Framebuffer: one 240x160 RGBA image, `FRAME_BYTES` = 153,600 bytes | the `render` system writes the `Framebuffer` resource | `engine_frame` (raw `tauri::ipc::Response` bytes) | `drawFrame` in `src/main.js`, which copies the bytes into a canvas with `putImageData` | +| Chip notes: tick-stamped `audio::Note`s in an `AudioBatch` | the `direct_audio` system feeds `AudioOut` | `engine_audio` | `speaker.play` in `src/speaker.js` | +| Screen transcript: plain sentences with a sequence number | `GameEngine::transcript_sentences`, published through `TranscriptChannel` | `engine_transcript { since }` | `pollTranscript` in `src/main.js`, which writes a polite live region | + +What flows the other way: + +| Shell call | Engine effect | +|---|---| +| `engine_input { button, pressed }` | `EngineCommand::Input`; `apply_commands` turns it into a press edge (`handle_press` runs only when the button was not already held) | +| `engine_power`, `engine_finish_boot` | `EngineCommand::Power`, `EngineCommand::BootComplete` | +| `engine_set_cartridge { path }` | lib builds a `CartridgeSpec`, then `EngineCommand::Cartridge` | +| `engine_set_ai_provider`, `verify_ai_provider` | provider selection in `AiProviderState` (lib) and `EngineCommand::AiProvider` (display name only) | +| `engine_set_reduced_motion` | `EngineCommand::ReducedMotion`; renderers read `GameState::motion_ticks`, `settled_ticks`, and `blink_lit` | + +Rules that follow from the boundary: + +- `src/` holds no gameplay state and draws no game content. The shell owns + only the physical device: the cartridge rack (`src/cartridge-library.js`), + battery door, power switch, boot overlay, window fitting, volume wheel, and + status strip (`src/device-shell.js`). +- Tauri commands never touch `GameState`. They send an `EngineCommand` over an + `mpsc` channel (`EngineRuntime::send`) or read a shared copy of an output. +- Sound and the transcript are derived from state, like pixels. Gameplay code + never calls audio and never writes transcript text. + +## 2. Module map + +| Module | Owns | +|---|---| +| `src-tauri/src/lib.rs` | Tauri commands, `AiProviderState` (selected vs verified provider), cartridge building (`build_cartridge`, `engine_cartridge`), provider calls (`ask_provider`, `command_output_with_timeout`), the generation pipeline (`ai_questions`), the question loader and answer recorder closures handed to the engine (`run`) | +| `src-tauri/src/engine.rs` | `GameState`, the Bevy schedule, input handling, the quiz run (`QuizRun`, `RunLedger`), the question deck and batches, retry scheduling, the lesson journal in memory, effects, CPU rendering, `EngineRuntime` | +| `src-tauri/src/engine/transcript.rs` | Screen sentences and `TranscriptChannel` | +| `src-tauri/src/audio.rs` | `AudioSnapshot`, `AudioDirector`, cues, loops, `AudioQueue` | +| `src-tauri/src/learning.rs` | `Concept` lenses, `Review`, `AnswerEvidence`, `LensRecord` and mastery gates, `Lesson`, `presentation_order`, rationale limits | +| `src-tauri/src/questions.rs` | Payload v2 (`QQuestion`, `QChoice`), acceptance policy, `Violation` diagnostics, repair, prompt construction, save batches and `SavedQuizProgress` | +| `src-tauri/src/repo_context.rs` | The anonymized, budgeted project brief (`project_brief`) | +| `src-tauri/src/scene_machine.rs` | `SceneHandler`, `SceneSignal`, `SceneMachineDefinition::compile`, the built-in quiz and quest templates, `SceneMachine` | +| `src-tauri/src/codequest.rs` | `CODEQUEST.toml` parsing, validation, and `runtime_machine` | +| `src-tauri/src/save.rs` | The sibling `.sav` file, `save::update` (serialized, atomic read-modify-write) | +| `src-tauri/src/provenance.rs` | Author ranking and explicit copyright detection for the chronicle card; `sanitized_metadata` for device-visible strings | +| `src-tauri/src/external_tools.rs` | Discovery of `git`, `claude`, `codex`, and the quest shell; `isolate_process_tree` and `kill_process_tree` | +| `src-tauri/src/question_eval.rs` (test only) | The live question-quality eval | +| `src-tauri/src/property_tests.rs` (test only) | Seeded property tests for the pure logic | + +## 3. Threads and the runtime + +```mermaid +flowchart LR + subgraph Shell["WebView (src/main.js)"] + RAF["drawFrame loop
(requestAnimationFrame)"] + IN["sendButton / device events"] + end + subgraph Tauri["Tauri command threads"] + CMD["sync commands
engine_input, engine_power,
engine_frame, engine_audio,
engine_transcript"] + ASYNC["async commands + spawn_blocking
engine_set_cartridge, pick_cartridge,
verify_ai_provider, cartridge_branch"] + end + subgraph Engine["cqa-bevy-engine thread (60 Hz)"] + LOOP["drain mpsc -> Inbox
App::update: apply_commands -> advance_game
-> direct_audio -> render
handle_effect for each EngineEffect
publish frame / notes / transcript"] + end + FRAME[("frame copy
Arc + RwLock")] + AUDIO[("AudioQueue
Arc + Mutex")] + TRANS[("TranscriptChannel
Arc + Mutex")] + LOADER["question loader thread
(one per RequestQuestions)"] + WRITER["cqa-save-writer thread"] + QUEST["quest threads
stdout, stderr, waiter"] + SAVE[("repo.sav
save::update under a process-wide lock")] + CLI["claude / codex CLI
(stdin prompt, process-tree timeout)"] + + IN --> CMD + RAF --> CMD + CMD -- "EngineCommand via mpsc" --> LOOP + ASYNC -- "EngineCommand::Cartridge" --> LOOP + LOOP --> FRAME & AUDIO & TRANS + CMD -. reads .-> FRAME & AUDIO & TRANS + LOOP -- "RequestQuestions" --> LOADER + LOADER --> CLI + LOADER -- "persist_ai_question_batch" --> SAVE + LOADER -- "EngineCommand::Questions {seq}" --> LOOP + LOOP -- "RecordAnsweredQuestion / MarkPeeked" --> WRITER + WRITER -- "persist_progress" --> SAVE + LOOP -- "RunQuest / AbortQuest" --> QUEST + QUEST -- "QuestOutput / QuestDone" --> LOOP +``` + +### `EngineRuntime` + +`EngineRuntime::spawn(question_loader, answered_question_recorder)` starts the +`cqa-bevy-engine` thread and returns a cloneable handle holding the command +sender and the three shared outputs. `run` in `lib.rs` stores it as Tauri +state (`EngineState`). Each loop iteration: + +1. Drains every pending `EngineCommand` from the channel into the `Inbox` + resource (`GameEngine::command`). +2. Runs `App::update`. `GameEngine::new` registers one chained schedule: + `apply_commands`, `advance_game`, `direct_audio`, `render`. One update is + one tick. +3. Takes the `Effects` queue (`GameEngine::take_effects`) and runs + `handle_effect` for each entry, outside the Bevy schedule. +4. Copies the framebuffer into the shared `RwLock`, appends the tick's notes + to the shared `AudioQueue`, and publishes the transcript. +5. Sleeps until the next `FRAME_TIME` (16,666,667 ns). When it falls behind it + re-anchors to now instead of running catch-up ticks. + +The shell never drives the tick rate. It reads whatever the latest copy is. + +### Commands and effects + +`EngineCommand` is everything that enters the engine: `Power`, +`ReducedMotion`, `AiProvider`, `BootComplete`, `Cartridge`, `Questions`, +`Input`, `QuestOutput`, `QuestDone`. Only `apply_commands` handles them. + +`EngineEffect` is everything that leaves it: `RunQuest`, `AbortQuest`, +`RequestQuestions { cartridge_id, level, count, seq }`, +`RecordAnsweredQuestion { cartridge_id, evidence }`, and +`MarkPeeked { cartridge_id, question }`. Engine systems push +effects; `handle_effect` executes them on the engine thread after the update: + +- `RequestQuestions` spawns a thread that calls the `QuestionLoader` closure + and sends the result back as `EngineCommand::Questions` with the same `seq`. +- `RecordAnsweredQuestion` and `MarkPeeked` call the + `AnsweredQuestionRecorder` closure with a `learning::ProgressEvent` + (`Answered` or `Peeked`). The closure built by `answer_recorder_with` only + sends on a channel; the `cqa-save-writer` thread applies events in the + order they happened, so a durable save write never stalls a frame. +- `RunQuest` calls `run_quest`, which spawns the quest shell with + `isolate_process_tree`, stores the child in a shared slot, and starts + stdout, stderr, and waiter threads. `AbortQuest` calls `kill_process_tree` + on that child. The waiter (`wait_for_quest_shell`) gives in-flight output + `QUEST_OUTPUT_GRACE` (1 s) and then kills any helper still holding the + pipes. + +### Request sequence numbers + +At most one question request is in flight per insert. +`request_question_batch` refuses while `questions_loading`, +`pending_questions`, or `question_retry_ticks > 0` is set, increments +`GameState::question_request_seq`, and records `question_request_level`. + +Inserting a cartridge (`EngineCommand::Cartridge`) clears the loading state +and increments the sequence again, so a request made for the previous insert +can still be running. `apply_commands` drops a `Questions` reply unless its +`cartridge_id` is the current quiz cartridge and its `seq` equals +`question_request_seq`. A superseded reply cannot end the newer request's +loading state, add a batch, or report a failure. + +A failed or empty reply sets `question_failure` (through +`oracle_failure_reason`, at most 24 characters) and +`question_retry_ticks = 300` (5 s). `advance_game` requests again once the +delay runs out and the next question is still missing. + +### Other threads + +- Tauri runs sync commands on its command threads. The async commands + (`engine_set_cartridge`, `pick_cartridge`, `cartridge_branch`, + `verify_ai_provider`) move git, save, and CLI work onto `spawn_blocking`. + `CartridgeLoads::order` keeps overlapping cartridge loads in request order, + and `PickedCartridge` lets the insert that follows a folder pick reuse that + build for up to 30 s. +- `command_output_with_timeout` starts a stdin writer thread and two pipe + readers (`read_to_end_in_background`). On timeout it kills the process tree + and abandons the readers instead of joining them, so a grandchild holding + the pipes cannot hold the call past its deadline. On Windows `taskkill /T` + cannot reach a helper that outlived its parent; the call still returns on + time (see `kill_process_tree`). +- `save::update` holds a process-wide lock (`save_write_lock`) for the whole + read-modify-write, so a batch saved by a loader thread and an answer saved + by the writer thread cannot lose each other. + +## 4. Power, boot, and cartridge insert + +1. Batteries: `engine_set_ai_provider` records the selection; + `AiProviderState::select` clears verification whenever the selection + changes. `verify_ai_provider` runs `prove_ai_provider`, which sends + `AI_PROBE_PROMPT` through the same stdin path as generation (60 s limit) and + requires the reply `CODEQUEST_READY`. `mark_verified` fails if the + batteries changed during the check. +2. Power on: `setPower` in `main.js` verifies the provider first, then calls + `engine_power { powered: true }`. `engine_power` refuses power-on unless + `ready_provider()` returns the verified provider. The engine clears held + buttons and the run, aborts any quest, and transitions to `Screen::Boot`. +3. Boot: the shell shows its boot overlay for `BOOT_DURATION_MS` (2600 ms) and + calls `engine_finish_boot`. `BootComplete` calls `GameState::start_machine`, + which resets the scene machine to its start scene. Nothing leaves `Boot` + without a cartridge. +4. Insert: `engine_set_cartridge` runs `load_cartridge`: `build_cartridge` + canonicalizes the path, requires a Git repository, loads + `CodeQuestConfig::load`, creates the save (`SaveFile::open_or_create`), + builds quests, provenance, branch, and revision, and picks the mode. + `engine_cartridge` compiles the scene machine (the manifest's + `runtime_machine`, else `SceneMachineDefinition::template`) and loads saved + questions, lessons, mastery, and attempts (`load_cartridge_questions`). +5. In the engine, `EngineCommand::Cartridge` builds a new `SceneMachine`, + resets the run and deck state, rebatches the saved deck (`rebatch`), + increments the request sequence, and requests a level-1 batch at once when + a quiz cartridge has no saved questions. Generation therefore runs behind + the credits and opening scenes. + +Mode selection (`build_cartridge`): a manifest's `game.type` decides; with no +manifest, `CODEQUEST.md` selects the quest mode (`"custom"`); otherwise the +cartridge is an endless quiz. + +## 5. The question pipeline + +```mermaid +flowchart TD + A["advance_game / Cartridge insert
request_question_batch(level)"] --> B["EngineEffect::RequestQuestions {seq}"] + B --> C["QuestionLoader closure (lib::run)
CQA_NO_AI check"] + C --> D["load_new_questions_with
-> load_verified_question_batch_with
(ready_provider)"] + D --> E["generate_and_save_questions -> ai_questions"] + E --> F["repo_context::project_brief
(git ls-files, anonymized, 20 KiB)"] + E --> G["questions::load_learner_state
(weakest lens, 24 asked stems)"] + F & G --> H["bounded_ai_question_prompt
(payload v2 prompt, 40 KiB cap)"] + H --> I["ask_provider: CLI on stdin
command_output_with_timeout (provider budget)"] + I --> J["parse_generated_batch
question_values -> normalized_question
-> generated_question_is_acceptable"] + J -->|"short batch, mechanical rejections, 10 s left"| K["repair_request -> repair_prompt
ask_provider -> merge_repairs"] + J --> L["GeneratedBatch::into_questions"] + K --> L + L --> M["persist_ai_question_batch
(ai.question_batches)"] + M --> N["playable_new_questions
(drop retired, mark same-launch misses)"] + N --> O["EngineCommand::Questions {seq}"] + O --> P{"screen == Quiz?"} + P -->|yes| Q["pending_questions
(held to a safe boundary)"] + P -->|no| R["append_question_batch"] + Q -->|Oracle or LevelUp| R + R --> S["present_current_question
presentation_order(identity, attempt)"] + S --> T["commit_answer -> AnswerEvidence
record_evidence, record_lesson, schedule_retry"] + T --> U["lesson card (lesson_lines)"] + T --> V["RecordAnsweredQuestion -> cqa-save-writer
persist_progress (quiz.progress)"] +``` + +### 5.1 The project brief + +`repo_context::project_brief(repo, tracked)` builds a deterministic brief of +at most `BRIEF_BUDGET` (20 KiB) of plain ASCII from the tracked files that +`tracked_files` lists (`git ls-files -z`, at most 20,000 paths). In order: + +- `CARTRIDGE SUMMARY:` from the manifest's `game.summary` (`cartridge_summary`). +- `README EXCERPT:` from the root README's prose (`readme`, `markdown_prose`). +- `DESIGN NOTE n:` outlines (headings and leading paragraphs) of design + documents in the priority order `design_documents` assigns. +- `COMPONENT SKELETONS`: doc comments and top-level signatures of up to + `MAX_COMPONENTS` (30) source files, each labelled `--- COMPONENT n ---`. + `spread_sources` takes one file per directory per round, largest first, and + the project's own directories before tooling, packaging, and shell scripts. + `component_allocations` shares the remaining budget, at most + `COMPONENT_BUDGET` bytes each. + +`Redactor` removes anything shaped like a path, file name, extension, or +link, and any word that names a tracked file or directory. No excerpt names a +location, so questions stay conceptual. + +### 5.2 The prompt (payload v2) + +`questions::ai_question_prompt` asks for exactly `count` questions at `level` +and states: + +- The tier (Initiate at 1, Adept at 2–3, Oracle-bound at 4+) and the five + lenses with a guide line each (`lens_guide`). +- FOCUS: at least `focus_minimum(count)` (4 of 6) questions on + `Concept::focus_for_level(level)`. +- TRANSFER at level 4 and above: at least `count.min(2)` questions beginning + `PREDICT:`. Acceptance does not check the prefix. +- The trivia ban, the choice rules, and the rationale rules, with + calibration strings so the provider can judge lengths. +- The display limits, taken from the same constants the renderer uses. +- LEARNER STATE: the weakest lens (`weakest_lens`: most misses relative to + evidence) and up to `MAX_AVOIDED_STEMS` (24) earlier stems. +- The JSON schema: `[{"q", "concept", "choices": [{"text", "why"} x4], "answer"}]`. + +`bounded_ai_question_prompt` trims the brief at a character boundary only if +the whole prompt exceeds `MAX_PROMPT_BYTES` (2 x `BRIEF_BUDGET`). A brief within +its own budget always fits. + +### 5.3 The provider call + +`ai_questions` checks the repository with `git_repo_check_within`, then calls +`ask_provider` within `configured_question_timeout(provider)`: 150 s for Claude and 300 s for Codex by default, or `CQA_AI_TIMEOUT_SECS` (whole seconds, clamped to 30-900) for both. `ai_prompt_command` builds: + +- Claude: `claude -p --output-format json --no-session-persistence --tools ""` + (plus `--model $CQA_CLAUDE_MODEL`). `ai_response_text` reads the `result` + field of the JSON envelope. +- Codex: `codex exec --ephemeral --sandbox read-only --skip-git-repo-check + --color never [--model $CQA_CODEX_MODEL] -`. The reply is stdout. + +The prompt always travels on stdin (`PromptCall`), never as an argument. +`provider_reply` turns a non-zero exit into `" CALL FAILED - "`, +using `failure_line` to find the meaningful stderr line past the CLI's +preamble. + +### 5.4 Parsing and the acceptance policy + +`parse_generated_batch` finds the first JSON array whose elements carry `"q"` +(`question_values`), even inside prose or code fences or cut off mid-array +(`leading_array_values`). Each object is deserialized, normalized +(`normalized_question`: typographic punctuation folded to ASCII, whitespace +collapsed, uppercased, lens spelling canonicalized), and split by +`generated_question_is_acceptable`: + +- `engine::quiz_question_fits`: a non-empty question that wraps into at most + `QUIZ_QUESTION_ROWS` (4) lines of `QUIZ_QUESTION_COLUMNS` (31), exactly four + distinct non-empty choices of at most `QUIZ_CHOICE_CHARS` (31), and a valid + answer index. +- No trivia in the question or choices (`cites_trivia`), and no location in a + rationale (`cites_location`). +- `has_complete_rationales`: a lens `Concept::parse` knows and a `why` for + every choice that fits `RATIONALE_ROWS` (3) lines of `RATIONALE_COLUMNS` + (34) (`learning::rationale_fits`). +- Every string renders in the handheld font (`renderable`: printable ASCII). + +Valid questions survive a mixed batch. `accepted_question_batch` keeps each +identity once, up to `count`. Rejected but readable questions are kept in +`GeneratedBatch::rejected`. + +Saved questions use the looser `question_is_acceptable`, which also admits +legacy questions with plain-string choices and no rationales. + +### 5.5 The repair pass + +`question_violations` measures every reason a question fails, with the same +primitives as the policy. `Violation::is_mechanical` is false only for +`Trivia` and `Malformed`: those questions ask the wrong thing and are never +repaired. + +When the batch is short, `GeneratedBatch::repair_request` collects up to +`count` rejected questions whose violations are all mechanical +(`is_repairable`) and builds `repair_prompt`: for each question, the exact +fields that break which limit and by how much, then the question JSON with an +`id`. `ai_questions` sends it through the same provider if at least +`MIN_REPAIR_TIME` (10 s) of that budget remains. `merge_repairs` accepts a +repair only if it echoes a sent `id`, keeps that question's answer index, +passes the full policy, and repeats nothing. The batch never exceeds `count`. + +`GeneratedBatch::into_questions` fails with `INCOMPLETE OR INVALID QUESTIONS` +when nothing survived. + +### 5.6 Saving batches and filtering for play + +`generate_and_save_questions` persists the accepted batch with its level +(`persist_ai_question_batch`, appended to `ai.question_batches` as raw JSON so +entries a newer build wrote are carried forward). `load_new_questions_with` +then maps it through `playable_new_questions`: retired identities are dropped, +and a stem the progress lists as missed arrives as `Review::InSession`. + +On failure the loader closure in `run` logs the full error and returns the +short category from `question_failure_reason` (`TIMED OUT`, `RATE LIMITED`, +`LOGIN NEEDED`, `REJECTED BATCH`, and so on). The engine shows it on the +Oracle line. + +### 5.7 The engine deck: batches and levels + +The cartridge's `questions` vector is the deck. `GameState::batch_ends` marks +batch boundaries and `batch_levels` holds each batch's generation level. A +batch is full when it holds `QUESTION_BATCH_SIZE` (6) new questions; review +copies ride along without counting (`new_question_count`, `batch_is_full`). + +- `append_question_batch` skips identities already in the deck or waiting as + deferred retries, tops the open last batch up to six new questions, forms + new batches from the rest at the requested level, then calls + `place_deferred_retries`. +- A delivery that arrives while the screen is `Quiz` is held in + `pending_questions` and appended by `advance_game` on the `Oracle` or + `LevelUp` screen, so the deck never shifts under a question on screen. +- `next_batch_level` asks for the level the run will have reached when the new + questions start (one level per full batch still ahead), and never a level + already queued; an open last batch is topped up at its own level. +- `advance_game` prefetches while at most six questions remain ahead in the + quiz, and keeps a catch-up request in flight on any powered screen where + `has_next_question` is false. +- `continue_after_lesson` completes a batch exactly at its end. A full batch + raises `run.level`, closes the ledger's batch, and signals `BatchComplete`. A + short batch with questions after it merges into the next one, so a level-up + always means a full batch was survived. Running out of questions signals + `NeedsQuestion` (back to the Oracle). +- `consumed_questions` marks the deck prefix committed this launch. + `retire_consumed_questions` drops it when a run ends or a new one starts, + requeues consumed questions whose lesson is still outstanding at the front + as `Review::InSession`, and rebatches. +- Saved batches load lowest level first (`cartridge_questions`), so a new run + does not open on leftover Oracle-bound questions. + +Run lifetime follows screens, not scenes: `GameState::transition` starts a +run (`start_quiz_run`) whenever the Oracle or quiz screen is entered from +outside the run screens (`Oracle`, `Quiz`, `LevelUp`), or with a broken ward. + +### 5.8 Presentation order + +`present_current_question` runs once per question index. It reads the +identity's committed attempt count from `CartridgeSpec::question_attempts` +(loaded from the save, raised to at least 1 for a review) and sets +`run.order = learning::presentation_order(identity, attempt)`. +`presentation_order` hashes the identity (FNV-1a) to one of the 24 +permutations and rotates it by `attempt % 4`, so the order is stable across +runs and platforms and every retry moves every choice. `run.selected` is a +display slot; `QuizRun::source_choice` maps it back to a source index. + +### 5.9 Commit, lesson card, and evidence + +`commit_answer` (A on the quiz screen, outside the lesson hold): + +1. Scores the pick: a correct answer raises the streak and adds + `score_award_for_streak`; a miss costs a ward and resets the streak. +2. Opens the lesson card: `run.feedback = (correct, QUIZ_FEEDBACK_TICKS)` (45 + ticks of ignored input, then A or Start continues via + `continue_after_lesson`). `lesson_lines` composes a miss as the pick (`-`, + red) with its misconception, then the answer (`+`, green) with its + rationale; a success shows the answer only. +3. Increments `question_attempts` for the identity. +4. Grades the attempt: a `Review::Fresh` question whose stem the journal + already holds is regraded as `InSession`. It builds `AnswerEvidence + { question, concept, correct, review, picked, picked_choice, peeked }`: + `picked` is the source index of a miss's pick and `picked_choice` its + text, and `peeked` is true when the player revealed this lesson's sealed + answer in the Codex first. +5. Updates mastery (`learning::record_evidence`) and the journal + (`record_lesson`, keyed by identity), and sets `run.lens_woke` when the + gated stage rose. +6. Schedules the retry of a survivable miss (`schedule_retry`) and sets the + `RetryNote` the card shows. +7. Pushes `EngineEffect::RecordAnsweredQuestion`. + +### 5.10 Saving progress and mastery + +The engine hands the host a `learning::ProgressEvent`: `Answered(evidence)` +for a commit, or `Peeked { question }` for a Codex reveal. The writer thread +calls `questions::persist_launch_progress`, which remembers each answered +identity for the rest of the launch and then applies +`SavedQuizProgress::apply` in one `save::update` of `quiz.progress` +(`persist_progress`). An answer goes through `SavedQuizProgress::record`: + +| Outcome | List it moves to | Attempt count | +|---|---|---| +| Correct, `Fresh` or `Spaced`, no peek | `answered_questions` (retired) | removed | +| Correct, `InSession`, or any correct answer after a peek | `relearned_questions` | incremented | +| Wrong, any | `missed_questions` | incremented | + +The three lists never share an identity. `SavedQuizProgress::retired` excludes +anything still missed or relearned. A relearning success on an already +retired question (a delivered repeat of its stem) only updates mastery, so +one question cannot earn redemption evidence twice. A miss also records its +pick in `missed_picks` (index) and `missed_pick_choices` (text), both kept +after a later correct answer, and every attempt clears the identity from +`peeked_questions`. A `Peeked` event adds the identity to `peeked_questions` +only while it is still missed or relearned. The same call updates `mastery` +with `learning::record_evidence`, so the save and the engine apply the same +rule. + +On the next load, `cartridge_questions_in_launch` queues missed and +relearned questions as `Review::Spaced`, or as `Review::InSession` when this +process already answered them on the same save (a cartridge reinserted +within one launch is not a later launch), drops retired ones, and journals +every recorded question (`QQuestion::lesson`, outstanding when missed, +`spaced_check` while a relearned question awaits its spaced check, with the +saved pick found by text and the peek flag). + +## 6. The learning model + +All of this lives in `learning.rs`, which depends on neither Tauri nor Bevy. + +### Lenses + +`Concept` has five lenses: `Purpose`, `Responsibility` (label `ROLES`), +`Interaction` (`FLOWS`), `Invariant`, and `Tradeoff`. `Concept::parse` accepts +case, plurals, and synonyms from providers and rejects anything else. +`Concept::focus_for_level` gives Purpose and Responsibility at level 1, +Interaction and Tradeoff at 2–3, and Invariant and Tradeoff at 4+. + +### Review kinds + +`Review` records why a question is asked, which decides what a correct answer +proves: + +| Kind | Arises from | Correct answer counts as | +|---|---|---| +| `Fresh` | first appearance | evidence (`first_try`), graded | +| `InSession` | `schedule_retry`, `retire_consumed_questions` requeue, `playable_new_questions` and `append_question_batch` for a same-launch miss, `commit_answer` regrading a journaled stem, a reload within the launch that answered it | relearning (`relearned`), not graded | +| `Spaced` | `cartridge_questions_in_launch` for a question missed or relearned in an earlier launch | evidence (`redeemed`), graded | + +A miss of any kind increments `missed` and is graded. Whatever the kind, a +correct answer with `AnswerEvidence::peeked` set (the answer was revealed in +the Codex first) is relearning, not graded. + +### Retry spacing + +`RETRY_GAP` is 3. `schedule_retry` inserts an `InSession` copy exactly three +questions after the miss (`retry_insertion_index`) and shifts every batch end +at or past that index, so a copy past the batch end joins the next batch and +the current batch still closes on time. When the deck is too short, the copy +goes to `GameState::deferred_retries` with its due index, and +`place_deferred_retries` inserts it after a later delivery (never before +`run.question + 1`). The card's `RetryNote` says `BACK IN n`, `LATER` +(deferred), or `NEXT RUN` (the ward broke). A new run drops deferred copies; +their outstanding lessons requeue through `retire_consumed_questions`. + +### Relearned vs redeemed + +- Relearned: a correct `InSession` answer, or a correct answer of any kind + after a Codex peek. It clears the pending review (the + lesson is no longer outstanding), is bannered `REDEEMED` on the card + (`run.redeemed`), and moves the question to `relearned_questions`. It lights + no rune, because the lesson card just showed the answer. The question + returns once as a `Spaced` check in a later launch. +- Redeemed: a correct `Spaced` answer. It is evidence and retires the + question. + +`RunLedger::record` follows the same grading for the debriefs: only non-review +answers count as first tries, and any correct review counts toward the +run's `REDEEMED` total. + +### Mastery runes + +`LensRecord` keeps `first_try`, `redeemed`, `missed`, `relearned`, and a shift +register of the newest graded outcomes (`recent`, `recent_len`, capacity +`RECENT_CAPACITY` = 8). Every field defaults, so older saves load. + +- `evidence()` is `first_try + redeemed`. +- `volume_stage()` is the number of `MASTERY_THRESHOLDS` ([1, 3, 5]) the + evidence reaches. +- `stage_with(outstanding)` applies two gates over the newest + `MASTERY_GATE_WINDOW` (5) outcomes: rune II needs at least 60% correct, and + rune III needs at least 80% and no outstanding miss on the lens. The gates + read `gate_accuracy`, where evidence the window never saw (a record from an + earlier build) fills its older slots as successes, so an upgraded veteran's + first miss weighs as one answer among its history. +- `cracks_with(outstanding)` is `volume_stage - stage_with`: runes the volume + earned that a gate holds back. Renderers draw them cracked + (`mastery_meter_styles`). The Codex totals row reads `CRACKED = REVIEW DUE` + while a cracked lens has a pending review, and `CRACKED = LOW ACCURACY` + when none does. + +`engine::lens_stage` is the one stage rule the trial footer, the Codex, and +the lens-wake check share; it counts outstanding lessons with +`pending_reviews`. Insight Runes (score thresholds 300, 900, 1800) and +Datafall charge and seals are separate, run-only meters. + +### The Codex + +The Codex screen pages between a mastery overview (page 0) and one page per +journal lesson (`codex_page_count`, `codex_lesson`). A learned page shows the +question, the answer, its rationale, and `LEARNED`, plus `ONCE CHOSE:` when +the lesson remembers a misconception (`Lesson::misconception`). The Codex +never changes the deck or mastery. + +A pending page is a self-test. It opens sealed (`codex_answer_sealed`): it +shows `YOU CHOSE`, the player's own wrong pick, and its misconception (or +`THINK, THEN A:REVEAL` for a miss saved before picks were recorded), and the +answer panel reads `A:REVEAL ANSWER`. A or Start reveals the answer +(`reveal_codex_answer`, `GameState::codex_revealed`); A and Start are inactive +on every other page, and every page turn and every visit seals the page +again. The first reveal marks the lesson `peeked` (status `PENDING PEEKED`) +and emits `EngineEffect::MarkPeeked`, which the host persists as +`quiz.progress.peeked_questions` through the same ordered save writer. The +next correct answer to a peeked question counts as `LensRecord::relearned`, +not evidence, moves it to `relearned_questions` so it returns once more as a +spaced check, and any attempt clears the peek. A relearned lesson whose +spaced check is still queued (`Lesson::spaced_check`) is sealed the same way +under `CHECK PENDING`, and a reveal there is recorded as a peek too, so the +check is never open-book. The reveal plays `Cue::QuestionReveal`, and the +screen transcript reads the sealed page without the answer. + +## 7. The scene machine and manifest compilation + +### Handlers, signals, transitions + +`SceneHandler` names a built-in engine behavior; `Screen::from(SceneHandler)` +maps it to the engine screen. `SceneSignal` is a semantic event the engine +emits. `SceneHandler::supports` lists which signals each handler can emit. A +`SceneTransition { signal, target, after_ticks }` routes a signal to a target +scene. + +`SceneMachineDefinition::compile(start_scene, specs)` refuses: + +- empty or duplicate scene ids, or a missing start scene; +- a transition to a missing scene; +- a signal the handler cannot emit; +- an `elapsed` transition without `after_ticks`; +- two transitions on one signal in one scene; +- any scene unreachable from the start scene. + +`SceneMachine::handle` treats `SceneEvent::Tick` as the `Elapsed` signal after +incrementing the scene's elapsed ticks. For any signal, a transition fires +only once `elapsed_ticks >= after_ticks`, so on a non-elapsed signal +`after_ticks` is a minimum dwell (the reference level-up ignores A for 60 +ticks). `can_handle` answers the same question without moving. + +The engine uses the machine through `GameState::signal`, `tick_machine` +(every tick outside `Off` and `Boot`), and `can_signal`. For example, the quiz +menu's second option opens the Codex only when `can_signal(OpenCodex)` holds, +and otherwise falls back to `Back`. + +### Manifest compilation + +`CodeQuestConfig::parse` deserializes `CODEQUEST.toml` with +`deny_unknown_fields` everywhere, then `validate` checks the schema version (1 +or 2), non-empty text, unique ids, resolved mechanic and art references, and +the start scene. In schema v2 every scene needs a handler, `next` is +forbidden, and `handler_supports_game` limits handlers to the game family +(credits, opening, and title for both; the quiz handlers, including `Codex`, +for quiz games; the quest handlers for quest games). `validate` ends by +calling `runtime_machine`, which compiles the scenes with +`SceneMachineDefinition::compile`. A manifest that does not compile refuses +the cartridge. Schema v1 returns `None`, and `engine_cartridge` falls back to +`SceneMachineDefinition::template`. + +Art entries with a `template` (`VisualTemplate`) select built-in renderers: +each `render_*` function checks `GameState::uses_visual_template` for the +current scene and otherwise draws its legacy layout. `opening_beat` picks the +opening plate from the scene's art ids. Mechanics and template-less art are +validated metadata only. + +### The reference cartridge + +The repository's own `CODEQUEST.toml` (schema 2, `type = "quiz"`, +`start_scene = "copyright"`) compiles to this graph. Numbers are +`after_ticks`. + +```mermaid +flowchart TD + copyright["copyright
repository-credits"] + opening["opening-fanfare
opening-fanfare"] + archive["archive-answer
opening-fanfare"] + vault["memory-vault
opening-fanfare"] + conv["convergence
opening-fanfare"] + awake["oracle-awakening
opening-fanfare"] + title["title
title"] + menu["quiz-menu
quiz-menu"] + codex["codex
codex"] + hero["character-creation
character-creation"] + oracle["oracle
oracle"] + quiz["quiz
concept-quiz"] + level["level-up
level-up"] + over["game-over
game-over"] + + copyright -->|"continue (60) / elapsed 180"| opening + opening -->|"elapsed 96"| archive + opening -->|"continue (90)"| title + archive -->|"elapsed 66"| vault + vault -->|"elapsed 66"| conv + conv -->|"elapsed 66"| awake + awake -->|"elapsed 66 / continue"| title + archive & vault & conv -->|continue| title + title -->|continue| menu + menu -->|new-run| hero + menu -->|open-codex| codex + menu -->|back| title + codex -->|back| menu + hero -->|hero-ready| oracle + hero -->|back| menu + oracle -->|questions-ready| quiz + oracle -->|back| menu + quiz -->|needs-question| oracle + quiz -->|batch-complete| level + quiz -->|hearts-empty| over + quiz -->|back| menu + level -->|"questions-ready (60)"| quiz + level -->|"needs-question (60)"| oracle + over -->|replay| menu +``` + +Who emits what: + +- `handle_press` emits `Continue` (A or Start on credits, opening, and title; + A, B, or Start on the quest result screens), the menu's `NewRun`/`OpenCodex`/`Back`, `HeroReady` through + `begin_quiz_run`, `Back` from the Oracle (`leave_quiz_run`) and from the quiz + on a second B within `QUIZ_LEAVE_CONFIRM_TICKS` (90), `Replay` on game over, + and the level-up continuation (`level_up_signal`). +- `advance_game` emits `QuestionsReady` from the Oracle once a question exists + and the Datafall has run at least 75 ticks, `NeedsQuestion` from the quiz + when the deck runs out, and the level-up continuation automatically at 180 + ticks. +- `continue_after_lesson` emits `BatchComplete`, `NeedsQuestion`, or + `HeartsEmpty`. + +## 8. The audio director + +Sound is a pure function of observed state changes. + +1. `direct_audio` builds an `AudioSnapshot` each tick with `audio_snapshot`: + power, `AudioScene`, scene ticks, held buttons as `pad` bits, menu and + hero cursors, Codex page, the run (`RunAudio`: question, selection, answer + phase, wards, multiplier, insight stage, level, `redeemed`, `lens_woke`, + leave arming), Datafall counts and stages, `QuestionStatus` (the same + `GameState::question_status` the Oracle line uses), and the presentation + `Tier`. +2. `AudioOut::observe` passes it to `AudioDirector::observe`, which diffs it + against the previous snapshot: + - Not audible (off, boot, or an `Unlisted` scene): `stop_scene` cuts every + sounding voice on this tick. + - Scene changed: `stop_scene`, `schedule_script` (the scene's scripted + tones, such as the opening beats), a new loop from `scene_loop` anchored + a few ticks later, and at most one `entry_cue` keyed by the previous and + next scene. + - Same scene: `follow_loop` swaps the bed when the state asks for another + one (for example `DANGER_BED` at one ward), and `scene_event` picks the + highest-priority cue among this tick's changes (`datafall_events`, + `quiz_events`, cursor moves, page turns). A press edge that changed + nothing gets `Cue::Unavailable` where `accepts_unavailable` allows it. +3. `play` starts the cue and first cuts whatever the previous cue still owns, + so cues replace each other instead of stacking. `emit_loop` plays loop + steps only on voices no cue or script has reserved, so loops yield and + resume. +4. A cut followed by a note on the same voice and tick is removed. + +Priorities (`Cue::priority`, higher wins): `Unavailable` 1; navigation, page +turns, cursor, traits, and question reveal 2; `DataCollect` 3; `BugHit` 4; +`Retry` and `LeaveWarning` 5; `Ready` and `ChargeRune` 6; `SealBreak` 7; +`Correct` and `Wrong` 8; `Redeemed`, `FlowUp`, and `LowWard` 9; `RuneAwaken` +and `WardBreak` 10; `LensWake` 11; scene-level cues (confirm, cancel, begin, +leave, batch complete, continue, defeat, replay, victory) 12. Within one +commit, `quiz_events` already picks one result: a woken lens rune, then a new +Insight Rune, then a redemption, then a flow increase, then plain correct. + +Loops (`scene_loop`): `TITLE_LOOP` on the title; `MENU_LOOP` on the quiz menu, +hero creation, and quest select; a Datafall arrangement per tier +(`DATAFALL_INITIATE`, `DATAFALL_ADEPT`, `DATAFALL_ORACLE_BOUND`) in the Oracle; +`DANGER_BED` in the quiz at one ward; `BATTLE_LOOP` in battle. The Codex has +no loop. Loop volumes stay at or below `AMBIENCE_MAX_VOLUME` (5). + +The voice model is four monophonic voices (two pulses with duty, a wave, and +noise). A note replaces what its voice was sounding, and a zero-volume note is +a cut. `AudioQueue` holds at most `AUDIO_QUEUE_CAPACITY` (512) notes and drops +the oldest when the shell falls behind. In `speaker.js`, `createTickClock` +maps engine ticks to the WebAudio clock, keeping the newest tick between 20 +and 120 ms ahead (60 ms target). Volume is a shell setting; the engine never +knows it. + +## 9. The transcript contract + +- `screen_sentences(state)` describes the current `Screen` in reading order: + the question, choices in display order with the focused one marked + `, selected.`, the lesson card, Codex pages, the Oracle's status and line, + and the controls that work. `Off` and `Boot` are silent. +- It must derive every sentence from the same state and helpers the renderer + uses (display order, lesson composition, feedback banner, + `question_status`, `quiz_menu_second_option`, and so on), so words and + pixels cannot disagree. Decorative motion, blinking, and staged reveals are + left out. +- The engine loop calls `TranscriptChannel::publish` every tick. It records a + new publication, and increments `seq`, only when the sentences differ from + the latest one. It keeps the last `TRANSCRIPT_HISTORY` (32) publications. +- `transcript_since(seq)` returns `None` when the reader is current. When the + reader's publication is still in history and on the same screen, it returns + `announcement(old, new)`: appended sentences alone, or the few sentences that + changed in place (skipping a choice that merely lost focus), or the whole + screen when the heading or most of it changed. Otherwise it returns the + whole screen. +- The shell polls every `TRANSCRIPT_POLL_MS` (150 ms) from `drawFrame`, behind + an in-flight guard, and replaces the live region's text only when `seq` + changes. + +When you change what a screen shows, change its transcript function in the +same commit. `tests/device-transcript-contract.mjs` checks the shell side; +the tests in `engine/transcript.rs` check the sentences. + +## 10. Persistence + +- The save sits beside the repository, never inside it: `save::path_for` + turns `/games/demo` into `/games/demo.sav`. It is a JSON document with + `schema_version` 1 and a `data` map. +- Keys: `ai.question_batches` (current batches), `claude.question_batches` + (legacy, read first so it counts as older), and `quiz.progress` + (`SavedQuizProgress`). +- Every write goes through `save::update`: it holds the process-wide lock, + re-reads the document from disk, refuses to replace a value it cannot + deserialize or a file that does not parse, and writes only when the value + changed, through a synced temporary file renamed over the save + (`persist_document`). +- New fields use `#[serde(default)]` (and `skip_serializing_if` where an empty + value should keep the old shape), so saves from earlier builds load + unchanged. `saved_batch` skips an unreadable question without discarding + its batch. + +## 11. Invariants to keep + +1. The shell holds no game state, and the engine does no device chrome. +2. Only `apply_commands` changes state in response to the outside; only + `handle_effect` performs side effects, after the update. +3. The engine thread never blocks on git, a CLI, or disk. Cartridge building + happens in lib before `EngineCommand::Cartridge`; generation runs on loader + threads; saves go through the writer thread. +4. A question reply lands only with the current cartridge id and request + sequence. +5. The deck never changes under a question on screen: deliveries wait in + `pending_questions` during `Quiz`. +6. Acceptance limits come from the renderer's constants + (`QUIZ_QUESTION_COLUMNS`, `QUIZ_QUESTION_ROWS`, `QUIZ_CHOICE_CHARS`, + `RATIONALE_COLUMNS`, `RATIONALE_ROWS`), and the prompt, repair prompt, + `question_violations`, and eval all read the same ones. +7. Only first tries and later-launch redemptions are mastery evidence. +8. Every reachable input is an edge. Held keys never repeat, the lesson hold + ignores input, and leaving a question takes two B presses. + +## 12. Making common changes + +- New scene handler: add it to `SceneHandler` and `SceneHandler::supports`, + `handler_supports_game`, `Screen` and `Screen::from`, `handle_press`, + `render`, `screen_sentences`, `audio_snapshot` (`AudioScene`), and + `entry_cue` where it needs an entry sound. Update the templates in + `SceneMachineDefinition::template` if the built-in flow uses it. +- Changing a display limit: change the engine or learning constant; the + prompt, repair prompt, violations, and eval follow. Check the calibration + strings in `ai_question_prompt` still match the new limit. +- New progress field: add it with serde defaults to `SavedQuizProgress` or + `LensRecord`, update `SavedQuizProgress::record` and the engine's matching + path in `commit_answer`, and add a legacy-load test beside + `legacy_progress_without_relearning_fields_loads`. +- New manifest field: the structs use `deny_unknown_fields`, so update + `codequest.rs`, `docs/reference/codequest-toml.md`, both `CODEQUEST.toml` + copies, and the validator in `.agents/skills/codequest-game-designer`. + +## 13. How to verify + +The core checks match CI (`.github/workflows/ci.yml`): + +```bash +node --check src/main.js +npm test # the tests/*-contract.mjs shell contracts +cargo fmt --manifest-path src-tauri/Cargo.toml --check +cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings +cargo test --manifest-path src-tauri/Cargo.toml +npm run tauri -- build --no-bundle +python3 .agents/skills/codequest-game-designer/scripts/validate_codequest.py CODEQUEST.toml +``` + +CI also runs clippy and the Rust tests on Windows and macOS (`verify-native`), +the only hosts that compile the platform-specific tool discovery, process-tree +shutdown, and shell-path code. + +Where the Rust tests live: + +- `engine.rs` `mod tests`: scenario tests that drive a `GameEngine` with + commands and ticks and assert on screens, deck, frames, and audio. + `engine/transcript.rs` has the same kind of tests for the transcript. +- `learning.rs`, `questions.rs`, `scene_machine.rs`, `codequest.rs`, + `audio.rs`, `save.rs`, and `lib.rs` (`question_policy_tests`): unit tests of + each module's rules. `lib.rs` also tests the writer thread's ordering. +- `property_tests.rs`: seeded SplitMix64 properties (300 cases each) over + choice shuffling, mastery, evidence, parsing and acceptance, the queue, + saves, provenance, and wrapping. A failure names the seed and case. + +Native frame previews, without the shell: + +```bash +./scripts/compile-oracle-assets.sh # after editing Oracle PNG sources +CQA_VISUAL_PREVIEW_DIR=/tmp/codequest-previews \ + cargo test --manifest-path src-tauri/Cargo.toml \ + oracle_templates_produce_nine_distinct_native_scene_frames --lib +``` + +Question quality against a real provider (spends quota): + +```bash +./scripts/eval-questions.sh claude 1,4 +CQA_CODEX_MODEL=gpt-5.5 ./scripts/eval-questions.sh codex 1,2,4 ~/src/other-repo +``` + +The script runs the ignored test +`question_eval::live_question_generation_quality`, which uses the app's brief, +bounded prompt, `ask_provider`, and `parse_generated_batch`, then prints each +candidate as ACCEPT or REJECT with its violations, plus answer-position, +lens, focus, and PREDICT tallies and the acceptance rate. Options +(`CQ_EVAL_REPO`, `CQ_EVAL_PROVIDER`, `CQ_EVAL_LEVELS`, `CQ_EVAL_ROUNDS`, +`CQ_EVAL_COUNT`, `CQ_EVAL_TIMEOUT_SECS`, `CQ_EVAL_LEARNER=save`) are +documented at the top of `question_eval.rs`. + +Headless GUI smoke test: the +[runbook](runbooks/headless-gui-smoke-test/README.md) launches the release +binary under Xvfb, focuses the window, sends XTEST keys with `xdotool`, and +captures the shell with `xwd`. `scripts/test-release-picker.sh` checks the +native cartridge picker of a built AppImage the same way. + +Useful switches while debugging: `CQA_NO_AI=1` makes every generation request +fail with `AI DISABLED` (the Oracle wait and retry path); `CQA_CLAUDE`, +`CQA_CODEX`, `CQA_GIT`, and `CQA_SHELL` point at specific binaries; +`CQA_CLAUDE_MODEL` and `CQA_CODEX_MODEL` pick models. Provider failures are +logged to stderr with the full error (`CODE QUEST question generation +failed: ...`), and every repair pass logs its before and after counts. diff --git a/docs/examples/CODEQUEST.toml b/docs/examples/CODEQUEST.toml index 015ceb5..2f8fb3d 100644 --- a/docs/examples/CODEQUEST.toml +++ b/docs/examples/CODEQUEST.toml @@ -35,6 +35,13 @@ inputs = ["d-pad", "a", "b", "start"] rules = ["The current choice is always visible.", "Confirm advances to the selected destination."] feedback = ["Highlight the current choice by pointer, shape, and color.", "Use distinct navigation, confirmation, cancellation, and unavailable responses.", "Emit at most one response per input edge and do not stack cues across scene transitions."] +[[mechanics]] +id = "review-lessons" +summary = "Open the Oracle Codex to see per-lens mastery and reread every lesson the Oracle has taught, so a missed concept can be studied before it returns." +inputs = ["d-pad", "l", "r", "a", "start", "b"] +rules = ["The Codex reads the cartridge's lesson journal and mastery evidence and never changes mastery or the question deck.", "Page 0 shows every lens (purpose, roles, flows, invariants, tradeoffs) with three mastery runes that wake at exactly 1, 3, and 5 pieces of evidence; evidence is first-try successes plus misses redeemed in a later launch. Rune II also needs 60% of the lens's newest five graded answers correct and rune III 80% plus no pending review; a rune those gates hold back shows cracked, and the totals row then reads CRACKED = REVIEW DUE while a cracked lens has a pending review, or CRACKED = LOW ACCURACY while only recent accuracy holds its runes back.", "Every later page shows one lesson, oldest first: its lens and the question, then for a learned lesson the correct answer and the rationale explaining why it holds; a sealed lesson shows its self-test instead until A reveals them.", "Left, Up, and L turn back one page; Right, Down, and R turn forward one page; paging wraps between the mastery page and the newest lesson.", "B returns to the quiz menu on its input edge; A and Start reveal a sealed lesson's answer and are inactive on every other page.", "A lesson whose latest attempt was wrong stays marked for review until the player answers it correctly when it returns.", "A pending lesson, or a relearned lesson whose spaced check is still due, is a self-test: it shows the wrong choice the player picked and the misconception it reveals, and keeps the answer sealed; every page turn and every visit seals it again.", "A reveals a sealed lesson's answer; revealing it before the review or spaced check counts the next correct answer as relearning, which lights no rune and brings the question back once more as a spaced check.", "An empty journal says so honestly and explains that answering trials writes lessons."] +feedback = ["Mark each lens with pending reviews by an amber count beside its runes, not by color alone.", "Show the page counter, lens, and mastery runes for the lesson's lens in the header of every lesson page.", "Mark outstanding lessons REVIEW PENDING in amber (PENDING PEEKED once revealed), relearned lessons awaiting their spaced check CHECK PENDING in amber (CHECK PEEKED once revealed), and learned lessons LEARNED in cyan.", "Show the pick in red with a - marker, the revealed or learned answer in green, and legacy lessons without a rationale as an explicit gap rather than blank space.", "Summarize learned lessons and pending reviews on the quiz menu, and on the mastery page while no rune is cracked; while one is, the per-lens amber counts carry the pending reviews and the totals row explains the crack."] + [[mechanics]] id = "customize-hero" summary = "Choose the hero name, path, and aura before beginning a run." @@ -53,8 +60,8 @@ feedback = ["Use distinct authored crystal and corruption silhouettes in additio id = "answer-question" summary = "Choose one of four answers to a conceptual project question." inputs = ["d-pad", "a", "b"] -rules = ["Exactly one answer is correct.", "A wrong answer costs one ward and resets flow; three, two, one, and zero wards are full, strained, fractured, and broken stages.", "Correct answers build flow: streaks 0-2 award x1 score, 3-5 award x2, and 6 or more award x3.", "Cumulative score awakens Insight Runes I, II, and III at 300, 900, and 1800 points.", "Committing an answer records that question in the cartridge save; future runs and launches skip every recorded question.", "After commitment, hold the revealed answer for 45 ticks; all inputs are intentionally inactive until the automatic transition completes."] -feedback = ["Reveal whether the answer was correct through text, shape, animation, and color.", "Show the correct answer after a mistake.", "Use three authored Oracle ward runes for health instead of generic pips; communicate full, strained, fractured, and broken states through fill, count, color, and a concise review banner.", "Show flow multiplier, raw score, and three awakening Insight Rune marks together; announce exact threshold crossings before establishing the new baseline.", "Replace active answer/back prompts with the owned review, ward, flow, or rune response during the 45-tick result hold."] +rules = ["Exactly one answer is correct.", "A wrong answer costs one ward and resets flow; three, two, one, and zero wards are full, strained, fractured, and broken stages.", "Correct answers build flow: streaks 0-2 award x1 score, 3-5 award x2, and 6 or more award x3.", "Cumulative score awakens Insight Runes I, II, and III at 300, 900, and 1800 points.", "Choices appear in a stable per-question shuffle, and every retry moves every choice, so answer position never predicts the answer.", "Committing an answer records evidence for its concept lens; a correct first try retires the question for later runs and launches, while a miss keeps it queued for review until a later launch redeems it.", "A survivable miss returns after exactly three other questions, carrying into the next batch when the current one ends sooner, so the batch still closes on time.", "A correct retry in the same launch clears the pending review but counts as relearning; only a correct answer when the miss returns in a later launch is a redemption and mastery evidence.", "After commitment, hold the lesson card for 45 ticks with every input intentionally inactive, then continue only on a fresh A or Start press.", "B arms a 90-tick leave confirmation; a second B inside the window leaves the run."] +feedback = ["Reveal whether the answer was correct through text, shape, animation, and color.", "Show the correct answer after a mistake.", "Use three authored Oracle ward runes for health instead of generic pips; communicate full, strained, fractured, and broken states through fill, count, color, and a concise review banner.", "Show flow multiplier, raw score, and three awakening Insight Rune marks together; announce exact threshold crossings before establishing the new baseline.", "Replace the choices with a lesson card: a miss shows the chosen answer with the misconception its rationale names, then the correct answer with why it holds; a success shows the answer and why it holds.", "Replace active answer/back prompts with the owned review, ward, flow, rune, or REDEEMED response during the lesson hold, then show A:CONTINUE."] [[mechanics]] id = "continue-after-reward" @@ -126,6 +133,13 @@ summary = "A rune-and-circuit menu frame that carries the Oracle world into navi template = "oracle-menu" requirements = ["Fits 240x160 without empty placeholder regions.", "Shows focus through pointer, shape, and color.", "Supports idle, focused, confirmed, and cancelled states."] +[[art]] +id = "codex-frame" +kind = "ui" +summary = "The Oracle Codex: an archive of lens mastery and a readable journal of every lesson." +template = "oracle-codex" +requirements = ["Composites the mastery page inside the dormant archive frame so earned runes are the only emissive marks.", "Composites lesson pages on the trial chamber so a lesson rereads in the same place it was first learned.", "Fits five lens rows with three mastery runes and a pending-review count, plus learned and pending totals.", "Fits a page counter, lens, four 31-character question lines, a two-line answer, and three 34-character rationale lines at native scale.", "Keeps every live foreground inside the measured usable interior of its panel, disjoint from siblings, and readable against the panel fill.", "Has explicit empty-journal and missing-rationale states."] + [[art]] id = "character-frame" kind = "ui-scene" @@ -333,7 +347,7 @@ id = "quiz-menu" title = "Quiz Menu" kind = "menu" handler = "quiz-menu" -summary = "Explain the run at a glance and offer start or return." +summary = "Summarize the lesson journal, then offer a new run, the Codex, or a return to the title." mechanics = ["navigate-menu"] art = ["menu-frame", "ui-soundscape"] @@ -341,10 +355,27 @@ art = ["menu-frame", "ui-soundscape"] signal = "new-run" target = "character-creation" +[[scenes.transitions]] +signal = "open-codex" +target = "codex" + [[scenes.transitions]] signal = "back" target = "title" +[[scenes]] +id = "codex" +title = "Oracle Codex" +kind = "review" +handler = "codex" +summary = "Make learning evidence visible: per-lens mastery runes and every lesson with its rationale, so a missed concept can be studied before its retry." +mechanics = ["review-lessons"] +art = ["codex-frame", "ui-soundscape"] + +[[scenes.transitions]] +signal = "back" +target = "quiz-menu" + [[scenes]] id = "character-creation" title = "Create Hero" diff --git a/docs/game-design/oracle-quiz.md b/docs/game-design/oracle-quiz.md index c07abc0..fc6cf65 100644 --- a/docs/game-design/oracle-quiz.md +++ b/docs/game-design/oracle-quiz.md @@ -9,7 +9,8 @@ brief explains intent and implementation status; the manifest supplies stable scene, mechanic, and art IDs. Its schema-v2 handlers and transitions compile into the engine's scene machine; typed visual templates select trusted built-in renderer assets, while mechanics and template-less art remain production -metadata. +metadata. Sound is derived by the engine from game state, so the manifest's +audio entries are production requirements rather than runtime switches. ## Experience frame @@ -21,9 +22,69 @@ question request runs behind the opening spectacle, and any remaining latency becomes Oracle Datafall: a Left/Right falling-object game in which the hero dodges bugs and runs into data while the verified battery provider works. -**Player outcome:** Build a durable mental model of a project's purpose, -responsibilities, interactions, invariants, and tradeoffs—not memorize file -names or repository trivia. +**Learner:** A developer who can read code but is new to, or returning to, the +inserted repository. They may know its language and framework; they do not yet +know why the project is shaped the way it is. + +**Learning objective:** After a run, the learner can explain the project's +purpose and which component owns each duty, predict how components collaborate +when a request or event moves through them, and justify the invariants and +tradeoffs that constrain a change, distinguishing each from a plausible +misconception. + +**Target mental model:** A durable model of the project across five concept +lenses (`learning::Concept`): purpose, responsibilities (`ROLES`), interactions +(`FLOWS`), invariants, and tradeoffs. Concretely, a responsibility map (what +owns which decision), an interaction model (what calls or informs what, and in +which direction), and the invariants and tradeoffs that explain why the +boundaries sit where they do—not file names or repository trivia. + +**Likely misconception:** Surface models that confuse where code lives or what +a name suggests with what a component is responsible for, for example +believing a presentation layer owns state because it displays it. The +generation prompt asks for every distractor to be a misconception a newcomer +might really hold, and acceptance requires a rationale for every choice, so a +wrong answer names the model the player held. + +**Evidence of learning:** A correct commitment to a conceptual question on its +first attempt, or a *redemption*: a correct answer, in a later launch, to a +previously missed question, presented in a different choice order from its +first showing. A miss's review copy always returns after exactly three other +questions (`RETRY_GAP`); when the batch ends sooner, the copy carries into the +next batch (waiting for the next delivery if none is queued) and the current +batch still closes on time. A correct same-launch retry is *relearning* +(`learning::Review::InSession`): it clears the pending review and is bannered +`REDEEMED`, but it follows the lesson card that just showed the answer, so it +lights no rune. Every missed question, including a relearned one, returns once +as a spaced check in a later launch, and only a correct answer there retires +it and counts as a redemption. A delivered question whose stem the lesson +journal already holds is never a first try: it is graded as relearning too. +Score, flow, survival, and Datafall counts are not evidence. + +**Mastery criterion:** Per lens, evidence (first-try successes plus +later-launch redemptions) sets how many runes volume alone would wake: 1, 3, +and 5 (`learning::MASTERY_THRESHOLDS`). Two gates read the lens's newest five +graded outcomes (first tries, spaced checks, and every miss; same-launch +relearning is not graded): rune II needs at least 60% recent accuracy, and +rune III needs at least 80% and no pending review on the lens +(`LensRecord::stage_with`). A rune the volume earned but a gate holds back is +drawn *cracked* (amber outline split by a crack, no core) rather than going +dark, so a slip reads as a review due, not lost progress. A lens with three +lit runes is therefore mastered: recent accuracy is high and nothing on it is +pending. Saves from earlier builds carry no recent outcomes, so only the +pending-review gate applies to them until new answers arrive. + +**Transfer task:** At Oracle-bound levels (4 and above), the generation prompt +requires at least two PREDICT questions per six-question batch. Each describes +a plausible change or failure the repository does not document—a removed +check, a reordered step, a crash mid-operation, a new kind of input—and asks +what the design implies, answered from invariants and tradeoffs rather than +recalled wording. The requirement lives in the prompt: acceptance does not +check the `PREDICT:` prefix, and a PREDICT question counts as evidence for +whichever lens it names. + +**Player goal:** Bind a code-seer to the Oracle, survive trials with three +wards, deepen the Oracle bond, and fill the Codex with awakened lens runes. **Design pillars** @@ -54,19 +115,36 @@ creates new renderer code. creation → Oracle → questions → level-up or game-over. A run lasts until the player's three ward seals break or they return to the menu. - **Core loop:** Consult Oracle → receive one valid question → choose an answer - → read feedback → continue or return to the Oracle. -- **Progression loop:** Survive a six-question batch → raise difficulty → mark - a level-up → visibly deepen the Oracle bond → begin or await the next batch. + → read the lesson card → continue or return to the Oracle. +- **Progression loop:** Survive a full six-question batch, including any review + copies its misses inserted within the retry gap → raise difficulty and + deepen the batch's concept lenses → mark a level-up with the batch's + first-try recap and the lenses the next batch focuses on → visibly deepen + the Oracle bond → begin or await the next batch. - **Success:** Correct answers build flow; streaks 3 and 6 raise the score multiplier to x2 and x3, cumulative score awakens Insight Runes at 300, 900, - and 1800, and completing a batch raises the level. -- **Failure/recovery:** A wrong answer costs one ward, resets flow, and reveals - the correct choice. At zero wards, show the final score, earned Insight Rune, - and a one-button replay path. -- **Save continuity:** Committing an answer records the question in the - cartridge save. Later runs and launches compare normalized text and filter all - recorded questions. Wards, score, flow, and presentation remain run-specific; - hero identity is not save-backed across app launches. + and 1800, and completing a batch raises the level. First-try successes and + later-launch redemptions also wake per-lens mastery runes at 1, 3, and 5 + evidence, gated by recent accuracy and pending reviews. +- **Failure/recovery:** A wrong answer costs one ward, resets flow, and opens a + lesson card that explains the chosen misconception before the correct answer + and its rationale. A survivable miss returns as a review three questions + later, in the next batch if needed. At zero wards, show the final score, + earned Insight Rune, the run's learning ledger (first-try successes, + corrected misses, open reviews, and the lens that woke, or the Codex while + reviews are open), and a steady one-button replay path; the unredeemed miss + waits for a later run. +- **Save continuity:** Committing an answer records it in the cartridge save + (`quiz.progress`) on a background writer. A first-try success or a correct + spaced check retires the question for later runs and launches; a miss keeps + it queued as a review, and a correct same-launch retry moves it to + `relearned_questions`, queued once more as a spaced check for the next + launch. Per-lens mastery (first-try, redeemed, relearned, and missed counts, + plus the newest eight graded outcomes) persists per cartridge, and cartridge + load rebuilds the lesson journal from the saved batches and that progress. + Every new field defaults, so older saves load unchanged. Wards, + score, flow, and presentation remain run-specific; hero identity is not + save-backed across app launches. - **Latency loop:** Request the first batch as soon as the cartridge is accepted, then continue behind the copyright, fanfare, title, menu, and hero creation. In Oracle Datafall, move into falling data to collect it on contact @@ -74,6 +152,16 @@ creates new renderer code. Enter the Oracle only when no valid unanswered question is ready; failed batches retry there instead of becoming generic trivia. +## Pedagogy map + +| Stage | Player experience | Concept or skill | Evidence/feedback | Scaffolding | +|---|---|---|---|---| +| Introduction | Initiate batches (level 1) ask about purpose and responsibilities while the opening, menu, and Datafall wait carry the fiction. | Purpose and responsibilities: the prompt asks for at least four of six questions on the level's focus lenses (`Concept::focus_for_level`). | First-try answers; every commitment opens a lesson card with the answer's rationale. | Choices are shown in a stable per-question shuffle (`learning::presentation_order`) so answer position carries no signal; the prompt asks for plausible distractors of similar length; there are no timers. | +| Scaffolded practice | A miss shows the committed pick in red with the misconception it represents, then the answer in green with why it holds. The question returns as a review after exactly three other questions with every choice moved. | The misconception named by the chosen distractor. | A correct same-launch review is bannered `REDEEMED` and recorded as relearning; only a correct spaced check in a later launch is a redemption and mastery evidence. | The card ignores input for 45 ticks and then waits for A or Start; a review copy that would land past the batch end carries into the next batch, so the gap never shrinks and the batch still levels up on time. | +| Independent practice | Adept batches (levels 2–3) shift to interactions and tradeoffs; flow multipliers reward consecutive correct answers. | Interactions and tradeoffs. | Per-lens first-try, redeemed, relearned, and missed counts plus the newest eight graded outcomes in the save. | Each request names the weakest lens (most misses relative to evidence) and asks for at least one question on it, and lists up to 24 earlier stems the provider must not repeat. | +| Assessment | The Codex mastery page shows every lens's three runes and an amber pending-review count; learned lesson pages reread question, answer, and rationale marked `LEARNED`; a pending lesson (`REVIEW PENDING`) is a self-test that shows the player's wrong pick and its misconception and seals the answer until A. The quiz menu summarizes `LESSONS NN REVIEW NN`. | All five lenses. | Runes at exactly 1, 3, and 5 evidence, with rune II gated at 60% and rune III at 80% recent accuracy plus no pending review; a gated rune shows cracked. Pending reviews clear when a miss is answered correctly; a correct answer after revealing the answer is relearning, not evidence. | The journal can be reread from the menu before any run; missed questions persist across launches until a spaced check answered without a peek retires them. | +| Transfer | Oracle-bound batches (level 4+) focus on invariants and tradeoffs and include at least two PREDICT questions about undocumented changes or failures. | Invariants and tradeoffs applied to a new situation. | A correct prediction is evidence for the lens the question names. | Rationales explain the implication, so a wrong prediction still teaches the invariant. | + ## Polish direction and felt progression The visual system is dark code-fantasy rendered at native 240×160: ink and @@ -95,14 +183,14 @@ difficulty: | Tier | Question focus | Environment and motion | Sound | Carry-forward | |---|---|---|---|---| -| Initiate — level 1 | Purpose and responsibilities | Sparse cyan nodes; the Oracle eye is mostly dormant. | Dry UI ticks, one data pulse, thin ambience. | The chosen hero and first lit node persist through Oracle, quiz, and results. | -| Adept — levels 2–3 | Component interactions and tradeoffs | Gold branches join the cyan frame; transitions gain one extra anticipation beat. | A second motif voice and stronger correct-answer cadence enter. | The expanded crest remains visible in the next Oracle and quiz frames. | -| Oracle-bound — level 4+ | Invariants and design rationale | Cyan and gold converge into the complete Oracle crest; reward motion reaches its maximum controlled intensity. | Full constrained arrangement and final reward cadence. | The complete crest holds until game-over or a clearly signaled new-run reset. | +| Initiate — level 1 | Purpose and responsibilities | Sparse cyan nodes; the Oracle eye is mostly dormant. | Two-voice Datafall loop and two-voice reward cadence; the replay cadence always returns to this thin timbre. | The chosen hero and first lit node persist through Oracle, quiz, and results. | +| Adept — levels 2–3 | Component interactions and tradeoffs | Gold branches join the cyan frame; transitions gain one extra anticipation beat. | A third voice joins the Datafall loop and the reward cadence. | The expanded crest remains visible in the next Oracle and quiz frames. | +| Oracle-bound — level 4+ | Invariants and tradeoffs, including PREDICT transfer questions | Cyan and gold converge into the complete Oracle crest; reward motion reaches its maximum controlled intensity. | Four-voice Datafall loop and the full Oracle reward cadence. | The complete crest holds until game-over or a clearly signaled new-run reset. | Crossing a tier is celebrated on `level-up` and established as the new visual baseline afterward. A new run visibly returns to Initiate. Typed template -selection and the three visual tiers are implemented; audio progression remains -proposed until the runtime owns sound playback. +selection, the three visual tiers, and the tiered Datafall and reward +arrangements are implemented. ### Tracked-metric threshold contract @@ -112,13 +200,15 @@ runes so the HUD reads as part of the illustrated world rather than debug text. | Metric | Desired direction | Exact stages | Reward or consequence | Cap and reset | |---|---|---|---|---| -| Wards (health) | Keep high | 3 full → 2 strained → 1 fractured → 0 broken | Three cyan/gold ward runes lose fill and change state; the review banner names strain, fracture, or break. Zero ends the run. | Capped at 3; restored by a new run. | -| Flow (correct-answer streak) | Build high | 0–2 = x1, 3–5 = x2, 6+ = x3 | Each correct answer awards 100 × the active multiplier; the header and review response establish the new flow stage. | Multiplier caps at x3; a wrong answer or new run resets it. | -| Insight score | Build high | 300 = Rune I, 900 = Rune II, 1800 = Rune III | Three header runes awaken at exact crossings, score color advances, and a crossing banner names the earned rune. | Visual rank caps at Rune III while the readable score continues to 9999; a new run resets both. | +| Wards (health) | Keep high | 3 full → 2 strained → 1 fractured → 0 broken | Three cyan/gold ward runes lose fill and change state; the lesson-card banner names strain, fracture, or break, and a heartbeat bed runs on the last ward. Zero ends the run. | Capped at 3; restored by a new run. | +| Flow (correct-answer streak) | Build high | 0–2 = x1, 3–5 = x2, 6+ = x3 | Each correct answer awards 100 × the active multiplier; the header and lesson-card banner establish the new flow stage. | Multiplier caps at x3; a wrong answer or new run resets it. | +| Insight score | Build high | 300 = Rune I, 900 = Rune II, 1800 = Rune III | Three header runes awaken at exact crossings, score color advances, and a crossing banner names the earned stage (`INSIGHT II RISES`); a lens-rune wake on the same commit takes the banner and the cue. | Visual rank caps at Rune III while the readable score continues to 9999; a new run resets both. | | Data charge | Build high | 3, 6, and 9 collected shards | One of three bottom-strip charge runes lights at each threshold. This is expressive reward only and never changes question generation or quiz score. | Rune meter caps at 3; count displays to 99; both reset on a new run. | | Corruption hits | Keep low | 0 intact; first seal breaks at 1, second at 3, third at 5 | Three containment runes visibly fracture/extinguish in stages, rewarding a clean wait while warning before breach. This remains isolated from quiz health. | Breach display caps after 5; count displays to 99; both reset on a new run. | -| Questions/batches | Complete six | Every 6 answered questions completes a batch | A batch-complete scene raises level and holds the new bond state before continuation. | Continues while questions are available; new run resets batch and level. | -| Oracle bond level | Build high | level 1 Initiate; 2–3 Adept; 4+ Oracle-bound | Palette, crest geometry, circuit density, reward frame, and final result change—not just the number. | Visual tier caps at Oracle-bound; new run resets to Initiate. | +| Questions/batches | Complete six | A batch holds six generated questions; each survivable miss inserts its review copy three questions later and moves every batch end at or past it, so a copy inside the batch extends it and a later copy joins the next batch (or waits for the next delivery); a batch completes at its retry-shifted end and only once it holds a full six | A batch-complete scene raises level and holds the new bond state before continuation. | Continues while questions are available; new run resets batch and level, and unanswered or unredeemed questions carry into it. | +| Oracle bond level | Build high | level 1 Initiate; 2–3 Adept; 4+ Oracle-bound | Palette, crest geometry, circuit density, reward frame, final result, generation focus lenses, and arrangement voices change—not just the number. | Visual tier caps at Oracle-bound; new run resets to Initiate. | +| Lens mastery (per lens) | Build high | Evidence (first-try successes plus later-launch redemptions; a correct answer after revealing the answer in the Codex is relearning, not evidence) 1 = rune I, 3 = rune II, 5 = rune III; rune II also needs 60% of the newest five graded outcomes correct, and rune III 80% plus no pending review on the lens | The Codex mastery page lights the lens's runes in a color that advances with the stage (cyan, amber, magenta), and the templated lesson-card footer shows the same meter beside the lens label. A rune the volume earned but a gate holds back is drawn cracked in amber, and the Codex totals row then reads `CRACKED = REVIEW DUE`. A rise of the gated stage is the run's loudest moment: the top banner (`FLOWS RUNE II`), a blinking footer rune through the input hold (cracked runes beside it hold still), and its own two-note lens-wake chime, each outranking every score threshold. | Caps at three runes while evidence keeps counting; recent outcomes keep the newest eight; persists per cartridge across runs and launches and is never reset by a new run. | +| Pending reviews | Keep at zero | 0 = `ALL CLEAR`; each outstanding miss adds one | An amber `!N` beside the lens on the Codex mastery page, `REVIEW NN` in the Codex totals (while no rune is cracked) and quiz-menu subtitle, and `REVIEW PENDING` (or `PENDING PEEKED` once its answer was revealed) on the lesson page; a correct retry clears its entry. | Displays to 99; persists across launches until each miss is answered correctly. | ## Scene storyboard @@ -131,12 +221,13 @@ runes so the HUD reads as part of the illustrated world rather than debug text. | `convergence` | Join source energy and earned knowledge without spending the climax early. | Cyan enters from the left, gold from the right, and an incomplete Oracle eye forms around a dark seed. | Timed exit to `oracle-awakening`; A/Start skips to `title`. | `play-opening-fanfare` | `opening-convergence`, `opening-soundscape` | | `oracle-awakening` | Resolve the story in the existing hero image instead of using it as the whole intro. | The complete cyan-and-gold Oracle sigil ignites around the code-seer; the frame reaches the sequence's maximum contrast. | Timed or A/Start exit to `title`. | `play-opening-fanfare` | `opening-fanfare`, `opening-soundscape` | | `title` | Resolve the fanfare into an invitation from the Oracle. | A/Start begins; the redrawn Oracle motif and title remain readable without glow. | `quiz-menu` | `begin-from-title` | `title-mark`, `ui-soundscape` | -| `quiz-menu` | Explain the run and offer a safe return. | D-pad selects; A/Start confirms; B returns; focus is visible by shape and color. | `character-creation` or `title` | `navigate-menu` | `menu-frame`, `ui-soundscape` | +| `quiz-menu` | Explain the run, summarize the lesson journal, and offer a new run or the Codex. | D-pad selects; A/Start confirms; B returns to the title; the subtitle reads `LESSONS NN REVIEW NN` (or `ALL CLEAR`) once lessons exist; focus is visible by shape and color. | `character-creation`, `codex`, or `title` | `navigate-menu` | `menu-frame`, `ui-soundscape` | +| `codex` | Make learning evidence visible and give every miss an informed-retry path. | Page 0 shows each lens with three mastery runes (1/3/5 evidence, cracked where an accuracy or pending-review gate holds one back) and an amber `!N` pending-review count; later pages show one lesson each: lens, question, green answer, and rationale for a lesson marked `LEARNED` in cyan (plus `ONCE CHOSE:` when a misconception is remembered); a lesson marked `REVIEW PENDING` in amber shows `YOU CHOSE`, the red `-` pick, and its misconception, with the answer sealed behind `A:REVEAL ANSWER`. Left/Up/L and Right/Down/R page with wrap and reseal; A/Start reveal a pending lesson's answer and are otherwise inactive; B returns. | `quiz-menu` | `review-lessons` | `codex-frame`, `ui-soundscape` | | `character-creation` | Give the player identity while the first question request is already in flight. | Change name, path, and aura through disjoint, centered identity rows; the hero's visible feet stay grounded on the atelier stage; aura selects an authored hero colorway without procedural equipment overlays. | `oracle` | `customize-hero` | `hero-set`, `character-frame`, `ui-soundscape` | -| `oracle` | Turn real generation latency into a safe, active interstitial. | Left/Right changes lanes; data fills charge runes at 3/6/9 and bug hits break containment runes at 1/3/5. A/Start remain inactive; B abandons the wait safely. The top header holds truthful Oracle context and the bottom strip holds themed instruments plus controls. | Automatically enters `quiz` when a valid question is ready; B returns to `quiz-menu`. | `consult-oracle` | `hero-set`, `oracle-sanctum`, `oracle-soundscape`, `run-progression` | -| `quiz` | Test one durable project concept. | D-pad selects; A commits; B abandons the run; ward, flow, score-rune, text, shape, animation, and sound states reveal consequence and reward. | `oracle`, `level-up`, `game-over`, or `quiz-menu` | `answer-question` | `hero-set`, `quiz-frame`, `quiz-soundscape`, `run-progression` | -| `level-up` | Recognize a completed batch and establish a visibly stronger Oracle bond. | A/Start continues after the reward has a readable hold. | `quiz` or `oracle` | `continue-after-reward` | `hero-set`, `reward-frame`, `progression-soundscape`, `run-progression` | -| `game-over` | Close the run, show what was earned, and make replay obvious. | Show final score, level, and completed crest state; A/B/Start returns to the menu and clearly resets progression. | `quiz-menu` | `replay-run` | `hero-set`, `result-frame`, `progression-soundscape`, `run-progression` | +| `oracle` | Turn real generation latency into a safe, active interstitial. | Left/Right changes lanes; data fills charge runes at 3/6/9 and bug hits break containment runes at 1/3/5. A/Start remain inactive; B abandons the wait safely. The top header holds truthful Oracle context and the bottom strip holds themed instruments plus controls. The header's second line says why the last request failed and when it retries (`TIMED OUT - RETRY IN 5S`), or otherwise recalls one journal lesson (`REVIEW` or `RECALL`). | Automatically enters `quiz` when a valid question is ready; B returns to `quiz-menu`. | `consult-oracle` | `hero-set`, `oracle-sanctum`, `oracle-soundscape`, `run-progression` | +| `quiz` | Test one durable project concept and explain the answer. | D-pad selects among shuffled choices; A commits; a lesson card then replaces the choices with the misconception and answer rationales and continues on A/Start after its 45-tick hold; B twice within 90 ticks leaves the run; ward, flow, score-rune, lens-rune, text, shape, and sound states reveal consequence and reward. The header shows batch progress (`TRIAL 2/7`, growing as misses insert retries), a returning review copy is labeled `RETRY` in amber before it is answered, and a miss's lesson card says when it returns (`BACK IN N`, `UP NEXT`, `LATER` while its retry waits for the next delivery, or `NEXT RUN`). | `oracle`, `level-up`, `game-over`, or `quiz-menu` | `answer-question` | `hero-set`, `quiz-frame`, `quiz-soundscape`, `run-progression` | +| `level-up` | Recognize a completed batch, recap it, and establish a visibly stronger Oracle bond. | The heading reads `ORACLE BOND ASCENDS` when the level crosses into a new tier and `ORACLE BOND DEEPENS` within one; the recap box shows the batch's `1ST TRY a/b`; until the gate opens the footer names the next batch's lenses (`NEXT: FLOWS+TRADEOFFS`, from `Concept::focus_for_level`). A/Start continue once the manifest's `after_ticks` gate opens (60 ticks here), when the continue prompt appears; the scene continues on its own at 180 ticks. | `quiz` or `oracle` | `continue-after-reward` | `hero-set`, `reward-frame`, `progression-soundscape`, `run-progression` | +| `game-over` | Close the run, show what was earned and learned, and make replay obvious. | Show final score, Insight Rune, tier, and level, then the run's learning ledger: `1ST TRY a/b`, `REDEEMED NN`, `REVIEW NN` (journal lessons still pending) or `ALL CLEAR`, and the lens whose mastery rose most this run with its rune stage, or `SEE CODEX` while reviews are open; the `A/B/START:MENU` prompt never blinks. A/B/Start returns to the menu and clearly resets progression. | `quiz-menu` | `replay-run` | `hero-set`, `result-frame`, `progression-soundscape`, `run-progression` | All scenes are reachable from `copyright`. The opening path is finite, and the `oracle` → `quiz` loop is deliberate. Every scene now has a player or timed exit, @@ -172,7 +263,8 @@ dependency on generation. |---|---|---|---|---| | Arrival | Enter `oracle`. | Reset the hero to center, clear in-flight objects, and show the real selected-provider status. Keep the existing minimum dwell so instant results do not flash past. | Left/Right begins moving immediately; B returns to the quiz menu; all other controls remain inactive. | Implemented. | | Datafall | Request is in flight. | Authored cyan-and-gold crystal shards and asymmetric magenta corruption glyphs fall through deterministic lanes. Data and bug-hit counts persist across Oracle visits; three charge runes light at 3/6/9 data while three containment runes break at 1/3/5 hits. | Move into data to collect it automatically; move away from bugs. | Implemented and breakpoint-tested. | -| Clouded vision | A batch returns empty or invalid. | ` RETRYING` distinguishes the real retry delay without a fake percentage. Falling-object play continues. | Left/Right remain available. | Implemented. | +| Clouded vision | A question request fails: no new questions, a rejected batch, or a provider error. | ` RETRYING` distinguishes the real retry delay without a fake percentage. For the 5-second retry delay the header's second line names the failure's category in amber with a whole-second countdown (` - RETRY IN NS`, the reason cut to 24 characters). Once the retry is in flight the line returns to recall, or reads ` - RETRYING` when the journal is empty. Falling-object play continues. | Left/Right remain available. | Implemented. | +| Recall | The journal holds lessons and no failure is waiting out its delay. | The header's second line recalls one lesson with its lens and answer: an outstanding miss as amber `REVIEW`, a cleared lesson as cyan `RECALL`. Outstanding misses come first, each group newest first, and every wait starts from the top. Each lesson holds for 240 ticks (4 seconds) and types in at two characters per tick; under reduced motion it appears whole. | None; recall is read-only and records no evidence. | Implemented. | | Vision ready | A valid unanswered question exists. | `QUESTION READY` may appear during the minimum dwell, then the scene transitions automatically. | No confirmation required; held D-pad inputs cannot answer the quiz. | Implemented. | | Long wait | Scrying continues beyond the normal beat. | The same deterministic play loop continues under truthful status copy, with no invented scan steps. | Keep playing until the question arrives. | Implemented. | @@ -201,7 +293,9 @@ and the selected provider retries. its opening impact. - **Inputs:** A or Start. - **Rules:** The sequence is finite and deterministic. Completion never implies - question readiness. Reduced motion changes transitions, not duration or data. + question readiness. Reduced motion freezes decorative motion—blinking + prompts stay lit and settling motions show their end state—without changing + duration, timing gates, input, or data. - **Feedback:** Carry one code-seer through five distinct authored frames: source ember, archive answer, memory vault, convergence, and complete Oracle. Each cut changes place or causality—not merely brightness—and total luminance @@ -217,6 +311,43 @@ and the selected provider retries. Navigation, confirm, cancel, and unavailable use distinct one-shot cues and never stack across a transition. +### `review-lessons` + +- **Decision:** Study a lens or a missed lesson before the next run. +- **Inputs:** D-pad and L/R page; B returns. A or Start reveals a pending + lesson's sealed answer; on every other page they are inactive. +- **Rules:** Read the cartridge's lesson journal and per-lens mastery; the + Codex never changes mastery or the question deck. Mastery evidence is + first-try successes plus redeemed misses from a later launch; same-launch + retries count as relearning. Volume wakes the first, second, and third rune + at exactly 1, 3, and 5 evidence; rune II also needs 60% of the newest five + graded outcomes correct, and rune III 80% plus no pending review on the + lens. A rune the gates hold back is drawn cracked, and while any rune is + cracked the totals row reads `CRACKED = REVIEW DUE` in amber, or + `CRACKED = LOW ACCURACY` when no cracked lens has a pending review. Lessons appear + oldest first; paging wraps between the mastery page and the newest lesson. + A pending lesson, or a relearned lesson whose spaced check is still due + (`CHECK PENDING`), opens sealed: it shows `YOU CHOSE`, the player's wrong + pick, and the misconception it reveals (or `THINK, THEN A:REVEAL` for a miss + recorded before picks were saved), and the answer panel reads + `A:REVEAL ANSWER`. A reveals a pending lesson's answer; revealing before the + review counts the next correct answer as relearning, which is saved but is + not evidence, and the question returns once more as a spaced check. The + first reveal marks the lesson `PENDING PEEKED` and persists; every page turn + and every visit seals the page again. The next attempt at the question + clears the peek. A learned lesson that remembers a misconception adds + `ONCE CHOSE:` and the pick below its rationale. An empty journal says + `NO LESSONS YET` and explains that answering trials writes lessons. +- **Instructional role:** Turns a wrong answer from lost health into a + retrieval-practice prompt: the player faces their own misconception and + recalls the answer before seeing it, and the lens that needs attention is + visible, so the retry is informed instead of a second guess, and reading the + answer first is never mistaken for evidence. +- **Feedback:** Pending reviews are an amber count beside the lens runes and an + amber `REVIEW PENDING` (or `PENDING PEEKED`) status on the lesson, never + color alone; the pick carries a `-` marker as well as red. Revealing plays + one soft reveal cue. Legacy lessons without a rationale say so explicitly. + ### `begin-from-title` - **Decision:** Accept the Oracle's invitation and enter the quiz menu. @@ -253,10 +384,25 @@ and the selected provider retries. unanswered question exists; empty results retry. No Datafall state affects generation, difficulty, quiz score, wards, or wait duration. A B press exits on its input edge and clears the active run, so the wait is never inescapable. +- **Instructional role:** Retrieval practice during the wait. Datafall itself + keeps the real generation wait active and honest, while the Oracle line + rereads journal lessons, outstanding misses first. Neither Datafall nor the + recall line produces learning evidence, and Datafall counts never enter + mastery. - **Feedback:** Data uses an authored cyan-and-gold crystal silhouette; bugs use an asymmetric magenta corruption silhouette. Keep `DATAFALL` and truthful - loading/retry/ready text in the top header. Compose raw two-digit counts, - three charging data runes, three breakable containment runes, and move/back + loading/retry/ready text in the top header. The header's second line, the + Oracle line, shows the failure's category while a failed request waits out + its 5-second delay: `TIMED OUT`, `CLI UNAVAILABLE`, `RATE LIMITED`, + `PROVIDER OVERLOADED`, `LOGIN NEEDED`, `BATTERIES UNVERIFIED`, + `NOT A GIT REPO`, `CALL FAILED`, `REJECTED BATCH`, `SAVE FAILED`, + `AI DISABLED`, `NO NEW QUESTIONS`, or `GENERATION FAILED`, followed by + `- RETRY IN NS`. Otherwise it recalls one journal lesson every 240 ticks + (`ORACLE_RECALL_TICKS`) as `REVIEW : ` for an outstanding miss + and `RECALL : ` for a cleared lesson, dropping the lens label + before any of the answer would be cut. It is omitted when there is neither a + journal nor a failure, and a ready question hides the failure. Compose raw + two-digit counts, three charging data runes, three breakable containment runes, and move/back controls in the bottom strip without collisions. Give each breakpoint, retry, ready state, and back action a distinct response while keeping ambience below quiz feedback. @@ -269,27 +415,67 @@ and the selected provider retries. four 31-character question lines, and 31 characters per choice. Wrong costs one ward and resets flow. Correct builds flow; streaks 0–2, 3–5, and 6+ award x1, x2, and x3 score. Score awakens Insight Runes at 300, 900, and 1800. - Commitment immediately records the question in the cartridge save; future - runs or launches compare normalized text and skip every recorded question. - After commitment, hold the revealed answer for 45 ticks with every input - intentionally inactive. -- **Feedback:** Keep the correct choice visible in green after either result; - show an incorrect committed choice in red. Do not add redundant correct/wrong - words. Use visibly spaced glyphs that begin beyond the plate's left divider, + Commitment immediately records the answer in the cartridge save: a correct + first try or spaced check retires the question (by normalized text) for later + runs and launches, a miss keeps it in the save's missed list so it returns as + a review, and a correct same-launch retry moves it to the relearned list so + it returns once as a spaced check in the next launch. Choices appear in a + stable order derived from the + normalized question and its attempt number, so the provider's answer + position never predicts the answer and every retry moves every choice. After + commitment, hold the lesson card for 45 ticks with every input intentionally + inactive, then wait for A or Start. A survivable miss inserts a review copy + after exactly three other questions and shifts every batch end at or past + it: a copy past the current batch end joins the next batch, and one past the + deck's end waits until a delivery reaches its due index and lands exactly + there, so a fresh question always comes first after the Oracle wait. A new run drops those + waiting copies and requeues their pending lessons instead. A miss that breaks + the last ward schedules no in-run retry; it stays + pending for a later run. Each commitment updates the + lens mastery record and upserts the lesson journal entry. B arms a 90-tick + in-scene confirmation; a second B inside the window leaves the run, and any + other button or the timeout disarms it. +- **Instructional role:** Assesses one lens per question and turns every + commitment into explained feedback. A miss names the misconception behind + the chosen distractor before the correct reasoning, then schedules an + informed retry. A correct same-launch retry is recorded as relearning; the + spaced check in a later launch is the redemption that counts as evidence. +- **Feedback:** Replace the four choice frames with one lesson panel: a miss + shows the committed pick in red with its rationale (the misconception) and + then the answer in green with its rationale; a success shows the answer with + its rationale. `-` and `+` markers repeat the verdict without relying on hue. + The panel footer names the question's lens with its three mastery runes + (lit, cracked, or unlit, as on the Codex), centers a miss's amber return + note (`BACK IN N` intervening questions, `UP NEXT`, `LATER` while the copy + waits for a delivery to reach its gap, or `NEXT RUN` when the ward broke) in + the free span for the whole card, and shows `A:CONTINUE` only once input is + live. When an answer wakes a lens rune, the newest lit footer rune blinks + through the input hold (solid under reduced motion) and settles lit. A + correct review attempt shows `REDEEMED` unless a lens rune or Insight + crossing takes precedence. + Use visibly spaced glyphs that begin beyond the plate's left divider, keeping every ornament outside glyph and inter-glyph cells, plus distinct cursor, commit, low-ward, and batch-complete cues. Replace generic health - stars with three Oracle ward runes; compose ward, flow multiplier, three - Insight Rune marks, and raw score across the header. During the input lock, - replace active controls with the owned review, ward, flow, or exact rune- - crossing banner. + stars with three Oracle ward runes; compose batch progress (`TRIAL P/N`, or + `RETRY P/N` in amber on a review copy), ward, flow multiplier, three Insight + marks, and raw score across the header. While the lesson card shows, replace + active controls with one banner in priority order: a woken lens rune + (`ROLES RUNE I`, in the stage's rune color), an Insight crossing + (`INSIGHT I RISES`), `REDEEMED`, flow (`FLOW X2`), or `CLEAR SIGHT`; a miss + names the ward state instead. The word REVIEW on screen only ever refers to + retries, and RUNE on a banner only ever names a lens. ### `continue-after-reward` - **Decision:** Continue after reading the earned level and presentation tier. - **Inputs:** A or Start. -- **Rules:** Keep the first 60 ticks non-interactive, then continue on the input - edge. Route to `quiz` when a valid question is ready and to `oracle` - otherwise. A held confirmation cannot answer the next question. +- **Rules:** The scene graph owns the hold: A/Start continue only when the + current transition would be accepted, so the manifest's `after_ticks` on the + level-up routes (60 ticks here) sets both the non-interactive hold and when + the continue prompt appears. After the hold, continue on the input edge; at + 180 ticks the scene continues on its own. Route to `quiz` when a valid + question is ready and to `oracle` otherwise, requesting a missing batch while + the reward holds. A held confirmation cannot answer the next question. - **Feedback:** Telegraph, celebrate, and hold the new crest state once, then use one tier-specific continuation cue. @@ -314,44 +500,77 @@ and the selected provider retries. | `opening-fanfare` | Scene/VFX | `oracle-awakening` | Resolve the five-scene story in the complete Oracle crescendo. | Existing authored 240×160 plate; brightest cyan/gold only here; clean title handoff. | `awakening.png` retained as the climax; five-beat luminance and distinctness tested. | | `title-mark` | Logo/UI | `title` | Identify the cartridge and Oracle motif; idle and prompt-pulse states. | Legible at 240×160 without glow. | `oracle-title` implemented. | | `menu-frame` | UI | `quiz-menu` | Carry the cathedral/rune language into focused and idle menu states. | Focus differs by pointer, shape, and color; no unowned empty space. | `oracle-menu` implemented. | +| `codex-frame` | UI | `codex` | Show lens mastery, pending reviews, and one lesson per page, plus empty-journal and missing-rationale states. | Mastery page sits inside the dormant archive frame; lesson pages overlay the trial chamber's panels while its portrait, brazier, and candles stay visible; worst-case copy is contained, disjoint, and readable against the brightest plate pixel under it. | `oracle-codex` implemented with native layout, exact rune-breakpoint, paging, and empty-state tests. | | `character-frame` | UI/scene | `character-creation` | Stage the customizable hero inside the same world with centered name, path, and aura rows plus loading, retry, and ready states. | Heading and rows are pairwise disjoint; labels and actions center in measured usable interiors; the hero's visible-alpha feet meet the stage support line; status is truthful. | `oracle-atelier` implemented with native layout assertions. | | `hero-set` | Sprite set | `character-creation`, `oracle`, `quiz`, `level-up`, `game-over` | Carry identity through the run with authored aura colorways plus idle, dodge, reward, and defeat variants. | Consistent silhouette across palettes/backgrounds; no procedural accessory or weapon overlays. | `oracle-hero` implemented with authored colorways, portrait, and defeat variants. | | `oracle-sanctum` | Scene/UI | `oracle` | Present Datafall, loading, retry, ready, and B-back as one place: moving hero, authored drops, staged data-charge and corruption-containment instruments, and a tier crest. | Fits 240×160; sprites stay contained and differ by silhouette/value/hue; top status remains distinct; raw counts and themed three-rune meters remain disjoint from centered controls at two digits. | `oracle-sanctum` implemented with authored drop sprites, themed threshold runes, exact breakpoint tests, and three visual tiers. | -| `quiz-frame` | HUD/UI | `quiz` | Hold question, four choices, focus, ward health, flow multiplier, score, Insight Runes, and answer review. | Honor text limits and plate-divider clearance; replace generic pips with three stateful Oracle runes; preserve raw score while exact flow/score thresholds change reward, fill, color, and concise response copy. | `oracle-trial` implemented with ornament-disjoint copy, shaped focus, themed instrumentation, staged scoring, and breakpoint tests. | -| `reward-frame` | Scene/UI | `level-up` | Telegraph the threshold, celebrate it once, and establish the new Oracle-bond baseline. | No rapid full-background flashing; tier and reward remain readable. | `oracle-ascension` implemented with tiered crest and readable hold. | -| `result-frame` | Scene/UI | `game-over` | Resolve the run with hero state, final score, earned Insight Rune, level, completed crest, and an obvious reset/replay path. | Defeat is clear without erasing earned progress; all accepted replay inputs are visible. | `oracle-aftermath` implemented with preserved tier, score-rune rank, and defeated hero. | -| `run-progression` | Presentation system | `oracle`, `quiz`, `level-up`, `game-over` | Own every tracked metric's direction, thresholds, crossing response, cap, reset, and carry-forward state. | At least two non-numeric channels change; exact breakpoints are tested; all run metrics reset deterministically. | Bond, ward, flow, Insight Rune, data-charge, and corruption-containment stages are implemented; tier audio remains proposed. | +| `quiz-frame` | HUD/UI | `quiz` | Hold question, four choices, focus, ward health, flow multiplier, score, Insight Runes, and the lesson card: committed pick and answer with their rationales, `-`/`+` verdict markers, the lens with its mastery runes, and the continue prompt. | Honor text limits and plate-divider clearance; replace generic pips with three stateful Oracle runes; preserve raw score while exact flow/score thresholds change reward, fill, color, and concise response copy; worst-case rationales stay inside the lesson panel. | `oracle-trial` implemented with ornament-disjoint copy, shaped focus, themed instrumentation, staged scoring, lesson-card layout and contrast, and breakpoint tests. | +| `reward-frame` | Scene/UI | `level-up` | Telegraph the threshold, celebrate it once, and establish the new Oracle-bond baseline. | No rapid full-background flashing; tier and reward remain readable. | `oracle-ascension` implemented with tiered crest, tier-crossing `ASCENDS`/within-tier `DEEPENS` heading, first-try batch recap, and next-lens hold footer. | +| `result-frame` | Scene/UI | `game-over` | Resolve the run with hero state, final score, earned Insight Rune, level, completed crest, the run's learning ledger, and an obvious reset/replay path. | Defeat is clear without erasing earned progress; all accepted replay inputs are visible on every frame; worst-case ledger copy stays inside the panel, disjoint, and readable against the brightest plate pixel under it. | `oracle-aftermath` implemented with preserved tier, `INSIGHT` rank, learning ledger, steady prompt, and defeated hero. | +| `run-progression` | Presentation system | `oracle`, `quiz`, `level-up`, `game-over` | Own every tracked metric's direction, thresholds, crossing response, cap, reset, and carry-forward state. | At least two non-numeric channels change; exact breakpoints are tested; all run metrics reset deterministically. | Bond, ward, flow, Insight Rune, data-charge, and corruption-containment stages are implemented; Datafall and reward audio grow by tier. | ## Sound requirement ledger -Sound entries are currently `art.kind = "audio"` production metadata. The -engine does not load or play them yet. +Sound entries remain `art.kind = "audio"` production metadata: the manifest has +no sound schema or audio template field, so editing an entry changes no sound. +The engine owns the runtime that implements them. `src-tauri/src/audio.rs` +describes a constrained +four-voice chip (two duty-selectable pulses, a triangle-like wave, and noise). +Its director samples an observable-state snapshot every tick and diffs it +against the previous tick, so sound is a pure function of state transitions +exactly like rendering; gameplay code never calls audio. Every note carries its +absolute engine tick, voices are monophonic, and a zero-volume note is a cut, so +a scene's loop ends on the exact tick the scene exits. At most one cue starts +per tick, chosen by priority, so one input edge can never stack cues. Loops and +beds stay at or below chip volume 5 while every cue peaks above it. The shell's +`src/speaker.js` only schedules the engine's notes through WebAudio behind a +gesture-gated audio context and the device's four-detent volume wheel (V cycles +MUTE, LOW, MID, HIGH). Scenes the director does not recognize stay silent. +All compositions are original, in a D minor palette that resolves to D major at +the Oracle's crest. | ID | Used by scenes | Player-facing purpose | Cues/loops and variants | Constraints and acceptance | Status | |---|---|---|---|---|---| -| `opening-soundscape` | `copyright` and all five opening story scenes | Make the dormant-to-Oracle reveal audible and give the credits intentional restraint. | Archival tick, source pulse, archive answer, vault branches, convergence, Oracle cadence, clean tail. | Constrained chip-style palette; starts near silent; fullest arrangement only at the crescendo; reduced-audio variant. | Proposed | -| `ui-soundscape` | `title`, `quiz-menu`, `character-creation` | Make focus, choice, cancel, customization, and confirmation instantly legible. | Navigate, confirm, cancel, unavailable, trait variants, begin-run cadence. | One input edge produces at most one cue; no cue crosses scenes unintentionally. | Proposed | -| `oracle-soundscape` | `oracle` | Separate Datafall play from truthful loading state without overwhelming it. | Low ambience; data, bug, retry, ready, and B-back cues; three progression-tier variants. | Loops stop on quiz/menu transition; status remains readable when muted. | Proposed | -| `quiz-soundscape` | `quiz` | Clarify cursor movement, answer commitment, result, danger, and batch completion. | Cursor, commit, correct, wrong, low-ward, flow-stage, batch-complete cues. | Correct/wrong never rely on sound alone; prevent stacked result cues. | Proposed | -| `progression-soundscape` | `level-up`, `game-over` | Make thresholds, earned tier, defeat, and reset feel conclusive. | Telegraph, reward cadence per tier, defeat fall, score hold, replay/reset cadence. | Reward arrangement grows by tier; new run audibly returns to Initiate. | Proposed | +| `opening-soundscape` | `copyright` and all five opening story scenes | Make the dormant-to-Oracle reveal audible and give the credits intentional restraint. | Archival tick, source pulse, archive answer, vault branches, convergence, Oracle cadence, clean tail. | Constrained chip-style palette; starts near silent; fullest arrangement only at the crescendo; reduced-audio variant. | Implemented: dry noise ticks on the four chronicle reveals; voices grow 1 → 2 → 3 → 3 → 4 and peak volume rises across the five beats; every beat tails before its scene exits; a skip cuts the fanfare and the title loop waits 30 ticks. The volume wheel's MUTE/LOW detents are the reduced-audio variant. | +| `ui-soundscape` | `title`, `quiz-menu`, `codex`, `character-creation` | Make focus, choice, cancel, customization, paging, and confirmation instantly legible. | Navigate, confirm, cancel, unavailable, page turn, trait variants, begin-run cadence. | One input edge produces at most one cue; no cue crosses scenes unintentionally. | Implemented: title and menu loops; name, path, and aura each answer on their own voice or duty; the Oracle motif is reserved for the bind cadence; the Codex has no loop under reading, pages turn on alternating pitches, opening it confirms and leaving it cancels; presses that change nothing, including A/Start in the Codex, get a soft unavailable cue; engine tests script every edge. | +| `oracle-soundscape` | `oracle` | Separate Datafall play from truthful loading state without overwhelming it. | Low ambience; data, bug, retry, ready, and B-back cues; three progression-tier variants. | Loops stop on quiz/menu transition; status remains readable when muted. | Implemented: the Datafall loop has two, three, and four voices at Initiate, Adept, and Oracle-bound; the 3/6/9 charge and 1/3/5 seal thresholds replace the plain data and bug cues; retry, ready, and leave derive from the truthful status line; B-back cuts the loop on its exit tick. | +| `quiz-soundscape` | `quiz` | Clarify cursor movement, answer commitment, result, danger, and batch completion. | Cursor, commit, correct, redeemed, lens-wake, wrong, low-ward, flow-stage, leave-warning, batch-complete cues. | Correct/wrong never rely on sound alone; prevent stacked result cues. | Implemented: every result opens with the same commit click, then plays exactly one variant (lens wake, a rising two-note D-major motif that climbs with the stage, over Insight rune awaken over redeemed over flow-stage over correct; ward break over low-ward over wrong); a heartbeat bed runs on the last ward; the first B of a leave plays a warning; the lesson card is silent and its ignored presses get no unavailable cue; continuing plays the question reveal; a new cue cancels the previous cue's remaining notes. | +| `progression-soundscape` | `level-up`, `game-over` | Make thresholds, earned tier, defeat, and reset feel conclusive. | Telegraph, reward cadence per tier, defeat fall, score hold, replay/reset cadence. | Reward arrangement grows by tier; new run audibly returns to Initiate. | Implemented: batch-complete telegraph, then a two/three/four-voice reward cadence that resolves inside the 60-tick hold; tier-specific continuation; defeat fall and a quiet score-hold chord; the replay cadence always uses the thin Initiate timbre. | ## Whole-game polish matrix | Scene | Static | Motion | Sound | Mechanical closure | Felt progression | Evidence/status | |---|---|---|---|---|---|---| -| `copyright` | Dim archive frame with hierarchy equal to gameplay; no glow. | Credits reveal in fixed readable steps, then clear cleanly. | Intentional near-silence; archival ticks remain proposed. | A/Start after minimum dwell or timed exit; missing provenance has explicit copy. | Establishes the dormant baseline and repository identity. | Visual template implemented and native-frame tested; audio proposed. | -| `opening-fanfare` | Lone code-seer, dormant cathedral depth, and one cyan ember establish a clear focal hierarchy. | A 1.6-second held establishing shot gives the story and skip gate time to read. | One source pulse remains proposed. | Elapsed enters `archive-answer`; A/Start becomes visible at 1.5 seconds and skips to title. | Establishes the darkest authored opening baseline. | Dedicated native plate and executable transitions implemented/tested; audio proposed. | -| `archive-answer` | Same code-seer and cathedral, with lit floor paths and waking monolith silhouettes. | The composition itself advances causality; staged cut avoids a full-frame flash. | Altar response remains proposed. | Elapsed enters `memory-vault`; A/Start skips to title. | Cyan expands into a second visual channel: environment response. | Dedicated native plate and executable transitions implemented/tested; audio proposed. | -| `memory-vault` | Foreground doors, bridge, and deep commit constellation create a new three-plane composition. | Staged cut moves the player deeper into the archive. | Branching commit sequence remains proposed. | Elapsed enters `convergence`; A/Start skips to title. | Space, node density, and narrative scale visibly expand. | Dedicated native plate and executable transitions implemented/tested; audio proposed. | -| `convergence` | Circular chamber, same code-seer, opposing cyan/gold streams, and incomplete dark eye. | Staged cut turns propagation into convergence without spending maximum white. | Two-voice convergence remains proposed. | Elapsed enters `oracle-awakening`; A/Start skips to title. | Gold joins cyan and the goal is visibly telegraphed. | Dedicated native plate and executable transitions implemented/tested; audio proposed. | -| `oracle-awakening` | Existing high-detail awakening composition becomes the earned final image. | Center luminance rises during the final hold, then clears cleanly to title. | Full Oracle cadence remains proposed. | Elapsed or A/Start lands on a fresh title frame. | Reaches the opening's brightest/densest state after four distinct scenes. | Existing plate retained; five-frame distinctness and luminance arc implemented/tested; audio proposed. | -| `title` | Restrained Oracle motif and legible title at native scale. | One controlled eye/prompt pulse never competes with title. | Title loop and start cue remain proposed. | A/Start continues; unavailable cartridge state remains honest. | Returns to an Initiate baseline while preserving the Oracle promise. | Visual template implemented/tested; audio proposed. | -| `quiz-menu` | Rune frame and shaped focus for both choices. | Focus moves on input edge. | Navigate, confirm, and cancel cues remain proposed. | Begin and back are explicit; held input cannot double-confirm. | New run previews the Initiate palette and reset. | Visual template implemented/tested; audio proposed. | -| `character-creation` | Authored hero, pairwise-disjoint identity rows, a grounded stage placement, centered action copy, and Oracle status form one staged composition. | Aura changes the authored colorway; identity rows react immediately; begin has one clean handoff. | Trait and begin cues remain proposed. | Every row stays centered and contained; B returns; loading/retry/ready states are truthful. | Establishes identity that remains visible across the run. | Native interior, support-line, and sibling-bound assertions implemented; audio proposed. | -| `oracle` | Sanctum, playfield, status, raw counts, themed charge/containment runes, controls, and tier crest remain distinct. | Exact 3/6/9 gains light charge runes; 1/3/5 hits break containment runes; Datafall and status retain owned exits. | Ambience and threshold cues remain proposed. | Questions-ready enters quiz; B abandons safely; all other controls are intentionally inactive. | Clean play preserves seals while collection fills runes; bond visuals retain all three tiers. | Native meter layout and exact first-breakpoint frame changes implemented/tested; audio proposed. | -| `quiz` | Question, choices, hero token, ward runes, flow multiplier, Insight Rune meter, raw score, and tier frame remain readable; answer copy clears every ornament. | Cursor, commit, 45-tick review, ward loss, x2/x3 flow, 300/900/1800 rune crossings, and batch threshold have causal timing. | Quiz cues remain proposed. | Green/red answer copy plus ward/flow/rune banners appear without redundant result words; every automatic outcome routes visibly. | Score reward changes mechanically at streak thresholds and earned runes persist into results. | HUD siblings, ward states, exact scoring breakpoints, and choice/plate bounds implemented/tested; audio proposed. | -| `level-up` | Hero and newly expanded crest dominate; level text supports rather than carries reward. | Crest growth → hero rise → one-second hold → continue; stable background avoids flashing. | Reward cadence remains proposed. | A/Start is inactive for 60 ticks, then routes to ready quiz or Oracle wait. | Explicit threshold celebration establishes the new visual baseline. | Visual template/progression implemented/tested; audio proposed. | -| `game-over` | Defeated hero, final score, Insight Rune rank, level, and earned crest share one conclusive frame. | Energy recedes without erasing earned bond or score rank; replay resets both on the next run. | Defeat/result/reset cues remain proposed. | The visible A/B/Start prompt returns to menu; the next new run resets all run state. | Shows the exact bond and Insight Rune stages reached before reset. | Visual template/progression and score-rune result implemented/tested; audio proposed. | +| `copyright` | Dim archive frame with hierarchy equal to gameplay; no glow. | Credits reveal in fixed readable steps, then clear cleanly. | Intentional near-silence with one dry archival tick per reveal. | A/Start after minimum dwell or timed exit; missing provenance has explicit copy. | Establishes the dormant baseline and repository identity. | Visual template implemented and native-frame tested; audio implemented/tested. | +| `opening-fanfare` | Lone code-seer, dormant cathedral depth, and one cyan ember establish a clear focal hierarchy. | A 1.6-second held establishing shot gives the story and skip gate time to read. | One source pulse and its echo on a single voice. | Elapsed enters `archive-answer`; A/Start becomes visible at 1.5 seconds and skips to title. | Establishes the darkest authored opening baseline. | Dedicated native plate and executable transitions implemented/tested; audio implemented/tested. | +| `archive-answer` | Same code-seer and cathedral, with lit floor paths and waking monolith silhouettes. | The composition itself advances causality; staged cut avoids a full-frame flash. | The altar answers the pulse a fifth above on a second voice. | Elapsed enters `memory-vault`; A/Start skips to title. | Cyan expands into a second visual channel: environment response. | Dedicated native plate and executable transitions implemented/tested; audio implemented/tested. | +| `memory-vault` | Foreground doors, bridge, and deep commit constellation create a new three-plane composition. | Staged cut moves the player deeper into the archive. | Two echoed commit-branch arpeggios over a vault bass. | Elapsed enters `convergence`; A/Start skips to title. | Space, node density, and narrative scale visibly expand. | Dedicated native plate and executable transitions implemented/tested; audio implemented/tested. | +| `convergence` | Circular chamber, same code-seer, opposing cyan/gold streams, and incomplete dark eye. | Staged cut turns propagation into convergence without spending maximum white. | A descending and a rising pulse meet in unison over the bass. | Elapsed enters `oracle-awakening`; A/Start skips to title. | Gold joins cyan and the goal is visibly telegraphed. | Dedicated native plate and executable transitions implemented/tested; audio implemented/tested. | +| `oracle-awakening` | Existing high-detail awakening composition becomes the earned final image. | Center luminance rises during the final hold, then clears cleanly to title. | The full four-voice Oracle cadence, tailing before the title. | Elapsed or A/Start lands on a fresh title frame. | Reaches the opening's brightest/densest state after four distinct scenes. | Existing plate retained; five-frame distinctness and luminance arc implemented/tested; audio implemented/tested. | +| `title` | Restrained Oracle motif and legible title at native scale. | One controlled eye/prompt pulse never competes with title. | A restrained title loop after a clean breath; one confirm cue. | A/Start continues; unavailable cartridge state remains honest. | Returns to an Initiate baseline while preserving the Oracle promise. | Visual template implemented/tested; audio implemented/tested. | +| `quiz-menu` | Rune frame and shaped focus for both choices. | Focus moves on input edge. | Menu bed plus navigate, confirm, cancel, and unavailable cues. | Begin and back are explicit; held input cannot double-confirm. | New run previews the Initiate palette and reset. | Visual template implemented/tested; audio implemented/tested. | +| `codex` | Archive frame holds five lens rows and totals; lesson pages hold counter, lens runes, question, answer, and rationale in bounded panels; a sealed pending page holds the reveal prompt, the pick, and a three-row misconception in the same panels. | Page turns are instant input-edge cuts; there is no idle animation to compete with reading. | Entry confirm, alternating page-turn cues, a soft reveal cue when A unseals a pending answer, unavailable cue for A/Start elsewhere, and back cancel; no ambience competes with reading. | B returns to the menu; A/Start reveal a sealed pending answer and are otherwise inactive; paging wraps and reseals; the empty journal has explicit guidance. | Runes wake at 1/3/5 evidence per lens, crack while an accuracy or pending-review gate holds them back, and pending reviews clear as misses are answered correctly; a correct answer after a reveal is relearning, not evidence. | Visual template, legacy renderer, and breakpoint tests implemented; page and back cues implemented. | +| `character-creation` | Authored hero, pairwise-disjoint identity rows, a grounded stage placement, centered action copy, and Oracle status form one staged composition. | Aura changes the authored colorway; identity rows react immediately; begin has one clean handoff. | Per-row trait timbres and the Oracle-motif begin cadence. | Every row stays centered and contained; B returns; loading/retry/ready states are truthful. | Establishes identity that remains visible across the run. | Native interior, support-line, and sibling-bound assertions implemented; audio implemented/tested. | +| `oracle` | Sanctum, playfield, status, the Oracle line (a failure reason with its retry countdown, or one recalled lesson), raw counts, themed charge/containment runes, controls, and tier crest remain distinct. | Exact 3/6/9 gains light charge runes; 1/3/5 hits break containment runes; a newly recalled lesson types in at two characters per tick and appears whole under reduced motion; Datafall and status retain owned exits. | Tier-grown Datafall ambience; data, bug, rune, seal, retry, ready, and leave cues. | Questions-ready enters quiz; B abandons safely; all other controls are intentionally inactive. | Clean play preserves seals while collection fills runes; bond visuals retain all three tiers. | Native meter layout and exact first-breakpoint frame changes implemented/tested; Oracle line containment and reveal tested (`the_oracle_line_stays_contained_disjoint_and_readable`, `recalled_lessons_are_written_in_but_stay_static_under_reduced_motion`); audio implemented/tested. | +| `quiz` | Batch progress (`TRIAL P/N` or amber `RETRY P/N`), question, choices, hero token, ward runes, flow multiplier, Insight Rune meter, raw score, and tier frame remain readable; answer copy clears every ornament; the lesson card holds worst-case rationales, `-`/`+` verdict markers, and the lens-rune footer inside one panel. | Cursor, commit, 45-tick lesson-card hold, ward loss, x2/x3 flow, 300/900/1800 rune crossings, and batch threshold have causal timing. | Cursor, one commit-and-result cue (including redeemed and lens wake), leave warning, last-ward heartbeat, question reveal; the lesson card itself is silent. | The lesson card shows the misconception and answer rationales under the lens-rune/Insight/`REDEEMED`/flow/ward banner, with a miss's return note on the footer, then waits for A/Start; B leaves only on a second press within 90 ticks; every automatic outcome routes visibly. | Score reward changes mechanically at streak thresholds, lens runes wake on the card footer, and earned runes persist into results. | HUD siblings, ward states, exact scoring breakpoints, choice/plate bounds, and lesson-card containment and contrast implemented/tested; audio implemented/tested. | +| `level-up` | Hero and newly expanded crest dominate; level and first-try recap text support rather than carry reward, and the hold footer names the next lenses. | Crest growth → hero rise → one-second hold → continue; stable background avoids flashing. | Batch telegraph, tier-grown reward cadence, tier continuation cue. | A/Start wait for the manifest's 60-tick gate, then route to a ready quiz or the Oracle wait; the scene continues on its own at 180 ticks. | Explicit threshold celebration establishes the new visual baseline. | Visual template/progression implemented/tested; audio implemented/tested. | +| `game-over` | Defeated hero, final score, Insight Rune rank, level, earned crest, and the run's learning ledger share one conclusive frame. | Energy recedes without erasing earned bond or score rank; replay resets both on the next run. | Defeat fall, score-hold chord, Initiate replay cadence. | The steadily lit A/B/Start prompt returns to menu; the next new run resets all run state, including the ledger. | Shows the exact bond and Insight Rune stages reached before reset. | Visual template/progression and score-rune result implemented/tested; audio implemented/tested. | + +Reduced motion applies to every row above. The shell forwards the system's +`prefers-reduced-motion` preference, and the engine then freezes its decorative +motion clock: blinking prompts stay lit, heroes stop bobbing, starfields stop +scrolling, and settling motions show their end state. Scene timing, timing +gates, input, sound, and data are unchanged, so each row's information and +duration survive the reduced variant. There is no in-game toggle. + +The screen transcript also applies to every row. The engine +(`src-tauri/src/engine/transcript.rs`) derives plain-language sentences from +the same state the renderers use, and the shell places them in a visually +hidden, polite live region through the `engine_transcript` command. The +transcript is republished only when its words change, so decorative motion is +never announced: a new screen is read whole, and a change within a screen, +such as focus moving to another choice, is read on its own. A new scene or +handler needs its own transcript sentences, which is engine work. ## Runtime traceability @@ -359,22 +578,26 @@ engine does not load or play them yet. |---|---|---| | Manifest title and `quiz`/`quest` type | Implemented | Parsed at cartridge load and used by the engine. | | Scene graph | Configured/executable | Schema-v2 handlers and semantic transitions are validated, compiled, and executed by the engine. | -| Mechanic and presentation graph | Mixed | Mechanics and audio remain metadata; typed visual templates are parsed, validated, and executed by scene renderers. | +| Mechanic and presentation graph | Mixed | Mechanics and `kind = "audio"` art entries remain validated metadata: the engine derives sound from state per trusted handler, and no manifest field selects or changes it. Typed visual templates are parsed, validated, and executed by scene renderers. | | First question request at cartridge acceptance | Implemented | Empty quiz cartridges call the question effect immediately when inserted. | +| Question generation | Implemented | Each request carries the anonymized project brief (`repo_context.rs`: README, design notes, manifest summary, and up to 30 `COMPONENT n` skeletons with every path withheld) and the learner state (weakest lens, up to 24 earlier stems) to the selected CLI on stdin under a provider-aware process-tree deadline (150 s for Claude, 300 s for Codex, or `CQA_AI_TIMEOUT_SECS`). Payload v2 requires a known lens and a fitting, location-free rationale for every choice; when a delivery falls short, a question whose only failures are mechanical (question, choice, or rationale length, a missing rationale, an unknown lens, or non-ASCII text) gets one repair call through the same CLI, which uses what is left of the same deadline and is skipped when less than 10 seconds remain, so one request costs at most two CLI calls. A repair counts only if it keeps its answer index and passes the full policy. Trivia, malformed, and unrepaired questions are dropped individually, and a short delivery tops the open batch up to a full six. | +| Level focus and transfer prompts | Implemented in the prompt | `Concept::focus_for_level` asks for at least four of six questions on the level's focus lenses, and level 4+ asks for at least two PREDICT questions. Acceptance checks that each question names a known lens; it does not enforce the focus share or the `PREDICT:` prefix. | | Repository authors, timeline, and explicit copyright extraction | Implemented | Cartridge preparation reads sanitized git shortlog/history data and scans bounded LICENSE/COPYRIGHT/NOTICE files. Commit authors are never treated as legal owners. | | Copyright and five-scene opening story | Implemented with asset-backed templates | Trusted Bevy handlers render the chronicle, source ember, archive answer, memory vault, convergence, and Oracle awakening before `Title`; manifest timing gates control per-scene auto-advance and direct skip while fanfare/title frames remain separate. | | Title, menu, hero creation, Oracle, quiz, level-up, and game-over screens | Implemented | Trusted handlers own input and rendering while the manifest routes their semantic events. | | First request, prefetch, invalid-batch retry, and Oracle hold | Implemented | Engine question effects, pending batches, and retry timer. | -| Oracle Datafall with safe recovery | Implemented in this pass | Held movement, authored drops, automatic counters, 3/6/9 charge runes, 1/3/5 breakable containment runes, split HUD, and B-back close the indefinite wait. | -| Themed run instrumentation and score thresholds | Implemented in this pass | Oracle ward glyphs replace stars; x1/x2/x3 flow changes score awards; 300/900/1800 Insight Runes change HUD and review feedback; exact breakpoints and native sibling bounds are tested. | +| Oracle Datafall with safe recovery | Implemented | Held movement, authored drops, automatic counters, 3/6/9 charge runes, 1/3/5 breakable containment runes, split HUD, and B-back close the indefinite wait. | +| Themed run instrumentation and score thresholds | Implemented | Oracle ward glyphs replace stars; x1/x2/x3 flow changes score awards; 300/900/1800 Insight Runes change HUD and review feedback; exact breakpoints and native sibling bounds are tested. | | Safe Oracle-to-quiz input boundary | Implemented | A/Start are ignored in Oracle; B exits to the menu; held D-pad controls have no answer action after the automatic transition. | -| Quiz result and reward input boundaries | Implemented in this pass | The 45-tick answer reveal replaces active controls, and level-up enforces a 60-tick hold before A/Start can leave. | -| Answered-question continuity | Implemented | Answer commitment records question text under `quiz.progress`; cartridge reload compares normalized text and filters recorded questions while serialized save updates preserve the independent AI-batch namespace and read the legacy Claude key. | -| Truthful multi-state Oracle presentation | Implemented with asset-backed templates | Loading, retry, and ready copy derives from actual engine state; B provides recovery from a permanently unavailable generator. A dedicated disabled explanation remains future work. | -| Concise answer review and reduced motion | Partially implemented | Green/red answer copy, shaped focus, stable level-up, and staged opening motion are implemented; a user-selectable reduced-motion setting remains proposed. | -| Visual templates selected from manifest | Implemented | Eleven typed built-in templates are selected by `art[].template`; `oracle-awakening` selects five art-ID-addressed opening plates, other Oracle templates composite their native illustrated plates and live state, unknown names fail validation, and untemplated cartridges keep their legacy renderers. | -| Whole-game sound design and playback | Configured/metadata; runtime proposed | Every scene references an audio requirement, but the current engine has no sound asset selection or playback system. | -| Felt presentation progression | Visual runtime implemented; audio proposed | Initiate, Adept, and Oracle-bound change palette, circuit density, crest geometry, and reward/result presentation; native-frame tests verify a non-numeric final-tier channel. | +| Quiz result and reward input boundaries | Implemented | The 45-tick lesson hold replaces active controls and then waits for A/Start; B leaves an active question only through a 90-tick confirmation. Level-up continuation asks the scene graph (`can_signal`), so the manifest's `after_ticks` sets both the input hold and the continue prompt (60 ticks here); the scene continues on its own at 180 ticks. | +| Lesson card, shuffled choices, and spaced retry | Implemented | Committed answers show the misconception and answer rationales in a composed lesson panel; display order follows `learning::presentation_order`; a survivable miss inserts a review copy after exactly `RETRY_GAP` (3) other questions, carrying into the next batch when needed; mastery and the lesson journal update on every commitment. Native layout, contrast, and flow tests cover worst-case copy (`lesson_cards_render_the_misconception_and_the_answer`, `a_missed_concept_is_journaled_retried_redeemed_and_reread_in_the_codex`). | +| Learner persistence | Implemented | Each commitment updates `quiz.progress` in one atomic save update: a first-try or spaced success moves the question to the retired list, a same-launch retry success to the relearned list, and a miss to the missed list (the latest attempt decides), and lens mastery records first-try, redeemed, relearned, and missed counts plus the newest eight graded outcomes. Cartridge load drops retired questions, queues missed and relearned ones as spaced reviews, journals both, and plays lower-level batches first. Serialized updates preserve the independent AI-batch namespace; answered lists from earlier builds load as retired, and the legacy Claude batch key is still read. | +| Truthful multi-state Oracle presentation | Implemented with asset-backed templates | Loading, retry, and ready copy derives from actual engine state; B provides recovery from a permanently unavailable generator. The Oracle line names each failure's category, such as `AI DISABLED`, `TIMED OUT`, or `REJECTED BATCH`, with a retry countdown, and otherwise recalls journal lessons (`failure_reasons_are_sanitized_to_one_short_device_line`, `the_oracle_line_stays_contained_disjoint_and_readable`). | +| Concise answer review and reduced motion | Implemented | Green/red lesson copy with `-`/`+` markers, shaped focus, stable level-up, and staged opening motion are implemented. The shell forwards the system's `prefers-reduced-motion` preference (`engine_set_reduced_motion`), and the engine freezes decorative motion while scene timing, input, and data stay identical (`reduced_motion_freezes_decorative_motion_but_keeps_scene_timing`). There is no in-game toggle. The engine's screen transcript (`engine/transcript.rs`, published through `engine_transcript` into a polite live region) reads every screen to screen readers, whole on entry and then only its changes (`a_new_screen_is_read_whole_and_a_focus_move_reads_only_the_new_focus`, `every_screen_has_a_transcript_that_does_not_panic`). | +| Visual templates selected from manifest | Implemented | Twelve typed built-in templates are selected by `art[].template`; `oracle-awakening` selects five art-ID-addressed opening plates, other Oracle templates composite their native illustrated plates and live state, unknown names fail validation, and untemplated cartridges keep their legacy renderers. | +| Oracle Codex lesson journal | Implemented | The `codex` handler and `open-codex` menu signal route the menu to a mastery-and-lesson journal whose pending pages are sealed self-tests; menus without the route keep `RETURN TO TITLE`. | +| Whole-game sound design and playback | Implemented | The engine's audio director derives every cue and scene loop from per-tick state snapshots and publishes tick-stamped chip notes through the bounded `engine_audio` queue; the shell speaker plays them behind a gesture-gated context and the persisted volume wheel. Scenes still select audio by trusted handler, not by manifest template. | +| Felt presentation progression | Implemented | Initiate, Adept, and Oracle-bound change palette, circuit density, crest geometry, reward/result presentation, and the voice count of the Datafall and reward arrangements; native-frame and audio tests verify non-numeric final-tier channels. | ## Implementation slices @@ -390,36 +613,57 @@ engine does not load or play them yet. 4. **Completed — FSM closure pass:** Add Oracle B-back recovery, own the quiz answer-review lock, enforce the level-up hold, display every accepted game-over input, and test runtime/template/manifest routes. -5. **Partially completed — Feedback/accessibility pass:** Correctness labels, - shaped focus, staged motion, and native-scale assertions are implemented; a - user-facing reduced-motion setting remains. -6. **Continuity pass:** Show the previous lesson and batch status during a wait - using state the engine already owns. -7. **Completed — Whole-game presentation pass:** Eleven typed built-in visual - templates cover all thirteen reachable scenes, beginning with the dormant +5. **Completed — Feedback/accessibility pass:** Correctness labels and + markers, shaped focus, staged motion, native-scale assertions, reduced + motion that follows the system preference, and a screen transcript for + screen readers are implemented. +6. **Completed — Continuity pass:** The waiting Oracle's second header line + uses state the engine already owns: while a failed request waits out its + retry delay it names the failure with a `RETRY IN NS` countdown, and + otherwise it recalls journal lessons every 240 ticks, outstanding misses + first (`REVIEW`), then cleared lessons (`RECALL `). Reduced motion + shows each lesson whole instead of typing it in. Batch progress stays on + the quiz header (`TRIAL P/N`). +7. **Completed — Whole-game presentation pass:** Twelve typed built-in visual + templates cover all fourteen reachable scenes, beginning with the dormant `copyright-card` and culminating in the fifth opening beat's Oracle crescendo. -8. **Sound runtime pass:** Add bounded template-audio selection and playback, - then implement every entry in the sound ledger with scene-owned loop exits. -9. **Partially completed — Felt-progression pass:** Tiered visuals carry the - crest, palette, circuit density, and hero identity across Oracle, quiz, - reward, and results; tiered audio remains part of the sound runtime pass. +8. **Completed — Sound runtime pass:** An engine-owned chip director implements + every entry in the sound ledger with scene-owned loop exits, one cue per + input edge, and tier-grown arrangements; manifest-selected audio templates + remain future work. +9. **Completed — Felt-progression pass:** Tiered visuals carry the crest, + palette, circuit density, and hero identity across Oracle, quiz, reward, and + results, and the Datafall and reward arrangements gain a voice per tier. 10. **Completed — Executable scene graph:** Add schema v2 handlers, semantic transitions, timing gates, reachability validation, built-in quiz/quest templates, and schema-v1 compatibility. -11. **Completed — Answered-question continuity:** Record committed questions in - the cartridge save, filter them on later runs and launches, and serialize - namespace updates so background batch writes cannot erase progress. +11. **Completed — Answered-question continuity:** Record committed answers in + the cartridge save, retire correct answers for later runs and launches, + keep misses queued for review until redeemed, and serialize namespace + updates so background batch writes cannot erase progress. 12. **Completed — Instrument and threshold pass:** Replace generic health pips with Oracle ward runes; attach exact stages to flow, score, Datafall charge, corruption containment, batches, and bond; verify native layout and exact breakpoint transitions. +13. **Completed — Learning-model pass:** Tag questions with concept lenses and + per-choice rationales (payload v2), replace the answer review with the + lesson card, shuffle choices per question and attempt, schedule spaced + retries inside the batch, record lens mastery and the lesson journal, add + the Oracle Codex, deepen lens focus by level with PREDICT transfer at level + 4+, and adapt each request to the weakest lens and earlier stems. ## Open decisions -- Should the generated question payload eventually include a short explanation, - or is revealing the correct choice enough feedback at this resolution? -- Beyond answered-question history, should score, wards, hero identity, or - presentation tier persist per cartridge across launches? -- Should B from an active quiz abandon immediately, or use an in-scene - confirmation state before leaving the run? +- Beyond learner progress (retired and missed questions, lens mastery), should + score, wards, hero identity, or presentation tier persist per cartridge + across launches? +- Rune III now requires no pending review and 80% recent accuracy, so three + lit runes already mean mastered. Should a lens also need transfer evidence + (a correct PREDICT answer) before its third rune lights? +- Should acceptance enforce the level's focus-lens share and the PREDICT count, + rejecting or topping up a batch that misses them, or keep both as prompt + guidance? + +Resolved: every choice carries a short rationale shown on the lesson card, and +B from an active quiz uses an in-scene 90-tick confirmation before leaving. diff --git a/docs/reference/codequest-toml.md b/docs/reference/codequest-toml.md index e3c0dcf..f8624d6 100644 --- a/docs/reference/codequest-toml.md +++ b/docs/reference/codequest-toml.md @@ -24,7 +24,7 @@ cannot drift without failing verification. | `scenes[].handler` | Selects a trusted Rust scene implementation. It does not load code from the cartridge. | | `scenes[].transitions` | Routes semantic engine signals to target scenes, with optional timing gates. | | `mechanics` | Validated design requirements referenced by scenes; they do not dynamically create code. | -| `art` | Validated production requirements. An optional typed `template` selects a safe renderer asset built into CODE QUEST; entries without it remain metadata. | +| `art` | Validated production requirements. An optional typed `template` selects a safe renderer asset built into CODE QUEST; entries without it remain metadata. There is no audio template: sound is derived by the engine from game state for each trusted handler, and `kind = "audio"` entries record its production requirements. | Without `CODEQUEST.toml`, the engine builds the same finite-state machine from its quiz or quest template. A repository with `CODEQUEST.md` uses the legacy @@ -84,8 +84,11 @@ target = "game-over" Each signal may appear at most once per scene. A transition may set `after_ticks` to prevent that signal from advancing until the scene has been -active for the given number of 60 fps ticks. The `elapsed` signal is generated -by the scene clock and always requires `after_ticks`: +active for the given number of 60 fps ticks. Handlers that draw a continue or +skip prompt (`repository-credits`, `opening-fanfare`, and `level-up`) show it +only once the gated signal would be accepted, so `after_ticks` also times the +prompt. The `elapsed` signal is generated by the scene clock and always +requires `after_ticks`: ```toml [[scenes.transitions]] @@ -109,12 +112,13 @@ Cycles and branches are allowed. Every declared scene must be reachable from | `repository-credits` | shared | `continue`, `elapsed` | | `opening-fanfare` | shared | `continue`, `elapsed` | | `title` | shared | `continue` | -| `quiz-menu` | quiz | `new-run`, `back` | +| `quiz-menu` | quiz | `new-run`, `open-codex`, `back` | | `character-creation` | quiz | `hero-ready`, `back` | | `oracle` | quiz | `questions-ready`, `back` | | `concept-quiz` | quiz | `needs-question`, `batch-complete`, `hearts-empty`, `back` | | `level-up` | quiz | `questions-ready`, `needs-question` | | `game-over` | quiz | `replay` | +| `codex` | quiz | `back` | | `quest-select` | quest | `quest-selected`, `back` | | `battle` | quest | `victory`, `defeat` | | `victory` | quest | `continue` | @@ -124,6 +128,44 @@ A transition using a signal that its handler cannot emit is rejected at cartridge load time. Quiz-only handlers are rejected in quest games and vice versa. +The quiz menu's second option follows its routes. When the current menu scene +declares an `open-codex` transition, that option reads `OPEN THE CODEX` and +emits `open-codex`; otherwise it keeps `RETURN TO TITLE` and emits `back`. B +always emits `back`, so a manifest without a Codex route behaves exactly as it +did before the Codex existed. With the route, the menu subtitle also summarizes +the lesson journal (for example `LESSONS 07 REVIEW 02`). + +The `codex` handler is a lesson journal that never changes mastery or the +question deck. Its first page shows every +concept lens with three mastery runes that wake at exactly 1, 3, and 5 pieces +of evidence (first-try successes plus misses redeemed in a later launch) and an +amber count of that lens's lessons awaiting review. Rune II also needs 60% of +the lens's newest five graded answers correct, and rune III 80% plus no pending +review; a rune those gates hold back is drawn cracked, and the totals row then +reads `CRACKED = REVIEW DUE` while a cracked lens has a pending review, or +`CRACKED = LOW ACCURACY` while only recent accuracy holds its runes back. +Each later page shows one lesson, oldest first: lens, question, and whether a +review is pending; a learned page adds the correct answer and rationale. A +pending lesson, or a relearned lesson whose spaced check is still due, is a +self-test: it shows the player's wrong pick and the misconception it reveals, +and seals the answer. A reveals a sealed lesson's answer; revealing before the +review or spaced check counts the next correct answer as relearning, which is +saved but is not evidence. Left, Up, and L page back; +Right, Down, and R page forward; paging wraps between the mastery page and the +newest lesson and seals the page again. A and Start are inactive on every +other page, and B emits `back`. An empty journal renders an explicit +`NO LESSONS YET` state. The built-in quiz template (used without a manifest) +includes `quiz-menu` → `codex` → `quiz-menu`. + +The `concept-quiz` handler shows each question's choices in a stable shuffled +order. Committing an answer opens a lesson card with the committed choice's +rationale and, after a miss, the correct answer's rationale; the card ignores +input for 45 ticks and then waits for A or Start. A survivable miss returns as a +review copy after exactly three other questions; a copy that would land past +the batch end joins the next batch instead, so `batch-complete` is emitted on +time and the retry gap never shrinks. These timings are engine behavior, not +manifest fields. + ## Mechanics A mechanic captures reusable rules and feedback. These fields are currently @@ -169,16 +211,21 @@ The built-in Oracle template catalog is: - `oracle-ascension` - `oracle-aftermath` - `oracle-progression` +- `oracle-codex` Scene handlers activate a visual template only when the current scene references -the corresponding art entry. `oracle-hero` and `oracle-progression` are shared +the corresponding art entry. `oracle-codex` composites the Codex mastery page on +the dormant archive plate and each lesson page on the trial-chamber plate; a +Codex scene without it uses the plain legacy renderer. `oracle-hero` and +`oracle-progression` are shared systems: once selected by the cartridge, they preserve the chosen hero and Initiate/Adept/Oracle-bound visual tier across the scenes that use them. Unknown template names are rejected instead of falling back silently. -Renderer changes can emit all thirteen reachable Oracle scenes—including the -five-beat opening story—as native 240×160 PPM files for visual review without -packaging the application: +Renderer changes can emit all fourteen reachable Oracle scenes—including the +five-beat opening story and the Codex mastery, sealed-lesson, and lesson +layouts—as native 240×160 PPM files +for visual review without packaging the application: ```bash CQA_VISUAL_PREVIEW_DIR=/tmp/codequest-previews \ @@ -186,6 +233,30 @@ CQA_VISUAL_PREVIEW_DIR=/tmp/codequest-previews \ oracle_templates_produce_nine_distinct_native_scene_frames --lib ``` +With the same variable, `lesson_cards_render_the_misconception_and_the_answer` +writes the quiz lesson-card states, and +`dogfood_manifest_routes_its_menu_into_the_templated_codex`, +`empty_codex_says_no_lessons_yet_and_cannot_page`, and +`codex_lesson_pages_show_the_answer_and_mark_outstanding_reviews` write the +journal menu and the remaining Codex states. The remaining writers cover newer +surfaces: + +- `the_header_counts_the_batch_and_labels_the_returning_retry`: batch progress, + the `BACK IN` note, and the `RETRY` header, templated and legacy. +- `waking_a_lens_rune_banners_blinks_and_sounds_above_insight`: the lens-rune + wake banner. +- `the_legacy_lesson_footer_carries_a_woken_rune_iii_banner_readably`: the + legacy lesson footer under a rune III banner. +- `codex_mastery_runes_crack_at_the_exact_gate_breakpoints_on_the_rendered_frame` + and `the_lesson_footer_meter_shows_a_cracked_rune_for_an_open_miss`: cracked + mastery runes on the Codex and the trial lesson footer. +- `the_oracle_line_stays_contained_disjoint_and_readable`: the Oracle recall + and failure line, templated and legacy. +- `recalled_lessons_are_written_in_but_stay_static_under_reduced_motion`: a + recalled lesson typing in on the Oracle line. +- `the_result_prompt_stays_lit_on_every_frame`: the Ascension and Aftermath + learning debriefs, templated and legacy. + ## Schema v1 compatibility Schema v1 remains supported so existing cartridges continue to load. In v1, diff --git a/docs/reference/save-format.md b/docs/reference/save-format.md new file mode 100644 index 0000000..498b020 --- /dev/null +++ b/docs/reference/save-format.md @@ -0,0 +1,534 @@ +# Cartridge save format + +This is the reference for the save file CODE QUEST ADVANCE keeps for each +cartridge (a git repository loaded as a game). It describes the format as +implemented in `src-tauri/src/save.rs`, `questions.rs`, `learning.rs` and +`lib.rs` on branch `claude/codequest-opus-improvements-0069d2`. Where a +behavior follows from the code but no test covers it, the text says so. + +## 1. Location and naming + +A save sits next to the cartridge directory, like an emulator save. It is not +inside the directory. `save::path_for` appends `.sav` to the directory's own +name: + +| Cartridge directory | Save file | +|---------------------|------------------------| +| `/games/demo` | `/games/demo.sav` | +| `/games/demo.v2` | `/games/demo.v2.sav` | + +- The extension is appended, not substituted, so a dotted name keeps its dots + (test `save_path_appends_to_a_dotted_cartridge_name`). +- `build_cartridge` canonicalizes the path first, so the save belongs to the + resolved directory, not to a symlink or relative spelling of it. +- Loading a cartridge always opens or creates its save + (`SaveFile::open_or_create`). A fresh save is + `{"schema_version": 1, "data": {}}` (test + `cartridge_load_creates_an_emulator_style_save_file`). +- The save never enters the repository. It holds no repository content, only + generated questions and learner progress. + +## 2. The envelope + +```json +{ + "schema_version": 1, + "data": { ".": , ... } +} +``` + +| Field | Type | Rules | +|------------------|-----------------|-------| +| `schema_version` | integer (`u32`) | Required. This build reads and writes only `1` (`SCHEMA_VERSION`). | +| `data` | object | Optional on read (defaults to `{}`). Maps namespaced keys to arbitrary JSON values. | + +- `data` is a `BTreeMap`, so keys are written in sorted (byte) order. +- Files are written as pretty-printed JSON (2-space indent) plus a trailing + newline. +- Each subsystem owns its keys, and no key is ever interpreted by another + subsystem. Unknown keys, for example `quest.progress` or keys written by a + newer build, are carried through every update unchanged (tests + `namespaced_data_survives_updates_from_other_game_systems`, + `generated_ai_batch_is_saved_without_replacing_other_game_data`). +- Keys can be any string. The property tests use `""`, keys with spaces, + Cyrillic and emoji keys. + +## 3. Reading, and refusal of damaged saves + +`read_document` classifies the file bytes: + +| File content | Result | +|-----------------------------------------------------|--------| +| Missing | Created empty (`create_new`, pretty JSON, fsync) while holding the save lock. | +| Empty, or ASCII whitespace only | Treated as a fresh save (a create that never got its first write). The file is not rewritten until an update changes a value (test `an_empty_save_file_starts_a_fresh_save`). | +| Valid envelope, `schema_version == 1` | Loaded. | +| Valid envelope, any other `schema_version` | Refused: `UNSUPPORTED CARTRIDGE SAVE`. | +| Anything else (truncated JSON, a JSON array, missing `schema_version`) | Refused: `CARTRIDGE SAVE IS CORRUPT`. | + +A refused save is never replaced or "repaired". Every writer starts from the +document it just read, so treating a damaged file as empty would let the next +update overwrite the whole save with one key. A corrupt or unsupported save +therefore makes the cartridge load fail with that message, and the file stays +byte-for-byte as it was (tests +`a_save_that_does_not_parse_is_refused_and_left_untouched`, and +`lib.rs::a_corrupt_save_stops_the_load_without_being_rewritten`). + +Error strings the save layer can return: + +| Message | Cause | +|---------|-------| +| `COULD NOT READ CARTRIDGE SAVE` | I/O error reading or stat-ing the file | +| `COULD NOT CREATE CARTRIDGE SAVE` | The first-time create failed | +| `CARTRIDGE SAVE IS CORRUPT` | The file is not a save document | +| `UNSUPPORTED CARTRIDGE SAVE` | `schema_version` is not `1` | +| `CARTRIDGE SAVE DATA IS UNREADABLE` | An update's key holds a value that does not deserialize as the expected type | +| `COULD NOT SERIALIZE CARTRIDGE SAVE` | Serializing a value failed | +| `COULD NOT WRITE CARTRIDGE SAVE` | The temp file write, fsync or rename failed | + +## 4. Writing: atomic replacement, serialized read-modify-write + +All writes go through `save::update(cartridge, key, change)`: + +1. Take the process-wide save lock (`save_write_lock`, a static `Mutex`). A + poisoned lock is recovered, because it guards no data. +2. Re-read the document from disk (or create it). Writers never write back an + old in-memory snapshot. +3. Deserialize `data[key]` as the caller's type `T`. A missing key starts + from `T::default()`. A present value that does not deserialize as `T` + aborts with `CARTRIDGE SAVE DATA IS UNREADABLE`, and the value is left + alone rather than reset (test `updates_refuse_to_replace_a_value_they_cannot_read`). +4. Apply `change`, then re-serialize the value. +5. If the value did not change, do not write anything (test + `unchanged_updates_do_not_rewrite_the_save`). Otherwise replace + `data[key]` and persist. + +Persisting is an atomic replace. The whole document is written to a +`tempfile::NamedTempFile` in the save's own directory, fsynced, then renamed +over the save. A crash leaves either the old file or the new one, never a +partial one. + +Consequences: + +- Concurrent updates to the same key or to different keys never lose each + other. Tests: `concurrent_updates_to_shared_and_separate_keys_never_lose_each_other` + (6 threads x 8 writes), `concurrent_batches_and_answers_all_reach_the_save`, + and the randomized `save_updates_round_trip_under_random_key_and_value_sequences`. +- A stale `SaveFile` snapshot cannot overwrite a newer update to another key + (test `a_stale_snapshot_cannot_overwrite_an_update_to_another_key`). +- Progress is written off the frame loop. `answer_recorder_with` feeds one + `cqa-save-writer` thread through a channel, which applies each + `learning::ProgressEvent` (a committed answer or a Codex reveal) in the + order it happened. The recorder ignores write errors (`let _ =`), so an + event that cannot be saved is dropped silently. +- Limitations (derived from the code, not tested): the lock only serializes + writers inside one process, so two running app instances could still race + on one save. The containing directory is not fsynced after the rename. + +`SaveFile` is the read side. It holds a snapshot taken at open, and `get::` +returns `None` when a key is missing or does not deserialize as `T`. Callers +use their defaults in that case. `SaveFile::set` exists only in tests, to stage +saves written by other builds. + +## 5. Keys used by this build + +| Key | Owner | Written by this build? | Type | +|-----|-------|------------------------|------| +| `ai.question_batches` | questions | Yes, append-only | array of batch objects | +| `claude.question_batches` | questions (legacy) | No, read only | array of batch objects | +| `quiz.progress` | questions / learning | Yes | progress object | + +The lesson journal is rebuilt at load from the batches and `quiz.progress` +(see 5.4). Only one Codex action is stored: revealing a sealed lesson's +answer (`peeked_questions` in 5.2). Merely opening a page records nothing. +`quest.progress` shows up only in tests, as an example of a foreign namespace +that updates must preserve. + +### 5.1 `ai.question_batches` and legacy `claude.question_batches` + +Each element is one generated batch: + +```json +{ "level": 1, "questions": [ , ... ] } +``` + +| Field | Type | Rules | +|-------|------|-------| +| `level` | `u32` | The Oracle bond level the batch was generated for. On load, `0` drops the batch. | +| `questions` | array | Defaults to `[]` on load. On load, a batch with no playable questions is dropped. | + +Writing (`persist_ai_question_batch`): + +- New batches go only to `ai.question_batches`, the provider-neutral key. + `claude.question_batches` comes from earlier Claude-only builds and is never + written again. +- A batch must have `level > 0`, at least one question, and every question + must pass the payload v2 generation policy (see 6.2). Anything else is + rejected with `INVALID AI QUESTION BATCH`. +- The stored array is updated as raw `serde_json::Value`, and the new batch + is appended. Entries this build cannot parse, such as a future + `{"level":9,"questions":[{"format":3}]}`, are kept as they are (test + `saving_a_batch_keeps_entries_this_build_cannot_read`). + +Loading (`saved_question_batches`): + +- Batches from `claude.question_batches` come first, then + `ai.question_batches`. That puts them oldest first, since the legacy key + predates the new one (test + `cartridge_reload_combines_legacy_and_current_ai_batches_oldest_first`). +- If a key is not an array, it counts as empty. +- Parsing is per question. A question that does not deserialize, or fails + the saved-question policy (6.3), is skipped, and the other questions in its + batch stay. +- The play queue is sorted by `level` (stable within a level), so lower-level + batches play first. The lesson journal keeps generation order. A question + that appears in more than one batch (same identity, see 7) is queued and + journaled only once. + +### 5.2 `quiz.progress` + +Learner progress for the cartridge (`SavedQuizProgress`). Every field has a +default, so any subset loads. + +| Field | Type | Default | Serialized when | Meaning | +|-------|------|---------|-----------------|---------| +| `answered_questions` | array of strings | `[]` | always | Retired questions. They were answered correctly on a first try or on a spaced (later-launch) check. Saves from earlier builds, which retired every committed answer, are read the same way. Retired questions are never asked again. | +| `missed_questions` | array of strings | `[]` | always | Questions whose latest attempt was wrong. They stay playable and come back as spaced reviews in later launches. | +| `relearned_questions` | array of strings | `[]` | always | Missed questions answered correctly on a retry in the same launch. The lesson counts as learned, but the question comes back once in a later launch, and only that spaced check retires it. | +| `mastery` | object: lens → `LensRecord` | `{}` | always | Per-lens evidence (5.3). | +| `question_attempts` | object: identity → `u32` | `{}` | only when non-empty | Committed attempts for each missed or relearned question, keyed by normalized identity. Choice rotation continues from this count across launches. | +| `missed_picks` | object: identity → `usize` | `{}` | only when non-empty | The source choice index of each question's latest wrong pick, from earlier builds and as a fallback. A later correct answer keeps the entry. On load, an index that is out of range or names the answer is ignored. | +| `missed_pick_choices` | object: identity → string | `{}` | only when non-empty | The text of each question's latest wrong pick. A stem repeated across batches can order its choices differently, so the reload finds the pick by this text first and falls back to `missed_picks`. | +| `peeked_questions` | array of strings | `[]` | only when non-empty | Missed questions, or relearned questions awaiting their spaced check, whose answer the player revealed in the Codex before answering them again. A reveal is recorded once per identity; the next attempt at the question removes it. | + +The three lists store the question text as it was first committed. They are +compared by identity (7), never by raw text. `SavedQuizProgress::record` keeps +the lists disjoint. The latest attempt moves the question into exactly one +list: + +| Answer | Review kind | Moves to | `question_attempts` | +|--------|-------------|----------|---------------------| +| correct | `Fresh` (first attempt), no peek | `answered_questions` | entry removed | +| correct | `Spaced` (miss from an earlier launch), no peek | `answered_questions` | entry removed | +| correct | `InSession` (retry of a miss from this launch) | `relearned_questions` | incremented | +| correct | any, after a Codex peek (`AnswerEvidence::peeked`) | `relearned_questions` | incremented | +| wrong | any | `missed_questions` | incremented | + +A wrong answer with a recorded pick also sets `missed_picks[identity]` and +`missed_pick_choices[identity]`, and every attempt removes the identity from +`peeked_questions`. A relearning success on a question that is already +retired (a delivered repeat of its stem) only updates `mastery`: it never +pulls the question back out for another check. The app persists +a `learning::ProgressEvent` through `persist_progress`: `Answered` applies +`record`, and `Peeked` applies `record_peek`. + +On increment, the count becomes `max(count, 1 if review else 0) + 1`. A review +therefore always shows at attempt 1 or later, even when an earlier build left +no count. Evidence with an empty identity is ignored. Each committed answer is +a single `save::update` of the whole `quiz.progress` value, so the lists and +the mastery can never disagree. + +"Retired" means `answered − missed − relearned`, compared by identity. This +covers hand-edited or legacy saves in which the lists overlap. + +### 5.3 `mastery` and `LensRecord` + +`mastery` is a `BTreeMap`. Keys are the kebab-case +lens names, in enum order: `purpose`, `responsibility`, `interaction`, +`invariant`, `tradeoff`. + +| Field | Type | Default | Meaning | +|-------|------|---------|---------| +| `first_try` | `u32` | 0 | Correct answers on a first attempt. | +| `redeemed` | `u32` | 0 | Correct answers, in a later launch, to a question missed before. | +| `missed` | `u32` | 0 | Wrong answers, counting every attempt. | +| `relearned` | `u32` | 0 | Correct same-launch retries, and correct answers given after a Codex peek. These are relearning, not evidence. | +| `recent` | `u8` | 0 | Shift register of the newest graded outcomes. Bit 0 is the newest, and a set bit means correct. Relearning is not graded. | +| `recent_len` | `u8` | 0 | How many bits of `recent` are valid (0 to 8, `RECENT_CAPACITY`). | + +Evidence is `first_try + redeemed`, and runes light at 1, 3 and 5. Rune II +needs at least 60% correct over the newest 5 graded outcomes. Rune III needs +at least 80% and no open miss on the lens. The gates read +`LensRecord::gate_accuracy`: evidence the window never saw (a record from an +earlier build) fills the window's older slots as successes, so an upgraded +veteran's first miss weighs as one answer among its history. A record with +`recent_len == 0` and such evidence passes both accuracy gates, and only the +open-miss gate applies to it (tests +`legacy_lens_records_load_and_keep_their_runes`, +`an_upgraded_veterans_first_miss_weighs_as_one_answer_among_its_history`). +Answers to questions without a known lens do not update `mastery`. + +### 5.4 Derived at load, never stored + +`load_cartridge_questions` reads the save once and builds these from the +batches and `quiz.progress`: + +- the play queue, with each question marked `Fresh` or `Spaced`, or + `InSession` for a missed or relearned identity this process already + answered on the same save (a cartridge ejected and reinserted within one + launch; the identities live in memory only and are never saved); +- the batch boundaries and levels; +- the lesson journal, with one entry for each missed, relearned or retired + question, flagged `outstanding` while it is still missed, carrying the saved + pick's text and misconception (`missed_pick_choices`, else `missed_picks`), + flagged `spaced_check` while a relearned question awaits its spaced check, + and flagged `peeked` while it is outstanding or awaiting that check and + listed in `peeked_questions`; +- the mastery; +- the attempt counts. + +None of these are written back. + +## 6. Question payload + +### 6.1 Schema (payload v2) + +```json +{ + "q": "WHAT SHOULD OWN GAMEPLAY STATE?", + "concept": "responsibility", + "choices": [ + { "text": "THE GAME ENGINE", "why": "THIS NAMES THE MODEL THE DESIGN DEPENDS ON." }, + { "text": "THE DEVICE SHELL", "why": "..." }, + { "text": "THE STYLES", "why": "..." }, + { "text": "THE VIEW", "why": "..." } + ], + "answer": 0 +} +``` + +| Field | Type | Rules | +|-------|------|-------| +| `q` | string | Required. Question stem. | +| `concept` | string, optional | The lens. Omitted from the output when absent (legacy). New saves store the canonical lens name. | +| `choices` | array | Each element is either a plain string (legacy) or `{ "text": string, "why": string }`. `why` defaults to `""` when missing. `QChoice` is `#[serde(untagged)]`: a string becomes `Plain` and an object becomes `Explained`. | +| `answer` | integer | Index of the correct choice. | + +For the correct choice, `why` says why it holds. For a distractor, it names +the misconception the choice represents. Unknown fields in a question object +are ignored when it is parsed. They stay in the file because batches are kept +as raw JSON (5.1). + +### 6.2 What this build writes + +Questions are normalized when a provider response is parsed, before they +reach the save (`normalized_question`). `persist_ai_question_batch` itself +does not normalize; it only validates. + +- `q`, every choice `text` and every `why` have typographic quotes, dashes and + ellipses folded to ASCII, whitespace collapsed, and letters uppercased. +- `concept` becomes the canonical lens name when `Concept::parse` recognizes + it. The parser accepts case variants, plurals and synonyms, so + `Responsibilities` becomes `responsibility` and `Flows` becomes + `interaction`. + +A question is saved only if it passes `generated_question_is_acceptable`: + +- `q` wraps into at most 4 lines of 31 columns; +- there are exactly 4 choices, all distinct and non-empty, each at most 31 + characters; +- `answer` is less than 4; +- no location or state-in-time trivia; +- a known lens; +- every choice is `{text, why}` with a `why` that fits 3 lines of 34 columns + (70 characters always fit) and names no location; +- every string is printable ASCII. + +### 6.3 What this build accepts when reading (legacy compatibility) + +`question_is_acceptable` applies the same layout and trivia rules as 6.2. On +top of those, a question must be one of these two kinds: + +- **Legacy:** every choice is a plain string, and `concept` is absent or a + known lens. It plays without rationales. Its lesson has an empty rationale, + and it records no mastery when it has no lens. It round-trips unchanged + (test `legacy_saved_questions_still_deserialize_and_play_without_rationales`). +- **Complete v2:** a known lens, and every choice explained with a fitting, + non-empty `why`. + +A question that mixes plain and explained choices, has an explained choice +with an empty `why`, or names an unknown lens is skipped on load. It stays in +the file. + +## 7. Question identity + +```rust +question.split_whitespace().collect::>().join(" ").to_ascii_uppercase() +``` + +Identity collapses runs of whitespace, trims both ends, and uppercases ASCII. +Non-ASCII characters pass through unchanged. It is the key for: + +- membership in `answered_questions`, `missed_questions`, + `relearned_questions` and `peeked_questions`; +- the keys of `question_attempts`, `missed_picks` and `missed_pick_choices`; +- de-duplication across batches, in the prompt's avoided-stems list and in + repair merges; +- the seed of the stable choice order (`presentation_order`). + +For example, `" what owns gameplay state? "` and `"WHAT OWNS GAMEPLAY STATE?"` +are the same question. Recording both leaves one entry, the first spelling +(test `answered_question_progress_is_namespaced_and_idempotent`). An empty +identity is never recorded. + +## 8. Compatibility rules + +**Envelope** + +- A missing `data` field defaults to `{}`. +- A `schema_version` other than `1` is refused outright, with no read and no + write. A build that knows only v1 will not load a save written with a future + `schema_version`. It fails the cartridge load with `UNSUPPORTED CARTRIDGE SAVE` + and leaves the file untouched. Bumping `schema_version` is therefore the + only way to make a breaking change safely. +- Extra top-level envelope fields besides `schema_version` and `data` are + ignored on read. From the code: they are dropped the next time the file is + rewritten, because `SaveDocument` does not preserve them. + +**Keys in `data`** + +- Unknown keys are always preserved. + +**`ai.question_batches` / `claude.question_batches`** + +- Batch entries and questions this build cannot read are kept in the file + and skipped when it loads. +- A question valid under an older, looser policy may be skipped by a newer + build. It is not deleted. + +**`quiz.progress`** + +- Every field defaults, so progress from any earlier build loads. This covers + saves with only `answered_questions`, saves without the relearning fields + or the Codex self-test fields (`missed_picks`, `missed_pick_choices`, + `peeked_questions`), and + `LensRecord`s without `relearned`, `recent` or `recent_len` (tests + `progress_from_earlier_builds_loads_as_retired_questions`, + `legacy_progress_without_relearning_fields_loads`, + `legacy_progress_without_picks_loads_and_ignores_bad_picks`). A miss saved + before picks were recorded has no `missed_picks` entry, so its sealed Codex + page asks the player to think first instead of showing a pick. +- Unknown fields inside `quiz.progress` or a `LensRecord` are ignored on + read. From the code: they are dropped the next time an answer is recorded, + because the update deserializes into `SavedQuizProgress` and serializes it + back. A newer build that adds a field there must accept that an older build + will strip it. +- A `quiz.progress` value that no longer deserializes at all leaves progress + unreadable. Examples: a different type, a lens name this build does not + know such as a future `"security"` key under `mastery`, or a count out of + range for `u8`/`u32`. In that case (from the code): + - Reads fall back to empty progress, so every question plays fresh and no + runes show. + - Every answer write fails with `CARTRIDGE SAVE DATA IS UNREADABLE`, which + the recorder ignores. + - The stored value is never overwritten, so a newer build still finds its + data intact. + +**Choice order** + +- `question_attempts` is absent in older saves. Reviews then start at + attempt 1. + +## 9. Annotated example + +This save combines state the tests build. It has: + +- a legacy batch under `claude.question_batches` + (`cartridge_reload_restores_legacy_claude_batches`); +- a v2 batch under `ai.question_batches` (`engine_state_question`, saved by + `persist_ai_question_batch` at level 2); +- a foreign key (`generated_ai_batch_is_saved_without_replacing_other_game_data`); +- the exact `quiz.progress` value asserted by + `progress_from_earlier_builds_loads_as_retired_questions`, after one miss + on a `tradeoff` question. + +Keys appear in the sorted order the writer produces. + +```json +{ + "schema_version": 1, + "data": { + "ai.question_batches": [ + { + "level": 2, + "questions": [ + { + "q": "WHAT SHOULD OWN GAMEPLAY STATE?", + "concept": "responsibility", + "choices": [ + { "text": "THE GAME ENGINE", "why": "THIS NAMES THE MODEL THE DESIGN DEPENDS ON." }, + { "text": "THE DEVICE SHELL", "why": "THIS NAMES THE MODEL THE DESIGN DEPENDS ON." }, + { "text": "THE STYLES", "why": "THIS NAMES THE MODEL THE DESIGN DEPENDS ON." }, + { "text": "THE VIEW", "why": "THIS NAMES THE MODEL THE DESIGN DEPENDS ON." } + ], + "answer": 0 + } + ] + } + ], + "claude.question_batches": [ + { + "level": 1, + "questions": [ + { + "q": "WHY KEEP THE DEVICE SHELL THIN?", + "choices": [ + "TO CENTRALIZE GAME RULES", + "TO DUPLICATE GAME STATE", + "TO HIDE ENGINE OUTPUT", + "TO BYPASS THE ENGINE" + ], + "answer": 0 + } + ] + } + ], + "quest.progress": { "bosses": 2 }, + "quiz.progress": { + "answered_questions": ["WHAT SHOULD OWN GAMEPLAY STATE?"], + "missed_questions": ["WHY KEEP THE SHELL THIN?"], + "relearned_questions": [], + "mastery": { + "tradeoff": { + "first_try": 0, + "redeemed": 0, + "missed": 1, + "relearned": 0, + "recent": 0, + "recent_len": 1 + } + }, + "question_attempts": { "WHY KEEP THE SHELL THIN?": 1 } + } + } +} +``` + +Notes on the example, from top to bottom: + +1. `schema_version` must be exactly `1`, or the save is refused (section 3). +2. `ai.question_batches` is the only batch key this build appends to. Every + choice is `{text, why}`, and `concept` holds the canonical lens name. All + text is uppercase ASCII because provider output is normalized at parse + time. +3. `claude.question_batches` is legacy and read-only. Its choices are plain + strings and it has no `concept`. It still plays, without rationales or + mastery, and it loads before the `ai.` batches. +4. `quest.progress` is a foreign namespace. Updates to other keys carry it + through untouched. +5. `answered_questions` retires its question. Here that question is the v2 + one, so the v2 question leaves the queue and stays in the journal. +6. `missed_questions` holds a question with no batch in this save. That is + fine: progress is keyed by identity, not by batch. A matching stem in any + batch would come back as a `Spaced` review with an `outstanding` lesson. +7. `relearned_questions` is serialized even when it is empty. +8. `mastery.tradeoff` records one miss. `recent = 0` with `recent_len = 1` + means one graded outcome, and it was wrong. +9. `question_attempts` is keyed by normalized identity and appears only when + non-empty. The miss's next review continues the choice rotation from + attempt 1. +10. `missed_picks`, `missed_pick_choices` and `peeked_questions` are absent + because they are empty: + this miss was recorded without a pick, and nothing was revealed in the + Codex. A miss committed in the app would add + `"missed_picks": { "WHY KEEP THE SHELL THIN?": }` and + `"missed_pick_choices": { "WHY KEEP THE SHELL THIN?": "" }`. diff --git a/docs/screenshots/oracle-codex-shell.png b/docs/screenshots/oracle-codex-shell.png new file mode 100644 index 0000000..e732484 Binary files /dev/null and b/docs/screenshots/oracle-codex-shell.png differ diff --git a/docs/screenshots/oracle-lesson-shell.png b/docs/screenshots/oracle-lesson-shell.png new file mode 100644 index 0000000..58cb074 Binary files /dev/null and b/docs/screenshots/oracle-lesson-shell.png differ diff --git a/docs/screenshots/oracle-live-question-shell.png b/docs/screenshots/oracle-live-question-shell.png new file mode 100644 index 0000000..1564556 Binary files /dev/null and b/docs/screenshots/oracle-live-question-shell.png differ diff --git a/package.json b/package.json index 856bca7..9a63ca9 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "version": "0.2.4", "type": "module", "scripts": { - "test": "node tests/display-contract.mjs && node tests/device-boot-contract.mjs && node tests/device-bounds-contract.mjs && node tests/device-pointer-contract.mjs && node tests/device-animation-contract.mjs && node tests/device-reverse-contract.mjs && node tests/provider-battery-contract.mjs && node tests/cartridge-library-contract.mjs && node tests/packaging-contract.mjs", + "test": "node tests/display-contract.mjs && node tests/device-boot-contract.mjs && node tests/device-bounds-contract.mjs && node tests/device-pointer-contract.mjs && node tests/device-animation-contract.mjs && node tests/device-reverse-contract.mjs && node tests/provider-battery-contract.mjs && node tests/cartridge-library-contract.mjs && node tests/device-shell-robustness-contract.mjs && node tests/packaging-contract.mjs && node tests/device-audio-contract.mjs && node tests/device-transcript-contract.mjs && node tests/docs-contract.mjs", "tauri": "tauri" }, "devDependencies": { diff --git a/scripts/eval-questions.sh b/scripts/eval-questions.sh new file mode 100755 index 0000000..fbfe4e4 --- /dev/null +++ b/scripts/eval-questions.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Measure live question-generation quality: run the app's exact generation path +# (project brief, bounded prompt, provider CLI on stdin, acceptance policy) +# against a repository and print every candidate as ACCEPT or REJECT with the +# limits it violated, plus answer-position, lens, focus, and PREDICT tallies +# and the acceptance rate. Each level costs one real provider request. +# +# Usage: scripts/eval-questions.sh [claude|codex] [levels] [repository] +# scripts/eval-questions.sh claude 1,4 +# CQA_CODEX_MODEL=gpt-5.5 scripts/eval-questions.sh codex 1,2,4 ~/src/other-repo +# +# Arguments override CQ_EVAL_PROVIDER, CQ_EVAL_LEVELS, and CQ_EVAL_REPO. +# CQ_EVAL_ROUNDS, CQ_EVAL_COUNT, CQ_EVAL_TIMEOUT_SECS, and CQ_EVAL_LEARNER=save +# are read as documented in src-tauri/src/question_eval.rs, and the provider +# binary and model come from CQA_CLAUDE, CQA_CODEX, CQA_CLAUDE_MODEL, and +# CQA_CODEX_MODEL, exactly as in the app. +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +export CQ_EVAL_PROVIDER="${1:-${CQ_EVAL_PROVIDER:-claude}}" +export CQ_EVAL_LEVELS="${2:-${CQ_EVAL_LEVELS:-1,4}}" +if [ -n "${3:-}" ]; then + CQ_EVAL_REPO="$(cd "$3" && pwd)" + export CQ_EVAL_REPO +fi + +exec cargo test --manifest-path "$root/src-tauri/Cargo.toml" --lib \ + question_eval::live_question_generation_quality -- --ignored --nocapture diff --git a/src-tauri/assets/oracle/README.md b/src-tauri/assets/oracle/README.md index 415666c..f78e010 100644 --- a/src-tauri/assets/oracle/README.md +++ b/src-tauri/assets/oracle/README.md @@ -12,10 +12,11 @@ templates selected by `art[].template` in `CODEQUEST.toml`. | `oracle-atelier` | `atelier.png` | Hero, customization values, truthful generation status, focus | | `oracle-hero` | `hero-*.png`, `portrait-*.png` | Selected authored colorway with no procedural accessory or weapon overlays | | `oracle-sanctum` | `sanctum.png`, `drop-*.png` | Authored Datafall collectibles/hazards, hero movement, raw counts, 3/6/9 charge runes, 1/3/5 containment breaches, status, tier grading | -| `oracle-trial` | `trial.png` | Question, choices, focus, Oracle ward runes, x1/x2/x3 flow, 300/900/1800 Insight Runes, raw score, correctness, review lock | +| `oracle-trial` | `trial.png` | Question, shuffled choices, focus, Oracle ward runes, x1/x2/x3 flow, 300/900/1800 Insight Runes, raw score, and the lesson card: committed pick and answer with rationales, `-`/`+` verdict markers, lens mastery footer, and continue prompt | | `oracle-ascension` | `ascension.png` | Hero rise, earned tier, level, batch, hold/continue state | | `oracle-aftermath` | `aftermath.png` | Defeated hero, final score, earned tier, replay prompt | | `oracle-progression` | shared plates | Cyan/gold balance, hero identity, and earned tier across the run | +| `oracle-codex` | `chronicle.png`, `trial.png`, `portrait-*.png` | Lens mastery runes (1/3/5 evidence), pending-review counts, and learned/pending totals inside the archive frame; one lesson per page (counter, lens, question, answer, rationale, review status) over the trial chamber | The PNG files are the inspectable source assets. Matching `.rgb` files are the dependency-free native buffers embedded by the Rust renderer. Hero and portrait @@ -60,6 +61,9 @@ runtime buffers and refuses incorrect dimensions or byte counts. restrained cyan ember, opens the archive, carries the code-seer into the memory vault, adds gold at convergence, and reveals the complete Oracle only in `awakening.png` at the crescendo. +- Runtime motion over a plate (bobbing heroes, pulsing prompts, scrolling + stars) is decorative. Under reduced motion the renderer draws its resting + composition, so every plate and live state must read without it. - Every reachable scene, including the repository chronicle and aftermath, must meet this floor at native resolution. A procedural palette swap is not an acceptable substitute for an illustrated plate. diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs new file mode 100644 index 0000000..a14f307 --- /dev/null +++ b/src-tauri/src/audio.rs @@ -0,0 +1,2500 @@ +//! Engine-owned chip sound for the CODE QUEST ADVANCE handheld. +//! +//! Sound is a pure function of observable state transitions, exactly like +//! rendering. The engine builds an [`AudioSnapshot`] every tick and the +//! [`AudioDirector`] diffs it against the previous tick to choose one-shot cues, +//! scene-owned loops, and scripted opening beats. Gameplay code never calls +//! audio, and the shell speaker never decides what to play. +//! +//! The output describes a constrained four-voice chip: two pulse voices with +//! selectable duty, a triangle-like wave voice, and a noise voice. Every +//! [`Note`] carries the absolute engine tick on which it starts. Voices are +//! monophonic: a note replaces whatever its voice was sounding, and a +//! zero-volume note is a cut. That rule lets the director end a scene's loop on +//! the exact tick the scene exits and keeps one cue from stacking over another. + +use std::collections::VecDeque; + +use bevy::prelude::Resource; +use serde::Serialize; + +/// Notes the transport holds for the shell before dropping the oldest. +pub const AUDIO_QUEUE_CAPACITY: usize = 512; +/// Scene loops and beds never exceed this volume, so every cue reads above them. +pub const AMBIENCE_MAX_VOLUME: u8 = 5; + +/// Engine ticks, relative to scene entry, on which the chronicle reveals its +/// header, notice, authors, and archive dates. +const ARCHIVE_REVEAL_TICKS: [u16; 4] = [1, 12, 24, 42]; +/// Level-up celebrates after the batch-complete telegraph has landed. +const REWARD_DELAY: u16 = 18; +/// The fullest level-up cadence ends by the reward's 60-tick input hold. +const REWARD_STEP: u16 = 5; +/// Game-over holds the final score under a quiet chord after the defeat fall. +const SCORE_HOLD_DELAY: u16 = 54; + +/// One chip voice. Each voice sounds at most one note at a time. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Voice { + Pulse1, + Pulse2, + Wave, + Noise, +} + +use Voice::{Noise, Pulse1, Pulse2, Wave}; + +impl Voice { + pub const ALL: [Self; 4] = [Pulse1, Pulse2, Wave, Noise]; + + fn index(self) -> usize { + self as usize + } +} + +/// Pulse duty cycle. The shell receives it in eighths of a period. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(into = "u8")] +pub enum Duty { + Eighth, + Quarter, + Half, + ThreeQuarters, +} + +impl From for u8 { + fn from(duty: Duty) -> Self { + match duty { + Duty::Eighth => 1, + Duty::Quarter => 2, + Duty::Half => 4, + Duty::ThreeQuarters => 6, + } + } +} + +/// One scheduled chip event. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Note { + pub voice: Voice, + /// Absolute engine tick on which the note starts. + pub tick: u64, + pub duration_ticks: u16, + /// MIDI note number. The noise voice reads it as brightness. + pub pitch: u8, + /// Chip volume 0-15. Zero cuts whatever the voice is sounding. + pub volume: u8, + /// Pulse duty; the wave and noise voices ignore it. + pub duty: Duty, + /// Ticks per one-step volume decrease; zero holds the volume. + pub decay: u8, + /// Semitones the pitch glides across the note; zero holds the pitch. + pub slide: i8, +} + +impl Note { + fn cut(voice: Voice, tick: u64) -> Self { + Self { + voice, + tick, + duration_ticks: 0, + pitch: 0, + volume: 0, + duty: Duty::Half, + decay: 0, + slide: 0, + } + } + + pub fn is_cut(&self) -> bool { + self.volume == 0 + } + + fn end(&self) -> u64 { + self.tick + u64::from(self.duration_ticks) + } +} + +/// The `engine_audio` payload: the engine's current tick and every note +/// emitted since the previous drain, oldest first. +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)] +pub struct AudioBatch { + pub tick: u64, + pub notes: Vec, +} + +/// A bounded note queue. When the reader falls behind, the oldest notes are +/// dropped: they would already be too late to play. +#[derive(Debug, Default)] +pub struct AudioQueue { + tick: u64, + notes: VecDeque, +} + +impl AudioQueue { + pub fn append(&mut self, batch: AudioBatch) { + self.tick = self.tick.max(batch.tick); + for note in batch.notes { + if self.notes.len() == AUDIO_QUEUE_CAPACITY { + self.notes.pop_front(); + } + self.notes.push_back(note); + } + } + + pub fn drain(&mut self) -> AudioBatch { + AudioBatch { + tick: self.tick, + notes: self.notes.drain(..).collect(), + } + } +} + +/// Bits for the device buttons held in an [`AudioSnapshot`]. +pub mod pad { + pub const UP: u16 = 1 << 0; + pub const DOWN: u16 = 1 << 1; + pub const LEFT: u16 = 1 << 2; + pub const RIGHT: u16 = 1 << 3; + pub const A: u16 = 1 << 4; + pub const B: u16 = 1 << 5; + pub const START: u16 = 1 << 6; + pub const SELECT: u16 = 1 << 7; + pub const L: u16 = 1 << 8; + pub const R: u16 = 1 << 9; +} + +/// The scene the player is in, as far as sound is concerned. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum AudioScene { + Off, + Boot, + Copyright, + Opening(OpeningBeat), + Title, + QuizMenu, + CharacterCreation, + Oracle, + Quiz, + LevelUp, + GameOver, + QuestSelect, + Battle, + Victory, + Defeat, + /// The Oracle Codex: page turns and answer reveals only, no ambience + /// under reading. + Codex, + /// A scene the sound design does not know yet. It is always silent. + #[default] + Unlisted, +} + +/// The five authored opening scenes, or the single-scene legacy fanfare. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum OpeningBeat { + Legacy, + SourceEmber, + ArchiveAnswer, + MemoryVault, + Convergence, + OracleAwakening, +} + +/// The run's presentation tier; arrangements gain voices as it rises. +#[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)] +pub enum Tier { + #[default] + Initiate, + Adept, + OracleBound, +} + +/// The truthful question-generation status the Oracle displays. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum QuestionStatus { + #[default] + Contacting, + Writing, + Retrying, + Ready, +} + +/// Where the current question is in its commit-and-review cycle. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum AnswerPhase { + #[default] + Choosing, + Correct, + Wrong, +} + +/// The audible parts of an active quiz run. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct RunAudio { + pub question: usize, + pub selected: usize, + pub phase: AnswerPhase, + pub hearts: u8, + pub multiplier: u32, + /// Insight Runes awakened by the run's score (0-3). + pub insight: usize, + pub level: u32, + pub completed_batches: usize, + /// The committed answer redeemed a previously missed question. + pub redeemed: bool, + /// The lens mastery stage (1-3) the committed answer woke, if any. + pub lens_woke: Option, + /// A first B press has armed the leave confirmation. + pub leave_armed: bool, +} + +/// Everything sound may react to, sampled once per engine tick. New scenes and +/// fields extend this struct; the director stays silent for anything it does +/// not recognize, and a default snapshot names no scene at all. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct AudioSnapshot { + pub powered: bool, + pub scene: AudioScene, + pub scene_ticks: u64, + /// Held buttons as [`pad`] bits; newly set bits are this tick's press edges. + pub held: u16, + pub menu_selected: usize, + pub hero_row: usize, + pub hero_name: usize, + pub hero_class: usize, + pub hero_style: usize, + pub quest_selected: usize, + /// The Codex page on screen (0 is the mastery overview). + pub codex_page: usize, + /// A pressed on a pending Codex page and revealed its sealed answer. + pub codex_revealed: bool, + pub run: Option, + pub data: u32, + /// Data-charge runes lit (0-3). + pub data_stage: usize, + pub bugs: u32, + /// Containment runes broken (0-3). + pub breach_stage: usize, + pub questions: QuestionStatus, + pub tier: Tier, +} + +impl AudioSnapshot { + fn is_audible(&self) -> bool { + self.powered + && !matches!( + self.scene, + AudioScene::Off | AudioScene::Boot | AudioScene::Unlisted + ) + } +} + +/// A named one-shot. At most one cue starts per tick, so one input edge can +/// never produce more than one cue. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Cue { + Navigate(usize), + PageTurn(usize), + Confirm, + Cancel, + Unavailable, + Trait { row: usize, value: usize }, + BeginRun, + OracleEnter, + DataCollect(u32), + ChargeRune(usize), + BugHit, + SealBreak(usize), + Retry, + Ready, + Leave, + QuestionReveal, + Cursor(usize), + Correct, + Redeemed, + LeaveWarning, + FlowUp(u32), + RuneAwaken(usize), + LensWake(u8), + Wrong, + LowWard, + WardBreak, + BatchComplete, + Continue(Tier), + Defeat, + Replay, + Victory, +} + +impl Cue { + /// Higher priorities win when several events land on one tick. + fn priority(self) -> u8 { + match self { + Self::Unavailable => 1, + Self::Navigate(_) + | Self::PageTurn(_) + | Self::Cursor(_) + | Self::Trait { .. } + | Self::QuestionReveal => 2, + Self::LeaveWarning => 5, + Self::DataCollect(_) => 3, + Self::BugHit => 4, + Self::Retry => 5, + Self::Ready | Self::ChargeRune(_) => 6, + Self::SealBreak(_) => 7, + Self::Correct | Self::Wrong => 8, + Self::Redeemed | Self::FlowUp(_) | Self::LowWard => 9, + Self::RuneAwaken(_) | Self::WardBreak => 10, + Self::LensWake(_) => 11, + Self::Confirm + | Self::Cancel + | Self::Leave + | Self::BeginRun + | Self::OracleEnter + | Self::BatchComplete + | Self::Continue(_) + | Self::Defeat + | Self::Replay + | Self::Victory => 12, + } + } + + fn tones(self) -> Vec { + match self { + Self::Navigate(index) => vec![tone(0, Pulse1, [E5, D5, C5, A4][index % 4], 3, 8) + .duty(Duty::Quarter) + .decay(1)], + Self::Confirm => vec![ + tone(0, Pulse1, D5, 4, 10), + tone(4, Pulse1, A5, 8, 10).decay(2), + ], + Self::Cancel => vec![ + tone(0, Pulse1, A4, 4, 9).duty(Duty::Quarter), + tone(4, Pulse1, D4, 8, 9).duty(Duty::Quarter).decay(2), + ], + Self::Unavailable => vec![tone(0, Pulse1, BB3, 5, 6) + .duty(Duty::Eighth) + .decay(1) + .slide(-1)], + // Each identity row has its own timbre; the Oracle motif is + // reserved for the final bind. + Self::Trait { row: 0, value } => { + vec![tone(0, Pulse1, PENTATONIC[value % 6], 4, 8).decay(1)] + } + Self::Trait { row: 1, value } => vec![tone(0, Pulse2, PENTATONIC[value % 6], 5, 8) + .duty(Duty::ThreeQuarters) + .decay(1)], + Self::Trait { value, .. } => { + vec![tone(0, Wave, PENTATONIC[value % 6], 8, 10).slide(2)] + } + Self::BeginRun => vec![ + tone(0, Pulse1, A4, 5, 11), + tone(5, Pulse1, D5, 5, 11), + tone(10, Pulse1, E5, 5, 11), + tone(15, Pulse1, F5, 14, 11).decay(2), + tone(15, Pulse2, A4, 14, 7).duty(Duty::Eighth).decay(2), + tone(0, Wave, D3, 28, 9), + ], + Self::OracleEnter => vec![ + tone(0, Wave, A4, 16, 8).decay(2).slide(-7), + tone(0, Pulse2, D5, 6, 6).duty(Duty::Eighth).decay(1), + ], + Self::DataCollect(count) => { + vec![tone(0, Pulse1, [A5, C6, D6][count as usize % 3], 4, 8) + .duty(Duty::Quarter) + .decay(1) + .slide(5)] + } + Self::ChargeRune(stage) => { + let run = [D5, F5, A5, D6, F6] + .into_iter() + .take(stage.clamp(1, 3) + 2) + .collect::>(); + let mut tones = run + .iter() + .enumerate() + .map(|(step, pitch)| tone(step as u16 * 3, Pulse1, *pitch, 3, 10)) + .collect::>(); + let top = run[run.len() - 1]; + tones.push( + tone(run.len() as u16 * 3, Pulse2, top - 12, 12, 7) + .duty(Duty::Quarter) + .decay(2), + ); + tones + } + Self::BugHit => vec![ + tone(0, Noise, NOISE_SNAP, 8, 9).decay(1), + tone(0, Pulse1, C4, 8, 8).duty(Duty::Eighth).slide(-5), + ], + Self::SealBreak(stage) => { + let stage = stage.clamp(1, 3) - 1; + vec![ + tone(0, Noise, NOISE_CRASH, 20, 11).decay(2), + tone(0, Pulse1, [A3, F3, D3][stage], 18, 10) + .duty(Duty::Eighth) + .decay(2) + .slide(-7), + tone(0, Wave, [D3, BB2, A2][stage], 18, 10), + ] + } + Self::Retry => vec![ + tone(0, Wave, F4, 10, 9), + tone(10, Wave, D4, 14, 9).decay(2), + tone(0, Pulse2, A4, 24, 6) + .duty(Duty::Eighth) + .decay(4) + .slide(-2), + ], + Self::Ready => vec![ + tone(0, Pulse1, A5, 4, 10).duty(Duty::Quarter), + tone(4, Pulse1, D6, 14, 10).duty(Duty::Quarter).decay(2), + tone(4, Pulse2, FS5, 14, 7).duty(Duty::Quarter).decay(2), + ], + Self::Leave => vec![ + tone(0, Pulse1, D5, 4, 9).duty(Duty::Quarter), + tone(4, Pulse1, A4, 4, 9).duty(Duty::Quarter), + tone(8, Pulse1, D4, 10, 9).duty(Duty::Quarter).decay(2), + tone(8, Wave, D3, 10, 8), + ], + Self::QuestionReveal => { + vec![tone(0, Wave, D5, 6, 7), tone(6, Wave, A5, 10, 7).decay(2)] + } + // A dry leaf-turn: a noise swish under a soft pulse that alternates + // pitch so paging feels directional without any ambience. + Self::PageTurn(page) => vec![ + tone(0, Noise, NOISE_HAT, 3, 5).decay(1), + tone(1, Pulse2, [D5, E5][page % 2], 4, 7) + .duty(Duty::Eighth) + .decay(1), + ], + // Two falling notes ask the question without committing to it. + Self::LeaveWarning => vec![ + tone(0, Pulse1, E5, 4, 8).duty(Duty::Quarter), + tone(6, Pulse1, BB4, 8, 8).duty(Duty::Quarter).decay(1), + ], + // The miss resolves: the correct cadence climbs past its old peak + // to the Oracle's A, with the wave voice confirming the root. + Self::Redeemed => commit_then(vec![ + tone(2, Pulse1, A4, 4, 11), + tone(6, Pulse1, D5, 4, 11), + tone(10, Pulse1, F5, 4, 11), + tone(14, Pulse1, A5, 16, 12).decay(2), + tone(14, Pulse2, D5, 16, 8).duty(Duty::Quarter).decay(2), + tone(2, Wave, D3, 28, 9), + ]), + Self::Cursor(slot) => { + vec![tone(0, Pulse1, [A5, G5, F5, E5][slot % 4], 3, 8).decay(1)] + } + Self::Correct => commit_then(vec![ + tone(2, Pulse1, E5, 5, 11), + tone(7, Pulse1, A5, 14, 11).decay(2), + tone(7, Pulse2, CS5, 14, 8).duty(Duty::Quarter).decay(2), + ]), + Self::FlowUp(multiplier) => commit_then(vec![ + tone(2, Pulse1, E5, 4, 12), + tone(6, Pulse1, A5, 4, 12), + tone(10, Pulse1, if multiplier >= 3 { E6 } else { CS6 }, 14, 12).decay(2), + tone(10, Pulse2, A5, 14, 8).duty(Duty::Quarter).decay(2), + ]), + Self::RuneAwaken(stage) => { + let mut tones = [A5, CS6, E6, A6, CS7] + .into_iter() + .take(stage.clamp(1, 3) + 2) + .enumerate() + .map(|(step, pitch)| tone(2 + step as u16 * 3, Pulse1, pitch, 3, 12)) + .collect::>(); + let crest = 2 + tones.len() as u16 * 3; + tones.extend([ + tone(crest, Pulse2, E5, 16, 8).duty(Duty::Quarter).decay(2), + tone(2, Wave, A4, crest + 12, 9).slide(12), + tone(crest, Noise, NOISE_HAT, 10, 6).decay(1), + ]); + commit_then(tones) + } + // A lens mastery rune wakes (stage 1-3), the run's loudest reward: + // two rising notes on the Oracle's D-major crest, climbing a chord + // tone higher with each stage, over the root and a bright shimmer. + Self::LensWake(stage) => { + let (low, high) = + [(D5, A5), (FS5, D6), (A5, FS6)][usize::from(stage.clamp(1, 3)) - 1]; + commit_then(vec![ + tone(2, Pulse1, low, 6, 12), + tone(8, Pulse1, high, 20, 13).decay(3), + tone(8, Pulse2, low, 20, 9).duty(Duty::Quarter).decay(3), + tone(2, Wave, D4, 26, 10), + tone(8, Noise, NOISE_HAT, 8, 7).decay(1), + ]) + } + Self::Wrong => commit_then(wrong_buzz()), + Self::LowWard => { + let mut tones = wrong_buzz(); + tones.extend([tone(20, Pulse2, BB5, 4, 9), tone(26, Pulse2, BB5, 4, 9)]); + commit_then(tones) + } + Self::WardBreak => commit_then(vec![ + tone(2, Noise, NOISE_CRASH, 24, 12).decay(2), + tone(2, Pulse1, D4, 28, 11) + .duty(Duty::Eighth) + .decay(2) + .slide(-12), + tone(2, Wave, D3, 28, 11).slide(-12), + ]), + Self::BatchComplete => { + let mut tones = [D5, E5, F5, G5, A5] + .into_iter() + .enumerate() + .map(|(step, pitch)| { + tone(step as u16 * 3, Pulse1, pitch, 3, 11).duty(Duty::Quarter) + }) + .collect::>(); + tones.push(tone(0, Noise, NOISE_CRASH, 16, 7).slide(12)); + tones + } + Self::Continue(tier) => { + let mut tones = vec![ + tone(0, Pulse1, D5, 4, 10).duty(Duty::Eighth), + tone(4, Pulse1, A5, 10, 10).duty(Duty::Eighth).decay(2), + ]; + if tier >= Tier::Adept { + tones.push(tone(4, Pulse2, F5, 10, 8).duty(Duty::Quarter).decay(2)); + } + if tier >= Tier::OracleBound { + tones.push(tone(4, Wave, D4, 12, 10)); + tones.push(tone(4, Noise, NOISE_HAT, 6, 6).decay(1)); + } + tones + } + Self::Defeat => vec![ + tone(0, Noise, NOISE_CRASH, 20, 11).decay(2), + tone(0, Pulse1, A4, 40, 11) + .duty(Duty::Eighth) + .decay(3) + .slide(-12), + tone(0, Wave, D3, 14, 11), + tone(14, Wave, A2, 14, 11), + tone(28, Wave, D2, 20, 11).decay(2), + ], + // The reset cadence always uses the thin Initiate timbre, so a new + // run audibly returns to the first tier. + Self::Replay => vec![ + tone(0, Pulse1, D5, 4, 10).duty(Duty::Eighth), + tone(4, Pulse1, A5, 4, 10).duty(Duty::Eighth), + tone(8, Pulse1, D6, 12, 10).duty(Duty::Eighth).decay(2), + tone(0, Wave, D3, 20, 8), + ], + Self::Victory => oracle_cadence(6), + } + } +} + +/// A tone inside a cue or script, relative to the phrase's start. +#[derive(Clone, Copy, Debug)] +struct Tone { + at: u16, + voice: Voice, + pitch: u8, + length: u16, + volume: u8, + duty: Duty, + decay: u8, + slide: i8, +} + +const fn tone(at: u16, voice: Voice, pitch: u8, length: u16, volume: u8) -> Tone { + Tone { + at, + voice, + pitch, + length, + volume, + duty: Duty::Half, + decay: 0, + slide: 0, + } +} + +impl Tone { + const fn duty(mut self, duty: Duty) -> Self { + self.duty = duty; + self + } + + const fn decay(mut self, decay: u8) -> Self { + self.decay = decay; + self + } + + const fn slide(mut self, slide: i8) -> Self { + self.slide = slide; + self + } + + const fn later(mut self, ticks: u16) -> Self { + self.at += ticks; + self + } + + #[cfg(test)] + fn end(&self) -> u16 { + self.at + self.length + } + + fn note(self, tick: u64) -> Note { + Note { + voice: self.voice, + tick, + duration_ticks: self.length, + pitch: self.pitch, + volume: self.volume, + duty: self.duty, + decay: self.decay, + slide: self.slide, + } + } +} + +fn later(tones: Vec, ticks: u16) -> Vec { + tones.into_iter().map(|tone| tone.later(ticks)).collect() +} + +// Pitches are MIDI note numbers. The palette sits in D minor and resolves to +// D major at the Oracle's crest. +const D2: u8 = 38; +const A2: u8 = 45; +const BB2: u8 = 46; +const C3: u8 = 48; +const D3: u8 = 50; +const E3: u8 = 52; +const F3: u8 = 53; +const A3: u8 = 57; +const BB3: u8 = 58; +const C4: u8 = 60; +const D4: u8 = 62; +const E4: u8 = 64; +const F4: u8 = 65; +const G4: u8 = 67; +const A4: u8 = 69; +const BB4: u8 = 70; +const C5: u8 = 72; +const CS5: u8 = 73; +const D5: u8 = 74; +const E5: u8 = 76; +const F5: u8 = 77; +const FS5: u8 = 78; +const G5: u8 = 79; +const A5: u8 = 81; +const BB5: u8 = 82; +const C6: u8 = 84; +const CS6: u8 = 85; +const D6: u8 = 86; +const E6: u8 = 88; +const F6: u8 = 89; +const FS6: u8 = 90; +const A6: u8 = 93; +const CS7: u8 = 97; +const PENTATONIC: [u8; 6] = [D5, E5, G5, A5, C6, D6]; +// Noise brightness: higher numbers are brighter and drier. +const NOISE_TICK: u8 = 100; +const NOISE_HAT: u8 = 96; +const NOISE_SNAP: u8 = 84; +const NOISE_CRASH: u8 = 72; +const NOISE_THUD: u8 = 48; +/// A loop step that leaves its voice alone. +const REST: u8 = 0; + +/// Every answer commitment begins with the same dry click. +fn commit_then(mut tones: Vec) -> Vec { + tones.insert(0, tone(0, Noise, NOISE_HAT, 2, 8)); + tones +} + +fn wrong_buzz() -> Vec { + vec![ + tone(2, Pulse1, F4, 16, 10) + .duty(Duty::Eighth) + .decay(1) + .slide(-2), + tone(2, Wave, BB2, 16, 10), + ] +} + +/// The full Oracle cadence: the four-note motif over a rising bVI-bVII bass, +/// resolving to a D major crest with every voice sounding. +fn oracle_cadence(step: u16) -> Vec { + let crest = step * 4; + let hold = step * 3 + 2; + vec![ + tone(0, Pulse1, A4, step, 12), + tone(step, Pulse1, D5, step, 12), + tone(step * 2, Pulse1, E5, step, 12), + tone(step * 3, Pulse1, F5, step, 12), + tone(crest, Pulse1, FS5, hold, 12).decay(3), + tone(0, Pulse2, F4, step * 2, 9).duty(Duty::Quarter), + tone(step * 2, Pulse2, G4, step * 2, 9).duty(Duty::Quarter), + tone(crest, Pulse2, A4, hold, 9) + .duty(Duty::Quarter) + .decay(3), + tone(0, Wave, BB2, step * 2, 11), + tone(step * 2, Wave, C3, step * 2, 11), + tone(crest, Wave, D3, hold, 11).decay(3), + tone(crest, Noise, NOISE_CRASH, hold.min(20), 8).decay(2), + ] +} + +/// The level-up celebration gains a voice with each presentation tier. +fn reward_cadence(tier: Tier) -> Vec { + let step = REWARD_STEP; + if tier == Tier::OracleBound { + return oracle_cadence(step); + } + let crest = step * 4; + let hold = step * 3 + 2; + let mut tones = vec![ + tone(0, Pulse1, A4, step, 11), + tone(step, Pulse1, D5, step, 11), + tone(step * 2, Pulse1, E5, step, 11), + tone(step * 3, Pulse1, F5, step, 11), + tone(crest, Pulse1, D5, hold, 11).decay(3), + tone(0, Wave, D3, crest + hold, 9).decay(5), + ]; + if tier == Tier::Adept { + tones.extend([ + tone(0, Pulse2, F4, step * 2, 8).duty(Duty::Quarter), + tone(step * 2, Pulse2, G4, step * 2, 8).duty(Duty::Quarter), + tone(crest, Pulse2, A4, hold, 8) + .duty(Duty::Quarter) + .decay(3), + ]); + } + tones +} + +/// One cyan source pulse and its echo in the dormant cathedral. +fn source_pulse() -> Vec { + vec![ + tone(18, Pulse2, A4, 30, 6).decay(5), + tone(54, Pulse2, A4, 20, 3).decay(5), + ] +} + +/// The altar answers the pulse a fifth above. +fn archive_answer() -> Vec { + vec![ + tone(0, Pulse2, A4, 12, 6).decay(2), + tone(14, Pulse1, E5, 14, 7).duty(Duty::Quarter).decay(2), + tone(30, Pulse2, A4, 10, 4).decay(2), + tone(44, Pulse1, E5, 12, 4).duty(Duty::Quarter).decay(2), + ] +} + +/// Commit constellations branch as two echoed arpeggios over the vault floor. +fn memory_vault() -> Vec { + let mut tones = Vec::new(); + for (start, branch) in [(0, [D5, F5, A5, C6]), (32, [C5, E5, G5, A5])] { + for (step, pitch) in branch.into_iter().enumerate() { + let at = start + step as u16 * 5; + let length = if step == 3 { 10 } else { 5 }; + tones.push( + tone(at, Pulse1, pitch, length, 8) + .duty(Duty::Quarter) + .decay(1), + ); + tones.push( + tone(at + 3, Pulse2, pitch, length, 4) + .duty(Duty::Eighth) + .decay(1), + ); + } + } + tones.push(tone(0, Wave, D3, 30, 7).decay(5)); + tones.push(tone(32, Wave, C3, 28, 7).decay(5)); + tones +} + +/// Cyan descends and gold rises until both pulses meet on the same note. +fn convergence() -> Vec { + let mut tones = [A5, G5, F5, E5, D5, C5, BB4] + .into_iter() + .enumerate() + .map(|(step, pitch)| tone(step as u16 * 6, Pulse1, pitch, 6, 8).duty(Duty::Quarter)) + .collect::>(); + tones.extend( + [D4, E4, F4, G4] + .into_iter() + .enumerate() + .map(|(step, pitch)| tone(step as u16 * 10, Pulse2, pitch, 10, 7)), + ); + tones.extend([ + tone(42, Pulse1, A4, 18, 9).duty(Duty::Quarter).decay(3), + tone(42, Pulse2, A4, 18, 8).decay(3), + tone(0, Wave, BB2, 30, 8), + tone(30, Wave, C3, 30, 8).decay(5), + ]); + tones +} + +fn opening_tones(beat: OpeningBeat) -> Vec { + match beat { + OpeningBeat::SourceEmber => source_pulse(), + OpeningBeat::ArchiveAnswer => archive_answer(), + OpeningBeat::MemoryVault => memory_vault(), + OpeningBeat::Convergence => convergence(), + OpeningBeat::OracleAwakening => later(oracle_cadence(8), 4), + // The single-scene fanfare plays the same arc against its own + // luminance timeline. + OpeningBeat::Legacy => [ + (0, source_pulse()), + (40, archive_answer()), + (110, memory_vault()), + (180, convergence()), + (250, later(oracle_cadence(8), 4)), + ] + .into_iter() + .flat_map(|(at, tones)| later(tones, at)) + .collect(), + } +} + +/// Scene-owned one-shots scheduled at scene entry and cut if the scene exits. +fn script_tones(snapshot: &AudioSnapshot) -> Vec { + match snapshot.scene { + AudioScene::Copyright => ARCHIVE_REVEAL_TICKS + .iter() + .map(|at| tone(*at, Noise, NOISE_TICK, 1, 4)) + .collect(), + AudioScene::Opening(beat) => opening_tones(beat), + AudioScene::LevelUp => later(reward_cadence(snapshot.tier), REWARD_DELAY), + AudioScene::GameOver => vec![ + tone(SCORE_HOLD_DELAY, Wave, D3, 90, 5).decay(18), + tone(SCORE_HOLD_DELAY, Pulse2, F4, 90, 4) + .duty(Duty::Eighth) + .decay(22), + ], + _ => Vec::new(), + } +} + +#[derive(Debug)] +struct LoopTrack { + voice: Voice, + /// One pitch per step; [`REST`] leaves the voice alone for that step. + steps: &'static [u8], + length: u16, + volume: u8, + duty: Duty, + decay: u8, +} + +#[derive(Debug)] +struct LoopPattern { + step_ticks: u16, + tracks: &'static [LoopTrack], +} + +/// A restrained invitation: a slow bass and a sparse motif fragment. +static TITLE_LOOP: LoopPattern = LoopPattern { + step_ticks: 12, + tracks: &[ + LoopTrack { + voice: Wave, + steps: &[ + D3, REST, REST, REST, REST, REST, REST, REST, BB2, REST, REST, REST, REST, REST, + REST, REST, C3, REST, REST, REST, REST, REST, REST, REST, D3, REST, REST, REST, A2, + REST, REST, REST, + ], + length: 90, + volume: 4, + duty: Duty::Half, + decay: 0, + }, + LoopTrack { + voice: Pulse2, + steps: &[ + REST, REST, REST, REST, A4, REST, D5, REST, E5, REST, REST, REST, REST, REST, REST, + REST, REST, REST, REST, REST, F5, REST, E5, REST, D5, REST, REST, REST, REST, REST, + REST, REST, + ], + length: 20, + volume: 3, + duty: Duty::Eighth, + decay: 5, + }, + ], +}; + +/// The quieter bed under the menu and the hero atelier. +static MENU_LOOP: LoopPattern = LoopPattern { + step_ticks: 12, + tracks: &[ + LoopTrack { + voice: Wave, + steps: &[ + D3, REST, REST, REST, A2, REST, REST, REST, BB2, REST, REST, REST, C3, REST, REST, + REST, + ], + length: 44, + volume: 3, + duty: Duty::Half, + decay: 0, + }, + LoopTrack { + voice: Pulse2, + steps: &[ + REST, REST, A4, REST, REST, REST, F4, REST, REST, REST, G4, REST, REST, REST, E4, + REST, + ], + length: 14, + volume: 2, + duty: Duty::Eighth, + decay: 3, + }, + ], +}; + +const DATAFALL_BASS: LoopTrack = LoopTrack { + voice: Wave, + steps: &[ + D3, REST, REST, REST, REST, REST, REST, REST, C3, REST, REST, REST, BB2, REST, REST, REST, + ], + length: 36, + volume: 4, + duty: Duty::Half, + decay: 0, +}; + +const DATAFALL_PULSE: LoopTrack = LoopTrack { + voice: Pulse2, + steps: &[ + REST, REST, REST, REST, A5, REST, REST, REST, REST, REST, REST, REST, REST, REST, REST, + REST, + ], + length: 8, + volume: 3, + duty: Duty::Quarter, + decay: 2, +}; + +const DATAFALL_DOUBLE_PULSE: LoopTrack = LoopTrack { + steps: &[ + REST, REST, REST, REST, A5, REST, REST, REST, REST, REST, REST, REST, D6, REST, REST, REST, + ], + ..DATAFALL_PULSE +}; + +const DATAFALL_COUNTER: LoopTrack = LoopTrack { + voice: Pulse1, + steps: &[ + REST, REST, D5, REST, REST, E5, REST, REST, REST, REST, F5, REST, REST, E5, REST, REST, + ], + length: 8, + volume: 2, + duty: Duty::Eighth, + decay: 3, +}; + +const DATAFALL_FULL_COUNTER: LoopTrack = LoopTrack { + steps: &[ + REST, REST, D5, REST, REST, E5, REST, F5, REST, REST, A5, REST, REST, G5, REST, E5, + ], + ..DATAFALL_COUNTER +}; + +const DATAFALL_HATS: LoopTrack = LoopTrack { + voice: Noise, + steps: &[ + NOISE_HAT, REST, NOISE_HAT, REST, NOISE_HAT, REST, NOISE_HAT, NOISE_HAT, NOISE_HAT, REST, + NOISE_HAT, REST, NOISE_HAT, REST, NOISE_HAT, REST, + ], + length: 2, + volume: 2, + duty: Duty::Half, + decay: 1, +}; + +/// Initiate: a thin bass and one data pulse per bar. +static DATAFALL_INITIATE: LoopPattern = LoopPattern { + step_ticks: 10, + tracks: &[DATAFALL_BASS, DATAFALL_PULSE], +}; + +/// Adept: a second motif voice answers the data pulse. +static DATAFALL_ADEPT: LoopPattern = LoopPattern { + step_ticks: 10, + tracks: &[DATAFALL_BASS, DATAFALL_DOUBLE_PULSE, DATAFALL_COUNTER], +}; + +/// Oracle-bound: the full constrained arrangement, with noise. +static DATAFALL_ORACLE_BOUND: LoopPattern = LoopPattern { + step_ticks: 10, + tracks: &[ + DATAFALL_BASS, + DATAFALL_DOUBLE_PULSE, + DATAFALL_FULL_COUNTER, + DATAFALL_HATS, + ], +}; + +/// A low heartbeat while the run hangs on its last ward. +static DANGER_BED: LoopPattern = LoopPattern { + step_ticks: 8, + tracks: &[ + LoopTrack { + voice: Wave, + steps: &[A2, A2, REST, REST, REST, REST, REST, REST], + length: 5, + volume: 4, + duty: Duty::Half, + decay: 1, + }, + LoopTrack { + voice: Noise, + steps: &[NOISE_THUD, NOISE_THUD, REST, REST, REST, REST, REST, REST], + length: 3, + volume: 2, + duty: Duty::Half, + decay: 1, + }, + ], +}; + +static BATTLE_LOOP: LoopPattern = LoopPattern { + step_ticks: 10, + tracks: &[ + LoopTrack { + voice: Wave, + steps: &[D3, REST, D3, REST, F3, REST, E3, REST], + length: 8, + volume: 3, + duty: Duty::Half, + decay: 1, + }, + LoopTrack { + voice: Noise, + steps: &[REST, REST, REST, REST, NOISE_SNAP, REST, REST, REST], + length: 2, + volume: 2, + duty: Duty::Half, + decay: 1, + }, + ], +}; + +/// The loop a scene owns and how many ticks after entry it begins. +fn scene_loop(snapshot: &AudioSnapshot) -> Option<(&'static LoopPattern, u64)> { + match snapshot.scene { + AudioScene::Title => Some((&TITLE_LOOP, 30)), + AudioScene::QuizMenu | AudioScene::CharacterCreation | AudioScene::QuestSelect => { + Some((&MENU_LOOP, 16)) + } + AudioScene::Oracle => Some(( + match snapshot.tier { + Tier::Initiate => &DATAFALL_INITIATE, + Tier::Adept => &DATAFALL_ADEPT, + Tier::OracleBound => &DATAFALL_ORACLE_BOUND, + }, + 32, + )), + AudioScene::Quiz if snapshot.run.is_some_and(|run| run.hearts == 1) => { + Some((&DANGER_BED, 24)) + } + AudioScene::Battle => Some((&BATTLE_LOOP, 8)), + _ => None, + } +} + +/// The cue that announces a scene change, keyed by where the player came from. +fn entry_cue(before: &AudioSnapshot, after: &AudioSnapshot) -> Option { + use AudioScene as Scene; + if !before.is_audible() { + return None; + } + match (before.scene, after.scene) { + (Scene::Title, Scene::QuizMenu | Scene::QuestSelect) + | (Scene::QuizMenu, Scene::CharacterCreation | Scene::Codex) + | (Scene::QuestSelect, Scene::Battle) + | (Scene::Victory | Scene::Defeat, _) => Some(Cue::Confirm), + (Scene::QuizMenu | Scene::QuestSelect, Scene::Title) + | (Scene::CharacterCreation | Scene::Quiz | Scene::Codex, Scene::QuizMenu) => { + Some(Cue::Cancel) + } + (Scene::CharacterCreation, Scene::Oracle | Scene::Quiz) => Some(Cue::BeginRun), + (Scene::Oracle, Scene::Quiz) => Some(Cue::QuestionReveal), + (Scene::Oracle, Scene::QuizMenu) => Some(Cue::Leave), + (Scene::Quiz, Scene::Oracle) => Some(Cue::OracleEnter), + (Scene::Quiz, Scene::LevelUp) => Some(Cue::BatchComplete), + (Scene::Quiz, Scene::GameOver) | (Scene::Battle, Scene::Defeat) => Some(Cue::Defeat), + (Scene::LevelUp, Scene::Quiz | Scene::Oracle) => Some(Cue::Continue(after.tier)), + (Scene::GameOver, Scene::QuizMenu) => Some(Cue::Replay), + (Scene::Battle, Scene::Victory) => Some(Cue::Victory), + _ => None, + } +} + +/// The most important cue among the changes inside one continuing scene. +fn scene_event(before: &AudioSnapshot, after: &AudioSnapshot) -> Option { + let mut candidates = Vec::new(); + match after.scene { + AudioScene::QuizMenu if after.menu_selected != before.menu_selected => { + candidates.push(Cue::Navigate(after.menu_selected)); + } + AudioScene::QuestSelect if after.quest_selected != before.quest_selected => { + candidates.push(Cue::Navigate(after.quest_selected)); + } + AudioScene::CharacterCreation => { + if after.hero_row != before.hero_row { + candidates.push(Cue::Navigate(after.hero_row)); + } + for (row, value, previous) in [ + (0, after.hero_name, before.hero_name), + (1, after.hero_class, before.hero_class), + (2, after.hero_style, before.hero_style), + ] { + if value != previous { + candidates.push(Cue::Trait { row, value }); + } + } + } + AudioScene::Codex if after.codex_page != before.codex_page => { + candidates.push(Cue::PageTurn(after.codex_page)); + } + // Revealing a sealed answer reuses the soft rising reveal motif. + AudioScene::Codex if after.codex_revealed && !before.codex_revealed => { + candidates.push(Cue::QuestionReveal); + } + AudioScene::Oracle => datafall_events(before, after, &mut candidates), + AudioScene::Quiz => quiz_events(before, after, &mut candidates), + _ => {} + } + let cue = candidates.into_iter().max_by_key(|cue| cue.priority()); + let pressed = after.held & !before.held; + cue.or_else(|| { + (pressed != 0 && accepts_unavailable(after, pressed)).then_some(Cue::Unavailable) + }) +} + +fn datafall_events(before: &AudioSnapshot, after: &AudioSnapshot, candidates: &mut Vec) { + if after.data > before.data { + candidates.push(if after.data_stage > before.data_stage { + Cue::ChargeRune(after.data_stage) + } else { + Cue::DataCollect(after.data) + }); + } + if after.bugs > before.bugs { + candidates.push(if after.breach_stage > before.breach_stage { + Cue::SealBreak(after.breach_stage) + } else { + Cue::BugHit + }); + } + if after.questions != before.questions { + match after.questions { + QuestionStatus::Retrying => candidates.push(Cue::Retry), + QuestionStatus::Ready => candidates.push(Cue::Ready), + QuestionStatus::Contacting | QuestionStatus::Writing => {} + } + } +} + +fn quiz_events(before: &AudioSnapshot, after: &AudioSnapshot, candidates: &mut Vec) { + let (Some(previous), Some(run)) = (before.run, after.run) else { + return; + }; + if run.question != previous.question { + candidates.push(Cue::QuestionReveal); + } else if previous.phase == AnswerPhase::Choosing && run.phase != AnswerPhase::Choosing { + // One result cue per commit: a woken lens rune (the pedagogical + // reward) outranks every score threshold, then the rarest one wins. + let lens_woke = run.lens_woke.filter(|_| previous.lens_woke.is_none()); + let result = match (run.phase, lens_woke) { + (AnswerPhase::Correct, Some(stage)) => Cue::LensWake(stage), + (AnswerPhase::Correct, None) if run.insight > previous.insight => { + Cue::RuneAwaken(run.insight) + } + (AnswerPhase::Correct, None) if run.redeemed => Cue::Redeemed, + (AnswerPhase::Correct, None) if run.multiplier > previous.multiplier => { + Cue::FlowUp(run.multiplier) + } + (AnswerPhase::Correct, None) => Cue::Correct, + (AnswerPhase::Wrong, _) if run.hearts == 0 => Cue::WardBreak, + (AnswerPhase::Wrong, _) if run.hearts == 1 => Cue::LowWard, + (AnswerPhase::Wrong | AnswerPhase::Choosing, _) => Cue::Wrong, + }; + candidates.push(result); + } else if run.phase == AnswerPhase::Choosing && run.selected != previous.selected { + candidates.push(Cue::Cursor(run.selected)); + } else if run.leave_armed && !previous.leave_armed { + candidates.push(Cue::LeaveWarning); + } +} + +/// Whether a press edge that changed nothing deserves the soft unavailable cue. +/// Cinematics, answer reviews, and Datafall movement stay quiet. +fn accepts_unavailable(after: &AudioSnapshot, pressed: u16) -> bool { + match after.scene { + AudioScene::Title + | AudioScene::QuizMenu + | AudioScene::CharacterCreation + | AudioScene::QuestSelect + | AudioScene::LevelUp + | AudioScene::GameOver + | AudioScene::Codex => true, + AudioScene::Oracle => pressed & !(pad::LEFT | pad::RIGHT) != 0, + AudioScene::Quiz => after + .run + .is_some_and(|run| run.phase == AnswerPhase::Choosing), + _ => false, + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +enum Owner { + #[default] + Idle, + Loop, + Cue, + Script, +} + +#[derive(Clone, Copy, Debug, Default)] +struct VoiceState { + owner: Owner, + sounding_until: u64, +} + +#[derive(Clone, Copy, Debug)] +struct Pending { + note: Note, + owner: Owner, +} + +#[derive(Clone, Copy, Debug)] +struct ActiveLoop { + pattern: &'static LoopPattern, + anchor: u64, +} + +/// Turns per-tick snapshots into notes. See the module documentation. +#[derive(Debug, Default)] +pub struct AudioDirector { + tick: u64, + previous: Option, + voices: [VoiceState; 4], + pending: Vec, + /// Ticks until which the current cue owns each voice; loops yield to it. + cue_reserved: [u64; 4], + /// Ticks until which the scene's script owns each voice. + script_reserved: [u64; 4], + scene_loop: Option, + last_cue: Option, +} + +impl AudioDirector { + /// Observes one engine tick and returns the notes that start on it. + pub fn observe(&mut self, snapshot: &AudioSnapshot) -> Vec { + self.tick += 1; + let now = self.tick; + let previous = self.previous.replace(snapshot.clone()); + self.last_cue = None; + let mut notes = Vec::new(); + if !snapshot.is_audible() { + self.stop_scene(now, &mut notes); + return notes; + } + + let continuing = previous.as_ref().filter(|before| { + before.is_audible() + && before.scene == snapshot.scene + && snapshot.scene_ticks >= before.scene_ticks + }); + let cue = if let Some(before) = continuing { + self.follow_loop(snapshot, now, &mut notes); + scene_event(before, snapshot) + } else { + self.stop_scene(now, &mut notes); + self.schedule_script(snapshot, now); + self.scene_loop = scene_loop(snapshot).map(|(pattern, delay)| ActiveLoop { + pattern, + anchor: now + delay, + }); + previous + .as_ref() + .and_then(|before| entry_cue(before, snapshot)) + }; + if let Some(cue) = cue { + self.play(cue, now, &mut notes); + } + self.emit_pending(now, &mut notes); + self.emit_loop(now, &mut notes); + + // A cut followed by a note on the same voice and tick is redundant. + let sounding = notes + .iter() + .filter(|note| !note.is_cut()) + .map(|note| note.voice) + .collect::>(); + notes.retain(|note| !note.is_cut() || !sounding.contains(¬e.voice)); + notes + } + + /// The absolute tick of the most recent observation. + pub fn tick(&self) -> u64 { + self.tick + } + + /// The cue that started on the most recent tick, if any. + #[cfg(test)] + pub fn last_cue(&self) -> Option { + self.last_cue + } + + /// Ends everything the previous scene owned on this exact tick. + fn stop_scene(&mut self, now: u64, notes: &mut Vec) { + self.pending.clear(); + self.scene_loop = None; + self.cue_reserved = [0; 4]; + self.script_reserved = [0; 4]; + for voice in Voice::ALL { + self.cut(voice, now, notes); + } + } + + fn cut(&mut self, voice: Voice, now: u64, notes: &mut Vec) { + let state = &mut self.voices[voice.index()]; + if state.sounding_until > now { + notes.push(Note::cut(voice, now)); + } + *state = VoiceState { + owner: Owner::Idle, + sounding_until: now.min(state.sounding_until), + }; + } + + fn schedule_script(&mut self, snapshot: &AudioSnapshot, now: u64) { + for tone in script_tones(snapshot) { + let tick = now + u64::from(tone.at).saturating_sub(snapshot.scene_ticks); + let reserved = &mut self.script_reserved[tone.voice.index()]; + *reserved = (*reserved).max(tick + u64::from(tone.length)); + self.pending.push(Pending { + note: tone.note(tick), + owner: Owner::Script, + }); + } + } + + /// Starts `cue`, silencing whatever the previous cue still had to say. + fn play(&mut self, cue: Cue, now: u64, notes: &mut Vec) { + self.last_cue = Some(cue); + self.pending.retain(|pending| pending.owner != Owner::Cue); + for voice in Voice::ALL { + if self.voices[voice.index()].owner == Owner::Cue { + self.cut(voice, now, notes); + } + } + // The replaced cue's voices are released at once. + self.cue_reserved = [0; 4]; + for tone in cue.tones() { + let tick = now + u64::from(tone.at); + let reserved = &mut self.cue_reserved[tone.voice.index()]; + *reserved = (*reserved).max(tick + u64::from(tone.length)); + self.pending.push(Pending { + note: tone.note(tick), + owner: Owner::Cue, + }); + } + } + + fn emit_pending(&mut self, now: u64, notes: &mut Vec) { + let (due, waiting): (Vec<_>, Vec<_>) = self + .pending + .drain(..) + .partition(|pending| pending.note.tick <= now); + self.pending = waiting; + for pending in due { + self.voices[pending.note.voice.index()] = VoiceState { + owner: pending.owner, + sounding_until: pending.note.end(), + }; + notes.push(pending.note); + } + } + + /// Restarts the loop when the scene's own state asks for a different bed, + /// such as the heartbeat when the run falls to its last ward. + fn follow_loop(&mut self, snapshot: &AudioSnapshot, now: u64, notes: &mut Vec) { + let desired = scene_loop(snapshot); + let unchanged = match (self.scene_loop, desired) { + (Some(active), Some((pattern, _))) => std::ptr::eq(active.pattern, pattern), + (None, None) => true, + _ => false, + }; + if unchanged { + return; + } + for voice in Voice::ALL { + if self.voices[voice.index()].owner == Owner::Loop { + self.cut(voice, now, notes); + } + } + self.scene_loop = desired.map(|(pattern, delay)| ActiveLoop { + pattern, + anchor: now + delay, + }); + } + + fn emit_loop(&mut self, now: u64, notes: &mut Vec) { + let Some(active) = self.scene_loop else { + return; + }; + let step_ticks = u64::from(active.pattern.step_ticks); + let Some(elapsed) = now.checked_sub(active.anchor) else { + return; + }; + if !elapsed.is_multiple_of(step_ticks) { + return; + } + let step = (elapsed / step_ticks) as usize; + for track in active.pattern.tracks { + debug_assert!(track.volume <= AMBIENCE_MAX_VOLUME); + let pitch = track.steps[step % track.steps.len()]; + let index = track.voice.index(); + if pitch == REST || self.cue_reserved[index].max(self.script_reserved[index]) > now { + continue; + } + let note = Note { + voice: track.voice, + tick: now, + duration_ticks: track.length, + pitch, + volume: track.volume, + duty: track.duty, + decay: track.decay, + slide: 0, + }; + self.voices[index] = VoiceState { + owner: Owner::Loop, + sounding_until: note.end(), + }; + notes.push(note); + } + } +} + +/// The engine's sound output: the director plus the notes it has emitted +/// since the transport last drained them. +#[derive(Resource, Debug, Default)] +pub struct AudioOut { + director: AudioDirector, + queue: AudioQueue, +} + +impl AudioOut { + pub fn observe(&mut self, snapshot: &AudioSnapshot) { + let notes = self.director.observe(snapshot); + self.queue.append(AudioBatch { + tick: self.director.tick(), + notes, + }); + } + + pub fn drain(&mut self) -> AudioBatch { + self.queue.drain() + } + + #[cfg(test)] + pub fn last_cue(&self) -> Option { + self.director.last_cue() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeSet; + + fn scene(scene: AudioScene) -> AudioSnapshot { + AudioSnapshot { + powered: true, + scene, + ..AudioSnapshot::default() + } + } + + fn quiz_scene(hearts: u8) -> AudioSnapshot { + AudioSnapshot { + run: Some(RunAudio { + hearts, + multiplier: 1, + level: 1, + ..RunAudio::default() + }), + ..scene(AudioScene::Quiz) + } + } + + /// Advances `snapshot` by one tick, counting scene ticks the way the + /// engine does, and returns the notes that start on that tick. + fn step(director: &mut AudioDirector, snapshot: &mut AudioSnapshot) -> Vec { + snapshot.scene_ticks += 1; + director.observe(snapshot) + } + + fn run_for( + director: &mut AudioDirector, + snapshot: &mut AudioSnapshot, + ticks: u64, + ) -> Vec { + (0..ticks).flat_map(|_| step(director, snapshot)).collect() + } + + fn enter( + director: &mut AudioDirector, + snapshot: &mut AudioSnapshot, + next: AudioScene, + ) -> Vec { + snapshot.scene = next; + snapshot.scene_ticks = 0; + director.observe(snapshot) + } + + fn audible(notes: &[Note]) -> Vec { + notes + .iter() + .copied() + .filter(|note| !note.is_cut()) + .collect() + } + + fn voices(notes: &[Note]) -> BTreeSet { + audible(notes).iter().map(|note| note.voice).collect() + } + + fn tone_voices(tones: &[Tone]) -> BTreeSet { + tones.iter().map(|tone| tone.voice).collect() + } + + fn every_cue() -> Vec { + let mut cues = vec![ + Cue::Confirm, + Cue::Cancel, + Cue::Unavailable, + Cue::BeginRun, + Cue::OracleEnter, + Cue::BugHit, + Cue::Retry, + Cue::Ready, + Cue::Leave, + Cue::QuestionReveal, + Cue::Correct, + Cue::Redeemed, + Cue::LeaveWarning, + Cue::Wrong, + Cue::LowWard, + Cue::WardBreak, + Cue::BatchComplete, + Cue::Defeat, + Cue::Replay, + Cue::Victory, + ]; + for index in 0..6 { + cues.extend([ + Cue::Navigate(index), + Cue::PageTurn(index), + Cue::Cursor(index), + Cue::DataCollect(index as u32), + ]); + for row in 0..3 { + cues.push(Cue::Trait { row, value: index }); + } + } + for stage in 1..=3 { + cues.extend([ + Cue::ChargeRune(stage), + Cue::SealBreak(stage), + Cue::RuneAwaken(stage), + Cue::LensWake(stage as u8), + Cue::FlowUp(stage as u32), + ]); + } + for tier in [Tier::Initiate, Tier::Adept, Tier::OracleBound] { + cues.push(Cue::Continue(tier)); + } + cues + } + + fn every_loop() -> [&'static LoopPattern; 7] { + [ + &TITLE_LOOP, + &MENU_LOOP, + &DATAFALL_INITIATE, + &DATAFALL_ADEPT, + &DATAFALL_ORACLE_BOUND, + &DANGER_BED, + &BATTLE_LOOP, + ] + } + + #[test] + fn nothing_plays_while_powered_off_or_booting() { + let mut director = AudioDirector::default(); + let mut off = AudioSnapshot { + scene: AudioScene::Off, + ..AudioSnapshot::default() + }; + assert!(run_for(&mut director, &mut off, 120).is_empty()); + off.held = pad::A | pad::START; + assert!(run_for(&mut director, &mut off, 10).is_empty()); + + let mut booting = scene(AudioScene::Boot); + booting.held = pad::START; + assert!(run_for(&mut director, &mut booting, 180).is_empty()); + + // Even a scene that owns a loop stays silent without power. + let mut unpowered = AudioSnapshot { + powered: false, + ..scene(AudioScene::Oracle) + }; + assert!(run_for(&mut director, &mut unpowered, 180).is_empty()); + } + + #[test] + fn power_loss_cuts_every_sounding_voice_on_that_tick() { + let mut director = AudioDirector::default(); + let mut snapshot = AudioSnapshot { + tier: Tier::OracleBound, + ..scene(AudioScene::Oracle) + }; + let before = run_for(&mut director, &mut snapshot, 120); + assert!( + !audible(&before).is_empty(), + "the Datafall loop should play" + ); + + snapshot.powered = false; + snapshot.scene = AudioScene::Off; + let cut = step(&mut director, &mut snapshot); + let tick = director.tick(); + assert!(cut.iter().all(|note| note.is_cut() && note.tick == tick)); + for note in before.iter().filter(|note| note.end() > tick) { + assert!( + cut.iter().any(|cut| cut.voice == note.voice), + "{:?} kept sounding after power loss", + note.voice + ); + } + assert!(run_for(&mut director, &mut snapshot, 240).is_empty()); + } + + #[test] + fn unknown_scenes_are_silent_and_end_the_previous_loop() { + let mut director = AudioDirector::default(); + let mut snapshot = scene(AudioScene::Title); + assert!(!audible(&run_for(&mut director, &mut snapshot, 200)).is_empty()); + + let exit = enter(&mut director, &mut snapshot, AudioScene::Unlisted); + assert!(!exit.is_empty() && exit.iter().all(Note::is_cut)); + snapshot.held = pad::A; + assert!(run_for(&mut director, &mut snapshot, 300).is_empty()); + } + + #[test] + fn datafall_arrangement_gains_a_voice_with_each_tier() { + let counts = [Tier::Initiate, Tier::Adept, Tier::OracleBound].map(|tier| { + let mut director = AudioDirector::default(); + let mut snapshot = AudioSnapshot { + tier, + ..scene(AudioScene::Oracle) + }; + let notes = run_for(&mut director, &mut snapshot, 400); + assert!(notes.iter().all(|note| note.volume <= AMBIENCE_MAX_VOLUME)); + voices(¬es).len() + }); + assert_eq!( + counts, + [2, 3, 4], + "Initiate, Adept, and Oracle-bound voices" + ); + } + + #[test] + fn scene_loops_end_on_the_exact_tick_their_scene_exits() { + let mut director = AudioDirector::default(); + let mut snapshot = AudioSnapshot { + tier: Tier::OracleBound, + ..scene(AudioScene::Oracle) + }; + let before = audible(&run_for(&mut director, &mut snapshot, 173)); + let exit = enter(&mut director, &mut snapshot, AudioScene::QuizMenu); + let exit_tick = director.tick(); + assert_eq!(director.last_cue(), Some(Cue::Leave)); + + let tails = before + .iter() + .filter(|note| note.end() > exit_tick) + .collect::>(); + assert!( + !tails.is_empty(), + "the exit should interrupt a sounding loop" + ); + for tail in tails { + assert!( + exit.iter() + .any(|note| note.voice == tail.voice && note.tick == exit_tick), + "{:?} carried the Datafall loop into the menu", + tail.voice + ); + } + + // Until the menu loop enters, the only notes are the Leave cue's. + let leave = Cue::Leave.tones(); + let after = run_for(&mut director, &mut snapshot, 15); + for note in audible(&exit).iter().chain(&audible(&after)) { + let offset = note.tick - exit_tick; + assert!( + leave.iter().any(|tone| u64::from(tone.at) == offset + && tone.voice == note.voice + && tone.pitch == note.pitch), + "unexpected {note:?} after the Oracle exit" + ); + } + } + + #[test] + fn simultaneous_events_start_only_the_highest_priority_cue() { + let mut director = AudioDirector::default(); + let mut snapshot = scene(AudioScene::Oracle); + run_for(&mut director, &mut snapshot, 40); + + snapshot.data = 3; + snapshot.data_stage = 1; + snapshot.bugs = 1; + snapshot.breach_stage = 1; + snapshot.questions = QuestionStatus::Ready; + snapshot.held = pad::A; + let notes = step(&mut director, &mut snapshot); + assert_eq!(director.last_cue(), Some(Cue::SealBreak(1))); + let seal = Cue::SealBreak(1).tones(); + for note in audible(¬es) { + assert!( + seal.iter().any(|tone| tone.at == 0 + && tone.voice == note.voice + && tone.pitch == note.pitch), + "{note:?} stacked over the seal-break cue" + ); + } + } + + #[test] + fn a_new_result_silences_the_previous_cue_instead_of_stacking() { + let mut director = AudioDirector::default(); + let mut snapshot = quiz_scene(3); + run_for(&mut director, &mut snapshot, 10); + + let mut run = snapshot.run.unwrap(); + run.phase = AnswerPhase::Wrong; + run.hearts = 2; + snapshot.run = Some(run); + step(&mut director, &mut snapshot); + assert_eq!(director.last_cue(), Some(Cue::Wrong)); + run_for(&mut director, &mut snapshot, 3); + + // A second result a few ticks later replaces the first entirely. + run.question = 1; + run.phase = AnswerPhase::Choosing; + snapshot.run = Some(run); + step(&mut director, &mut snapshot); + run.phase = AnswerPhase::Correct; + snapshot.run = Some(run); + let second = step(&mut director, &mut snapshot); + let start = director.tick(); + assert_eq!(director.last_cue(), Some(Cue::Correct)); + let later = run_for(&mut director, &mut snapshot, 40); + let correct = Cue::Correct.tones(); + for note in audible(&second).iter().chain(&audible(&later)) { + assert!( + correct + .iter() + .any(|tone| start + u64::from(tone.at) == note.tick + && tone.voice == note.voice + && tone.pitch == note.pitch), + "{note:?} leaked from an earlier cue" + ); + } + } + + #[test] + fn loops_yield_their_voices_to_a_cue_and_resume_after_it() { + let mut director = AudioDirector::default(); + let mut snapshot = quiz_scene(1); + // The last-ward heartbeat enters 24 ticks after the scene and beats + // twice every 64 ticks; commit just before its second beat. + let bed = audible(&run_for(&mut director, &mut snapshot, 31)); + assert!(bed + .iter() + .any(|note| note.voice == Wave && note.pitch == A2)); + + snapshot.run = Some(RunAudio { + phase: AnswerPhase::Wrong, + ..snapshot.run.unwrap() + }); + let later = audible(&run_for(&mut director, &mut snapshot, 90)); + let start = director.tick() - 89; + assert_eq!(later[0].tick, start); + let cue_end = start + + Cue::LowWard + .tones() + .iter() + .map(|tone| u64::from(tone.end())) + .max() + .unwrap(); + for note in &later { + let from_bed = note.pitch == A2 || note.pitch == NOISE_THUD; + assert!( + !from_bed || note.tick >= cue_end || !matches!(note.voice, Wave | Noise), + "{note:?} interrupted the low-ward cue" + ); + } + assert!( + later + .iter() + .any(|note| note.voice == Wave && note.pitch == A2 && note.tick >= cue_end), + "the heartbeat returns once the cue releases its voices" + ); + } + + #[test] + fn quiz_results_choose_the_single_most_meaningful_variant() { + // (hearts, multiplier, insight) before -> (phase, hearts, multiplier, insight) after. + let cases = [ + ((3, 1, 0), (AnswerPhase::Correct, 3, 1, 0), Cue::Correct), + ((3, 1, 0), (AnswerPhase::Correct, 3, 2, 0), Cue::FlowUp(2)), + ( + (3, 2, 0), + (AnswerPhase::Correct, 3, 3, 1), + Cue::RuneAwaken(1), + ), + ((3, 1, 0), (AnswerPhase::Wrong, 2, 1, 0), Cue::Wrong), + ((2, 1, 0), (AnswerPhase::Wrong, 1, 1, 0), Cue::LowWard), + ((1, 1, 0), (AnswerPhase::Wrong, 0, 1, 0), Cue::WardBreak), + ]; + for ( + (hearts, multiplier, insight), + (phase, hearts_after, multiplier_after, insight_after), + expected, + ) in cases + { + let mut director = AudioDirector::default(); + let mut snapshot = quiz_scene(hearts); + let run = RunAudio { + multiplier, + insight, + ..snapshot.run.unwrap() + }; + snapshot.run = Some(run); + run_for(&mut director, &mut snapshot, 5); + snapshot.run = Some(RunAudio { + phase, + hearts: hearts_after, + multiplier: multiplier_after, + insight: insight_after, + ..run + }); + step(&mut director, &mut snapshot); + assert_eq!(director.last_cue(), Some(expected)); + } + } + + #[test] + fn a_woken_lens_rune_outranks_every_score_threshold_with_one_cue() { + // (lens stage woken, insight after, redeemed) -> the single result cue. + let cases = [ + (Some(1), 0, false, Cue::LensWake(1)), + (Some(2), 1, false, Cue::LensWake(2)), + (Some(3), 0, true, Cue::LensWake(3)), + (None, 1, false, Cue::RuneAwaken(1)), + ]; + for (lens_woke, insight_after, redeemed, expected) in cases { + let mut director = AudioDirector::default(); + let mut snapshot = quiz_scene(3); + let run = RunAudio { + multiplier: 2, + ..snapshot.run.unwrap() + }; + snapshot.run = Some(run); + run_for(&mut director, &mut snapshot, 5); + snapshot.run = Some(RunAudio { + phase: AnswerPhase::Correct, + multiplier: 3, + insight: insight_after, + redeemed, + lens_woke, + ..run + }); + step(&mut director, &mut snapshot); + assert_eq!(director.last_cue(), Some(expected)); + // The held lesson card never starts a second result cue. + for _ in 0..60 { + step(&mut director, &mut snapshot); + assert_eq!(director.last_cue(), None); + } + } + // Each stage climbs: the crest note rises with the stage. + let crest = |stage: u8| { + Cue::LensWake(stage) + .tones() + .iter() + .filter(|tone| tone.voice == Pulse1) + .map(|tone| tone.pitch) + .max() + .unwrap() + }; + assert!(crest(1) < crest(2) && crest(2) < crest(3)); + let pitches = |cue: Cue| { + cue.tones() + .iter() + .map(|tone| (tone.voice, tone.pitch)) + .collect::>() + }; + assert_ne!(pitches(Cue::LensWake(1)), pitches(Cue::RuneAwaken(1))); + } + + #[test] + fn press_edges_that_change_nothing_get_one_soft_unavailable_cue() { + let mut director = AudioDirector::default(); + let mut snapshot = scene(AudioScene::QuizMenu); + run_for(&mut director, &mut snapshot, 5); + snapshot.held = pad::LEFT; + step(&mut director, &mut snapshot); + assert_eq!(director.last_cue(), Some(Cue::Unavailable)); + // Holding the button is not a new edge. + step(&mut director, &mut snapshot); + assert_eq!(director.last_cue(), None); + + // Datafall movement and cinematic presses stay quiet. + let mut oracle = scene(AudioScene::Oracle); + run_for(&mut director, &mut oracle, 5); + oracle.held = pad::LEFT | pad::RIGHT; + step(&mut director, &mut oracle); + assert_eq!(director.last_cue(), None); + oracle.held |= pad::A; + step(&mut director, &mut oracle); + assert_eq!(director.last_cue(), Some(Cue::Unavailable)); + + let mut opening = scene(AudioScene::Opening(OpeningBeat::SourceEmber)); + run_for(&mut director, &mut opening, 5); + opening.held = pad::START; + step(&mut director, &mut opening); + assert_eq!(director.last_cue(), None); + } + + #[test] + fn copyright_is_near_silent_apart_from_dry_archival_ticks() { + let mut director = AudioDirector::default(); + let mut snapshot = scene(AudioScene::Boot); + step(&mut director, &mut snapshot); + let mut notes = enter(&mut director, &mut snapshot, AudioScene::Copyright); + let entry = director.tick(); + notes.extend(run_for(&mut director, &mut snapshot, 179)); + assert_eq!( + notes + .iter() + .map(|note| note.tick - entry) + .collect::>(), + ARCHIVE_REVEAL_TICKS.map(u64::from).to_vec() + ); + assert!(notes + .iter() + .all(|note| note.voice == Noise && note.volume <= 4 && note.duration_ticks == 1)); + } + + #[test] + fn opening_motif_grows_from_one_pulse_to_the_full_cadence() { + // Durations mirror the elapsed gates in the root CODEQUEST.toml. + let beats = [ + (OpeningBeat::SourceEmber, 96), + (OpeningBeat::ArchiveAnswer, 66), + (OpeningBeat::MemoryVault, 66), + (OpeningBeat::Convergence, 66), + (OpeningBeat::OracleAwakening, 66), + ]; + let mut previous_voices = 0; + let mut previous_peak = 0; + for (beat, duration) in beats { + let tones = opening_tones(beat); + let voice_count = tone_voices(&tones).len(); + let peak = tones.iter().map(|tone| tone.volume).max().unwrap(); + assert!(voice_count >= previous_voices, "{beat:?} lost a layer"); + assert!( + peak >= previous_peak, + "{beat:?} is quieter than its predecessor" + ); + assert!( + tones.iter().all(|tone| tone.end() <= duration - 4), + "{beat:?} must tail cleanly before its scene ends" + ); + previous_voices = voice_count; + previous_peak = peak; + } + assert_eq!(tone_voices(&source_pulse()).len(), 1, "one source pulse"); + assert_eq!( + tone_voices(&opening_tones(OpeningBeat::OracleAwakening)).len(), + 4, + "the crescendo is the only full arrangement" + ); + assert!(opening_tones(OpeningBeat::Legacy) + .iter() + .all(|tone| tone.end() <= 326)); + } + + #[test] + fn skipping_the_opening_cuts_the_fanfare_at_the_title_handoff() { + let mut director = AudioDirector::default(); + let mut snapshot = scene(AudioScene::Opening(OpeningBeat::OracleAwakening)); + director.observe(&snapshot); + run_for(&mut director, &mut snapshot, 20); + let handoff = enter(&mut director, &mut snapshot, AudioScene::Title); + assert!(!handoff.is_empty() && handoff.iter().all(Note::is_cut)); + // The title loop waits for a clean breath before it begins. + assert!(run_for(&mut director, &mut snapshot, 29).is_empty()); + assert!(!run_for(&mut director, &mut snapshot, 1).is_empty()); + } + + #[test] + fn level_up_reward_gains_voices_by_tier_and_resolves_within_its_hold() { + let counts = [Tier::Initiate, Tier::Adept, Tier::OracleBound].map(|tier| { + let tones = later(reward_cadence(tier), REWARD_DELAY); + assert!(tones.iter().all(|tone| tone.end() <= 60), "{tier:?}"); + tone_voices(&tones).len() + }); + assert_eq!(counts, [2, 3, 4]); + let continuation = [Tier::Initiate, Tier::Adept, Tier::OracleBound] + .map(|tier| tone_voices(&Cue::Continue(tier).tones()).len()); + assert_eq!(continuation, [1, 2, 4]); + assert!(Cue::BatchComplete + .tones() + .iter() + .all(|tone| tone.end() <= REWARD_DELAY)); + } + + #[test] + fn feedback_cues_read_above_every_ambience_loop() { + for pattern in every_loop() { + for track in pattern.tracks { + assert!(track.volume <= AMBIENCE_MAX_VOLUME, "{track:?}"); + } + } + for cue in every_cue() { + let tones = cue.tones(); + let peak = tones.iter().map(|tone| tone.volume).max().unwrap_or(0); + assert!( + peak > AMBIENCE_MAX_VOLUME, + "{cue:?} is no louder than ambience" + ); + assert!(tones.iter().all(|tone| tone.volume <= 15 && tone.pitch > 0)); + } + } + + #[test] + fn identity_rows_answer_with_distinct_timbres() { + let timbres = [0, 1, 2].map(|row| { + let tone = Cue::Trait { row, value: 1 }.tones()[0]; + (tone.voice, tone.duty, tone.slide) + }); + assert_ne!(timbres[0], timbres[1]); + assert_ne!(timbres[1], timbres[2]); + assert_ne!(timbres[0], timbres[2]); + } + + #[test] + fn the_same_snapshots_always_produce_the_same_notes() { + let script = |director: &mut AudioDirector| { + let mut snapshot = scene(AudioScene::Title); + let mut notes = run_for(director, &mut snapshot, 90); + notes.extend(enter(director, &mut snapshot, AudioScene::QuizMenu)); + snapshot.held = pad::DOWN; + snapshot.menu_selected = 1; + notes.extend(run_for(director, &mut snapshot, 40)); + notes.extend(enter(director, &mut snapshot, AudioScene::Oracle)); + snapshot.data = 1; + notes.extend(run_for(director, &mut snapshot, 200)); + notes + }; + let first = script(&mut AudioDirector::default()); + let second = script(&mut AudioDirector::default()); + assert!(first.len() > 10); + assert_eq!(first, second); + } + + #[test] + fn the_transport_queue_drops_the_oldest_notes_beyond_its_bound() { + let mut queue = AudioQueue::default(); + let note = |tick| tone(0, Pulse1, A4, 4, 10).note(tick); + queue.append(AudioBatch { + tick: 700, + notes: (0..700).map(note).collect(), + }); + let batch = queue.drain(); + assert_eq!(batch.tick, 700); + assert_eq!(batch.notes.len(), AUDIO_QUEUE_CAPACITY); + assert_eq!(batch.notes[0].tick, 700 - AUDIO_QUEUE_CAPACITY as u64); + assert_eq!(batch.notes.last().unwrap().tick, 699); + assert_eq!( + queue.drain(), + AudioBatch { + tick: 700, + notes: Vec::new() + } + ); + } + + #[test] + fn engine_audio_payload_serializes_for_the_shell_speaker() { + let batch = AudioBatch { + tick: 42, + notes: vec![ + tone(0, Pulse1, A4, 8, 10) + .duty(Duty::Quarter) + .decay(2) + .slide(-3) + .note(40), + Note::cut(Noise, 41), + ], + }; + assert_eq!( + serde_json::to_value(&batch).unwrap(), + serde_json::json!({ + "tick": 42, + "notes": [ + { + "voice": "pulse1", + "tick": 40, + "durationTicks": 8, + "pitch": 69, + "volume": 10, + "duty": 2, + "decay": 2, + "slide": -3 + }, + { + "voice": "noise", + "tick": 41, + "durationTicks": 0, + "pitch": 0, + "volume": 0, + "duty": 4, + "decay": 0, + "slide": 0 + } + ] + }) + ); + for (voice, name) in Voice::ALL + .into_iter() + .zip(["pulse1", "pulse2", "wave", "noise"]) + { + assert_eq!(serde_json::to_value(voice).unwrap(), name); + } + for (duty, eighths) in [ + (Duty::Eighth, 1), + (Duty::Quarter, 2), + (Duty::Half, 4), + (Duty::ThreeQuarters, 6), + ] { + assert_eq!(serde_json::to_value(duty).unwrap(), eighths); + } + } + + #[test] + fn datafall_cues_name_the_single_event_that_changed() { + // (data, data_stage, bugs, breach_stage) after a settled 1/0/0/0 field. + let cases = [ + ((2, 0, 0, 0), Cue::DataCollect(2)), + ((2, 1, 0, 0), Cue::ChargeRune(1)), + ((1, 0, 1, 0), Cue::BugHit), + ((1, 0, 1, 1), Cue::SealBreak(1)), + // A bug costs the run, so it outranks a pickup on the same tick. + ((2, 0, 1, 0), Cue::BugHit), + ]; + for ((data, data_stage, bugs, breach_stage), expected) in cases { + let mut director = AudioDirector::default(); + let mut snapshot = AudioSnapshot { + data: 1, + ..scene(AudioScene::Oracle) + }; + run_for(&mut director, &mut snapshot, 5); + snapshot.data = data; + snapshot.data_stage = data_stage; + snapshot.bugs = bugs; + snapshot.breach_stage = breach_stage; + step(&mut director, &mut snapshot); + assert_eq!( + director.last_cue(), + Some(expected), + "{:?}", + (data, data_stage, bugs, breach_stage) + ); + } + } + + #[test] + fn a_question_status_is_announced_once_when_it_changes() { + let mut director = AudioDirector::default(); + let mut snapshot = scene(AudioScene::Oracle); + run_for(&mut director, &mut snapshot, 5); + for (status, expected) in [ + (QuestionStatus::Writing, None), + (QuestionStatus::Retrying, Some(Cue::Retry)), + (QuestionStatus::Ready, Some(Cue::Ready)), + ] { + snapshot.questions = status; + step(&mut director, &mut snapshot); + assert_eq!(director.last_cue(), expected, "{status:?}"); + for _ in 0..30 { + step(&mut director, &mut snapshot); + assert_eq!(director.last_cue(), None, "{status:?} repeated its cue"); + } + } + } + + #[test] + fn quiz_edges_start_one_cue_and_held_state_starts_none() { + let mut director = AudioDirector::default(); + let mut snapshot = quiz_scene(3); + run_for(&mut director, &mut snapshot, 5); + + // Redeeming a miss is rarer than a flow step, so it wins the commit. + let mut run = snapshot.run.unwrap(); + run.phase = AnswerPhase::Correct; + run.multiplier = 2; + run.redeemed = true; + snapshot.run = Some(run); + step(&mut director, &mut snapshot); + assert_eq!(director.last_cue(), Some(Cue::Redeemed)); + + // The answer review ignores cursor moves and dead presses. + run.selected = 2; + snapshot.run = Some(run); + snapshot.held = pad::DOWN; + step(&mut director, &mut snapshot); + assert_eq!(director.last_cue(), None, "the review has no cursor"); + snapshot.held = 0; + step(&mut director, &mut snapshot); + snapshot.held = pad::LEFT; + step(&mut director, &mut snapshot); + assert_eq!( + director.last_cue(), + None, + "the review keeps dead presses quiet" + ); + snapshot.held = 0; + + // A lens rune an earlier answer woke is not announced again. + run.question = 1; + run.phase = AnswerPhase::Choosing; + run.redeemed = false; + run.lens_woke = Some(1); + snapshot.run = Some(run); + step(&mut director, &mut snapshot); + assert_eq!(director.last_cue(), Some(Cue::QuestionReveal)); + run_for(&mut director, &mut snapshot, 5); + run.phase = AnswerPhase::Correct; + snapshot.run = Some(run); + step(&mut director, &mut snapshot); + assert_eq!(director.last_cue(), Some(Cue::Correct)); + + // Arming the leave confirmation warns once, however long it stays armed. + run.question = 2; + run.phase = AnswerPhase::Choosing; + run.lens_woke = None; + snapshot.run = Some(run); + step(&mut director, &mut snapshot); + run_for(&mut director, &mut snapshot, 5); + run.leave_armed = true; + snapshot.run = Some(run); + step(&mut director, &mut snapshot); + assert_eq!(director.last_cue(), Some(Cue::LeaveWarning)); + for _ in 0..30 { + step(&mut director, &mut snapshot); + assert_eq!(director.last_cue(), None, "an armed leave warns once"); + } + } + + #[test] + fn scene_entries_announce_where_the_player_came_from() { + use AudioScene as S; + let cases = [ + (S::Title, S::QuizMenu, Some(Cue::Confirm)), + (S::QuizMenu, S::Title, Some(Cue::Cancel)), + (S::QuizMenu, S::Codex, Some(Cue::Confirm)), + (S::Codex, S::QuizMenu, Some(Cue::Cancel)), + (S::CharacterCreation, S::Oracle, Some(Cue::BeginRun)), + (S::Oracle, S::Quiz, Some(Cue::QuestionReveal)), + (S::Oracle, S::QuizMenu, Some(Cue::Leave)), + (S::Quiz, S::Oracle, Some(Cue::OracleEnter)), + (S::Quiz, S::LevelUp, Some(Cue::BatchComplete)), + (S::Quiz, S::GameOver, Some(Cue::Defeat)), + (S::GameOver, S::QuizMenu, Some(Cue::Replay)), + (S::QuestSelect, S::Battle, Some(Cue::Confirm)), + (S::Battle, S::Victory, Some(Cue::Victory)), + (S::Battle, S::Defeat, Some(Cue::Defeat)), + (S::Victory, S::QuestSelect, Some(Cue::Confirm)), + (S::Copyright, S::Opening(OpeningBeat::SourceEmber), None), + (S::Opening(OpeningBeat::OracleAwakening), S::Title, None), + ]; + for (from, to, expected) in cases { + let mut director = AudioDirector::default(); + let mut snapshot = scene(from); + run_for(&mut director, &mut snapshot, 5); + enter(&mut director, &mut snapshot, to); + assert_eq!(director.last_cue(), expected, "{from:?} -> {to:?}"); + } + + // Continuing from a level-up plays the arrangement of the tier reached. + let mut director = AudioDirector::default(); + let mut snapshot = scene(S::LevelUp); + run_for(&mut director, &mut snapshot, 5); + snapshot.tier = Tier::Adept; + enter(&mut director, &mut snapshot, S::Oracle); + assert_eq!(director.last_cue(), Some(Cue::Continue(Tier::Adept))); + + // A scene last seen without power announces nothing when power returns. + let mut director = AudioDirector::default(); + let mut snapshot = AudioSnapshot { + powered: false, + ..scene(S::Defeat) + }; + run_for(&mut director, &mut snapshot, 5); + snapshot.powered = true; + enter(&mut director, &mut snapshot, S::Title); + assert_eq!(director.last_cue(), None); + } + + #[test] + fn re_entering_a_scene_restarts_its_script() { + let mut director = AudioDirector::default(); + let mut snapshot = scene(AudioScene::Copyright); + let first = run_for(&mut director, &mut snapshot, 60); + assert_eq!(audible(&first).len(), ARCHIVE_REVEAL_TICKS.len()); + // The engine restarts the scene: its tick count falls back to zero. + let mut again = enter(&mut director, &mut snapshot, AudioScene::Copyright); + again.extend(run_for(&mut director, &mut snapshot, 59)); + assert_eq!( + audible(&again).len(), + ARCHIVE_REVEAL_TICKS.len(), + "a restarted chronicle ticks through its reveals again" + ); + } + + #[test] + fn a_new_cue_cuts_every_voice_the_previous_cue_still_sounds() { + let mut director = AudioDirector::default(); + let mut snapshot = quiz_scene(3); + run_for(&mut director, &mut snapshot, 5); + let mut run = snapshot.run.unwrap(); + run.phase = AnswerPhase::Wrong; + run.hearts = 2; + snapshot.run = Some(run); + let mut wrong = step(&mut director, &mut snapshot); + assert_eq!(director.last_cue(), Some(Cue::Wrong)); + wrong.extend(run_for(&mut director, &mut snapshot, 4)); + + // The reveal speaks on the wave voice alone while the buzz still + // sounds on Pulse1; the buzz must not ring on under it. + run.question = 1; + run.phase = AnswerPhase::Choosing; + snapshot.run = Some(run); + let reveal = step(&mut director, &mut snapshot); + let tick = director.tick(); + assert_eq!(director.last_cue(), Some(Cue::QuestionReveal)); + let ringing = audible(&wrong) + .into_iter() + .filter(|note| note.end() > tick) + .map(|note| note.voice) + .collect::>(); + assert!(ringing.contains(&Pulse1), "the buzz is still sounding"); + for voice in ringing { + assert!( + reveal + .iter() + .any(|note| note.voice == voice && note.tick == tick), + "{voice:?} kept the wrong-answer buzz under the reveal" + ); + } + } + + #[test] + fn loops_reclaim_a_voice_as_soon_as_no_cue_holds_it() { + // The Initiate bass enters 32 ticks after the scene (tick 33) and next + // plays C3 on step 8, 80 ticks later: tick 113. + let bass_returns = |notes: &[Note]| { + notes + .iter() + .any(|note| note.voice == Wave && note.tick == 113 && note.pitch == C3) + }; + // A seal break holds the wave voice for 18 ticks: one at tick 95 ends + // exactly at 113, and one at 105 is replaced by a pickup at 107. + for (seal_at, pickup_at) in [(95, None), (105, Some(107))] { + let mut director = AudioDirector::default(); + let mut snapshot = scene(AudioScene::Oracle); + run_for(&mut director, &mut snapshot, seal_at - 1); + snapshot.bugs = 1; + snapshot.breach_stage = 1; + let mut notes = step(&mut director, &mut snapshot); + assert_eq!(director.tick(), seal_at); + assert_eq!(director.last_cue(), Some(Cue::SealBreak(1))); + if let Some(pickup_at) = pickup_at { + notes.extend(run_for( + &mut director, + &mut snapshot, + pickup_at - seal_at - 1, + )); + snapshot.data = 1; + notes.extend(step(&mut director, &mut snapshot)); + assert_eq!(director.last_cue(), Some(Cue::DataCollect(1))); + } + let remaining = 120 - director.tick(); + notes.extend(run_for(&mut director, &mut snapshot, remaining)); + assert!( + bass_returns(¬es), + "the bass must return at tick 113 after a seal break at {seal_at}" + ); + } + } + + #[test] + fn the_heartbeat_stops_on_the_tick_the_last_ward_breaks() { + let mut director = AudioDirector::default(); + let mut snapshot = quiz_scene(1); + // The bed enters at tick 25 and beats again at tick 33 for five ticks. + let bed = audible(&run_for(&mut director, &mut snapshot, 34)); + assert!(bed + .iter() + .any(|note| note.voice == Wave && note.tick == 33 && note.end() > 35)); + + snapshot.run = Some(RunAudio { + phase: AnswerPhase::Wrong, + hearts: 0, + ..snapshot.run.unwrap() + }); + let broken = step(&mut director, &mut snapshot); + assert_eq!(director.tick(), 35); + assert_eq!(director.last_cue(), Some(Cue::WardBreak)); + assert!( + broken + .iter() + .any(|note| note.voice == Wave && note.tick == 35 && note.is_cut()), + "the sounding heartbeat is cut when the last ward breaks" + ); + let after = audible(&run_for(&mut director, &mut snapshot, 200)); + assert!( + !after + .iter() + .any(|note| note.pitch == A2 || note.pitch == NOISE_THUD), + "the heartbeat must not return with no ward left" + ); + } +} diff --git a/src-tauri/src/codequest.rs b/src-tauri/src/codequest.rs index bb2b58b..f2050a9 100644 --- a/src-tauri/src/codequest.rs +++ b/src-tauri/src/codequest.rs @@ -104,6 +104,8 @@ pub enum VisualTemplate { Aftermath, #[serde(rename = "oracle-progression")] Progression, + #[serde(rename = "oracle-codex")] + Codex, } impl CodeQuestConfig { @@ -282,6 +284,7 @@ fn handler_supports_game(handler: SceneHandler, game_type: GameType) -> bool { | Handler::ConceptQuiz | Handler::LevelUp | Handler::GameOver + | Handler::Codex ), GameType::Quest => matches!( handler, @@ -419,6 +422,112 @@ mod tests { assert!(error.contains("handler `Battle` is not available for `Quiz` games")); } + #[test] + fn schema_v2_rejects_the_lesson_codex_in_quest_games() { + let error = CodeQuestConfig::parse( + r#" + schema_version = 2 + + [game] + type = "quest" + start_scene = "codex" + + [[scenes]] + id = "codex" + title = "Oracle Codex" + kind = "review" + handler = "codex" + "#, + ) + .expect_err("quest cartridges have no lesson journal to review"); + + assert!(error.contains("handler `Codex` is not available for `Quest` games")); + } + + #[test] + fn schema_v2_compiles_a_quiz_menu_codex_route() { + let config = CodeQuestConfig::parse( + r#" + schema_version = 2 + + [game] + type = "quiz" + start_scene = "quiz-menu" + + [[art]] + id = "codex-frame" + kind = "ui" + summary = "The lesson journal." + template = "oracle-codex" + + [[scenes]] + id = "quiz-menu" + title = "Quiz Menu" + kind = "menu" + handler = "quiz-menu" + + [[scenes.transitions]] + signal = "open-codex" + target = "codex" + + [[scenes]] + id = "codex" + title = "Oracle Codex" + kind = "review" + handler = "codex" + art = ["codex-frame"] + + [[scenes.transitions]] + signal = "back" + target = "quiz-menu" + "#, + ) + .unwrap(); + assert_eq!(config.art[0].template, Some(VisualTemplate::Codex)); + + let mut machine = SceneMachine::new(config.runtime_machine().unwrap().unwrap()); + let change = machine + .handle(SceneEvent::Signal(SceneSignal::OpenCodex)) + .unwrap(); + assert_eq!( + (change.target.as_str(), change.handler), + ("codex", SceneHandler::Codex) + ); + assert_eq!( + machine + .handle(SceneEvent::Signal(SceneSignal::Back)) + .unwrap() + .target, + "quiz-menu" + ); + } + + #[test] + fn codex_routes_must_use_signals_the_codex_emits() { + let error = CodeQuestConfig::parse( + r#" + schema_version = 2 + + [game] + type = "quiz" + start_scene = "codex" + + [[scenes]] + id = "codex" + title = "Oracle Codex" + kind = "review" + handler = "codex" + + [[scenes.transitions]] + signal = "new-run" + target = "codex" + "#, + ) + .expect_err("the codex is read-only and can only go back"); + + assert!(error.contains("handler `Codex` cannot emit signal `NewRun`")); + } + #[test] fn repository_manifest_defines_the_shipped_quiz_storyboard() { let repository_manifest = include_str!("../../CODEQUEST.toml"); @@ -481,9 +590,36 @@ mod tests { .filter_map(|art| art.template) .collect::>() .len(), - 11, + 12, "the dogfood cartridge should exercise every built-in Oracle visual template" ); + let menu = config + .scenes + .iter() + .find(|scene| scene.id == "quiz-menu") + .expect("the storyboard should include the quiz menu"); + assert!(menu.transitions.iter().any(|transition| { + transition.signal == SceneSignal::OpenCodex && transition.target == "codex" + })); + let codex = config + .scenes + .iter() + .find(|scene| scene.id == "codex") + .expect("the storyboard should let the player reread every lesson"); + assert_eq!(codex.handler, Some(SceneHandler::Codex)); + assert_eq!( + codex.transitions, + vec![SceneTransition { + signal: SceneSignal::Back, + target: "quiz-menu".into(), + after_ticks: None, + }] + ); + assert!(codex.mechanics.iter().any(|id| id == "review-lessons")); + assert!(codex.art.iter().any(|art_id| config + .art + .iter() + .any(|art| art.id == *art_id && art.template == Some(VisualTemplate::Codex)))); assert!(config.scenes.iter().any(|scene| scene.id == "quiz")); assert!(config.runtime_machine().unwrap().is_some()); } diff --git a/src-tauri/src/engine.rs b/src-tauri/src/engine.rs index 08892ee..a374e34 100644 --- a/src-tauri/src/engine.rs +++ b/src-tauri/src/engine.rs @@ -1,15 +1,25 @@ -use std::collections::{HashSet, VecDeque}; +use std::cell::RefCell; +use std::collections::{HashMap, HashSet, VecDeque}; use std::io::{BufRead, BufReader}; -use std::process::{Child, Stdio}; -use std::sync::{mpsc, Arc, Mutex, RwLock}; +use std::process::{Child, ExitStatus, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{mpsc, Arc, Mutex, OnceLock, RwLock}; use std::thread; use std::time::{Duration, Instant}; use bevy::prelude::*; +use crate::audio::{ + self, pad, AnswerPhase, AudioBatch, AudioOut, AudioQueue, AudioScene, AudioSnapshot, + QuestionStatus, RunAudio, Tier, +}; use crate::codequest::{CodeQuestConfig, GameType, VisualTemplate}; use crate::external_tools; use crate::font5x7::{glyph, GLYPH_ADVANCE, GLYPH_WIDTH, LINE_HEIGHT}; +use crate::learning::{ + self, AnswerEvidence, Concept, LensRecord, Lesson, Mastery, ProgressEvent, Review, + RATIONALE_COLUMNS, RATIONALE_ROWS, +}; use crate::scene_machine::{ SceneEvent, SceneHandler, SceneMachine, SceneMachineDefinition, SceneSignal, }; @@ -36,10 +46,18 @@ const ORACLE_HERO_SPEED: i32 = 2; const ORACLE_DROP_INTERVAL: u64 = 30; const ORACLE_COLLISION_Y: i32 = 100; const QUIZ_FEEDBACK_TICKS: u16 = 45; -const LEVEL_UP_HOLD_TICKS: u64 = 60; +/// Ticks after a first B in which a second B leaves an active question. +const QUIZ_LEAVE_CONFIRM_TICKS: u16 = 90; +/// Other questions answered between a miss and its review copy. The copy may +/// carry into the next batch to keep this gap. +const RETRY_GAP: usize = 3; const SCORE_RUNE_THRESHOLDS: [u32; 3] = [300, 900, 1_800]; const DATA_CHARGE_THRESHOLDS: [u32; 3] = [3, 6, 9]; const BUG_BREACH_THRESHOLDS: [u32; 3] = [1, 3, 5]; +/// How often the quest waiter checks whether the quest shell has exited. +const QUEST_POLL_INTERVAL: Duration = Duration::from_millis(25); +/// How long a finished quest's remaining output may keep arriving. +const QUEST_OUTPUT_GRACE: Duration = Duration::from_secs(1); const INK: Color = Color::rgb(26, 28, 44); const NAVY: Color = Color::rgb(41, 54, 111); @@ -291,6 +309,26 @@ const TRIAL_WARD_X: i32 = 126; const TRIAL_STREAK_X: i32 = 180; const TRIAL_SCORE_RUNES_X: i32 = 194; const TRIAL_SCORE_X: i32 = 215; +/// The composed lesson panel that replaces the trial plate's four choice +/// frames after a commitment. It spans the frames plus the free margin beside +/// them so a full rationale row keeps visible padding inside the border. +const TRIAL_LESSON_BOX: UiBox = UiBox { + x: 20, + y: 69, + width: 210, + height: 89, +}; +/// The legacy quiz's lesson panel over its choice rows. +const QUIZ_LESSON_BOX: UiBox = UiBox { + x: 5, + y: 68, + width: 230, + height: 80, +}; +/// Top of the legacy lesson card's INK footer strip, just below the panel. +const QUIZ_LESSON_FOOTER_STRIP_Y: i32 = QUIZ_LESSON_BOX.y + QUIZ_LESSON_BOX.height; +/// Extra pixels between the misconception block and the answer block. +const LESSON_BLOCK_GAP: i32 = 4; const ASCENSION_TITLE_BOX: UiBox = UiBox { x: 47, y: 68, @@ -303,10 +341,11 @@ const ASCENSION_LEVEL_BOX: UiBox = UiBox { width: 70, height: 16, }; +/// The batch recap beside the risen hero, wide enough for `1ST TRY 99/99`. const ASCENSION_BATCH_BOX: UiBox = UiBox { - x: 158, + x: 148, y: 117, - width: 75, + width: 85, height: 16, }; const AFTERMATH_CONTENT_BOX: UiBox = UiBox { @@ -315,6 +354,65 @@ const AFTERMATH_CONTENT_BOX: UiBox = UiBox { width: 85, height: 116, }; +const CODEX_HEADING_BOX: UiBox = UiBox { + x: 62, + y: 40, + width: 116, + height: 7, +}; +const CODEX_LENS_ROW_Y: i32 = 54; +const CODEX_LENS_ROW_PITCH: i32 = 11; +const CODEX_LENS_LABEL_X: i32 = 69; +const CODEX_LENS_RUNES_X: i32 = 133; +const CODEX_LENS_PENDING_X: i32 = 159; +const CODEX_TOTALS_BOX: UiBox = UiBox { + x: 42, + y: 122, + width: 156, + height: 17, +}; +const CODEX_PROMPT_BOX: UiBox = UiBox { + x: 42, + y: 141, + width: 156, + height: 16, +}; +const CODEX_LESSON_HEADER_BOX: UiBox = UiBox { + x: 37, + y: 2, + width: 203, + height: 28, +}; +const CODEX_QUESTION_BOX: UiBox = UiBox { + x: 17, + y: 31, + width: 197, + height: 38, +}; +/// Lesson panels start clear of the trial plate's portrait frame and end +/// before its brazier (question) or pillar (answer and rationale). +const CODEX_ANSWER_BOX: UiBox = UiBox { + x: 17, + y: 71, + width: 212, + height: 15, +}; +const CODEX_RATIONALE_BOX: UiBox = UiBox { + x: 17, + y: 88, + width: 212, + height: 66, +}; +const CODEX_TEXT_X: i32 = 23; +const CODEX_ANSWER_TEXT_X: i32 = 31; +const CODEX_QUESTION_Y: i32 = 35; +const CODEX_ANSWER_Y: i32 = 75; +const CODEX_WHY_Y: i32 = 103; +const CODEX_RATIONALE_Y: i32 = 116; +/// A sealed lesson's self-test: the `YOU CHOSE` heading, then the pick and +/// the first misconception row, each `CODEX_CHOSE_GAP` pixels apart. +const CODEX_CHOSE_Y: i32 = 99; +const CODEX_CHOSE_GAP: i32 = 11; #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum PresentationTier { @@ -405,16 +503,28 @@ pub struct QuestSpec { pub command: String, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Default)] pub struct QuizQuestion { pub question: String, pub choices: Vec, pub answer: usize, + /// The conceptual lens this question assesses, when the provider named one. + pub concept: Option, + /// One rationale per choice (same order as `choices`), or empty for legacy + /// questions generated before rationales existed. + pub rationales: Vec, + /// Whether this question returns because the player previously missed + /// it, and whether that miss was in this launch or an earlier one. + pub review: Review, } +/// Generates a question batch for a cartridge, level, and count. A failure +/// carries a short upper-case reason the waiting Oracle shows the player. pub type QuestionLoader = - Arc Vec + Send + Sync + 'static>; -pub type AnsweredQuestionRecorder = Arc; + Arc Result, String> + Send + Sync + 'static>; +/// Persists a learner-progress event (a committed answer or a Codex reveal) +/// for the cartridge id it is given. +pub type AnsweredQuestionRecorder = Arc; pub fn quiz_question_fits(question: &str, choices: &[String], answer: usize) -> bool { !question.trim().is_empty() @@ -457,6 +567,18 @@ pub struct CartridgeSpec { pub quests: Vec, pub questions: Vec, pub question_batch_ends: Vec, + /// The generation level of each batch, parallel to `question_batch_ends`. + /// Empty when the save did not record levels; the engine then assumes the + /// batches climb one level each from 1. + pub question_batch_levels: Vec, + /// The player's lesson journal for this cartridge, oldest first. + pub lessons: Vec, + /// Per-lens mastery evidence accumulated across launches. + pub mastery: Mastery, + /// Committed attempts per normalized question identity, kept across runs + /// and (for questions still in play) launches, so a question that returns + /// always rotates its choices past the layout the player last saw. + pub question_attempts: HashMap, } impl CartridgeSpec { @@ -514,6 +636,7 @@ enum Screen { Quiz, LevelUp, GameOver, + Codex, QuestSelect, Battle, Victory, @@ -532,6 +655,7 @@ impl From for Screen { SceneHandler::ConceptQuiz => Self::Quiz, SceneHandler::LevelUp => Self::LevelUp, SceneHandler::GameOver => Self::GameOver, + SceneHandler::Codex => Self::Codex, SceneHandler::QuestSelect => Self::QuestSelect, SceneHandler::Battle => Self::Battle, SceneHandler::Victory => Self::Victory, @@ -540,15 +664,22 @@ impl From for Screen { } } +// Commands are short-lived queue entries and cartridge inserts are rare, so +// the large cartridge variant is not worth boxing. +#[allow(clippy::large_enum_variant)] #[derive(Clone, Debug)] enum EngineCommand { Power(bool), + ReducedMotion(bool), AiProvider(Option), BootComplete, Cartridge(Option), Questions { cartridge_id: String, - questions: Vec, + /// The generated batch, or why generation failed. + result: Result, String>, + /// The `RequestQuestions` sequence number this reply answers. + seq: u64, }, Input { button: Button, @@ -557,22 +688,37 @@ enum EngineCommand { QuestOutput { line: String, stderr: bool, + /// The `RunQuest` generation this output belongs to; output from an + /// earlier quest never lands in a later quest's log. + quest: u64, }, QuestDone { success: bool, + quest: u64, }, } #[derive(Clone, Debug)] enum EngineEffect { - RunQuest(String), + RunQuest { + command: String, + /// Echoed by the quest's output and completion. + quest: u64, + }, AbortQuest, RequestQuestions { cartridge_id: String, level: u32, count: usize, + /// Echoed by the reply so a superseded request cannot land. + seq: u64, }, RecordAnsweredQuestion { + cartridge_id: String, + evidence: AnswerEvidence, + }, + /// The player revealed a pending lesson's answer in the Codex. + MarkPeeked { cartridge_id: String, question: String, }, @@ -589,6 +735,83 @@ struct Framebuffer { pixels: Vec, } +/// A plate's address and grade (base, cyan, and gold scales), or no grade. +type PlateKey = (usize, Option<[u16; 3]>); + +thread_local! { + /// RGBA expansions of the RGB plates drawn on this thread. + static PLATES: RefCell)>> = const { RefCell::new(Vec::new()) }; +} + +/// Expands an RGB plate to opaque RGBA. A grade scales cyan-leaning pixels, +/// gold-leaning pixels, and all others by its three `/255` factors. +fn expand_plate(rgb: &[u8; NATIVE_RGB_BYTES], grade: Option<[u16; 3]>, rgba: &mut [u8]) { + let pixels = rgb + .as_chunks::<3>() + .0 + .iter() + .zip(rgba.as_chunks_mut::<4>().0.iter_mut()); + let Some([base, cyan, gold]) = grade else { + for (source, destination) in pixels { + destination.copy_from_slice(&[source[0], source[1], source[2], 255]); + } + return; + }; + for (source, destination) in pixels { + let [red, green, blue] = [source[0] as u16, source[1] as u16, source[2] as u16]; + let scale = if is_cyan_pixel(red, green, blue) { + cyan + } else if is_gold_pixel(red, green, blue) { + gold + } else { + base + }; + destination.copy_from_slice(&[ + (red * scale / 255) as u8, + (green * scale / 255) as u8, + (blue * scale / 255) as u8, + 255, + ]); + } +} + +fn is_cyan_pixel(red: u16, green: u16, blue: u16) -> bool { + blue > red.saturating_add(12) && green > red +} + +fn is_gold_pixel(red: u16, green: u16, blue: u16) -> bool { + red > blue.saturating_add(14) && green > blue +} + +/// Which awakening strength lights each pixel of the awakening plate: 0 the +/// Oracle's center diamond, 1 cyan, 2 gold, 3 ambient. The plate is constant, +/// so the classes are computed once. +fn awakening_classes() -> &'static [u8] { + static CLASSES: OnceLock> = OnceLock::new(); + CLASSES.get_or_init(|| { + ORACLE_AWAKENING + .as_chunks::<3>() + .0 + .iter() + .enumerate() + .map(|(index, source)| { + let x = (index % WIDTH) as i32; + let y = (index / WIDTH) as i32; + let [red, green, blue] = [source[0] as u16, source[1] as u16, source[2] as u16]; + if (x - 120).abs() + (y - 80).abs() < 47 { + 0 + } else if is_cyan_pixel(red, green, blue) { + 1 + } else if is_gold_pixel(red, green, blue) { + 2 + } else { + 3 + } + }) + .collect() + }) +} + impl Default for Framebuffer { fn default() -> Self { Self { @@ -604,39 +827,39 @@ impl Framebuffer { } } - fn blit_rgb(&mut self, rgb: &[u8; NATIVE_RGB_BYTES]) { - for (source, destination) in rgb - .as_chunks::<3>() - .0 - .iter() - .zip(self.pixels.as_chunks_mut::<4>().0.iter_mut()) - { - destination.copy_from_slice(&[source[0], source[1], source[2], 255]); - } + fn blit_rgb(&mut self, rgb: &'static [u8; NATIVE_RGB_BYTES]) { + self.blit_plate(rgb, None); } - fn blit_rgb_graded(&mut self, rgb: &[u8; NATIVE_RGB_BYTES], base: u16, cyan: u16, gold: u16) { - for (source, destination) in rgb - .as_chunks::<3>() - .0 - .iter() - .zip(self.pixels.as_chunks_mut::<4>().0.iter_mut()) - { - let [red, green, blue] = [source[0] as u16, source[1] as u16, source[2] as u16]; - let scale = if blue > red.saturating_add(12) && green > red { - cyan - } else if red > blue.saturating_add(14) && green > blue { - gold - } else { - base - }; - destination.copy_from_slice(&[ - (red * scale / 255) as u8, - (green * scale / 255) as u8, - (blue * scale / 255) as u8, - 255, - ]); - } + fn blit_rgb_graded( + &mut self, + rgb: &'static [u8; NATIVE_RGB_BYTES], + base: u16, + cyan: u16, + gold: u16, + ) { + self.blit_plate(rgb, Some([base, cyan, gold])); + } + + /// Copies a plate's RGBA expansion, built once per plate and grade on + /// this thread: every plate and grade is a constant, so expanding it + /// again each tick would only redo identical work. + fn blit_plate(&mut self, rgb: &'static [u8; NATIVE_RGB_BYTES], grade: Option<[u16; 3]>) { + // A `'static` plate's address identifies it for the life of the + // process; a duplicated constant only costs one more cache entry. + let key = (rgb.as_ptr() as usize, grade); + PLATES.with_borrow_mut(|plates| { + let index = plates + .iter() + .position(|(cached, _)| *cached == key) + .unwrap_or_else(|| { + let mut expanded = vec![0; FRAME_BYTES].into_boxed_slice(); + expand_plate(rgb, grade, &mut expanded); + plates.push((key, expanded)); + plates.len() - 1 + }); + self.pixels.copy_from_slice(&plates[index].1); + }); } fn blit_awakening(&mut self, ticks: u64) { @@ -644,35 +867,32 @@ impl Framebuffer { let gold_strength = 38 + ticks.saturating_sub(112).min(108) as u16 * 197 / 108; let center_strength = 38 + ticks.saturating_sub(188).min(48) as u16 * 217 / 48; let ambient_strength = 34 + ticks.min(236) as u16 * 38 / 236; + // One scaled channel table per pixel class, indexed like + // `AwakeningClass`; each entry is the per-pixel `value * strength / 255`. + let tables = [ + center_strength, + cyan_strength, + gold_strength, + ambient_strength, + ] + .map(|strength| { + std::array::from_fn::(|value| (value as u16 * strength / 255) as u8) + }); - for (index, (source, destination)) in ORACLE_AWAKENING + for ((source, class), destination) in ORACLE_AWAKENING .as_chunks::<3>() .0 .iter() + .zip(awakening_classes()) .zip(self.pixels.as_chunks_mut::<4>().0.iter_mut()) - .enumerate() { - let x = (index % WIDTH) as i32; - let y = (index / WIDTH) as i32; - let [red, green, blue] = [source[0] as u16, source[1] as u16, source[2] as u16]; - let in_oracle = (x - 120).abs() + (y - 80).abs() < 47; - let cyan_pixel = blue > red.saturating_add(12) && green > red; - let gold_pixel = red > blue.saturating_add(14) && green > blue; - let strength = if in_oracle { - center_strength - } else if cyan_pixel { - cyan_strength - } else if gold_pixel { - gold_strength - } else { - ambient_strength - }; - destination.copy_from_slice(&[ - (red * strength / 255) as u8, - (green * strength / 255) as u8, - (blue * strength / 255) as u8, + let table = &tables[*class as usize]; + *destination = [ + table[source[0] as usize], + table[source[1] as usize], + table[source[2] as usize], 255, - ]); + ]; } } @@ -769,7 +989,7 @@ impl Framebuffer { fn text(&mut self, x: i32, y: i32, text: &str, color: Color, scale: i32) { let mut cursor = x; - for ch in text.to_ascii_uppercase().chars() { + for ch in text.chars().map(|ch| ch.to_ascii_uppercase()) { for (gy, row) in glyph(ch).iter().enumerate() { for gx in 0..GLYPH_WIDTH { let mask = 1u8 << (GLYPH_WIDTH - 1 - gx) as u32; @@ -790,7 +1010,7 @@ impl Framebuffer { fn compact_text(&mut self, x: i32, y: i32, text: &str, color: Color) { let mut cursor = x; - for ch in text.to_ascii_uppercase().chars() { + for ch in text.chars().map(|ch| ch.to_ascii_uppercase()) { for (glyph_y, row) in glyph(ch).iter().enumerate() { for glyph_x in 0..GLYPH_WIDTH { let mask = 1u8 << (GLYPH_WIDTH - 1 - glyph_x) as u32; @@ -896,13 +1116,319 @@ impl Framebuffer { struct QuizRun { question: usize, completed_batches: usize, + /// The focused display slot; `order` maps it to a source choice index. selected: usize, hearts: u8, score: u32, level: u32, streak: u32, leveled_up: bool, + /// The committed result and its remaining input hold. The lesson card + /// stays up after the hold reaches zero until A or Start continues. feedback: Option<(bool, u16)>, + /// Display order of the current question: `order[slot]` is the source + /// choice index drawn in that slot. + order: Vec, + /// The question index `order` and `attempt` were derived for. + presented: Option, + /// Earlier committed attempts at the current question's identity. + attempt: u32, + /// True when the committed answer redeemed a previously missed question. + redeemed: bool, + /// When the committed miss returns, told on its lesson card. + retry_note: Option, + /// The lens and mastery stage the committed answer woke, if it crossed + /// a lens threshold. + lens_woke: Option<(Concept, usize)>, + /// Ticks left in which a second B leaves the run. + leave_armed: u16, + /// What this run taught, reported by the Ascension and Aftermath debriefs. + ledger: RunLedger, +} + +/// A per-run learning tally for the end-of-batch and end-of-run debriefs. It +/// only reports; mastery evidence lives in the cartridge's `Mastery`. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +struct RunLedger { + /// First attempts (non-review questions) committed this run. + first_try: u32, + /// First attempts answered correctly this run. + first_try_right: u32, + /// Review questions answered correctly this run. + redeemed: u32, + /// First attempts committed in the batch still in progress. + batch_first_try: u32, + /// First attempts answered correctly in the batch still in progress. + batch_first_try_right: u32, + /// `(right, attempted)` first tries of the batch that completed last. + last_batch: (u32, u32), + /// Lit mastery runes per lens (`Concept::ALL` order) when the run began. + stages_at_start: [usize; 5], +} + +impl RunLedger { + /// Records one committed answer. Only a review can redeem; only a + /// question's first appearance counts as a first try. + fn record(&mut self, review: bool, correct: bool) { + if review { + self.redeemed += u32::from(correct); + } else { + self.first_try += 1; + self.batch_first_try += 1; + self.first_try_right += u32::from(correct); + self.batch_first_try_right += u32::from(correct); + } + } + + /// Snapshots the batch that just completed for the level-up screen and + /// starts counting the next one. + fn close_batch(&mut self) { + self.last_batch = (self.batch_first_try_right, self.batch_first_try); + self.batch_first_try = 0; + self.batch_first_try_right = 0; + } +} + +/// `1ST TRY a/b`, capped so the worst case is `1ST TRY 99/99`. +fn first_try_label((right, attempted): (u32, u32)) -> String { + format!("1ST TRY {}/{}", right.min(99), attempted.min(99)) +} + +/// The roman numeral of a lit mastery-rune stage. +fn rune_numeral(stage: usize) -> &'static str { + match stage { + 0 => "-", + 1 => "I", + 2 => "II", + _ => "III", + } +} + +/// When a missed question comes back, as its lesson card tells it. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum RetryNote { + /// After this many intervening questions; the copy may carry into the + /// next batch to keep its gap. + In(usize), + /// The deck does not reach the retry gap yet: the copy waits for the + /// Oracle's next questions (see `RetrySlot::Deferred`). + Later, + /// The ward broke: the miss waits in the deck for the next run. + NextRun, +} + +impl RetryNote { + fn label(self) -> String { + match self { + Self::In(0) => "UP NEXT".into(), + Self::In(gap @ 1..=9) => format!("BACK IN {gap}"), + Self::In(_) | Self::Later => "LATER".into(), + Self::NextRun => "NEXT RUN".into(), + } + } +} + +impl QuizRun { + fn new() -> Self { + Self { + question: 0, + completed_batches: 0, + selected: 0, + hearts: 3, + score: 0, + level: 1, + streak: 0, + leveled_up: false, + feedback: None, + order: Vec::new(), + presented: None, + attempt: 0, + redeemed: false, + retry_note: None, + lens_woke: None, + leave_armed: 0, + ledger: RunLedger::default(), + } + } + + /// The source choice indices in display order for a question with + /// `choice_count` choices. Until the current question has been presented, + /// choices keep their source order. + fn display_order(&self, choice_count: usize) -> Vec { + if self.presented == Some(self.question) && self.order.len() == choice_count { + self.order.clone() + } else { + (0..choice_count).collect() + } + } + + /// The source choice index shown in display `slot`. + fn source_choice(&self, slot: usize, choice_count: usize) -> usize { + self.display_order(choice_count) + .get(slot) + .copied() + .unwrap_or(slot) + } +} + +/// Normalized question identity: uppercase with collapsed whitespace, the same +/// rule the cartridge save uses to recognize answered questions. +fn question_identity(question: &str) -> String { + question + .split_whitespace() + .collect::>() + .join(" ") + .to_ascii_uppercase() +} + +/// Where a missed question's review copy is inserted: exactly `RETRY_GAP` +/// other questions after the miss. A retry always waits `RETRY_GAP` questions +/// and may carry into the next batch, so the current batch still closes on +/// time. `None` when the deck does not yet reach that far. +fn retry_insertion_index(current: usize, question_count: usize) -> Option { + let index = current + 1 + RETRY_GAP; + (index <= question_count).then_some(index) +} + +/// Where a miss's review copy went. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum RetrySlot { + /// Inserted into the deck at this index. + At(usize), + /// Held in `GameState::deferred_retries` until the deck reaches its gap. + Deferred, +} + +/// Inserts a same-launch review copy of the missed question at `current` and +/// shifts every batch end at or beyond the insertion point, so a copy past +/// the current batch end joins the next batch. When the deck is too short for +/// the gap, the copy is pushed onto `deferred` with the index it is due at. +fn schedule_retry( + questions: &mut Vec, + batch_ends: &mut [usize], + deferred: &mut Vec<(usize, QuizQuestion)>, + current: usize, +) -> Option { + let mut review = questions.get(current)?.clone(); + review.review = Review::InSession; + let Some(index) = retry_insertion_index(current, questions.len()) else { + deferred.push((current + 1 + RETRY_GAP, review)); + return Some(RetrySlot::Deferred); + }; + insert_retry(questions, batch_ends, index, review); + Some(RetrySlot::At(index)) +} + +fn insert_retry( + questions: &mut Vec, + batch_ends: &mut [usize], + index: usize, + review: QuizQuestion, +) { + questions.insert(index, review); + for end in batch_ends.iter_mut().filter(|end| **end >= index) { + *end += 1; + } +} + +/// Places deferred review copies once a delivery has grown the deck, in due +/// order, each at its due index, so the full retry gap holds and at least one +/// fresh question precedes it after an Oracle wait. A copy the deck does not +/// reach yet waits for a later delivery; if the run ends first, its lesson is +/// still outstanding and requeues for the next run. +fn place_deferred_retries(state: &mut GameState) { + if !state.run_is_live() { + return; + } + let (Some(run), Some(cartridge)) = (state.quiz.as_ref(), state.cartridge.as_mut()) else { + return; + }; + let mut deferred = std::mem::take(&mut state.deferred_retries); + deferred.sort_by_key(|(due, _)| *due); + let mut waiting = Vec::new(); + for (due, review) in deferred { + // `due` already exceeds every index the player had reached when the + // copy was deferred; the guard keeps that true however the deck moved. + let index = due.max(run.question + 1); + if index > cartridge.questions.len() { + waiting.push((due, review)); + continue; + } + insert_retry( + &mut cartridge.questions, + &mut state.batch_ends, + index, + review, + ); + } + state.deferred_retries = waiting; +} + +/// Lit mastery runes (0-3) for `concept`, gated by recent accuracy and the +/// lens's open misses in `lessons`: the one stage rule the footer, the Codex, +/// and the lens-wake check share. +fn lens_stage(mastery: &Mastery, lessons: &[Lesson], concept: Concept) -> usize { + mastery + .get(&concept) + .copied() + .unwrap_or_default() + .stage_with(pending_reviews(lessons, concept)) +} + +/// Upserts the journal entry for a committed question, keyed by identity. A +/// miss remembers the source choice `picked` and the misconception it reveals; +/// a later correct attempt clears the outstanding flag but keeps that +/// misconception. Every attempt clears the peeked flag. +fn record_lesson(lessons: &mut Vec, question: &QuizQuestion, correct: bool, picked: usize) { + let identity = question_identity(&question.question); + let existing = lessons + .iter() + .position(|entry| question_identity(&entry.question) == identity); + let misconception = if correct { + existing.and_then(|index| lessons[index].misconception.clone()) + } else { + question.choices.get(picked).map(|choice| { + ( + choice.clone(), + choice_rationale(question, picked) + .unwrap_or_default() + .to_string(), + ) + }) + }; + let lesson = Lesson { + question: question.question.clone(), + answer: question + .choices + .get(question.answer) + .cloned() + .unwrap_or_default(), + rationale: choice_rationale(question, question.answer) + .unwrap_or_default() + .to_string(), + concept: question.concept, + outstanding: !correct, + misconception, + peeked: false, + spaced_check: false, + }; + match existing { + Some(index) => lessons[index] = lesson, + None => lessons.push(lesson), + } +} + +/// The rationale for source choice `index`, when the question carries a full +/// set of rationales and that one is not blank. +fn choice_rationale(question: &QuizQuestion, index: usize) -> Option<&str> { + if question.rationales.len() != question.choices.len() { + return None; + } + question + .rationales + .get(index) + .map(|rationale| rationale.trim()) + .filter(|rationale| !rationale.is_empty()) } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -931,6 +1457,8 @@ struct OracleDrop { #[derive(Resource)] struct GameState { powered: bool, + /// Mirrors the host's reduced-motion preference for decorative motion. + reduced_motion: bool, ai_provider: Option, cartridge: Option, machine: Option, @@ -938,6 +1466,11 @@ struct GameState { screen_ticks: u64, held: HashSet
+
@@ -192,11 +201,13 @@ BACK - diff --git a/src/main.js b/src/main.js index 10d4b4f..f1154cd 100644 --- a/src/main.js +++ b/src/main.js @@ -1,13 +1,30 @@ /* CODE QUEST ADVANCE device adapter. * The browser owns only the shell. Bevy owns game state, timing, process - * execution, and the fixed 240x160 RGBA framebuffer exposed by Rust. */ + * execution, the fixed 240x160 RGBA framebuffer, and every sound the speaker + * plays; the shell only forwards input and presents what Rust emits. */ import { CARTRIDGE_DRAG_THRESHOLD, MAX_CARTRIDGES, cartridgeDragIntent, + isRefusedCartridgeError, normalizeCartridges, + rackFocusIndex, upsertCartridge, } from "./cartridge-library.js"; +import { + GUIDE_MESSAGE_MAX_LENGTH, + createWheelDetents, + deviceMessageText, + leavesKeyToFocusedControl, + trappedFocusTarget, +} from "./device-shell.js"; +import { + VOLUME_LABELS, + VOLUME_LEVELS, + createSpeaker, + nextVolume, + stepVolume, +} from "./speaker.js"; "use strict"; @@ -21,6 +38,8 @@ import { const BOOT_SKIP_DELAY_MS = 650; const TURN_DURATION_MS = 520; const POWER_REJECTION_MS = 760; + const MESSAGE_DURATION_MS = 4000; + const TRANSCRIPT_POLL_MS = 150; const PROVIDER_STORAGE_KEY = "cqa-ai-provider"; const PROVIDERS = Object.freeze({ codex: { label: "CODEX" }, @@ -57,6 +76,14 @@ import { const rearPowerSwitch = $("rear-power-switch"); const powerSwitches = [powerSwitch, rearPowerSwitch]; const powerLed = document.querySelector(".power-led"); + const cartTray = $("cart-tray"); + const trayError = $("tray-error"); + const batteryTrayError = $("battery-tray-error"); + const deviceMessage = $("device-message"); + const screenTranscript = $("engine-transcript"); + const volumeWheels = [$("volume-wheel"), $("rear-volume-wheel")]; + const volumeMeter = $("volume-meter"); + const speaker = createSpeaker(); const context = canvas.getContext("2d", { alpha: false }); context.imageSmoothingEnabled = false; const image = context.createImageData(WIDTH, HEIGHT); @@ -68,13 +95,21 @@ import { let trayOpen = false; let batteryTrayOpen = false; let picking = false; + let inserting = false; + let ejecting = false; + let trayReturnFocus = null; let framePending = false; + let audioPending = false; + let transcriptPending = false; + let transcriptPolledAt = -Infinity; + let transcriptSeq = 0; let bootTimer = null; let bootStartedAt = 0; let bootFinishing = false; let bootGeneration = 0; let bootHeld = false; - let trayMessageTimer = null; + let messageTimer = null; + let messageSurface = null; let shellBackVisible = false; let shellTurning = false; let batteryDoorOpen = false; @@ -84,6 +119,7 @@ import { let powerTransitioning = false; let powerGeneration = 0; let lastPowerFailure = ""; + let providerCheck = null; const held = Object.create(null); const swallowedByBoot = Object.create(null); @@ -103,7 +139,60 @@ import { } } + /* The speaker drains engine notes on its own in-flight guard, so a slow + * audio poll never delays the next framebuffer read, or the reverse. */ + async function pollAudio() { + if (audioPending) return; + audioPending = true; + try { + speaker.play(await invoke("engine_audio")); + } catch (error) { + console.error("CQA: failed to read Bevy audio", error); + } finally { + audioPending = false; + } + } + + /* The engine describes each screen for screen readers. The shell polls at a + * modest cadence behind its own in-flight guard and replaces the live + * region only when the engine publishes a new sequence number, so an + * unchanged screen is never announced twice. */ + async function pollTranscript() { + const now = performance.now(); + if (transcriptPending || now - transcriptPolledAt < TRANSCRIPT_POLL_MS) return; + transcriptPending = true; + transcriptPolledAt = now; + try { + const update = await invoke("engine_transcript", { since: transcriptSeq }); + if (update && Number.isInteger(update.seq) && update.seq !== transcriptSeq) { + transcriptSeq = update.seq; + screenTranscript.textContent = update.text; + } + } catch (error) { + console.error("CQA: failed to read Bevy transcript", error); + } finally { + transcriptPending = false; + } + } + + function renderVolume(level) { + const label = VOLUME_LABELS[level]; + for (const wheel of volumeWheels) { + wheel.dataset.level = level; + wheel.setAttribute("aria-valuenow", String(VOLUME_LEVELS.indexOf(level))); + wheel.setAttribute("aria-valuetext", label); + wheel.setAttribute("aria-label", `Volume wheel, ${label}`); + } + volumeMeter.dataset.level = level; + } + + function setVolume(level) { + renderVolume(speaker.setVolume(level)); + } + async function drawFrame() { + void pollAudio(); + void pollTranscript(); if (!framePending) { framePending = true; try { @@ -148,7 +237,7 @@ import { if (powered && generation === bootGeneration) hideDeviceBoot(); } catch (error) { if (generation === bootGeneration) bootFinishing = false; - showTrayError(error); + showDeviceError(error); } } @@ -230,7 +319,7 @@ import { function setBatteryDoorOpen(open, { force = false } = {}) { const nextOpen = Boolean(open); if (nextOpen && powered && !force) { - setBatteryStatus("TURN POWER OFF TO CHANGE BATTERIES", "failed"); + showDeviceError("TURN POWER OFF TO CHANGE BATTERIES"); batteryCompartment.classList.add("locked"); return false; } @@ -259,12 +348,12 @@ import { async function setInstalledProvider(provider) { if (powered || powerTransitioning || batteryChanging) { - setBatteryStatus("TURN POWER OFF TO CHANGE BATTERIES", "failed"); + showDeviceError("TURN POWER OFF TO CHANGE BATTERIES"); return false; } const nextProvider = normalizeProvider(provider); if (nextProvider && installedProvider) { - setBatteryStatus("EJECT INSTALLED BATTERIES FIRST", "failed"); + showDeviceError("EJECT INSTALLED BATTERIES FIRST"); return false; } const previousProvider = installedProvider; @@ -282,40 +371,66 @@ import { installedProvider = previousProvider; verifiedProvider = previousVerified; renderProviderBatteries(); - setBatteryStatus("BATTERY CONTACT FAILED", "failed"); - showTrayError(error); + showDeviceError(`BATTERY CONTACT FAILED · ${deviceMessageText(error)}`); return false; } finally { batteryChanging = false; } } - async function verifyInstalledProvider() { + function checkProvider(provider) { + // A readiness probe is a real CLI call that cannot be cancelled, so a + // power-on that follows an abandoned one joins its in-flight probe. + if (providerCheck?.provider === provider) return providerCheck.result; + const check = { provider, result: invoke("verify_ai_provider", { provider }) }; + const settle = () => { + if (providerCheck === check) providerCheck = null; + }; + providerCheck = check; + check.result.then(settle, settle); + return check.result; + } + + async function verifyInstalledProvider(generation) { if (!installedProvider) throw new Error("INSTALL AI BATTERIES"); + const provider = installedProvider; + const current = () => generation === powerGeneration; batteryCompartment.classList.add("checking"); setBatteryStatus(`${providerLabel()} · CHECKING`, "checking"); try { - const result = await invoke("verify_ai_provider", { provider: installedProvider }); - if (!result?.ready || normalizeProvider(result.provider) !== installedProvider) { - throw new Error(`${providerLabel()} READINESS CHECK FAILED`); + const result = await checkProvider(provider); + if (!result?.ready || normalizeProvider(result.provider) !== provider) { + throw new Error(`${providerLabel(provider)} READINESS CHECK FAILED`); } - verifiedProvider = installedProvider; + if (!current()) return; + verifiedProvider = provider; lastPowerFailure = ""; setBatteryStatus(`${providerLabel()} · READY`, "ready"); } catch (error) { - verifiedProvider = null; - setBatteryStatus(`${providerLabel()} · NOT READY`, "failed"); + if (current()) { + verifiedProvider = null; + setBatteryStatus(`${providerLabel()} · NOT READY`, "failed"); + } throw error; } finally { - batteryCompartment.classList.remove("checking"); + if (current()) batteryCompartment.classList.remove("checking"); } } + function powerFailureReason(error) { + if (!installedProvider) return "NO BATTERIES"; + return deviceMessageText(error, { + fallback: `${providerLabel()} NOT READY`, + maxLength: GUIDE_MESSAGE_MAX_LENGTH, + }); + } + async function rejectPowerOn(message) { powered = false; hideDeviceBoot(); - lastPowerFailure = installedProvider ? `${providerLabel()} NOT READY` : "NO BATTERIES"; + lastPowerFailure = powerFailureReason(message); renderProviderBatteries(); + showDeviceError(lastPowerFailure); setPhysicalPowerSwitch({ on: false, label: `Power switch, off: ${lastPowerFailure}` }); batteryCompartment.classList.remove("locked"); powerLed.classList.remove("off", "checking"); @@ -328,6 +443,29 @@ import { if (message) console.warn("CQA: power rejected", message); } + function renderBatteryGuides(failure) { + viewGuide.querySelector(".guide-action").textContent = failure || "CHECK BATTERIES"; + viewGuide.querySelector(".guide-detail").textContent = failure + ? "TURN UNIT OVER TO CHECK BATTERIES" + : "TURN UNIT OVER"; + viewGuide.setAttribute( + "aria-label", + failure + ? `${failure}. Turn the device over to check the batteries` + : "Turn the device over to check the batteries", + ); + batteryGuide.querySelector(".guide-action").textContent = failure || "OPEN BATTERY TAB"; + batteryGuide.querySelector(".guide-detail").textContent = failure + ? "FIX IT AND POWER ON · OR OPEN TAB TO SWAP" + : "TO LOAD 2×AA"; + batteryGuide.setAttribute( + "aria-label", + failure + ? `${failure}. Fix it and power on again, or open the battery cover to swap batteries` + : "Open the battery cover to load batteries", + ); + } + function updateControlGuides() { const needsBatteryCheck = ready && @@ -354,19 +492,20 @@ import { !powerTransitioning && !needsBatteryCheck && Boolean(cartridge) !== powered; + const batteryFailure = installedProvider ? lastPowerFailure : ""; const needsBatteryTab = ready && shellBackVisible && !batteryTrayOpen && !powered && - !installedProvider && + (!installedProvider || Boolean(batteryFailure)) && !batteryDoorOpen; const needsBatteryBay = ready && shellBackVisible && !batteryTrayOpen && !powered && - !installedProvider && + (!installedProvider || Boolean(batteryFailure)) && batteryDoorOpen; cartGuide.classList.toggle("hidden", !needsCart); powerGuide.classList.toggle("hidden", !needsPower); @@ -376,6 +515,7 @@ import { $("power-switch").classList.toggle("guided", needsPower); batteryDoor.classList.toggle("guided", needsBatteryTab); batteryBay.classList.toggle("guided", needsBatteryBay); + renderBatteryGuides(batteryFailure); const switchingOff = powered && !cartridge; powerGuide.classList.toggle("switching-off", switchingOff); powerGuide.querySelector(".guide-action").textContent = switchingOff @@ -429,7 +569,13 @@ import { async function setPower(on) { const target = Boolean(on); - if (powered === target || (target && powerTransitioning)) return; + // initialize() restores the batteries and the saved cartridge and then + // switches the engine off, so power input waits until it has finished. + if (!ready || powered === target || (target && powerTransitioning)) return; + if (target && cartridgeBusy()) { + showDeviceError("WAIT FOR THE CARTRIDGE"); + return; + } const generation = ++powerGeneration; powerTransitioning = true; @@ -447,7 +593,8 @@ import { updateControlGuides(); try { await waitForPaint(); - await verifyInstalledProvider(); + if (generation !== powerGeneration || !powered) return; + await verifyInstalledProvider(generation); if (generation !== powerGeneration || !powered) return; await invoke("engine_power", { powered: true }); if (generation !== powerGeneration || !powered) { @@ -472,9 +619,9 @@ import { setPhysicalPowerSwitch({ on: false, label: "Power switch, off" }); powerLed.classList.remove("checking", "rejected"); powerLed.classList.add("off"); - batteryCompartment.classList.remove("locked"); + batteryCompartment.classList.remove("locked", "checking"); hideDeviceBoot(); - updateControlGuides(); + renderProviderBatteries(); try { await invoke("engine_power", { powered: false }); } catch (error) { @@ -483,7 +630,7 @@ import { setPhysicalPowerSwitch({ on: true, label: "Power on; shutdown failed" }); powerLed.classList.remove("off"); batteryCompartment.classList.add("locked"); - showTrayError(error); + showDeviceError(`SHUTDOWN FAILED · ${deviceMessageText(error)}`); } } finally { if (generation === powerGeneration) { @@ -536,6 +683,9 @@ import { function forgetCartridge(path) { cartridges = cartridges.filter((entry) => entry.path !== path); + // A saved slot that failed to load at startup is kept for a retry; once + // its rack entry goes, the next launch must not bring it back. + if (localStorage.getItem("cqa-cart-id") === path) localStorage.removeItem("cqa-cart-id"); persistCartridges(); } @@ -547,51 +697,80 @@ import { return null; } })); - let changed = false; + const changedPaths = []; for (const update of updates) { if (!update) continue; const index = cartridges.findIndex(({ path }) => path === update.path); if (index < 0 || cartridges[index].branch === update.branch) continue; cartridges[index] = { ...cartridges[index], branch: update.branch }; - changed = true; + changedPaths.push(update.path); } - if (!changed) return; + if (!changedPaths.length) return; persistCartridges(); - if (trayOpen) buildTray(); + if (!trayOpen) return; + for (const value of cartridges) { + if (changedPaths.includes(value.path)) relabelTrayCard(value); + } + } + + function activeMessageSurface() { + if (trayOpen) return trayError; + if (batteryTrayOpen) return batteryTrayError; + return deviceMessage; + } + + function hideDeviceMessage() { + if (messageTimer !== null) window.clearTimeout(messageTimer); + messageTimer = null; + if (messageSurface) messageSurface.textContent = ""; + messageSurface = null; + } + + function showDeviceMessage(message, tone = "error", { persist = false } = {}) { + hideDeviceMessage(); + messageSurface = activeMessageSurface(); + messageSurface.classList.toggle("notice", tone === "notice"); + messageSurface.textContent = deviceMessageText(message); + if (!persist) messageTimer = window.setTimeout(hideDeviceMessage, MESSAGE_DURATION_MS); } - function showTrayMessage(message, tone = "error") { - const error = $("tray-error"); - if (trayMessageTimer !== null) window.clearTimeout(trayMessageTimer); - error.textContent = String(message); - error.classList.toggle("notice", tone === "notice"); - error.classList.remove("hidden"); - trayMessageTimer = window.setTimeout(() => { - error.classList.add("hidden"); - trayMessageTimer = null; - }, 4000); + function showDeviceError(message, options) { + showDeviceMessage(message, "error", options); } - function showTrayError(message) { - showTrayMessage(message); + /* Loading or ejecting a cartridge is an async engine call that can walk git + * history for seconds. Power and the slot never change under each other: + * power-on waits for the cartridge, and the slot refuses while power is on + * or switching, so the engine and the shell always agree on the game. */ + function cartridgeBusy() { + return picking || inserting || ejecting; } async function insertCartridge(value) { - if (!value || cartridge) return; + if (!value || cartridge || inserting) return; + if (powered || powerTransitioning) { + showDeviceError("TURN POWER OFF TO LOAD A GAME"); + return; + } const alreadyCached = cartridges.some((entry) => entry.path === value.path); if (!alreadyCached && cartridges.length >= MAX_CARTRIDGES) { - showTrayError("CARTRIDGE RACK FULL · RECYCLE ONE FIRST"); + showDeviceError("CARTRIDGE RACK FULL · RECYCLE ONE FIRST"); return; } - const configured = await invoke("engine_set_cartridge", { path: value.path }); - cartridge = configured; - localStorage.setItem("cqa-cart-id", configured.path); - if (!cacheCartridge(configured)) { - await invoke("engine_set_cartridge", { path: null }); - cartridge = null; - localStorage.removeItem("cqa-cart-id"); - showTrayError("CARTRIDGE RACK FULL · RECYCLE ONE FIRST"); - return; + inserting = true; + try { + const configured = await invoke("engine_set_cartridge", { path: value.path }); + cartridge = configured; + localStorage.setItem("cqa-cart-id", configured.path); + if (!cacheCartridge(configured)) { + await invoke("engine_set_cartridge", { path: null }); + cartridge = null; + localStorage.removeItem("cqa-cart-id"); + showDeviceError("CARTRIDGE RACK FULL · RECYCLE ONE FIRST"); + return; + } + } finally { + inserting = false; } renderCartridge(); closeTray(); @@ -602,30 +781,39 @@ import { try { await insertCartridge({ path }); } catch (error) { - forgetCartridge(path); - buildTray(); - showTrayError(error); + // Only a folder that is no longer a repository leaves the rack on its + // own; fixable faults (bad CODEQUEST.toml, missing drive) stay racked. + if (isRefusedCartridgeError(error)) { + forgetCartridge(path); + if (trayOpen) buildTray(); + } + showDeviceError(error); } } async function addFromDisk() { if (picking || cartridge) return; if (cartridges.length >= MAX_CARTRIDGES) { - showTrayError("CARTRIDGE RACK FULL · RECYCLE ONE FIRST"); + showDeviceError("CARTRIDGE RACK FULL · RECYCLE ONE FIRST"); return; } picking = true; try { await insertCartridge(await invoke("pick_cartridge")); } catch (error) { - showTrayError(error); + showDeviceError(error); } finally { picking = false; } } function ejectCartridge() { - if (!cartridge) return; + if (!cartridge || ejecting) return; + if (powered || powerTransitioning) { + showDeviceError("TURN POWER OFF TO EJECT"); + return; + } + ejecting = true; const slot = $("cart-back"); slot.classList.add("ejecting"); window.setTimeout(async () => { @@ -637,7 +825,9 @@ import { if (trayOpen) buildTray(); } catch (error) { slot.classList.remove("ejecting"); - showTrayError(error); + showDeviceError(error); + } finally { + ejecting = false; } }, 240); } @@ -651,15 +841,16 @@ import { function recycleCartridge(value, card) { if (card.classList.contains("recycling")) return; if (cartridge?.path === value.path) { - showTrayError("EJECT THIS CARTRIDGE BEFORE RECYCLING IT"); + showDeviceError("EJECT THIS CARTRIDGE BEFORE RECYCLING IT"); return; } + const focusIndex = [...card.parentElement.children].indexOf(card); card.classList.add("recycling"); card.disabled = true; window.setTimeout(() => { forgetCartridge(value.path); - if (trayOpen) buildTray(); - showTrayMessage(`RECYCLED ${value.title} · REPOSITORY UNTOUCHED`, "notice"); + if (trayOpen) buildTray({ focusIndex }); + showDeviceMessage(`RECYCLED ${value.title} · REPOSITORY UNTOUCHED`, "notice"); }, 180); } @@ -714,13 +905,14 @@ import { clearDrag(); if (intent === "load") insertByPath(value.path); else if (intent === "recycle") recycleCartridge(value, card); - else if (deniedLoad) showTrayError("EJECT THE CURRENT CARTRIDGE BEFORE LOADING ANOTHER"); - else if (deniedRecycle) showTrayError("EJECT THIS CARTRIDGE BEFORE RECYCLING IT"); + else if (deniedLoad) showDeviceError("EJECT THE CURRENT CARTRIDGE BEFORE LOADING ANOTHER"); + else if (deniedRecycle) showDeviceError("EJECT THIS CARTRIDGE BEFORE RECYCLING IT"); else if (!moved && !cartridge) insertByPath(value.path); }); card.addEventListener("pointercancel", clearDrag); card.addEventListener("keydown", (event) => { + if (event.repeat) return; if ((event.key === "Enter" || event.key === " ") && !cartridge) { event.preventDefault(); insertByPath(value.path); @@ -731,23 +923,35 @@ import { }); } - function buildTray() { + function cartridgeCardLabel(value, current) { + const accessibilityAction = current + ? "Currently in the device. Use the Eject Cartridge control before recycling." + : cartridge + ? "Drag down or press Delete to recycle. Eject the current cartridge before loading." + : "Drag up or press Enter to load. Drag down or press Delete to recycle."; + return `${value.title}, branch ${value.branch}. ${accessibilityAction}`; + } + + function relabelTrayCard(value) { + const card = [...$("tray-carts").children].find((element) => element.dataset.path === value.path); + if (!card) return; + card.querySelector(".cc-sub").textContent = value.branch; + card.setAttribute("aria-label", cartridgeCardLabel(value, cartridge?.path === value.path)); + } + + function buildTray({ focusIndex = null } = {}) { const list = $("tray-carts"); + const focused = list.contains(document.activeElement) ? document.activeElement : null; + const focusPath = focused?.dataset.path ?? null; + const restoreIndex = focused ? [...list.children].indexOf(focused) : focusIndex; list.innerHTML = ""; for (const value of cartridges) { const card = document.createElement("button"); const current = cartridge?.path === value.path; card.type = "button"; card.className = `cart-card${current ? " current" : ""}`; - const accessibilityAction = current - ? "Currently in the device. Use the Eject Cartridge control before recycling." - : cartridge - ? "Drag down or press Delete to recycle. Eject the current cartridge before loading." - : "Drag up or press Enter to load. Drag down or press Delete to recycle."; - card.setAttribute( - "aria-label", - `${value.title}, branch ${value.branch}. ${accessibilityAction}`, - ); + card.dataset.path = value.path; + card.setAttribute("aria-label", cartridgeCardLabel(value, current)); const gesture = current ? "EJECT FIRST" : "↑ LOAD · ↓ RECYCLE"; card.innerHTML = `CODEQUEST ADVANCE${escapeHtml(value.title)}${escapeHtml(value.branch)}${gesture}`; bindCartridgeDrag(card, value, current); @@ -777,26 +981,54 @@ import { }); list.appendChild(eject); } - document.querySelector(".tray-head").textContent = `CARTRIDGE RACK · ${cartridges.length}/${MAX_CARTRIDGES}`; - $("tray-error").classList.add("hidden"); + $("cart-tray-head").textContent = `CARTRIDGE RACK · ${cartridges.length}/${MAX_CARTRIDGES}`; document.querySelector(".tray-hint").textContent = cartridge ? "EJECT CURRENT · DRAG OTHER CARTS DOWN TO RECYCLE" : "DRAG ↑ TO LOAD · DRAG ↓ TO RECYCLE · ESC TO CLOSE"; + const cards = [...list.children]; + const restore = rackFocusIndex(cards.map((card) => card.dataset.path), focusPath, restoreIndex); + if (restore >= 0) cards[restore].focus(); + } + + function syncTrayModality() { + const modal = trayOpen || batteryTrayOpen; + scaleEl.inert = modal; + viewToggle.inert = modal; + viewGuide.inert = modal; + } + + function trapTabFocus(event, container) { + const focusables = [...container.querySelectorAll("button:not(:disabled)")]; + const target = trappedFocusTarget(focusables, document.activeElement, event.shiftKey); + if (target || !focusables.length) event.preventDefault(); + target?.focus(); } function openTray() { - if (powered) return; + if (!ready || powered || powerTransitioning) return; if (batteryTrayOpen) closeBatteryTray({ restoreFocus: false }); + const active = document.activeElement; + trayReturnFocus = active instanceof HTMLElement && active !== document.body ? active : null; buildTray(); - $("cart-tray").classList.remove("hidden"); + cartTray.classList.remove("hidden"); + cartTray.setAttribute("aria-hidden", "false"); trayOpen = true; + syncTrayModality(); + $("tray-carts").querySelector("button:not(:disabled)")?.focus(); refreshCartridgeBranches().catch(() => {}); updateControlGuides(); } function closeTray() { - $("cart-tray").classList.add("hidden"); + const focusWasInTray = + cartTray.contains(document.activeElement) || document.activeElement === document.body; + cartTray.classList.add("hidden"); + cartTray.setAttribute("aria-hidden", "true"); trayOpen = false; + if (messageSurface === trayError) hideDeviceMessage(); + syncTrayModality(); + if (focusWasInTray) trayReturnFocus?.focus(); + trayReturnFocus = null; updateControlGuides(); } @@ -809,22 +1041,29 @@ import { choice.disabled = hasProvider; }); batteryEject.disabled = !hasProvider; - document.querySelector(".battery-tray-hint").textContent = hasProvider - ? "EJECT CURRENT PACK BEFORE LOADING ANOTHER" - : "SELECT A PACK · ESC TO CLOSE"; + document.querySelector(".battery-tray-hint").textContent = !hasProvider + ? "SELECT A PACK · ESC TO CLOSE" + : lastPowerFailure + ? `${lastPowerFailure} · EJECT TO SWAP` + : "EJECT CURRENT PACK BEFORE LOADING ANOTHER"; + } + + function focusBatteryTray() { + const firstAction = installedProvider + ? batteryEject + : batteryOptions.querySelector("[data-provider]"); + firstAction?.focus(); } function openBatteryTray() { - if (powered || batteryChanging || !batteryDoorOpen) return; + if (!ready || powered || batteryChanging || !batteryDoorOpen) return; if (trayOpen) closeTray(); renderBatteryTray(); batteryTray.classList.remove("hidden"); batteryTray.setAttribute("aria-hidden", "false"); batteryTrayOpen = true; - const firstAction = installedProvider - ? batteryEject - : batteryOptions.querySelector("[data-provider]"); - firstAction?.focus(); + syncTrayModality(); + focusBatteryTray(); updateControlGuides(); } @@ -832,6 +1071,8 @@ import { batteryTray.classList.add("hidden"); batteryTray.setAttribute("aria-hidden", "true"); batteryTrayOpen = false; + if (messageSurface === batteryTrayError) hideDeviceMessage(); + syncTrayModality(); if (restoreFocus && shellBackVisible && batteryDoorOpen) { (installedProvider ? batteryPack : batteryChooser).focus(); } @@ -862,6 +1103,12 @@ import { }); } + function isShellControl(target) { + return target instanceof Element + && target !== document.body + && target.matches("button, [role=button], [role=switch]"); + } + const keyMap = { ArrowUp: "up", ArrowDown: "down", ArrowLeft: "left", ArrowRight: "right", KeyD: "a", KeyS: "b", Enter: "start", NumpadEnter: "start", @@ -874,16 +1121,7 @@ import { event.preventDefault(); closeBatteryTray(); } else if (event.key === "Tab") { - const choices = [...batteryOptions.querySelectorAll("button:not(:disabled)")]; - const firstChoice = choices[0]; - const lastChoice = choices.at(-1); - if (event.shiftKey && document.activeElement === firstChoice) { - event.preventDefault(); - lastChoice.focus(); - } else if (!event.shiftKey && document.activeElement === lastChoice) { - event.preventDefault(); - firstChoice.focus(); - } + trapTabFocus(event, batteryOptions); } return; } @@ -900,10 +1138,19 @@ import { if (!event.repeat) trayOpen ? closeTray() : openTray(); return; } + if (event.code === "KeyV") { + if (!event.repeat) setVolume(nextVolume(speaker.volume)); + return; + } if (trayOpen && (event.key === "Escape" || event.code === "KeyS")) { closeTray(); return; } + if (trayOpen && event.key === "Tab") { + trapTabFocus(event, cartTray); + return; + } + if (leavesKeyToFocusedControl(event.code, isShellControl(event.target))) return; const button = keyMap[event.code]; if (!button) return; event.preventDefault(); @@ -947,6 +1194,9 @@ import { element.addEventListener("pointercancel", release); }); + // Keep mouse focus off the floating toggle so Enter stays START afterwards; + // keyboard focus still activates it (see leavesKeyToFocusedControl). + viewToggle.addEventListener("pointerdown", (event) => event.preventDefault()); viewToggle.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); @@ -985,6 +1235,7 @@ import { if (installedProvider) return; setInstalledProvider(choice.dataset.provider).then((changed) => { if (changed) closeBatteryTray(); + else if (batteryTrayOpen) focusBatteryTray(); }); }); }); @@ -994,6 +1245,7 @@ import { if (!installedProvider) return; setInstalledProvider(null).then((changed) => { if (changed) batteryOptions.querySelector("[data-provider]")?.focus(); + else if (batteryTrayOpen) focusBatteryTray(); }); }); @@ -1003,6 +1255,40 @@ import { setBatteryDoorOpen(true); }); + /* Browsers only start audio from a user gesture. The first key press, the + * power switch, or any other press on the device wakes the speaker. */ + for (const gesture of ["keydown", "pointerdown"]) { + window.addEventListener(gesture, () => speaker.unlock(), { capture: true }); + } + + for (const wheel of volumeWheels) { + const roll = createWheelDetents(); + wheel.addEventListener("pointerdown", (event) => { + event.preventDefault(); + event.stopPropagation(); + setVolume(nextVolume(speaker.volume)); + }); + wheel.addEventListener("wheel", (event) => { + event.preventDefault(); + const detents = roll(event.deltaY, event.deltaMode, performance.now()); + if (!detents) return; + let level = speaker.volume; + for (let turned = 0; turned < Math.abs(detents); turned += 1) level = stepVolume(level, detents); + setVolume(level); + }, { passive: false }); + wheel.addEventListener("keydown", (event) => { + const direction = { ArrowUp: 1, ArrowRight: 1, ArrowDown: -1, ArrowLeft: -1 }[event.key]; + if (direction) setVolume(stepVolume(speaker.volume, direction)); + else if (event.key === "Home") setVolume(VOLUME_LEVELS[0]); + else if (event.key === "End") setVolume(VOLUME_LEVELS.at(-1)); + else if (event.key === "Enter" || event.key === " ") setVolume(nextVolume(speaker.volume)); + else return; + // A focused wheel owns its keys; they must not also reach the D-pad. + event.preventDefault(); + event.stopPropagation(); + }); + } + for (const switchControl of powerSwitches) { switchControl.addEventListener("pointerdown", (event) => { event.preventDefault(); @@ -1012,10 +1298,12 @@ import { switchControl.addEventListener("keydown", (event) => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); + if (event.repeat) return; setPower(!powered); }); } $("cart-back").addEventListener("pointerdown", (event) => { + event.preventDefault(); event.stopPropagation(); trayOpen ? closeTray() : openTray(); }); @@ -1035,9 +1323,22 @@ import { }); window.addEventListener("resize", fit); + // The engine freezes decorative framebuffer motion when the host asks for + // reduced motion; the shell only reports the preference. + function syncReducedMotion() { + const reduced = Boolean(window.matchMedia?.("(prefers-reduced-motion: reduce)").matches); + invoke("engine_set_reduced_motion", { reduced }).catch((error) => { + console.error("CQA: failed to forward reduced-motion preference", error); + }); + } + async function initialize() { fit(); + renderVolume(speaker.volume); setShellBackVisible(false); + syncReducedMotion(); + window.matchMedia?.("(prefers-reduced-motion: reduce)") + .addEventListener?.("change", syncReducedMotion); rearSerial.textContent = await invoke("app_revision"); installedProvider = normalizeProvider(localStorage.getItem(PROVIDER_STORAGE_KEY)); if (!installedProvider) localStorage.removeItem(PROVIDER_STORAGE_KEY); @@ -1047,10 +1348,10 @@ import { try { await invoke("engine_set_ai_provider", { provider: installedProvider }); } catch (error) { + // Keep the saved choice so the next launch retries the same batteries. installedProvider = null; - localStorage.removeItem(PROVIDER_STORAGE_KEY); renderProviderBatteries(); - showTrayError(error); + showDeviceError(`BATTERY CONTACT FAILED · ${deviceMessageText(error)}`); } const savedPath = localStorage.getItem("cqa-cart-id"); try { @@ -1070,10 +1371,13 @@ import { cartridges.pop(); } cacheCartridge(cartridge); - } catch (_) { + } catch (error) { cartridge = null; - localStorage.removeItem("cqa-cart-id"); - forgetCartridge(savedPath); + // Only a refused folder is forgotten. A fault that may clear (git + // unavailable or timed out, a bad CODEQUEST.toml, a missing drive) + // keeps its rack entry and the saved slot, so the next launch retries. + if (isRefusedCartridgeError(error)) forgetCartridge(savedPath); + showDeviceError(`CARTRIDGE NOT LOADED · ${deviceMessageText(error)}`); } } renderCartridge(); @@ -1083,7 +1387,9 @@ import { window.requestAnimationFrame(drawFrame); } - initialize().catch((error) => showTrayError(error)); + initialize().catch((error) => { + showDeviceError(`DEVICE FAULT · ${deviceMessageText(error)}`, { persist: true }); + }); function createBrowserDemo() { const frame = new Uint8Array(FRAME_BYTES); @@ -1092,12 +1398,14 @@ import { } return async (command, args) => { if (command === "engine_frame") return frame.buffer; + if (command === "engine_audio") return { tick: 0, notes: [] }; + if (command === "engine_transcript") return null; if (command === "app_revision") return "0000000"; - if (["engine_power", "engine_finish_boot", "engine_input", "engine_set_ai_provider"].includes(command)) return null; + if (["engine_power", "engine_finish_boot", "engine_input", "engine_set_ai_provider", "engine_set_reduced_motion"].includes(command)) return null; if (command === "verify_ai_provider") return { provider: args?.provider, ready: true }; if (command === "engine_set_cartridge" && args?.path == null) return null; if (command === "engine_set_cartridge") throw new Error("RUN IN TAURI TO LOAD CARTRIDGES"); - if (command === "pick_cartridge") return null; + if (command === "pick_cartridge") throw new Error("RUN IN TAURI TO LOAD CARTRIDGES"); if (command === "cartridge_branch") return "BRANCH UNKNOWN"; throw new Error(`UNKNOWN COMMAND ${command}`); }; diff --git a/src/speaker.js b/src/speaker.js new file mode 100644 index 0000000..15f84a2 --- /dev/null +++ b/src/speaker.js @@ -0,0 +1,404 @@ +/* CODE QUEST ADVANCE speaker. + * The engine decides every sound and stamps each chip note with the engine + * tick it starts on. This module is the handheld's speaker hardware: it maps + * those ticks onto the WebAudio clock and plays four monophonic voices (two + * pulses, a triangle wave, and noise) through the volume wheel. It holds no + * game state and never chooses what to play. */ + +export const TICKS_PER_SECOND = 60; +/** Anchoring puts the engine's newest tick this far ahead of the audio clock, + * so polling jitter never clips the notes that start on it. */ +export const SCHEDULE_LEAD_SECONDS = 0.06; +/** The newest tick must land at least this far ahead. Closer means the engine + * fell behind the audio clock (a slow frame), so the clock re-anchors rather + * than start every later note late. */ +export const MIN_LEAD_SECONDS = 0.02; +/** ...and at most this far ahead. Further means the audio clock fell behind + * (or a late poll anchored late), so the clock re-anchors rather than keep + * the extra delay on every later note. */ +export const MAX_LEAD_SECONDS = 0.12; +/** Notes later than this do not start; they still end whatever their voice + * was sounding, as they would have on time. */ +export const MAX_LATENESS_SECONDS = 0.1; +const RELEASE_SECONDS = 0.004; +/* Noise brightness is the linear-feedback register's clock. At the reference + * pitch it steps every second sample of the buffer; at the maximum rate it + * steps every sample, the brightest a sampled register can be. */ +const NOISE_REFERENCE_PITCH = 84; +const NOISE_MIN_RATE = 0.0625; +const NOISE_MAX_RATE = 2; +const NOISE_CEILING_PITCH = NOISE_REFERENCE_PITCH + 12 * Math.log2(NOISE_MAX_RATE); + +export const VOLUME_STORAGE_KEY = "cqa-volume"; +export const VOLUME_LEVELS = Object.freeze(["mute", "low", "mid", "high"]); +export const VOLUME_LABELS = Object.freeze({ mute: "MUTE", low: "LOW", mid: "MID", high: "HIGH" }); +export const DEFAULT_VOLUME = "mid"; +const MASTER_GAIN = Object.freeze({ mute: 0, low: 0.2, mid: 0.45, high: 0.8 }); +/* Per-voice ceilings keep four full-volume voices below clipping; the + * triangle is naturally quieter than a pulse at the same amplitude. */ +const VOICE_GAIN = Object.freeze({ pulse1: 0.2, pulse2: 0.2, wave: 0.38, noise: 0.14 }); +const VOICES = Object.freeze(Object.keys(VOICE_GAIN)); + +export function normalizeVolume(value) { + const level = String(value ?? "").trim().toLowerCase(); + return VOLUME_LEVELS.includes(level) ? level : DEFAULT_VOLUME; +} + +/** The next detent when cycling with V or a click: MUTE, LOW, MID, HIGH, MUTE. */ +export function nextVolume(level) { + const index = VOLUME_LEVELS.indexOf(normalizeVolume(level)); + return VOLUME_LEVELS[(index + 1) % VOLUME_LEVELS.length]; +} + +/** Rolls the wheel one detent without wrapping past either end stop. */ +export function stepVolume(level, direction) { + const index = VOLUME_LEVELS.indexOf(normalizeVolume(level)) + Math.sign(direction); + return VOLUME_LEVELS[Math.max(0, Math.min(VOLUME_LEVELS.length - 1, index))]; +} + +export function midiToHz(pitch) { + return 440 * 2 ** ((pitch - 69) / 12); +} + +/** Fourier series of a pulse wave whose high phase lasts `eighths` / 8. */ +export function pulseHarmonics(eighths, count = 48) { + const duty = Math.min(7, Math.max(1, Number(eighths) || 4)) / 8; + const real = new Float32Array(count + 1); + const imag = new Float32Array(count + 1); + for (let harmonic = 1; harmonic <= count; harmonic += 1) { + const phase = 2 * Math.PI * harmonic * duty; + real[harmonic] = Math.sin(phase) / (Math.PI * harmonic); + imag[harmonic] = (1 - Math.cos(phase)) / (Math.PI * harmonic); + } + return { real, imag }; +} + +/** Maps engine ticks onto an audio clock. The first batch anchors the map + * `lead` seconds in the future. Every later batch keeps the anchor only while + * the engine's newest tick still maps between `minLead` and `maxLead` ahead of + * `now`; outside that window, in either direction, it anchors again. Engine + * overruns (which only ever lose engine time) and audio-clock stalls therefore + * cost one short jump instead of lag that builds up until every note is late. + * A jump back maps new ticks earlier than notes already planned; the speaker + * drops that planned material rather than let it start after later ticks. */ +export function createTickClock({ + lead = SCHEDULE_LEAD_SECONDS, + minLead = MIN_LEAD_SECONDS, + maxLead = MAX_LEAD_SECONDS, +} = {}) { + let anchorTick = null; + let anchorTime = 0; + const timeFor = (tick) => anchorTime + (tick - anchorTick) / TICKS_PER_SECOND; + return { + get anchored() { + return anchorTick !== null; + }, + /** Aligns the engine's current tick with `now`; true when it re-anchored. */ + sync(engineTick, now) { + if (anchorTick !== null) { + const ahead = timeFor(engineTick) - now; + if (ahead >= minLead && ahead <= maxLead) return false; + } + anchorTick = engineTick; + anchorTime = now + lead; + return true; + }, + timeFor, + reset() { + anchorTick = null; + }, + }; +} + +/** True unless the note is a cut: a zero volume step or zero length. */ +function audible(note) { + const volume = Math.min(15, Math.max(0, Number(note.volume) || 0)); + const ticks = Math.max(0, Number(note.durationTicks) || 0); + return volume > 0 && ticks > 0; +} + +/** Plans one `engine_audio` batch against `clock` at audio time `now`. Each + * note on a known voice becomes `{ note, at, sounds }`: at `at` its voice stops + * what it was sounding, and when `sounds` is true the note then starts there. + * A cut never sounds. A note due more than MAX_LATENESS_SECONDS ago does not + * start either, but it still ends its voice now: under monophony a late note + * or cut means the voice's previous note is over, and dropping that would + * leave a stale note ringing to its natural end. */ +export function planBatch(clock, batch, now) { + if (!Array.isArray(batch?.notes)) return []; + clock.sync(Number(batch.tick) || 0, now); + const plan = []; + for (const note of batch.notes) { + if (!VOICES.includes(note?.voice)) continue; + const due = clock.timeFor(Number(note.tick) || 0); + if (due < now - MAX_LATENESS_SECONDS) plan.push({ note, at: now, sounds: false }); + else plan.push({ note, at: Math.max(due, now), sounds: audible(note) }); + } + return plan; +} + +/** How the noise voice renders a brightness `pitch`: the register's playback + * rate, and a highpass cutoff in Hz (0 for none). Rates stop at the brightest + * a sampled register can be (a faster rate only skips samples of the same + * white noise), so brightness past that ceiling thins the noise from below + * instead: each octave past it removes half of the band that is left. Every + * step of the brightness scale therefore sounds distinct. */ +export function noiseColor(pitch, sampleRate = 44100) { + const rate = 2 ** ((pitch - NOISE_REFERENCE_PITCH) / 12); + const excess = Math.max(0, pitch - NOISE_CEILING_PITCH); + return { + rate: Math.min(NOISE_MAX_RATE, Math.max(NOISE_MIN_RATE, rate)), + highpass: (sampleRate / 2) * (1 - 2 ** (-excess / 12)), + }; +} + +function browserStorage() { + try { + return globalThis.localStorage ?? null; + } catch (_) { + return null; + } +} + +function readStoredVolume(storage) { + try { + return normalizeVolume(storage?.getItem(VOLUME_STORAGE_KEY)); + } catch (_) { + return DEFAULT_VOLUME; + } +} + +export function createSpeaker({ + AudioContextClass = globalThis.AudioContext || globalThis.webkitAudioContext, + storage = browserStorage(), +} = {}) { + let context = null; + let master = null; + let noiseBuffer = null; + let volume = readStoredVolume(storage); + const clock = createTickClock(); + const pulseWaves = new Map(); + /* Every note each voice has scheduled that may still sound, oldest first: + * `{ source, gate, start, end }`, where `end` is when its gate reaches 0. */ + const sounding = new Map(); + + /** Creates or resumes the audio context. Call only from a user gesture. */ + function unlock() { + if (!AudioContextClass) return false; + if (!context) { + try { + context = new AudioContextClass(); + } catch (error) { + console.warn("CQA: speaker unavailable", error); + AudioContextClass = null; + return false; + } + master = context.createGain(); + master.gain.value = MASTER_GAIN[volume]; + master.connect(context.destination); + } + if (context.state === "suspended") { + context.resume?.()?.catch?.(() => {}); + } + return true; + } + + function pulseWave(eighths) { + const key = Number(eighths) || 4; + if (!pulseWaves.has(key)) { + const { real, imag } = pulseHarmonics(key); + pulseWaves.set(key, context.createPeriodicWave(real, imag)); + } + return pulseWaves.get(key); + } + + /* One second of the 15-bit linear-feedback noise used by chip sound. */ + function noise() { + if (!noiseBuffer) { + const length = Math.max(1, Math.round(context.sampleRate || 44100)); + noiseBuffer = context.createBuffer(1, length, context.sampleRate || 44100); + const samples = noiseBuffer.getChannelData(0); + let register = 0x7fff; + for (let index = 0; index < length; index += 1) { + if (index % 2 === 0) { + const bit = (register ^ (register >> 1)) & 1; + register = (register >> 1) | (bit << 14); + } + samples[index] = register & 1 ? 1 : -1; + } + } + return noiseBuffer; + } + + /* A note that has not started by `at` never sounds. Callers pass `at` no + * earlier than now, so the note has not started yet either. */ + function cancel(playing, at) { + playing.end = playing.start; + playing.gate.disconnect(); + try { + playing.source.stop(at); + } catch (_) { + /* Already stopped. */ + } + } + + /* Ends everything `voice` has scheduled at `at`: a note sounding then is + * released, and a note planned to start at or after `at` never starts. */ + function stopVoice(voice, at) { + for (const playing of sounding.get(voice) ?? []) { + if (playing.start >= at) { + if (playing.end > playing.start) cancel(playing, at); + continue; + } + // Silent by then, or already released no later than this would. + if (playing.end <= at + RELEASE_SECONDS) continue; + playing.end = at + RELEASE_SECONDS; + playing.gate.gain.cancelScheduledValues(at); + playing.gate.gain.setValueAtTime(1, at); + playing.gate.gain.linearRampToValueAtTime(0, at + RELEASE_SECONDS); + try { + playing.source.stop(at + RELEASE_SECONDS * 2); + } catch (_) { + /* Already stopped. */ + } + } + } + + /* A batch planned after the clock jumped back can start before material + * already planned from earlier ticks. That material, on every voice, never + * starts, so notes keep engine-tick order. With a steady or later map every + * earlier tick already starts before `at`, so this changes nothing. */ + function yieldTo(at) { + for (const voice of VOICES) { + for (const playing of sounding.get(voice) ?? []) { + if (playing.start > at && playing.end > playing.start) cancel(playing, at); + } + } + } + + /* The noise register, thinned by a highpass only when its brightness is + * past the playback-rate ceiling at either end of a slide. */ + function createNoise(note, start, end) { + const slide = Number(note.slide) || 0; + const from = noiseColor(Number(note.pitch) || 0, context.sampleRate); + const to = noiseColor((Number(note.pitch) || 0) + slide, context.sampleRate); + const source = context.createBufferSource(); + source.buffer = noise(); + source.loop = true; + source.playbackRate.setValueAtTime(from.rate, start); + if (slide) source.playbackRate.exponentialRampToValueAtTime(to.rate, end); + if (from.highpass <= 0 && to.highpass <= 0) return { source, output: source }; + const filter = context.createBiquadFilter(); + filter.type = "highpass"; + filter.frequency.setValueAtTime(from.highpass, start); + if (slide) filter.frequency.linearRampToValueAtTime(to.highpass, end); + source.connect(filter); + return { source, output: filter }; + } + + function createSource(voice, note, start, end) { + if (voice === "noise") return createNoise(note, start, end); + const slide = Number(note.slide) || 0; + const source = context.createOscillator(); + if (voice === "wave") source.type = "triangle"; + else source.setPeriodicWave(pulseWave(note.duty)); + source.frequency.setValueAtTime(midiToHz(note.pitch), start); + if (slide) source.frequency.exponentialRampToValueAtTime(midiToHz(note.pitch + slide), end); + return { source, output: source }; + } + + /* Starts an audible note, replacing whatever its voice was sounding. */ + function scheduleNote(note, start) { + const voice = note.voice; + stopVoice(voice, start); + const volumeStep = Math.min(15, Math.max(0, Number(note.volume) || 0)); + const ticks = Math.max(0, Number(note.durationTicks) || 0); + const end = start + ticks / TICKS_PER_SECOND; + const peak = (volumeStep / 15) * VOICE_GAIN[voice]; + const envelope = context.createGain(); + envelope.gain.setValueAtTime(peak, start); + const decay = Math.max(0, Number(note.decay) || 0); + if (decay > 0) { + // The chip steps down one volume unit every `decay` ticks. + const silentAt = start + (volumeStep * decay) / TICKS_PER_SECOND; + if (silentAt <= end) envelope.gain.linearRampToValueAtTime(0, silentAt); + else envelope.gain.linearRampToValueAtTime(peak * (1 - ticks / (volumeStep * decay)), end); + } + const gate = context.createGain(); + gate.gain.setValueAtTime(1, start); + gate.gain.setValueAtTime(1, Math.max(start, end - RELEASE_SECONDS)); + gate.gain.linearRampToValueAtTime(0, end); + + const { source, output } = createSource(voice, note, start, end); + output.connect(envelope); + envelope.connect(gate); + gate.connect(master); + source.start(start); + source.stop(end + RELEASE_SECONDS); + const playing = { source, gate, start, end }; + const now = context.currentTime; + const pending = (sounding.get(voice) ?? []).filter((entry) => entry.end > Math.max(now, entry.start)); + pending.push(playing); + sounding.set(voice, pending); + source.onended = () => { + const list = sounding.get(voice); + const index = list ? list.indexOf(playing) : -1; + if (index >= 0) list.splice(index, 1); + gate.disconnect(); + }; + } + + function silence() { + const now = context?.currentTime ?? 0; + for (const voice of VOICES) stopVoice(voice, now); + clock.reset(); + } + + /** Schedules one `engine_audio` batch; returns how many notes will sound. */ + function play(batch) { + if (!context || !master || !Array.isArray(batch?.notes)) return 0; + if (context.state !== "running" || volume === "mute") { + // Drained notes are dropped rather than replayed as a burst later. + silence(); + return 0; + } + let scheduled = 0; + const plan = planBatch(clock, batch, context.currentTime); + if (plan.length) yieldTo(Math.min(...plan.map((step) => step.at))); + for (const { note, at, sounds } of plan) { + if (!sounds) { + stopVoice(note.voice, at); + continue; + } + scheduleNote(note, at); + scheduled += 1; + } + return scheduled; + } + + function setVolume(level) { + volume = normalizeVolume(level); + try { + storage?.setItem(VOLUME_STORAGE_KEY, volume); + } catch (_) { + /* Private windows may refuse storage; the wheel still works. */ + } + if (context && master) { + master.gain.setTargetAtTime(MASTER_GAIN[volume], context.currentTime, 0.015); + if (volume === "mute") silence(); + } + return volume; + } + + return { + get volume() { + return volume; + }, + get unlocked() { + return Boolean(context); + }, + unlock, + play, + setVolume, + }; +} diff --git a/src/styles.css b/src/styles.css index eedeefc..882734d 100644 --- a/src/styles.css +++ b/src/styles.css @@ -792,7 +792,7 @@ html, body { position: absolute; left: 7px; right: 7px; - top: 39px; + top: 36px; width: calc(100% - 14px); border-collapse: separate; border-spacing: 0; @@ -2021,6 +2021,92 @@ html, body { #rear-power-switch.on { top: 70px; } .ps-nub { display: none; } +/* ============================ VOLUME ============================ */ +/* A knurled thumbwheel on the same edge as the power slider, below it, like + the handheld's analog volume wheel. Each detent rolls the knurl so its + index notch moves, and the molded meter beside it lights one LED per step. */ +.volume-wheel { + position: absolute; + top: 146px; + width: 9px; + height: 40px; + overflow: hidden; + background: #8c8da6; + border: 3px solid #101124; + cursor: pointer; + z-index: 5; + touch-action: none; +} +#volume-wheel { right: -12px; border-radius: 0 5px 5px 0; } +#rear-volume-wheel { left: -12px; border-radius: 5px 0 0 5px; } +.volume-wheel:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; } +.volume-knurl { + position: absolute; + left: 0; + right: 0; + top: -12px; + bottom: -12px; + background: + linear-gradient(90deg, rgba(0,0,0,.28), transparent 45%, rgba(255,255,255,.18) 70%, rgba(0,0,0,.2)), + repeating-linear-gradient(to bottom, #e1e2ee 0 2px, #9798b1 2px 4px); + transition: transform .14s ease-out; +} +.volume-knurl::after { + /* the index notch: a dark groove that rides with the knurl */ + content: ''; + position: absolute; + left: 0; + right: 0; + top: 50%; + height: 3px; + margin-top: -1px; + background: #1a1c2c; + box-shadow: 0 1px 0 rgba(255,255,255,.35); +} +.volume-wheel[data-level="mute"] .volume-knurl { transform: translateY(12px); } +.volume-wheel[data-level="low"] .volume-knurl { transform: translateY(4px); } +.volume-wheel[data-level="mid"] .volume-knurl { transform: translateY(-4px); } +.volume-wheel[data-level="high"] .volume-knurl { transform: translateY(-12px); } +.volume-meter { + /* molded legend beside the wheel: VOL under a rising three-step ladder */ + position: absolute; + right: 9px; + top: 150px; + width: 20px; + display: flex; + flex-direction: column-reverse; + align-items: center; + gap: 3px; + pointer-events: none; +} +.volume-legend { + margin-top: 2px; + font-size: 6px; + letter-spacing: 1px; + color: #c3c4da; + text-shadow: 0 1px 0 rgba(0,0,0,.4); +} +.volume-led { + height: 4px; + border-radius: 1px; + background: #2b2d5c; + box-shadow: inset 0 1px 1px rgba(0,0,0,.45), 0 1px 0 rgba(255,255,255,.12); + transition: background .12s, box-shadow .12s; +} +.volume-led:nth-child(2) { width: 6px; } +.volume-led:nth-child(3) { width: 9px; } +.volume-led:nth-child(4) { width: 12px; } +.volume-meter[data-level="low"] .volume-led:nth-child(2), +.volume-meter[data-level="mid"] .volume-led:nth-child(-n+3), +.volume-meter[data-level="high"] .volume-led { + background: var(--green); + box-shadow: 0 0 4px var(--green); +} +.volume-meter[data-level="mute"] .volume-legend { color: #f0798b; } +@media (prefers-reduced-motion: reduce) { + .volume-knurl, .volume-led { transition: none; } +} + @media (prefers-reduced-motion: reduce) { .power-led.rejected { animation: none; background: #f02f4f; filter: none; } .power-led.checking { animation: none; } @@ -2428,17 +2514,45 @@ html, body { .cart-card.add { background: #1c1d38; } .cart-card.add .cc-label { background: transparent; border-style: dashed; color: #9ec3ff; } .cart-card.add, .cart-card.eject { cursor: pointer; } -#tray-error { - margin-top: 12px; +/* Message lines stay rendered while empty so their live regions are already + in the accessibility tree when a message arrives. */ +#tray-error, +#battery-tray-error { font-size: 7px; letter-spacing: 1px; color: #ff5a70; animation: errBlink 1s steps(2) infinite; } -#tray-error.hidden { display: none; } -#tray-error.notice { color: #67dc87; } +#tray-error:not(:empty), +#battery-tray-error:not(:empty) { margin-top: 12px; } +#tray-error.notice, +#battery-tray-error.notice { color: #67dc87; } @keyframes errBlink { 50% { opacity: .45; } } +/* Device status strip: the visible, announced surface for shell messages + raised while no tray is open (startup, power, boot, and battery faults). */ +#device-message { + position: fixed; + left: 50%; + bottom: 18px; + z-index: 55; + max-width: min(560px, calc(100vw - 32px)); + padding: 8px 12px 7px; + font: 8px/1.6 'Press Start 2P', monospace; + letter-spacing: 1px; + text-align: center; + overflow-wrap: anywhere; + color: #ff7187; + background: var(--ink); + border: 2px solid #ff5a70; + border-radius: 4px; + box-shadow: 3px 3px 0 rgba(16,17,36,.55), 0 8px 24px rgba(0,0,0,.38); + transform: translateX(-50%); + pointer-events: none; +} +#device-message:empty { opacity: 0; } +#device-message.notice { color: #67dc87; border-color: #38b764; } + /* ============================ QUIZ MODE ============================ */ #scr-menu { background: var(--navy); } .menu-title { diff --git a/tests/cartridge-library-contract.mjs b/tests/cartridge-library-contract.mjs index d39df8d..6e37d1a 100644 --- a/tests/cartridge-library-contract.mjs +++ b/tests/cartridge-library-contract.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { + CARTRIDGE_DRAG_THRESHOLD, MAX_CARTRIDGES, cartridgeDragIntent, normalizeCartridges, @@ -97,6 +98,17 @@ assert.equal( assert.equal(cartridgeDragIntent(-50), "load", "Dragging up should load"); assert.equal(cartridgeDragIntent(50), "recycle", "Dragging down should recycle"); assert.equal(cartridgeDragIntent(-20), null, "Small movement should remain a click"); +assert.equal( + cartridgeDragIntent(-CARTRIDGE_DRAG_THRESHOLD), + "load", + "A drag of exactly the threshold already loads", +); +assert.equal( + cartridgeDragIntent(CARTRIDGE_DRAG_THRESHOLD), + "recycle", + "A drag of exactly the threshold already recycles", +); +assert.equal(cartridgeDragIntent(1 - CARTRIDGE_DRAG_THRESHOLD), null, "Just short of the threshold is a click"); assert.equal( cartridgeDragIntent(-50, { canLoad: false }), null, diff --git a/tests/contract-css.mjs b/tests/contract-css.mjs new file mode 100644 index 0000000..1f7632b --- /dev/null +++ b/tests/contract-css.mjs @@ -0,0 +1,19 @@ +/* Stylesheet helpers for the contracts. A helper module, not a contract. */ + +/** The rules inside every `@media (prefers-reduced-motion: reduce)` block, + * joined, so an assertion cannot be satisfied by a query elsewhere in the + * file followed by an unrelated rule. */ +export function reducedMotionCss(css) { + const opener = /@media\s*\(prefers-reduced-motion:\s*reduce\)\s*\{/g; + const blocks = []; + for (let match = opener.exec(css); match; match = opener.exec(css)) { + let depth = 1; + let at = opener.lastIndex; + for (; at < css.length && depth > 0; at += 1) { + if (css[at] === "{") depth += 1; + else if (css[at] === "}") depth -= 1; + } + blocks.push(css.slice(opener.lastIndex, at - 1)); + } + return blocks.join("\n"); +} diff --git a/tests/device-animation-contract.mjs b/tests/device-animation-contract.mjs index da95608..6763b27 100644 --- a/tests/device-animation-contract.mjs +++ b/tests/device-animation-contract.mjs @@ -2,6 +2,8 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; const css = readFileSync(new URL("../src/styles.css", import.meta.url), "utf8"); +const adapter = readFileSync(new URL("../src/main.js", import.meta.url), "utf8"); +const rust = readFileSync(new URL("../src-tauri/src/lib.rs", import.meta.url), "utf8"); function block(selector) { const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -32,4 +34,15 @@ assert.match(css, /@keyframes guideBob\s*\{[^}]*transform:/s, "Guide movement is assert.doesNotMatch(css, /@keyframes guideBob\s*\{[^}]*margin-top:/s, "Guide movement triggers layout every frame"); assert.match(canvas, /image-rendering:\s*pixelated/, "Smoothing the shell must not blur the game framebuffer"); -console.log("Device animation contract OK: smooth shell motion with a crisp framebuffer"); +// The framebuffer's own decorative motion follows the host preference too: the +// shell forwards prefers-reduced-motion (initially and on change) and the +// engine, not the shell, decides what freezes. +const syncMotion = adapter.match(/function syncReducedMotion\(\) \{([\s\S]*?)\n \}/); +assert.ok(syncMotion, "The shell must forward the reduced-motion preference"); +assert.match(syncMotion[1], /prefers-reduced-motion: reduce/); +assert.match(syncMotion[1], /invoke\("engine_set_reduced_motion", \{ reduced \}\)/); +assert.match(adapter, /addEventListener\?\.\("change", syncReducedMotion\)/, "Preference changes must reach the engine"); +assert.match(rust, /fn engine_set_reduced_motion\(/); +assert.match(rust, /engine_set_reduced_motion,/, "The reduced-motion command must be registered"); + +console.log("Device animation contract OK: smooth shell motion, crisp framebuffer, forwarded reduced motion"); diff --git a/tests/device-audio-contract.mjs b/tests/device-audio-contract.mjs new file mode 100644 index 0000000..6b31812 --- /dev/null +++ b/tests/device-audio-contract.mjs @@ -0,0 +1,735 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +import { + DEFAULT_VOLUME, + MAX_LATENESS_SECONDS, + MAX_LEAD_SECONDS, + MIN_LEAD_SECONDS, + SCHEDULE_LEAD_SECONDS, + VOLUME_LEVELS, + VOLUME_STORAGE_KEY, + createSpeaker, + createTickClock, + midiToHz, + nextVolume, + noiseColor, + normalizeVolume, + planBatch, + pulseHarmonics, + stepVolume, +} from "../src/speaker.js"; +import { + WHEEL_DETENT_PX, + WHEEL_GESTURE_GAP_MS, + createWheelDetents, +} from "../src/device-shell.js"; +import { reducedMotionCss } from "./contract-css.mjs"; +import { bootShell } from "./shell-harness.mjs"; + +const html = readFileSync(new URL("../src/index.html", import.meta.url), "utf8"); +const css = readFileSync(new URL("../src/styles.css", import.meta.url), "utf8"); +const adapter = readFileSync(new URL("../src/main.js", import.meta.url), "utf8"); +const speakerSource = readFileSync(new URL("../src/speaker.js", import.meta.url), "utf8"); +const rust = readFileSync(new URL("../src-tauri/src/lib.rs", import.meta.url), "utf8"); +const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")); + +function block(selector) { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = css.match(new RegExp(`${escaped}\\s*\\{([\\s\\S]*?)\\}`)); + assert.ok(match, `Missing CSS block for ${selector}`); + return match[1]; +} + +/* ---------- A recording stand-in for the WebAudio graph ---------- */ + +class FakeParam { + constructor(value = 0) { + this.value = value; + this.events = []; + } + setValueAtTime(value, time) { this.events.push(["set", value, time]); } + linearRampToValueAtTime(value, time) { this.events.push(["ramp", value, time]); } + exponentialRampToValueAtTime(value, time) { this.events.push(["exp", value, time]); } + setTargetAtTime(value, time) { this.events.push(["target", value, time]); } + cancelScheduledValues(time) { this.events.push(["cancel", time]); } +} + +class FakeNode { + constructor(context, kind) { + this.kind = kind; + context.nodes.push(this); + } + connect(node) { this.output = node; return node; } + disconnect() { this.output = null; } +} + +class FakeSource extends FakeNode { + start(time) { this.startAt = time; } + stop(time) { this.stopAt = time; } +} + +class FakeAudioContext { + static created = 0; + constructor() { + FakeAudioContext.created += 1; + this.state = "running"; + this.currentTime = 10; + this.sampleRate = 8000; + this.destination = { kind: "destination" }; + this.nodes = []; + FakeAudioContext.last = this; + } + createGain() { + const node = new FakeNode(this, "gain"); + node.gain = new FakeParam(1); + return node; + } + createOscillator() { + const node = new FakeSource(this, "oscillator"); + node.frequency = new FakeParam(440); + node.setPeriodicWave = (wave) => { node.wave = wave; }; + return node; + } + createBufferSource() { + const node = new FakeSource(this, "noise"); + node.playbackRate = new FakeParam(1); + return node; + } + createBuffer(_channels, length) { + const data = new Float32Array(length); + return { getChannelData: () => data }; + } + createBiquadFilter() { + const node = new FakeNode(this, "filter"); + node.type = "lowpass"; + node.frequency = new FakeParam(350); + return node; + } + createPeriodicWave(real, imag) { return { real, imag }; } + resume() { this.state = "running"; return Promise.resolve(); } +} + +function memoryStorage(initial = {}) { + const values = new Map(Object.entries(initial)); + return { + getItem: (key) => (values.has(key) ? values.get(key) : null), + setItem: (key, value) => values.set(key, String(value)), + values, + }; +} + +const note = (overrides) => ({ + voice: "pulse1", + tick: 100, + durationTicks: 6, + pitch: 69, + volume: 12, + duty: 2, + decay: 0, + slide: 0, + ...overrides, +}); + +/* ---------- Tick clock ---------- */ + +const clock = createTickClock(); +assert.equal(clock.anchored, false); +assert.equal(clock.sync(600, 2), true, "The first batch anchors the clock"); +assert.equal(clock.timeFor(600), 2 + SCHEDULE_LEAD_SECONDS, "Notes start slightly ahead of now"); +assert.ok(SCHEDULE_LEAD_SECONDS >= 0.04 && SCHEDULE_LEAD_SECONDS <= 0.08, "The lookahead is roughly 60 ms"); +assert.ok(Math.abs(clock.timeFor(660) - clock.timeFor(600) - 1) < 1e-9, "Sixty engine ticks are one second"); +assert.equal(clock.sync(606, 2.1 + 0.02), false, "Ordinary polling jitter keeps the anchor"); +assert.equal(clock.sync(606, 2.1 - 0.02), false, "Jitter the other way keeps it too"); +assert.ok( + MIN_LEAD_SECONDS > 0 && MIN_LEAD_SECONDS < SCHEDULE_LEAD_SECONDS && SCHEDULE_LEAD_SECONDS < MAX_LEAD_SECONDS, + "The anchor lead sits inside the window the clock tolerates", +); +assert.ok(MAX_LEAD_SECONDS <= 0.15, "The lookahead is bounded"); +assert.equal(clock.sync(612, 2.5), true, "A stalled poll re-anchors instead of bursting stale notes"); +assert.equal(clock.timeFor(612), 2.5 + SCHEDULE_LEAD_SECONDS); + +// An engine overrun loses engine time, so the engine tick falls behind the +// audio clock. 100 ms behind, the newest tick would map 40 ms into the past +// and every note would start late at poll time; the clock re-anchors instead. +const behind = createTickClock(); +behind.sync(600, 2); +assert.equal(behind.sync(606, 2.2), true, "An engine 100 ms behind re-anchors at once"); +assert.equal(behind.timeFor(606), 2.2 + SCHEDULE_LEAD_SECONDS, "The newest tick gets the full lead back"); +assert.equal(behind.sync(612, 2.3 + 0.03), false, "30 ms of lag inside the window keeps the anchor"); +// An audio clock that fell behind leaves every later note scheduled too far +// ahead; the clock re-anchors instead of keeping that delay. +const ahead = createTickClock(); +ahead.sync(600, 2); +assert.equal(ahead.sync(612, 2.1), true, "Notes mapping 160 ms ahead re-anchor"); +assert.equal(ahead.timeFor(612), 2.1 + SCHEDULE_LEAD_SECONDS); + +/* ---------- Scheduling a synthetic engine stream ---------- */ + +/** Drives planBatch with a 60 Hz engine and rAF-paced polls, both on one wall + * clock. `stalls` are engine overruns: the engine resets its frame deadline + * after one, so the stalled time is never caught up. `audioStalls` freeze + * the audio clock, which then resumes behind wall time. Polls jitter by up to + * 3 ms, arrive after 0.5-3 ms of IPC, and read an audio clock quantized to + * 128-frame render quanta. Every engine tick emits one note. */ +function simulateStream({ seconds = 20, stalls = [], audioStalls = [], seed = 7 } = {}) { + let state = seed; + const random = () => { + state = (state * 1103515245 + 12345) % 2 ** 31; + return state / 2 ** 31; + }; + const engineTimes = []; + const pendingStalls = [...stalls].sort((a, b) => a.at - b.at); + for (let wall = 0; wall < seconds + 1; wall += 1 / 60) { + while (pendingStalls.length && pendingStalls[0].at <= wall) wall += pendingStalls.shift().stall; + engineTimes.push(wall); + } + const quantum = 128 / 48000; + const audioTime = (wall) => { + let lost = 0; + for (const { at, stall } of audioStalls) lost += Math.min(stall, Math.max(0, wall - at)); + return Math.floor((10 + wall - lost) / quantum) * quantum; + }; + const tickClock = createTickClock(); + const polls = []; + let drained = -1; + for (let frame = 1; frame / 60 < seconds; frame += 1) { + const drainAt = frame / 60 + (random() - 0.5) * 0.006; + let tick = drained; + while (tick + 1 < engineTimes.length && engineTimes[tick + 1] <= drainAt) tick += 1; + if (tick < 0) continue; + const notes = []; + for (let at = drained + 1; at <= tick; at += 1) { + notes.push(note({ tick: at, durationTicks: 1, volume: 8 })); + } + drained = tick; + const now = audioTime(drainAt + 0.0005 + random() * 0.0025); + const offset = tickClock.anchored ? tickClock.timeFor(0) : null; + const plan = planBatch(tickClock, { tick, notes }, now); + polls.push({ + wall: drainAt, + now, + tick, + notes, + plan, + lead: tickClock.timeFor(tick) - now, + reanchored: offset === null || Math.abs(tickClock.timeFor(0) - offset) > 1e-9, + }); + } + return polls; +} + +function assertOnTime(polls, label) { + for (const poll of polls) { + assert.ok( + poll.lead >= MIN_LEAD_SECONDS - 1e-9 && poll.lead <= MAX_LEAD_SECONDS + 1e-9, + `${label}: the newest tick maps ${poll.lead.toFixed(3)} s ahead at audio time ${poll.now.toFixed(3)}`, + ); + // A poll after an ordinary frame carries a tick or two; none may start + // late at poll time or be dropped. + if (poll.notes.length > 2) continue; + for (const step of poll.plan) { + assert.ok(step.sounds && step.at > poll.now, `${label}: tick ${step.note.tick} started late`); + } + } +} + +const steady = simulateStream(); +assert.equal(steady.filter((poll) => poll.reanchored).length, 1, "Polling jitter alone never re-anchors"); +assertOnTime(steady, "steady"); +const starts = steady.flatMap((poll) => poll.plan.map((step) => step.at)); +assert.equal(starts.length, steady.at(-1).tick + 1, "Every engine tick's note is scheduled"); +assert.ok( + starts.every((at, index) => index === 0 || Math.abs(at - starts[index - 1] - 1 / 60) < 1e-9), + "Note starts follow engine ticks exactly, not poll timing", +); + +// Hitches: two long overruns, then a run of small ones that add up to 100 ms. +const stalls = [ + { at: 3, stall: 0.1 }, + { at: 7, stall: 0.07 }, + ...Array.from({ length: 20 }, (_, index) => ({ at: 11 + index * 0.2, stall: 0.005 })), +]; +/** Re-anchors after the first happen only while, or just after, the clock + * was disturbed; never from jitter alone. */ +function assertReanchorsFollow(polls, events, label) { + const reanchors = polls.filter((poll) => poll.reanchored).slice(1); + for (const poll of reanchors) { + assert.ok( + events.some(({ at, stall }) => poll.wall >= at && poll.wall < at + stall + 0.25), + `${label}: re-anchored at ${poll.wall.toFixed(3)} s with nothing disturbing the clock`, + ); + } + return reanchors.length; +} + +const hitched = simulateStream({ stalls }); +assertOnTime(hitched, "engine overruns"); +const overrunReanchors = assertReanchorsFollow(hitched, stalls, "engine overruns"); +assert.ok(overrunReanchors >= 3, "Long overruns and accumulated small ones each re-anchor"); +assert.ok(overrunReanchors < stalls.length / 2, "Small overruns re-anchor only once enough lag builds up"); +const lastBeat = hitched.slice(-60).flatMap((poll) => poll.plan.map((step) => step.at)); +assert.ok( + lastBeat.every((at, index) => index === 0 || Math.abs(at - lastBeat[index - 1] - 1 / 60) < 1e-9), + "After overruns the rhythm follows engine ticks again", +); + +const audioStalls = [{ at: 5, stall: 0.1 }, { at: 9, stall: 0.2 }]; +const audioBehind = simulateStream({ audioStalls }); +assertOnTime(audioBehind, "audio clock stalls"); +assert.ok( + assertReanchorsFollow(audioBehind, audioStalls, "audio clock stalls") >= audioStalls.length, + "An audio clock that falls behind re-anchors the lookahead back down", +); + +// After a long poll gap the backlog is old: notes past the lateness bound do +// not start, but each still ends its voice at once. +const backlog = createTickClock(); +backlog.sync(100, 5); +const gap = planBatch( + backlog, + { + tick: 130, + notes: [ + note({ tick: 110 }), + note({ voice: "wave", tick: 112, volume: 0, durationTicks: 0 }), + note({ tick: 129 }), + note({ voice: "mystery", tick: 129 }), + ], + }, + 5.5, +); +assert.equal(gap.length, 3, "Unknown voices are skipped"); +assert.deepEqual( + gap.map(({ note: { voice, tick }, at, sounds }) => [voice, tick, Number(at.toFixed(4)), sounds]), + [ + ["pulse1", 110, 5.5, false], + ["wave", 112, 5.5, false], + ["pulse1", 129, Number((5.5 + SCHEDULE_LEAD_SECONDS - 1 / 60).toFixed(4)), true], + ], + "Late notes and cuts end their voice now; notes in time keep their tick", +); +assert.ok(MAX_LATENESS_SECONDS >= 0.05, "Only clearly stale notes are skipped"); +{ + // A note only slightly late still sounds, starting now rather than in the + // past; a zero-length note is a cut whatever its volume. + const slightly = createTickClock(); + slightly.sync(100, 5); + const plan = planBatch( + slightly, + { tick: 106, notes: [note({ tick: 100 }), note({ voice: "wave", tick: 106, durationTicks: 0 })] }, + 5.1, + ); + assert.deepEqual( + plan.map(({ note: { voice }, at, sounds }) => [voice, Number(at.toFixed(4)), sounds]), + [ + ["pulse1", 5.1, true], + ["wave", Number((5.1 + SCHEDULE_LEAD_SECONDS).toFixed(4)), false], + ], + "A note 40 ms late starts at once, and a zero-length note never sounds", + ); +} + +/* ---------- Volume wheel detents ---------- */ + +assert.deepEqual(VOLUME_LEVELS, ["mute", "low", "mid", "high"]); +assert.deepEqual( + ["mute", "low", "mid", "high"].map(nextVolume), + ["low", "mid", "high", "mute"], + "V cycles MUTE, LOW, MID, HIGH and wraps", +); +assert.equal(stepVolume("high", 1), "high", "Rolling past the end stop stays at HIGH"); +assert.equal(stepVolume("mute", -1), "mute", "Rolling below MUTE stays muted"); +assert.equal(stepVolume("mid", -1), "low"); +assert.equal(normalizeVolume("HIGH"), "high"); +assert.equal(normalizeVolume("loud"), DEFAULT_VOLUME, "Unknown stored levels fall back safely"); + +/* ---------- Wheel travel, not wheel events, turns the detents ---------- */ + +const rollGesture = (roll, deltas, { start = 0, every = 8, mode = 0 } = {}) => + deltas.map((delta, index) => roll(delta, mode, start + index * every)).reduce((sum, turn) => sum + turn, 0); +assert.equal(WHEEL_DETENT_PX, 100, "One classic mouse notch is one detent"); +assert.equal(WHEEL_GESTURE_GAP_MS, 200); +{ + const roll = createWheelDetents(); + assert.equal(rollGesture(roll, Array(30).fill(-4)), 1, "A light touchpad swipe turns one detent, not thirty"); + assert.equal( + rollGesture(roll, Array(60).fill(-5), { start: 1000 }), + 3, + "A long swipe turns one detent per 100 px of travel", + ); + assert.equal(rollGesture(roll, Array(40).fill(3), { start: 2000 }), -1, "Rolling down turns the wheel down"); +} +{ + const roll = createWheelDetents(); + const notches = [0, 60, 120, 180].map((at) => roll(-100, 0, at)); + assert.deepEqual(notches, [1, 1, 1, 1], "A notched mouse turns one detent per notch"); + assert.equal(roll(-120, 0, 1000), 1, "A single larger notch is still one detent"); + assert.equal(roll(-3, 1, 2000), 1, "Line-mode wheels turn one detent per notch"); + assert.equal(roll(-3, 1, 2050), 1); + assert.equal(roll(1, 2, 3000), -1, "Page-mode wheels turn one detent per page"); + assert.equal(roll(0, 0, 3001), 0, "Horizontal-only events leave the wheel alone"); + assert.equal(roll(-2, 0, 3002), 1, "Reversing direction mid-gesture responds at once"); + assert.equal(roll(Number.NaN, 0, 3003), 0); +} +{ + // A pause ends the gesture, so each separate light nudge still clicks. + const roll = createWheelDetents(); + assert.equal(rollGesture(roll, Array(5).fill(-4)), 1, "A light nudge turns one detent"); + assert.equal( + rollGesture(roll, Array(5).fill(-4), { start: 1000 }), + 1, + "A second nudge after a pause turns one more, not zero", + ); + assert.equal(roll(-4, 0, 1100), 0, "Within one gesture, small travel waits for the next detent"); +} +{ + // A fast spin reported as one large event turns every detent it covers, + // and the keyboard end stops jump straight to MUTE and HIGH. + const shell = await bootShell({ storage: { [VOLUME_STORAGE_KEY]: "mute" } }); + const wheel = shell.el("volume-wheel"); + wheel.dispatch("wheel", { deltaY: -300 }); + assert.equal(wheel.dataset.level, "high", "One 300 px spin turns the wheel three detents"); + wheel.dispatch("keydown", { key: "Home" }); + assert.equal(wheel.dataset.level, "mute", "Home rolls the wheel to MUTE"); + wheel.dispatch("keydown", { key: "End" }); + assert.equal(wheel.dataset.level, "high", "End rolls the wheel to HIGH"); + shell.close(); +} +{ + // The real adapter wires the accumulator into the wheel it renders. + const shell = await bootShell({ storage: { [VOLUME_STORAGE_KEY]: "low" } }); + const wheel = shell.el("volume-wheel"); + assert.equal(wheel.dataset.level, "low"); + for (let event = 0; event < 30; event += 1) { + const wheeled = wheel.dispatch("wheel", { deltaY: -4 }); + assert.ok(wheeled.defaultPrevented, "The wheel never scrolls the page"); + } + assert.equal(wheel.dataset.level, "mid", "Thirty small touchpad events turn the front wheel one detent"); + assert.equal(shell.storage.getItem(VOLUME_STORAGE_KEY), "mid"); + shell.close(); +} + +/* ---------- Pulse duty and pitch ---------- */ + +assert.equal(midiToHz(69), 440); +assert.ok(Math.abs(midiToHz(81) - 880) < 1e-9); +const half = pulseHarmonics(4, 8); +assert.ok(Math.abs(half.imag[1] - 2 / Math.PI) < 1e-6, "A 50% pulse is a square wave"); +assert.ok(Math.abs(half.imag[2]) < 1e-6, "A square wave has no even harmonics"); +const eighth = pulseHarmonics(1, 8); +assert.ok(Math.abs(eighth.imag[2]) > 0.01, "A 12.5% pulse keeps its even harmonics"); + +/* ---------- Gesture-gated speaker ---------- */ + +const storage = memoryStorage({ [VOLUME_STORAGE_KEY]: "low" }); +const speaker = createSpeaker({ AudioContextClass: FakeAudioContext, storage }); +assert.equal(speaker.volume, "low", "The wheel restores its persisted detent"); +assert.equal(FakeAudioContext.created, 0, "No audio context exists before a user gesture"); +assert.equal(speaker.play({ tick: 100, notes: [note()] }), 0, "Notes before a gesture are dropped"); +assert.equal(FakeAudioContext.created, 0, "Engine audio alone never creates an audio context"); + +assert.equal(speaker.unlock(), true); +assert.equal(speaker.unlock(), true); +assert.equal(FakeAudioContext.created, 1, "Repeated gestures reuse one audio context"); +const audio = FakeAudioContext.last; + +assert.equal( + speaker.play({ + tick: 100, + notes: [ + note(), + note({ voice: "wave", pitch: 50, duty: 4 }), + note({ voice: "noise", pitch: 96, decay: 1 }), + note({ voice: "pulse2", tick: 103, duty: 1, slide: 5 }), + note({ voice: "unknown" }), + ], + }), + 4, + "Each known voice schedules its note", +); +const sources = () => audio.nodes.filter((node) => node.kind === "oscillator" || node.kind === "noise"); +assert.equal(sources().length, 4); +const [pulse, wave, noiseSource, slid] = sources(); +assert.equal(pulse.startAt, 10 + SCHEDULE_LEAD_SECONDS, "The batch tick maps to now plus the lead"); +assert.ok(Math.abs(slid.startAt - pulse.startAt - 3 / 60) < 1e-9, "Later ticks keep their spacing"); +assert.ok(pulse.wave, "Pulse voices use a duty-shaped periodic wave"); +assert.equal(wave.type, "triangle", "The wave voice is triangle-like"); +assert.equal(noiseSource.loop, true, "The noise voice loops a noise buffer"); +assert.ok(slid.frequency.events.some(([kind]) => kind === "exp"), "Slides glide the pitch"); +// Decay steps the volume down one unit every `decay` ticks: the noise note +// (volume 12, decay 1, 6 ticks) ends halfway down; a held note stays level. +const noiseEnvelope = noiseSource.output.gain.events; +assert.deepEqual(noiseEnvelope.map(([kind]) => kind), ["set", "ramp"], "A decaying note ramps its volume"); +assert.ok(Math.abs(noiseEnvelope[1][1] - noiseEnvelope[0][1] / 2) < 1e-9, "Six of twelve decay steps halve it"); +assert.ok(Math.abs(noiseEnvelope[1][2] - (noiseSource.startAt + 6 / 60)) < 1e-9, "The ramp lands on the note's end"); +assert.deepEqual(pulse.output.gain.events.map(([kind]) => kind), ["set"], "A note without decay holds its volume"); + +// A zero-volume note cuts its voice on the requested tick. +speaker.play({ tick: 101, notes: [note({ tick: 101, volume: 0, durationTicks: 0 })] }); +assert.ok(pulse.stopAt < pulse.startAt + 6 / 60, "A cut stops the sounding pulse early"); +assert.equal(sources().length, 4, "A cut never starts a new source"); + +// Monophony: a new note on a sounding voice replaces it. +speaker.play({ tick: 102, notes: [note({ voice: "wave", tick: 102 })] }); +assert.ok(wave.stopAt <= sources().at(-1).startAt + 0.01, "A voice sounds one note at a time"); + +// A cut that arrives too late to land on its tick still ends the voice now, +// instead of leaving a long loop note ringing to its natural end. +const lateSpeaker = createSpeaker({ AudioContextClass: FakeAudioContext, storage: memoryStorage() }); +lateSpeaker.unlock(); +const lateAudio = FakeAudioContext.last; +const lateSources = () => lateAudio.nodes.filter((node) => node.kind === "oscillator" || node.kind === "noise"); +lateAudio.currentTime = 20; +lateSpeaker.play({ + tick: 100, + notes: [note({ voice: "wave", tick: 100, durationTicks: 90 }), note({ tick: 100, durationTicks: 90 })], +}); +const [longWave, longPulse] = lateSources(); +assert.ok(longWave.stopAt > 21.5, "The loop note would ring for a second and a half"); +lateAudio.currentTime = 20.36; +assert.equal( + lateSpeaker.play({ + tick: 118, + notes: [ + note({ voice: "wave", tick: 101, volume: 0, durationTicks: 0 }), + note({ tick: 102, durationTicks: 90 }), + ], + }), + 0, + "Stale notes do not start", +); +assert.equal(lateSources().length, 2, "A late note never starts a source"); +assert.ok(Math.abs(longWave.stopAt - 20.36) < 0.01, "A late cut silences its voice immediately"); +assert.ok(Math.abs(longPulse.stopAt - 20.36) < 0.01, "A late replacement ends the note it replaces"); +assert.ok( + longWave.output.output.gain.events.some(([kind, time]) => kind === "cancel" && time === 20.36), + "The silenced voice's gate is released now", +); + +// A clock that jumps back maps new ticks before notes already planned. Those +// notes must never sound after the later ticks, nor on top of them. Each test +// note's pitch is its tick, so a source names the tick it plays. +const RELEASE_TOLERANCE = 0.008 + 1e-9; +function soundingNotes(context) { + return context.nodes + .filter((node) => node.kind === "oscillator") + .map((source) => ({ + source, + tick: Math.round(69 + 12 * Math.log2(source.frequency.events[0][1] / 440)), + start: source.startAt, + stop: source.stopAt, + connected: source.output.output.output != null, + })) + .filter(({ start, stop, connected }) => stop > start && connected); +} +function assertTickOrderAndMonophony(context, voiceOf, label) { + const heard = soundingNotes(context).map((entry) => ({ ...entry, voice: voiceOf(entry.tick) })); + for (const earlier of heard) { + for (const later of heard) { + if (earlier.tick >= later.tick) continue; + assert.ok( + earlier.start <= later.start + 1e-9, + `${label}: tick ${earlier.tick} starts at ${earlier.start.toFixed(3)}, after tick ${later.tick} at ${later.start.toFixed(3)}`, + ); + if (earlier.voice === later.voice) { + assert.ok( + earlier.stop <= later.start + RELEASE_TOLERANCE, + `${label}: ${earlier.voice} plays tick ${earlier.tick} over tick ${later.tick}`, + ); + } + } + } + return heard; +} +{ + // The review repro: the audio clock freezes while the engine keeps ticking, + // until tick 104 maps past the lookahead and the clock re-anchors back. + const frozen = createSpeaker({ AudioContextClass: FakeAudioContext, storage: memoryStorage() }); + frozen.unlock(); + const frozenAudio = FakeAudioContext.last; + for (let tick = 100; tick <= 104; tick += 1) { + frozen.play({ tick, notes: [note({ tick, pitch: tick, durationTicks: 30 })] }); + } + const heard = assertTickOrderAndMonophony(frozenAudio, () => "pulse1", "frozen audio clock"); + assert.deepEqual(heard.map(({ tick }) => tick), [104], "Material the jump overtook never starts; the newest tick plays"); + assert.equal(heard[0].start, 10 + SCHEDULE_LEAD_SECONDS); +} +{ + // Across voices too: a later tick on one voice never plays before an + // earlier tick planned on another. + const voices = ["pulse1", "pulse2", "wave"]; + const voiceOf = (tick) => voices[tick % voices.length]; + const frozen = createSpeaker({ AudioContextClass: FakeAudioContext, storage: memoryStorage() }); + frozen.unlock(); + const frozenAudio = FakeAudioContext.last; + for (let tick = 100; tick <= 112; tick += 1) { + frozen.play({ tick, notes: [note({ voice: voiceOf(tick), tick, pitch: tick, durationTicks: 30 })] }); + } + const heard = assertTickOrderAndMonophony(frozenAudio, voiceOf, "frozen clock, three voices"); + assert.ok(heard.some(({ tick }) => tick === 112), "The newest tick still plays"); +} +{ + // The synthetic stream with audio-clock stalls, through the real speaker. + const streamed = createSpeaker({ AudioContextClass: FakeAudioContext, storage: memoryStorage() }); + streamed.unlock(); + const streamAudio = FakeAudioContext.last; + const polls = simulateStream({ seconds: 11, audioStalls: [{ at: 5, stall: 0.1 }, { at: 9, stall: 0.2 }] }); + for (const poll of polls) { + streamAudio.currentTime = poll.now; + streamed.play({ + tick: poll.tick, + notes: poll.notes.map((entry) => ({ ...entry, pitch: entry.tick, durationTicks: 3 })), + }); + } + const heard = assertTickOrderAndMonophony(streamAudio, () => "pulse1", "audio clock stalls"); + assert.ok(heard.length > polls.at(-1).tick * 0.9, "Only the few notes a jump overtakes are dropped"); +} + +// Noise brightness: every authored step sounds distinct, even past the +// playback-rate ceiling (the archival tick is brighter than the hat). +const NOISE_SCALE = [48, 72, 84, 96, 100]; +for (const pitch of NOISE_SCALE.filter((value) => value <= 96)) { + assert.deepEqual( + noiseColor(pitch, 48000), + { rate: 2 ** ((pitch - 84) / 12), highpass: 0 }, + `Noise ${pitch} keeps its register rate and no filter`, + ); +} +for (let index = 1; index < NOISE_SCALE.length; index += 1) { + const darker = noiseColor(NOISE_SCALE[index - 1], 48000); + const brighter = noiseColor(NOISE_SCALE[index], 48000); + assert.ok( + brighter.rate > darker.rate || brighter.highpass > darker.highpass, + `Noise ${NOISE_SCALE[index]} must sound brighter than ${NOISE_SCALE[index - 1]}`, + ); +} +const tickColor = noiseColor(100, 48000); +assert.equal(tickColor.rate, 2, "The register never runs past one step per sample"); +assert.ok(tickColor.highpass > 2000 && tickColor.highpass < 12000, "The tick is thinned to a dry, bright click"); +assert.ok(noiseColor(160, 48000).highpass < 24000, "The highpass stays below Nyquist"); +lateAudio.currentTime = 30; +lateSpeaker.play({ + tick: 300, + notes: [note({ voice: "noise", tick: 300, pitch: 96 }), note({ voice: "noise", tick: 306, pitch: 100 })], +}); +const [hatNoise, tickNoise] = lateSources().slice(-2); +assert.equal(hatNoise.output.kind, "gain", "The hat plays the register unfiltered"); +assert.equal(tickNoise.output.kind, "filter", "The tick passes through a filter"); +assert.equal(tickNoise.output.type, "highpass"); +assert.equal(tickNoise.output.frequency.events[0][1], noiseColor(100, lateAudio.sampleRate).highpass); +assert.deepEqual( + [hatNoise, tickNoise].map((source) => source.playbackRate.events[0][1]), + [2, 2], + "Both run at the ceiling rate; only the filter tells them apart", +); + +/* ---------- Mute and persistence ---------- */ + +// A note a later batch cancelled before it started (its stop precedes its +// start) never sounds, so only notes that will really play are checked. +const stillSounding = [noiseSource, slid, sources().at(-1)].filter( + (source) => source.stopAt > source.startAt, +); +assert.ok(stillSounding.length >= 2, "The check needs notes that really sound"); +assert.ok(stillSounding.every((source) => source.stopAt > audio.currentTime + 0.05), "Notes are still sounding"); +assert.equal(speaker.setVolume("mute"), "mute"); +assert.ok( + stillSounding.every((source) => source.stopAt <= audio.currentTime + 0.01), + "MUTE silences the notes already sounding, not only later ones", +); +assert.equal(storage.values.get(VOLUME_STORAGE_KEY), "mute", "The detent persists"); +const before = sources().length; +assert.equal(speaker.play({ tick: 200, notes: [note({ tick: 200 })] }), 0, "MUTE schedules nothing"); +assert.equal(sources().length, before); +speaker.setVolume("high"); +assert.equal(storage.values.get(VOLUME_STORAGE_KEY), "high"); +assert.equal(speaker.play({ tick: 205, notes: [note({ tick: 205 })] }), 1); + +{ + // A suspended context drops drained notes instead of replaying them as a + // burst, and the next gesture resumes it. + const suspended = createSpeaker({ AudioContextClass: FakeAudioContext, storage: memoryStorage() }); + suspended.unlock(); + const suspendedAudio = FakeAudioContext.last; + suspendedAudio.state = "suspended"; + const nodeCount = suspendedAudio.nodes.length; + assert.equal(suspended.play({ tick: 100, notes: [note()] }), 0, "A suspended speaker schedules nothing"); + assert.equal(suspendedAudio.nodes.length, nodeCount); + assert.equal(suspended.unlock(), true); + assert.equal(suspendedAudio.state, "running", "A later gesture resumes a suspended context"); + assert.equal(suspended.play({ tick: 110, notes: [note({ tick: 110 })] }), 1); +} + +const unavailable = createSpeaker({ AudioContextClass: null, storage: memoryStorage() }); +assert.equal(unavailable.unlock(), false, "A missing WebAudio implementation stays silent"); +assert.equal(unavailable.play({ tick: 1, notes: [note()] }), 0); +assert.equal(unavailable.volume, DEFAULT_VOLUME); + +/* ---------- Presentation-only boundary ---------- */ + +assert.doesNotMatch(speakerSource, /invoke\(|__TAURI__/, "The speaker never talks to the engine itself"); +assert.doesNotMatch( + speakerSource, + /\b(?:quiz|question|score|hearts?|wards?|oracle|datafall|cartridge|scene|screen)\b/i, + "The speaker must not know game rules or state", +); +assert.match(adapter, /from "\.\/speaker\.js"/, "The adapter loads the speaker module"); +assert.match(adapter, /let audioPending = false/, "Audio polling needs its own in-flight guard"); +assert.match( + adapter, + /async function pollAudio\(\) \{\s*if \(audioPending\) return;[\s\S]*?invoke\("engine_audio"\)/, + "The adapter polls engine_audio behind its guard", +); +assert.match( + adapter, + /async function drawFrame\(\) \{\s*void pollAudio\(\);/, + "Every animation frame polls audio without awaiting it", +); +assert.match( + adapter, + /for \(const gesture of \["keydown", "pointerdown"\]\)[\s\S]*?speaker\.unlock\(\)/, + "The first key press or device press wakes the speaker", +); +const keyV = adapter.match(/if \(event\.code === "KeyV"\) \{([^}]*)\}/); +assert.ok(keyV, "The device keydown handler needs a V branch"); +assert.match(keyV[1], /if \(!event\.repeat\) setVolume\(nextVolume\(speaker\.volume\)\);/, "V cycles the volume wheel once per press"); +assert.match(adapter, /command === "engine_audio"\) return \{ tick: 0, notes: \[\] \}/, "The browser demo is silent"); +assert.match(rust, /fn engine_audio\(state: State\) -> audio::AudioBatch/, "Rust exposes engine_audio"); +assert.match(rust, /generate_handler!\[[\s\S]*?engine_audio/, "engine_audio is registered with Tauri"); + +/* ---------- Volume hardware ---------- */ + +for (const id of ["volume-wheel", "rear-volume-wheel"]) { + const wheel = html.match(new RegExp(`
]*>`))?.[0] || ""; + assert.match(wheel, /role="slider"/, `${id} must be a slider`); + assert.match(wheel, /aria-valuemin="0"[^>]*aria-valuemax="3"/, `${id} needs four detents`); + assert.match(wheel, /aria-valuetext="MID"/, `${id} needs a readable detent name`); + assert.match(wheel, /tabindex="0"/, `${id} must be keyboard reachable`); +} +assert.match(html, /id="volume-meter"[\s\S]*?(?:class="volume-led"[\s\S]*?){3}/, "The meter needs three detent LEDs"); +assert.match(block("#volume-wheel"), /right:\s*-12px/, "The front wheel protrudes from the right edge"); +assert.match(block("#rear-volume-wheel"), /left:\s*-12px/, "The rear wheel mirrors onto the left edge"); +for (const level of VOLUME_LEVELS) { + assert.match( + css, + new RegExp(`\\.volume-wheel\\[data-level="${level}"\\] \\.volume-knurl \\{[^}]*translateY`), + `The ${level} detent must visibly roll the knurl`, + ); +} +assert.match(css, /\.volume-meter\[data-level="mute"\]/, "MUTE needs its own visible state"); +assert.match( + reducedMotionCss(css), + /\.volume-knurl\b[^{}]*\{[^}]*transition:\s*none/, + "The wheel roll needs a reduced-motion state", +); +assert.match(adapter, /wheel\.setAttribute\("aria-valuetext", label\)/, "The wheel announces its detent"); +assert.match( + adapter, + /wheel\.addEventListener\("pointerdown", \(event\) => \{\s*event\.preventDefault\(\);/, + "Clicking the wheel must not steal focus from the D-pad", +); +assert.match(html, /VOLUME<\/th>\s*V<\/td>/, "The rear label lists the V volume key"); +assert.match(packageJson.scripts.test, /node tests\/device-audio-contract\.mjs/, "npm test runs this contract"); + +console.log("Device audio contract OK: gesture-gated four-voice speaker, persisted volume wheel, engine-owned sound"); diff --git a/tests/device-reverse-contract.mjs b/tests/device-reverse-contract.mjs index d5e14a8..9c17523 100644 --- a/tests/device-reverse-contract.mjs +++ b/tests/device-reverse-contract.mjs @@ -1,6 +1,8 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; +import { reducedMotionCss } from "./contract-css.mjs"; + const html = readFileSync(new URL("../src/index.html", import.meta.url), "utf8"); const css = readFileSync(new URL("../src/styles.css", import.meta.url), "utf8"); const adapter = readFileSync(new URL("../src/main.js", import.meta.url), "utf8"); @@ -171,7 +173,7 @@ assert.match(css, /@keyframes frontDisplayOut\s*\{[\s\S]*?opacity:\s*0/s, "The f assert.match(css, /#device-back\s*\{[^}]*visibility:\s*hidden/s, "WebKit can expose the mirrored rear face before the turn"); assert.match(css, /#shell-scale\.showing-back\s+#device-back\s*\{[^}]*visibility:\s*visible/s, "The rear face never becomes explicitly visible"); assert.match(css, /#shell-scale\.showing-back\s+#device-front\s*\{[^}]*visibility:\s*hidden/s, "The front face remains visible behind the rear face"); -assert.match(css, /@media\s*\(prefers-reduced-motion:\s*reduce\)[\s\S]*?#shell-scale\.turning\s+#device-rotator\s*\{[^}]*animation:\s*none/s, "The turn animation has no reduced-motion behavior"); +assert.match(reducedMotionCss(css), /#shell-scale\.turning\s+#device-rotator\s*\{[^}]*animation:\s*none/,"The turn animation has no reduced-motion behavior"); assert.ok(pixels(block(".rear-screw-sw"), "bottom", "lower-left screw inset") >= 40, "Lower-left screw overlaps the bottom shell shading"); assert.ok(pixels(block(".rear-screw-se"), "bottom", "lower-right screw inset") >= 40, "Lower-right screw overlaps the bottom shell shading"); diff --git a/tests/device-shell-robustness-contract.mjs b/tests/device-shell-robustness-contract.mjs new file mode 100644 index 0000000..7e129d6 --- /dev/null +++ b/tests/device-shell-robustness-contract.mjs @@ -0,0 +1,487 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +import { + DEVICE_MESSAGE_MAX_LENGTH, + GUIDE_MESSAGE_MAX_LENGTH, + deviceMessageText, + leavesKeyToFocusedControl, + trappedFocusTarget, +} from "../src/device-shell.js"; +import { + CARTRIDGE_REFUSED_MESSAGE, + isRefusedCartridgeError, + rackFocusIndex, +} from "../src/cartridge-library.js"; +import { bootShell, rackStorage } from "./shell-harness.mjs"; + +const html = readFileSync(new URL("../src/index.html", import.meta.url), "utf8"); +const css = readFileSync(new URL("../src/styles.css", import.meta.url), "utf8"); +const adapter = readFileSync(new URL("../src/main.js", import.meta.url), "utf8"); +const backend = readFileSync(new URL("../src-tauri/src/lib.rs", import.meta.url), "utf8"); + +function fn(name) { + const start = adapter.search(new RegExp(`\\n (?:async )?function ${name}\\(`)); + assert.ok(start >= 0, `Missing function ${name}`); + const end = adapter.indexOf("\n }\n", start); + assert.ok(end > start, `Unterminated function ${name}`); + return adapter.slice(start, end + 4); +} + +function listener(target, type) { + const escaped = target.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = adapter.match( + new RegExp(`${escaped}\\.addEventListener\\("${type}", \\(event\\) => \\{[\\s\\S]*?\\n \\}\\);`), + ); + assert.ok(match, `Missing ${type} listener on ${target}`); + return match[0]; +} + +// 18: Enter activates a focused shell control instead of becoming START. +assert.equal(leavesKeyToFocusedControl("Enter", true), true); +assert.equal(leavesKeyToFocusedControl("NumpadEnter", true), true); +assert.equal(leavesKeyToFocusedControl("Enter", false), false, "Enter on the page body must stay START"); +assert.equal(leavesKeyToFocusedControl("KeyD", true), false, "Game keys must keep reaching the engine"); +const keydown = adapter.match(/window\.addEventListener\("keydown", \(event\) => \{[\s\S]*?\n \}\);/)?.[0] || ""; +assert.ok(keydown, "Missing the device keydown handler"); +const focusGuardAt = keydown.search( + /if \(leavesKeyToFocusedControl\(event\.code, isShellControl\(event\.target\)\)\) return;/, +); +const startMappingAt = keydown.indexOf("const button = keyMap[event.code]"); +assert.ok(focusGuardAt >= 0, "The keydown handler must leave Enter to a focused shell control"); +assert.ok(startMappingAt >= 0, "The keydown handler must map keys to device buttons"); +assert.ok( + focusGuardAt < startMappingAt, + "Focused shell controls must keep Enter before the START mapping cancels it", +); +assert.match(fn("isShellControl"), /button, \[role=button\], \[role=switch\]/); +assert.match( + adapter, + /viewToggle\.addEventListener\("pointerdown", \(event\) => event\.preventDefault\(\)\)/, + "Mouse use of the FRONT/BACK toggle must not capture Enter from START", +); + +// 19: key repeat never re-toggles power or re-inserts a cartridge. +const switchKeydown = adapter.match( + /switchControl\.addEventListener\("keydown", \(event\) => \{[\s\S]*?\n \}\);/, +)?.[0] || ""; +assert.match(switchKeydown, /if \(event\.repeat\) return;\s*setPower\(!powered\)/, "Held power keys must toggle once"); +assert.match(listener("card", "keydown"), /^[^]*?\{\s*if \(event\.repeat\) return;/, "Held cart keys must act once"); +const insertCartridge = fn("insertCartridge"); +assert.match(insertCartridge, /if \(!value \|\| cartridge \|\| inserting\) return;/); +assert.match(insertCartridge, /inserting = true;[\s\S]*?finally \{\s*inserting = false;/); +const checkProvider = fn("checkProvider"); +assert.match( + checkProvider, + /if \(providerCheck\?\.provider === provider\) return providerCheck\.result;/, + "A new power-on must join an abandoned cycle's in-flight CLI probe instead of starting another", +); +assert.match(checkProvider, /invoke\("verify_ai_provider", \{ provider \}\)/); + +// 20: the real power-on failure reaches the device and both battery guides. +assert.equal(deviceMessageText("CODEX CLI UNAVAILABLE"), "CODEX CLI UNAVAILABLE"); +assert.equal(deviceMessageText(new Error("RUN IN TAURI TO LOAD CARTRIDGES")), "RUN IN TAURI TO LOAD CARTRIDGES"); +assert.equal(deviceMessageText({ message: "CLAUDE CALL TIMED OUT" }), "CLAUDE CALL TIMED OUT"); +assert.equal(deviceMessageText(" INVALID CODEQUEST.toml:\n line 3 "), "INVALID CODEQUEST.toml: line 3"); +assert.equal(deviceMessageText(""), "UNKNOWN FAULT"); +assert.equal(deviceMessageText(null, { fallback: "CODEX NOT READY" }), "CODEX NOT READY"); +assert.equal(deviceMessageText("X".repeat(400)).length, DEVICE_MESSAGE_MAX_LENGTH); +assert.ok(deviceMessageText("X".repeat(400)).endsWith("…")); +assert.equal( + deviceMessageText("Z".repeat(DEVICE_MESSAGE_MAX_LENGTH)), + "Z".repeat(DEVICE_MESSAGE_MAX_LENGTH), + "A message that exactly fits the device line is shown whole", +); +assert.equal( + deviceMessageText("Y".repeat(80), { maxLength: GUIDE_MESSAGE_MAX_LENGTH }).length, + GUIDE_MESSAGE_MAX_LENGTH, +); +assert.match(fn("powerFailureReason"), /deviceMessageText\(error, \{[\s\S]*?NOT READY/); +const rejectPowerOn = fn("rejectPowerOn"); +assert.match(rejectPowerOn, /lastPowerFailure = powerFailureReason\(message\);/, "Keep the backend's failure reason"); +assert.match(rejectPowerOn, /showDeviceError\(lastPowerFailure\)/, "Show the failure reason on the device"); +const guides = fn("updateControlGuides"); +assert.match(guides, /const batteryFailure = installedProvider \? lastPowerFailure : "";/); +assert.match( + guides, + /const needsBatteryTab =[\s\S]*?\(!installedProvider \|\| Boolean\(batteryFailure\)\)[\s\S]*?!batteryDoorOpen/, + "Installed-but-failed batteries must guide the back face to the battery tab", +); +assert.match( + guides, + /const needsBatteryBay =[\s\S]*?\(!installedProvider \|\| Boolean\(batteryFailure\)\)[\s\S]*?batteryDoorOpen/, + "Installed-but-failed batteries must light the bay once the cover is off", +); +const batteryGuides = fn("renderBatteryGuides"); +assert.match(batteryGuides, /viewGuide\.querySelector\("\.guide-action"\)\.textContent = failure \|\| "CHECK BATTERIES"/); +assert.match(batteryGuides, /batteryGuide\.querySelector\("\.guide-action"\)\.textContent = failure \|\| "OPEN BATTERY TAB"/); +assert.match(fn("renderBatteryTray"), /`\$\{lastPowerFailure\} · EJECT TO SWAP`/); + +// 21: transient load faults keep rack entries and the saved battery choice. +assert.equal(CARTRIDGE_REFUSED_MESSAGE, "NOT A GIT REPOSITORY - CARTRIDGE REFUSED"); +assert.equal(isRefusedCartridgeError(CARTRIDGE_REFUSED_MESSAGE), true); +assert.equal(isRefusedCartridgeError(new Error(CARTRIDGE_REFUSED_MESSAGE)), true); +assert.equal(isRefusedCartridgeError({ message: CARTRIDGE_REFUSED_MESSAGE }), true); +for (const transient of [ + "INVALID CODEQUEST.toml: expected `=`", + "DIRECTORY NOT FOUND", + "BEVY ENGINE STOPPED", + "CANNOT READ CODEQUEST.toml: denied", + // Git that could not answer says nothing about the folder (lib.rs + // git_repo_check_within), and a damaged save is the player's to repair. + "GIT CALL TIMED OUT", + "GIT CLI UNAVAILABLE - NOT FOUND", + "GIT CALL FAILED - FATAL: CANNOT CHANGE TO 'D:\\REPO': PERMISSION DENIED", + "CARTRIDGE SAVE IS CORRUPT", + "GIT CALL FAILED: program not found", + // The bare phrase is what a failed git probe used to collapse into. + "NOT A GIT REPOSITORY", + new Error("NOT A GIT REPOSITORY"), + null, +]) { + assert.equal(isRefusedCartridgeError(transient), false, `${transient} must not recycle a cartridge`); +} +// The shell and the backend must agree on the one refusal, and no other +// backend message may be mistaken for it. +const backendMessages = [...backend.matchAll(/"([A-Z][A-Z0-9 .:·'\/_-]{5,})"/g)].map(([, text]) => text); +assert.ok( + backendMessages.includes(CARTRIDGE_REFUSED_MESSAGE), + "The backend must still refuse non-repositories with the text the shell recycles on", +); +for (const message of backendMessages) { + assert.equal( + isRefusedCartridgeError(message), + message === CARTRIDGE_REFUSED_MESSAGE, + `Backend message ${message} must not recycle a cartridge`, + ); +} +const insertByPath = fn("insertByPath"); +assert.match( + insertByPath, + /if \(isRefusedCartridgeError\(error\)\) \{\s*forgetCartridge\(path\);/, + "A failed load may forget only a folder that is no longer a repository", +); +assert.match(insertByPath, /showDeviceError\(error\)/); +const initialize = fn("initialize"); +assert.doesNotMatch( + initialize, + /removeItem\(PROVIDER_STORAGE_KEY\);\s*renderProviderBatteries\(\);\s*show/, + "An engine error at startup must not erase the saved battery choice", +); +assert.match(initialize, /if \(isRefusedCartridgeError\(error\)\) forgetCartridge\(savedPath\);/); +assert.doesNotMatch( + initialize, + /catch \(error\) \{[^}]*localStorage\.removeItem\("cqa-cart-id"\)/, + "A startup fault that may clear must keep the saved slot for the next launch", +); +assert.match(initialize, /showDeviceError\(`CARTRIDGE NOT LOADED · \$\{deviceMessageText\(error\)\}`\)/); +assert.doesNotMatch(initialize, /catch \(_\) \{\s*cartridge = null/, "Startup cartridge faults must not be swallowed"); + +// 22: abandoned power-on attempts never touch the battery UI. +const verifyInstalledProvider = fn("verifyInstalledProvider"); +assert.match(verifyInstalledProvider, /const current = \(\) => generation === powerGeneration;/); +assert.match(verifyInstalledProvider, /if \(!current\(\)\) return;\s*verifiedProvider = provider;/); +assert.match(verifyInstalledProvider, /if \(current\(\)\) \{\s*verifiedProvider = null;/); +assert.match(verifyInstalledProvider, /finally \{\s*if \(current\(\)\) batteryCompartment\.classList\.remove\("checking"\);/); +const setPower = fn("setPower"); +assert.match( + setPower, + /await waitForPaint\(\);\s*if \(generation !== powerGeneration \|\| !powered\) return;\s*await verifyInstalledProvider\(generation\);/, + "A power-on abandoned during the first paint must not start a probe", +); +assert.match( + setPower, + /batteryCompartment\.classList\.remove\("locked", "checking"\);\s*hideDeviceBoot\(\);\s*renderProviderBatteries\(\);/, + "Power-off must clear a stale CHECKING state", +); + +// 23: one visible, announced device message surface outside the trays. +assert.match( + html, + /
<\/div>/, + "The device needs a visible live status surface", +); +assert.ok( + html.indexOf('id="device-message"') < html.indexOf('id="cart-tray"') + && html.indexOf('id="device-message"') > html.indexOf('id="shell-scale"'), + "The device message must live outside the cartridge tray and the inert device", +); +for (const id of ["tray-error", "battery-tray-error"]) { + assert.match( + html, + new RegExp(`
<\\/div>`), + `${id} must be an announced message line that stays rendered`, + ); +} +assert.doesNotMatch(adapter, /showTray(?:Error|Message)\(/, "Messages must route through the device surface"); +assert.doesNotMatch( + adapter, + /setBatteryStatus\("(?:TURN POWER OFF|EJECT INSTALLED|BATTERY CONTACT)/, + "Refusals are device messages, not a second announcement from the battery state region", +); +const surface = fn("activeMessageSurface"); +assert.match(surface, /if \(trayOpen\) return trayError;\s*if \(batteryTrayOpen\) return batteryTrayError;\s*return deviceMessage;/); +assert.match(fn("hideDeviceMessage"), /messageSurface\.textContent = ""/, "Hidden messages must leave no stale text"); +assert.match(fn("showDeviceMessage"), /if \(!persist\) messageTimer = window\.setTimeout\(hideDeviceMessage, MESSAGE_DURATION_MS\)/); +assert.match( + adapter, + /initialize\(\)\.catch\(\(error\) => \{\s*showDeviceError\(`DEVICE FAULT · \$\{deviceMessageText\(error\)\}`, \{ persist: true \}\);/, + "A failed startup must leave a visible fault instead of a blank device", +); +assert.match(fn("finishDeviceBoot"), /showDeviceError\(error\)/); +const deviceMessageCss = css.match(/#device-message \{([\s\S]*?)\}/)?.[1] || ""; +assert.match(deviceMessageCss, /position:\s*fixed/); +assert.match(deviceMessageCss, /z-index:\s*55/, "Device messages must sit above the tray dimmers"); +assert.match(deviceMessageCss, /font:\s*8px\/1\.6 'Press Start 2P'/, "Device messages must use the shell typeface"); +assert.match(deviceMessageCss, /pointer-events:\s*none/); +assert.match(css, /#device-message:empty \{ opacity: 0; \}/); +assert.doesNotMatch(css, /#tray-error\.hidden/, "Live message lines must not be removed from the accessibility tree"); + +// 24: both trays are real modals. +assert.match( + html, + /id="cart-tray"[^>]*role="dialog"[^>]*aria-modal="true"[^>]*aria-labelledby="cart-tray-head"/, + "The cartridge rack must open as a labelled modal dialog", +); +assert.match(html, /id="cart-tray-head" class="tray-head"/); +assert.match(fn("buildTray"), /\$\("cart-tray-head"\)\.textContent =/); +assert.equal(trappedFocusTarget([], null), null); +const [first, middle, last] = ["first", "middle", "last"]; +const focusables = [first, middle, last]; +assert.equal(trappedFocusTarget(focusables, "body"), first, "Tab from outside the dialog must enter at the start"); +assert.equal(trappedFocusTarget(focusables, "body", true), last, "Shift+Tab from outside must enter at the end"); +assert.equal(trappedFocusTarget(focusables, last), first, "Tab must wrap at the end"); +assert.equal(trappedFocusTarget(focusables, first, true), last, "Shift+Tab must wrap at the start"); +assert.equal(trappedFocusTarget(focusables, middle), null, "Inner Tab moves stay native"); +const modality = fn("syncTrayModality"); +assert.match(modality, /const modal = trayOpen \|\| batteryTrayOpen;/); +for (const target of ["scaleEl", "viewToggle", "viewGuide"]) { + assert.match(modality, new RegExp(`${target}\\.inert = modal;`), `${target} must be inert behind an open tray`); +} +assert.match(fn("trapTabFocus"), /trappedFocusTarget\(focusables, document\.activeElement, event\.shiftKey\)/); +assert.match(keydown, /trapTabFocus\(event, batteryOptions\)/); +assert.match(keydown, /if \(trayOpen && event\.key === "Tab"\) \{\s*trapTabFocus\(event, cartTray\);/); +const openTray = fn("openTray"); +assert.match(openTray, /trayReturnFocus = /, "Opening the rack must remember the previous focus"); +assert.match(openTray, /syncTrayModality\(\);\s*\$\("tray-carts"\)\.querySelector\("button:not\(:disabled\)"\)\?\.focus\(\);/); +assert.match(openTray, /cartTray\.setAttribute\("aria-hidden", "false"\)/); +const closeTray = fn("closeTray"); +assert.match(closeTray, /syncTrayModality\(\);\s*if \(focusWasInTray\) trayReturnFocus\?\.focus\(\);/); +assert.match(closeTray, /cartTray\.setAttribute\("aria-hidden", "true"\)/); +assert.match(fn("openBatteryTray"), /syncTrayModality\(\);\s*focusBatteryTray\(\);/); +assert.match(fn("closeBatteryTray"), /syncTrayModality\(\);\s*if \(restoreFocus/, "Focus must return only after the device is interactive again"); +assert.match(adapter, /else if \(batteryTrayOpen\) focusBatteryTray\(\);/, "A failed battery change must re-focus the tray"); +assert.match( + adapter, + /\$\("cart-back"\)\.addEventListener\("pointerdown", \(event\) => \{\s*event\.preventDefault\(\);/, + "Opening the rack by pointer must not immediately blur its focused card", +); + +assert.equal(rackFocusIndex(["/a", "/b", undefined], "/b", 0), 1, "A rebuilt rack keeps focus on the same cartridge"); +assert.equal(rackFocusIndex(["/a", undefined], "/b", 1), 1, "A recycled card hands focus to its neighbor"); +assert.equal(rackFocusIndex(["/a"], null, 4), 0, "Focus clamps to the last remaining card"); +assert.equal(rackFocusIndex(["/a", undefined], null, null), -1, "Unfocused racks stay unfocused"); +assert.equal(rackFocusIndex([undefined], undefined, null), -1, "Action tiles never match a missing path"); +assert.equal(rackFocusIndex([], "/a", 0), -1); +const buildTray = fn("buildTray"); +assert.match(buildTray, /card\.dataset\.path = value\.path;/); +assert.match(buildTray, /rackFocusIndex\(cards\.map\(\(card\) => card\.dataset\.path\), focusPath, restoreIndex\)/); +assert.match(fn("recycleCartridge"), /if \(trayOpen\) buildTray\(\{ focusIndex \}\);/); + +// 25: branch refresh relabels cards in place. +const refresh = fn("refreshCartridgeBranches"); +assert.doesNotMatch(refresh, /buildTray\(/, "A background refresh must not rebuild the open rack"); +assert.match(refresh, /relabelTrayCard\(value\)/); +const relabel = fn("relabelTrayCard"); +assert.match(relabel, /element\.dataset\.path === value\.path/); +assert.match(relabel, /\.cc-sub"\)\.textContent = value\.branch/); +assert.match(relabel, /setAttribute\("aria-label", cartridgeCardLabel\(/); +assert.doesNotMatch(buildTray, /tray-error|hideDeviceMessage/, "Rebuilding the rack must not hide a fresh message"); + +// 26: the browser demo explains why ADD FROM DISK cannot load. +assert.match( + fn("createBrowserDemo"), + /if \(command === "pick_cartridge"\) throw new Error\("RUN IN TAURI TO LOAD CARTRIDGES"\);/, +); + +/* ---------- The real adapter, driven through its own handlers ---------- */ + +const cartridgeFor = (path) => ({ + path, + title: path.split("/").at(-1).toUpperCase(), + branch: "main", + revision: "abc1234", + color: "#6a6fd1", +}); +const rackPaths = (shell) => JSON.parse(shell.storage.getItem("cqa-repo-carts")).map(({ path }) => path); +const poweredOn = (shell) => shell.calledWith("engine_power", ({ powered }) => powered === true).length; + +// 27: power never switches on under a cartridge load that is still walking git. +{ + const shell = await bootShell({ storage: rackStorage(["/repos/big"]) }); + await shell.key("KeyC"); + assert.ok(shell.trayOpen, "C opens the rack while powered off"); + shell.hold("engine_set_cartridge"); + await shell.dispatch(shell.rackCard(0), "keydown", { key: "Enter" }); + assert.ok(shell.isHeld("engine_set_cartridge"), "Enter on a rack card starts the load"); + await shell.key("KeyP"); + await shell.paint(); + assert.equal(shell.calledWith("verify_ai_provider").length, 0, "P during a cartridge load must not start a power-on"); + assert.equal(shell.powered, false, "The power switch stays off while the cartridge loads"); + assert.match(shell.messages, /WAIT FOR THE CARTRIDGE/, "A refused power-on says why"); + await shell.answer("engine_set_cartridge", cartridgeFor("/repos/big")); + assert.ok(shell.loaded && !shell.trayOpen, "The finished load seats the cartridge and closes the rack"); + await shell.key("KeyP"); + await shell.paint(); + assert.equal(shell.powered, true, "Power-on works once the cartridge is seated"); + assert.equal(poweredOn(shell), 1); + await shell.advance(2600); + assert.equal(shell.calledWith("engine_finish_boot").length, 1, "The boot logo finishes on its own timer"); + shell.close(); +} +{ + // The native folder picker is part of the same cartridge operation. + const shell = await bootShell({ storage: rackStorage([]) }); + await shell.key("KeyC"); + shell.hold("pick_cartridge"); + await shell.dispatch(shell.rackAction("add"), "click"); + await shell.key("KeyP"); + await shell.paint(); + assert.equal(shell.calledWith("verify_ai_provider").length, 0, "P while the folder picker is open must not power on"); + await shell.answer("pick_cartridge", cartridgeFor("/repos/picked")); + assert.ok(shell.loaded, "The picked cartridge still loads"); + assert.equal(shell.powered, false); + shell.close(); +} + +// 27: ejecting is fenced the same way, and a double click unloads once. +{ + const shell = await bootShell({ storage: rackStorage(["/repos/big"], { current: "/repos/big" }) }); + assert.ok(shell.loaded, "The saved cartridge is seated at startup"); + await shell.key("KeyC"); + const eject = shell.rackAction("eject"); + await shell.dispatch(eject, "click"); + await shell.dispatch(eject, "click"); + await shell.key("KeyP"); + await shell.paint(); + assert.equal(shell.calledWith("verify_ai_provider").length, 0, "P during the eject animation must not power on"); + shell.hold("engine_set_cartridge"); + await shell.advance(240); + assert.ok(shell.isHeld("engine_set_cartridge"), "The eject unloads the engine after its animation"); + await shell.key("KeyP"); + await shell.paint(); + assert.equal(shell.calledWith("verify_ai_provider").length, 0, "P during the unload must not power on"); + await shell.answer("engine_set_cartridge", null); + await shell.advance(240); + assert.equal(shell.loaded, false); + assert.equal( + shell.calledWith("engine_set_cartridge", ({ path }) => path === null).length, + 1, + "A double-clicked eject unloads the engine once", + ); + assert.equal(shell.storage.getItem("cqa-cart-id"), null); + shell.close(); +} + +// 28: power input waits for initialize(), which then never switches it off. +{ + const shell = await bootShell({ + storage: rackStorage(["/repos/big"], { current: "/repos/big" }), + hold: ["app_revision", "engine_set_cartridge"], + }); + await shell.key("KeyP"); + await shell.paint(); + await shell.answer("app_revision", "abc1234"); + assert.ok(shell.isHeld("engine_set_cartridge"), "Startup is still loading the saved cartridge"); + await shell.dispatch(shell.el("power-switch"), "pointerdown"); + await shell.key("KeyC"); + await shell.paint(); + assert.equal(shell.calledWith("verify_ai_provider").length, 0, "Power input before startup finishes must be ignored"); + assert.equal(shell.powered, false); + assert.equal(shell.trayOpen, false, "The rack cannot open under the startup load"); + await shell.answer("engine_set_cartridge", cartridgeFor("/repos/big")); + assert.deepEqual( + shell.calledWith("engine_power").map(({ args }) => args.powered), + [false], + "Startup switches the engine off exactly once", + ); + assert.equal( + shell.el("battery-status").textContent, + "CODEX · UNTESTED", + "An early P must not leave a stale NO BATTERIES failure behind", + ); + await shell.key("KeyP"); + await shell.paint(); + assert.equal(shell.powered, true); + assert.equal(shell.calledWith("engine_power").at(-1).args.powered, true, "Nothing switches the engine off afterwards"); + await shell.advance(2600); + assert.equal(shell.calledWith("engine_finish_boot").length, 1, "The saved cartridge boots on its own timer"); + shell.close(); +} + +// 13: only the backend's refusal recycles a cartridge; git faults keep it. +{ + const shell = await bootShell({ + storage: rackStorage(["/repos/share", "/repos/gone"], { current: "/repos/share" }), + hold: ["engine_set_cartridge"], + }); + await shell.answer("engine_set_cartridge", new Error("GIT CALL TIMED OUT")); + assert.match(shell.messages, /CARTRIDGE NOT LOADED · GIT CALL TIMED OUT/); + assert.deepEqual(rackPaths(shell), ["/repos/share", "/repos/gone"], "A git timeout at startup keeps the rack entry"); + assert.equal(shell.storage.getItem("cqa-cart-id"), "/repos/share", "The next launch retries the saved cartridge"); + await shell.key("KeyC"); + shell.hold("engine_set_cartridge"); + await shell.dispatch(shell.rackCard(0), "keydown", { key: "Enter" }); + await shell.answer("engine_set_cartridge", new Error("GIT CALL FAILED: program not found")); + assert.deepEqual(rackPaths(shell), ["/repos/share", "/repos/gone"], "Git that cannot start keeps the rack entry"); + shell.hold("engine_set_cartridge"); + await shell.dispatch(shell.rackCard(1), "keydown", { key: "Enter" }); + await shell.answer("engine_set_cartridge", new Error(CARTRIDGE_REFUSED_MESSAGE)); + assert.deepEqual(rackPaths(shell), ["/repos/share"], "A refused folder leaves the rack"); + assert.equal(shell.rackCard(0).dataset.path, "/repos/share", "The open rack is rebuilt without it"); + shell.close(); +} +{ + const shell = await bootShell({ + storage: rackStorage(["/repos/plain"], { current: "/repos/plain" }), + hold: ["engine_set_cartridge"], + }); + await shell.answer("engine_set_cartridge", new Error(CARTRIDGE_REFUSED_MESSAGE)); + assert.deepEqual(rackPaths(shell), [], "A refused saved cartridge is forgotten at startup"); + assert.equal(shell.storage.getItem("cqa-cart-id"), null); + shell.close(); +} +{ + // A kept saved slot follows its rack entry when the player recycles it. + const shell = await bootShell({ + storage: rackStorage(["/repos/share"], { current: "/repos/share" }), + hold: ["engine_set_cartridge"], + }); + await shell.answer("engine_set_cartridge", new Error("GIT CALL TIMED OUT")); + await shell.key("KeyC"); + await shell.dispatch(shell.rackCard(0), "keydown", { key: "Delete" }); + await shell.advance(180); + assert.deepEqual(rackPaths(shell), []); + assert.equal(shell.storage.getItem("cqa-cart-id"), null, "A recycled cartridge must not come back next launch"); + shell.close(); +} + +// 29: what the rack learns is saved for the next launch. +{ + const shell = await bootShell({ + storage: rackStorage(["/repos/big"]), + respond: { cartridge_branch: () => "story/next-chapter" }, + }); + await shell.key("KeyC"); + assert.equal( + JSON.parse(shell.storage.getItem("cqa-repo-carts"))[0].branch, + "story/next-chapter", + "A refreshed branch label is saved with the rack", + ); + await shell.dispatch(shell.rackCard(0), "keydown", { key: "Enter" }); + assert.ok(shell.loaded); + assert.equal(shell.storage.getItem("cqa-cart-id"), "/repos/big", "A seated cartridge is reseated next launch"); + shell.close(); +} + +console.log("Device shell robustness contract OK: focus, repeat, fault surfacing, modal trays, stable rack"); diff --git a/tests/device-transcript-contract.mjs b/tests/device-transcript-contract.mjs new file mode 100644 index 0000000..50a1d4c --- /dev/null +++ b/tests/device-transcript-contract.mjs @@ -0,0 +1,181 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +const html = readFileSync(new URL("../src/index.html", import.meta.url), "utf8"); +const css = readFileSync(new URL("../src/styles.css", import.meta.url), "utf8"); +const adapter = readFileSync(new URL("../src/main.js", import.meta.url), "utf8"); +const lib = readFileSync(new URL("../src-tauri/src/lib.rs", import.meta.url), "utf8"); +const engine = readFileSync(new URL("../src-tauri/src/engine.rs", import.meta.url), "utf8"); +const transcript = readFileSync(new URL("../src-tauri/src/engine/transcript.rs", import.meta.url), "utf8"); +const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")); + +function fn(name) { + const start = adapter.search(new RegExp(`\\n (?:async )?function ${name}\\(`)); + assert.ok(start >= 0, `Missing function ${name}`); + const end = adapter.indexOf("\n }\n", start); + assert.ok(end > start, `Unterminated function ${name}`); + return adapter.slice(start, end + 4); +} + +/* ---------- A visually hidden, announced screen transcript ---------- */ + +const region = html.match(/
]*><\/div>/)?.[0] || ""; +assert.ok(region, "The screen needs a transcript region"); +assert.match(region, /class="sr-only"/, "The transcript is hidden visually, not from assistive technology"); +assert.match(region, /role="status"/); +assert.match(region, /aria-live="polite"/, "Screen changes must not interrupt the player"); +assert.match(region, /aria-atomic="true"/, "Each screen is read as a whole"); +assert.doesNotMatch(region, /\shidden\b|aria-hidden/, "The transcript must stay in the accessibility tree"); +/** The ids of every element enclosing the first tag carrying `id`. */ +function ancestorIds(markup, id) { + const target = markup.lastIndexOf("<", markup.indexOf(`id="${id}"`)); + const open = []; + const tags = /<(\/?)([a-zA-Z][\w-]*)([^>]*?)(\/?)>/g; + for (let tag = tags.exec(markup); tag && tag.index < target; tag = tags.exec(markup)) { + const [, closing, name, attributes, selfClosing] = tag; + if (closing) { + while (open.length && open.pop().name !== name.toLowerCase()); + } else if (!selfClosing && !/^(?:meta|link|input|br|img|hr|source)$/i.test(name)) { + open.push({ name: name.toLowerCase(), id: attributes.match(/\sid="([^"]+)"/)?.[1] }); + } + } + return open.map((element) => element.id).filter(Boolean); +} +assert.ok(ancestorIds(html, "engine-canvas").includes("device-front"), "The parser sees the screen inside the front face"); +// The shell hides a face (aria-hidden + inert) whenever the other one shows, +// and makes the whole device inert behind a tray, while the engine keeps +// publishing. The transcript therefore sits outside both, beside the device +// message, so no presentation state can hide what the engine says. +assert.deepEqual( + ancestorIds(html, "engine-transcript"), + ancestorIds(html, "device-message"), + "The transcript lives beside the device message", +); +for (const hider of ["shell-scale", "device-rotator", "device-front", "device-back"]) { + assert.ok(!ancestorIds(html, "engine-transcript").includes(hider), `The transcript must not sit inside #${hider}`); +} +for (const hidden of ["frontFace", "backFace", "scaleEl"]) { + assert.match(adapter, new RegExp(`${hidden}\\.inert = `), `${hidden} is still one of the regions the shell makes inert`); +} +const srOnly = css.match(/\.sr-only \{([\s\S]*?)\}/)?.[1] || ""; +assert.match(srOnly, /position:\s*absolute/); +assert.match(srOnly, /clip:\s*rect\(0, 0, 0, 0\)/); +assert.doesNotMatch(srOnly, /display:\s*none|visibility:\s*hidden/, "display:none would silence the region"); +assert.doesNotMatch(css, /#engine-transcript/, "No rule may restyle the region out of the accessibility tree"); + +/* ---------- The shell only presents what the engine publishes ---------- */ + +assert.match(adapter, /const screenTranscript = \$\("engine-transcript"\);/); +assert.match(adapter, /let transcriptPending = false;/, "Transcript polling needs its own in-flight guard"); +assert.match(adapter, /let transcriptSeq = 0;/); +const cadence = Number(adapter.match(/const TRANSCRIPT_POLL_MS = (\d+);/)?.[1]); +assert.ok(cadence >= 100 && cadence <= 250, "The transcript is polled at a modest cadence"); +assert.match( + adapter, + /async function drawFrame\(\) \{\s*void pollAudio\(\);\s*void pollTranscript\(\);/, + "Every animation frame offers a transcript poll without awaiting it", +); +assert.equal( + adapter.match(/screenTranscript\./g)?.length, + 1, + "The shell writes the region in exactly one place and never composes its text", +); +assert.match( + adapter, + /command === "engine_transcript"\) return null;/, + "The browser demo has no engine and so no transcript", +); + +// Run the real polling function against a stand-in engine. +const poll = fn("pollTranscript"); +function harness() { + const calls = []; + const replies = []; + const clock = { now: 0 }; + const target = { textContent: "" }; + const invoke = (command, args) => { + calls.push([command, args]); + return replies.shift() ?? Promise.resolve(null); + }; + const quiet = { error() {} }; + const build = new Function( + "invoke", + "performance", + "screenTranscript", + "TRANSCRIPT_POLL_MS", + "console", + `let transcriptPending = false; + let transcriptPolledAt = -Infinity; + let transcriptSeq = 0; + ${poll} + return pollTranscript;`, + ); + return { + calls, + replies, + clock, + target, + poll: build(invoke, { now: () => clock.now }, target, cadence, quiet), + }; +} + +const live = harness(); +live.replies.push(Promise.resolve({ seq: 1, text: "TRANSCRIPT TEST. Repository Oracle. Press Start." })); +await live.poll(); +assert.deepEqual(live.calls, [["engine_transcript", { since: 0 }]], "The first poll asks for anything newer than nothing"); +assert.equal(live.target.textContent, "TRANSCRIPT TEST. Repository Oracle. Press Start."); + +live.clock.now = cadence / 2; +await live.poll(); +assert.equal(live.calls.length, 1, "Animation frames between polls do not call the engine"); + +live.clock.now = cadence * 2; +live.target.textContent = "UNCHANGED"; +await live.poll(); +assert.deepEqual(live.calls[1], ["engine_transcript", { since: 1 }], "Later polls pass the last presented sequence"); +assert.equal(live.target.textContent, "UNCHANGED", "No newer transcript leaves the region alone"); + +live.clock.now = cadence * 3; +live.replies.push(Promise.resolve({ seq: 1, text: "A REPEAT" })); +await live.poll(); +assert.equal(live.target.textContent, "UNCHANGED", "A repeated sequence number is never re-announced"); + +live.clock.now = cadence * 4; +live.replies.push(Promise.resolve({ seq: 2, text: "" })); +await live.poll(); +assert.equal(live.target.textContent, "", "Powering off clears the region"); + +live.clock.now = cadence * 5; +let release; +live.replies.push(new Promise((resolve) => { release = resolve; })); +const pending = live.poll(); +live.clock.now = cadence * 7; +await live.poll(); +assert.equal(live.calls.length, 5, "A slow reply blocks further polls until it lands"); +release({ seq: 3, text: "Oracle Datafall." }); +await pending; +assert.equal(live.target.textContent, "Oracle Datafall."); + +const failing = harness(); +failing.replies.push(Promise.reject(new Error("BEVY ENGINE STOPPED"))); +await failing.poll(); +failing.clock.now = cadence * 2; +await failing.poll(); +assert.equal(failing.calls.length, 2, "A failed poll releases its guard"); + +/* ---------- The engine owns the words ---------- */ + +assert.match( + lib, + /fn engine_transcript\(state: State, since: u64\) -> Option/, + "Rust exposes engine_transcript", +); +assert.match(lib, /generate_handler!\[[\s\S]*?engine_audio,\s*engine_transcript\s*\]/, "engine_transcript is registered beside engine_audio"); +assert.match(engine, /pub fn transcript_since\(&self, seq: u64\) -> Option/); +assert.match(engine, /let \(screen, sentences\) = engine\.transcript_sentences\(\);\s*if let Ok\(mut channel\) = shared_transcript\.lock\(\) \{\s*channel\.publish\(screen, sentences\);/); +assert.match(transcript, /pub\(super\) fn screen_transcript\(state: &GameState\) -> String/); +assert.match(transcript, /fn publish\(&mut self, screen: Screen, sentences: Vec\) -> bool \{[\s\S]*?if latest == sentences\.as_slice\(\) \{\s*return false;/, "Only a changed transcript is published"); +assert.match(transcript, /fn announcement\(old: &\[String\], new: &\[String\]\) -> String/, "A change on the same screen is announced on its own"); +assert.match(packageJson.scripts.test, /node tests\/device-transcript-contract\.mjs/, "npm test runs this contract"); + +console.log("Device transcript contract OK: engine-written screen transcript in a polite live region, change-only polling"); diff --git a/tests/docs-contract.mjs b/tests/docs-contract.mjs new file mode 100644 index 0000000..c806a24 --- /dev/null +++ b/tests/docs-contract.mjs @@ -0,0 +1,126 @@ +import assert from "node:assert/strict"; +import { readFileSync, readdirSync } from "node:fs"; + +// The design brief, the designer skill, and the README describe engine +// behavior in prose. These checks tie each described fact to the source it +// comes from, so a change to one without the other fails here. + +const read = (path) => + readFileSync(new URL(`../${path}`, import.meta.url), "utf8").replaceAll("\r\n", "\n"); +// Prose wraps anywhere, so phrases are matched with whitespace collapsed. +const prose = (path) => read(path).replace(/\s+/g, " "); + +const rustFiles = readdirSync(new URL("../src-tauri/src/", import.meta.url), { + recursive: true, +}) + .filter((name) => name.endsWith(".rs")) + .map((name) => `src-tauri/src/${name.replaceAll("\\", "/")}`); +const rust = rustFiles.map((path) => [path, read(path)]); +const allRust = rust.map(([, source]) => source).join("\n"); + +function constant(name) { + const value = allRust.match(new RegExp(`const ${name}: [^=]+= ([^;]+);`))?.[1]; + assert.ok(value, `Missing constant ${name}`); + return value; +} + +const brief = prose("docs/game-design/oracle-quiz.md"); +const skill = prose(".agents/skills/codequest-game-designer/SKILL.md"); +const polish = prose(".agents/skills/codequest-game-designer/references/polish.md"); +const reference = prose("docs/reference/codequest-toml.md"); +const readme = prose("README.md"); +const manifests = ["CODEQUEST.toml", "docs/examples/CODEQUEST.toml"]; + +/* ---------- Every preview-writing test is listed where designers look ---------- */ + +const previewWriters = new Set(); +for (const [, source] of rust) { + let test = null; + for (const line of source.split("\n")) { + const declared = line.match(/^ {4}fn ([a-z0-9_]+)\(/); + if (declared) test = declared[1]; + if (/maybe_write_preview\(/.test(line) && !/fn maybe_write_preview/.test(line)) { + assert.ok(test, "A preview is written outside any function"); + previewWriters.add(test); + } + } +} +assert.ok(previewWriters.size > 0, "No test writes CQA_VISUAL_PREVIEW_DIR frames"); +for (const test of previewWriters) { + // Named inline or in the command block. + assert.match(reference, new RegExp(`\\b${test}\\b`), `codequest-toml.md omits preview writer ${test}`); + assert.match(skill, new RegExp(`\\b${test}\\b`), `SKILL.md omits preview writer ${test}`); +} + +/* ---------- The spaced-retry distance matches RETRY_GAP ---------- */ + +const words = ["zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine"]; +const gap = words[Number(constant("RETRY_GAP"))]; +assert.ok(gap, "RETRY_GAP needs a spelled-out number in these checks"); +// Every retry waits the full gap (it carries into the next batch or waits +// for a delivery rather than coming back sooner). +const retryPhrase = new RegExp(`after (exactly )?${gap} other questions`); +for (const [path, text] of [ + ["README.md", readme], + ...manifests.map((path) => [path, prose(path)]), + ["SKILL.md", skill], + ["oracle-quiz.md", brief], +]) { + assert.match(text, retryPhrase, `${path} must say a miss returns after ${gap} other questions`); + assert.doesNotMatch( + text, + /up to \w+ intervening questions/, + `${path} still says a retry can come back sooner than the gap`, + ); +} + +/* ---------- The generation repair pass is described ---------- */ + +const minRepair = constant("MIN_REPAIR_TIME").match(/from_secs\((\d+)\)/)?.[1]; +assert.ok(minRepair, "MIN_REPAIR_TIME should be whole seconds"); +const generationRow = brief.match(/\| Question generation \|[^\n]*?\| (?=\| )/)?.[0] || ""; +assert.ok(generationRow, "The brief needs its Question generation traceability row"); +for (const [path, text] of [ + ["oracle-quiz.md Question generation row", generationRow], + ["README.md", readme], +]) { + assert.match(text, /one (bounded )?repair call/, `${path} must describe the repair call`); + assert.ok(text.includes(`${minRepair} seconds remain`), `${path} must give the repair floor`); + assert.match(text, /at most two CLI calls/, `${path} must give the provider cost`); +} +assert.match(skill, /one bounded repair call/, "SKILL.md must say mechanical rejections get a repair"); + +/* ---------- The Oracle line's copy, categories, and cadence ---------- */ + +const reasonSource = allRust.match(/fn question_failure_reason\([\s\S]*?\n\}\n/)?.[0] || ""; +assert.ok(reasonSource, "Missing question_failure_reason"); +const reasons = [...reasonSource.matchAll(/^\s*"([A-Z][A-Z ]+)"\s*$/gm)].map((match) => match[1]); +assert.ok(reasons.length > 3, "question_failure_reason should return its categories"); +for (const extra of ["AI DISABLED", "NO NEW QUESTIONS"]) { + if (allRust.includes(`"${extra}"`)) reasons.push(extra); +} +for (const reason of reasons) { + assert.ok(brief.includes(`\`${reason}\``), `oracle-quiz.md omits the Oracle reason ${reason}`); +} +assert.match(allRust, /"- RETRY IN \{seconds\}S"/, "The Oracle line's countdown copy moved"); +assert.ok(brief.includes("RETRY IN NS"), "oracle-quiz.md must show the retry countdown copy"); +const recallTicks = constant("ORACLE_RECALL_TICKS"); +assert.ok( + brief.includes(`every ${recallTicks} ticks`) && brief.includes("`ORACLE_RECALL_TICKS`"), + "oracle-quiz.md must give the recall cadence", +); +assert.doesNotMatch(brief, /being implemented now/, "The brief still calls shipped work in progress"); + +/* ---------- The screen transcript is an existing mechanism ---------- */ + +if (/fn engine_transcript\(/.test(allRust)) { + for (const [path, text] of [ + ["SKILL.md", skill], + ["polish.md", polish], + ["oracle-quiz.md", brief], + ]) { + assert.ok(text.includes("`engine_transcript`"), `${path} omits the screen transcript`); + } +} + +console.log("Docs contract: ok"); diff --git a/tests/provider-battery-contract.mjs b/tests/provider-battery-contract.mjs index eb79fc3..cecbf12 100644 --- a/tests/provider-battery-contract.mjs +++ b/tests/provider-battery-contract.mjs @@ -1,6 +1,8 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; +import { reducedMotionCss } from "./contract-css.mjs"; + const html = readFileSync(new URL("../src/index.html", import.meta.url), "utf8"); const css = readFileSync(new URL("../src/styles.css", import.meta.url), "utf8"); const adapter = readFileSync(new URL("../src/main.js", import.meta.url), "utf8"); @@ -266,7 +268,11 @@ assert.match(rearLatch, /top:\s*-13px/, "The removable cover lever must straddle assert.doesNotMatch(rearLatch, /bottom:/, "The cover lever must not drift back to the bottom edge"); assert.match(css, /@keyframes powerIndicatorRejected/, "Rejected power-on attempts need an LED flash sequence"); assert.doesNotMatch(css, /#power-switch\.power-rejected/, "The physical power toggle must never flash red"); -assert.match(css, /prefers-reduced-motion:[\s\S]*?\.power-led\.rejected/s, "The rejection LED needs a reduced-motion state"); +assert.match( + reducedMotionCss(css), + /\.power-led\.rejected\s*\{[^}]*animation:\s*none/, + "The rejection LED needs a reduced-motion state", +); for (const animationName of ["providerChecking", "powerIndicatorRejected"]) { const animation = css.match(new RegExp(`@keyframes ${animationName}\\s*\\{([\\s\\S]*?)\\n\\}`))?.[1] || ""; assert.doesNotMatch( @@ -285,8 +291,9 @@ assert.match(adapter, /const PROVIDER_STORAGE_KEY = "cqa-ai-provider"/, "Provide assert.match(adapter, /invoke\("engine_set_ai_provider"/, "Battery changes must reach the backend"); assert.match(adapter, /invoke\("verify_ai_provider"/, "Power-on must prove the selected provider works"); const verifyInstalledProvider = adapter.match( - /async function verifyInstalledProvider\(\) \{[\s\S]*?\n \}(?=\n\n async function rejectPowerOn)/, + /async function verifyInstalledProvider\(generation\) \{[\s\S]*?\n \}(?=\n\n function powerFailureReason)/, )?.[0] || ""; +assert.ok(verifyInstalledProvider, "Missing the generation-scoped provider readiness check"); assert.doesNotMatch( verifyInstalledProvider, /verifiedProvider === installedProvider[\s\S]*?return/, @@ -294,14 +301,14 @@ assert.doesNotMatch( ); assert.match( adapter, - /await verifyInstalledProvider\(\)[\s\S]*?invoke\("engine_power", \{ powered: true \}\)/, + /await verifyInstalledProvider\(generation\)[\s\S]*?invoke\("engine_power", \{ powered: true \}\)/, "The engine must not power on until provider verification succeeds", ); assert.match(adapter, /function rejectPowerOn\(/, "Missing the failed power-on recovery path"); const setPower = adapter.match( /async function setPower\(on\) \{[\s\S]*?\n \}(?=\n\n function renderCartridge)/, )?.[0] || ""; -const powerOnVerification = setPower.indexOf("await verifyInstalledProvider()"); +const powerOnVerification = setPower.indexOf("await verifyInstalledProvider(generation)"); assert.ok(powerOnVerification > 0, "Power-on must verify the installed provider"); for (const immediatePowerEffect of [ "powered = true", diff --git a/tests/shell-harness.mjs b/tests/shell-harness.mjs new file mode 100644 index 0000000..c15f299 --- /dev/null +++ b/tests/shell-harness.mjs @@ -0,0 +1,326 @@ +/* Runs the real device adapter (src/main.js) against a minimal fake DOM, a + * manual clock, and a scripted Tauri bridge, so contracts can drive the shell + * through real event handlers and hold backend calls open to test races. + * This is a helper module, not a contract: npm test does not run it alone. */ + +let scenarios = 0; + +class FakeClassList { + constructor() { this.names = new Set(); } + add(...names) { for (const name of names) this.names.add(name); } + remove(...names) { for (const name of names) this.names.delete(name); } + contains(name) { return this.names.has(name); } + toggle(name, force) { + const on = force === undefined ? !this.names.has(name) : Boolean(force); + if (on) this.names.add(name); + else this.names.delete(name); + return on; + } +} + +function fakeEvent(target, init = {}) { + return { + target, + button: 0, + pointerId: 1, + repeat: false, + shiftKey: false, + deltaMode: 0, + deltaY: 0, + clientY: 0, + defaultPrevented: false, + preventDefault() { this.defaultPrevented = true; }, + stopPropagation() {}, + ...init, + }; +} + +function createDom() { + const document = { activeElement: null }; + + class FakeElement { + constructor(tagName = "div", id = "") { + this.tagName = tagName.toUpperCase(); + this.id = id; + this.classList = new FakeClassList(); + this.attributes = new Map(); + this.dataset = {}; + this.style = { setProperty() {}, removeProperty() {} }; + this.children = []; + this.parentElement = null; + this.listeners = new Map(); + this.selected = new Map(); + this.textContent = ""; + this.disabled = false; + this.inert = false; + this.offsetWidth = 0; + this.markup = ""; + } + get className() { return [...this.classList.names].join(" "); } + set className(value) { + this.classList = new FakeClassList(); + this.classList.add(...String(value).split(/\s+/).filter(Boolean)); + } + get innerHTML() { return this.markup; } + set innerHTML(value) { + this.markup = String(value); + if (!this.markup) { + for (const child of this.children) child.parentElement = null; + this.children = []; + } + } + setAttribute(name, value) { this.attributes.set(name, String(value)); } + getAttribute(name) { return this.attributes.has(name) ? this.attributes.get(name) : null; } + appendChild(child) { + child.parentElement = this; + this.children.push(child); + return child; + } + contains(other) { + for (let node = other; node; node = node.parentElement) if (node === this) return true; + return false; + } + matches() { return this.tagName === "BUTTON"; } + focus() { document.activeElement = this; } + setPointerCapture() {} + getContext() { + return { + imageSmoothingEnabled: true, + createImageData: (width, height) => ({ data: new Uint8ClampedArray(width * height * 4) }), + putImageData() {}, + }; + } + querySelector(selector) { + if (selector.startsWith("button")) { + return this.children.find((child) => child.tagName === "BUTTON" && !child.disabled) ?? null; + } + if (!this.selected.has(selector)) this.selected.set(selector, new FakeElement("div")); + return this.selected.get(selector); + } + querySelectorAll() { return []; } + addEventListener(type, handler) { + if (!this.listeners.has(type)) this.listeners.set(type, []); + this.listeners.get(type).push(handler); + } + dispatch(type, init) { + const event = fakeEvent(this, init); + for (const handler of this.listeners.get(type) ?? []) handler(event); + return event; + } + } + + const elements = new Map(); + const queried = new Map(); + document.body = new FakeElement("body"); + document.activeElement = document.body; + document.getElementById = (id) => { + if (!elements.has(id)) elements.set(id, new FakeElement("div", id)); + return elements.get(id); + }; + document.querySelector = (selector) => { + if (!queried.has(selector)) queried.set(selector, new FakeElement("div")); + return queried.get(selector); + }; + document.querySelectorAll = () => []; + document.createElement = (tagName) => new FakeElement(tagName); + return { document, FakeElement }; +} + +function memoryStorage(initial) { + const values = new Map(Object.entries(initial)); + return { + getItem: (key) => (values.has(key) ? values.get(key) : null), + setItem: (key, value) => values.set(key, String(value)), + removeItem: (key) => values.delete(key), + values, + }; +} + +const settle = () => new Promise((resolve) => setImmediate(resolve)); + +/** + * Boots a fresh copy of the adapter. `storage` seeds localStorage and + * `hold` lists commands whose first call stays pending until the test + * resolves or rejects it through `pending(command)`. + */ +export async function bootShell({ storage = {}, hold = [], respond = {} } = {}) { + const { document, FakeElement } = createDom(); + const localStorage = memoryStorage(storage); + const calls = []; + const held = new Map(); + const holding = new Set(hold); + const timers = []; + let frames = []; + let now = 0; + let timerId = 0; + + const defaults = { + app_revision: () => "abc1234", + engine_frame: () => new ArrayBuffer(0), + engine_audio: () => ({ tick: 0, notes: [] }), + verify_ai_provider: (args) => ({ provider: args.provider, ready: true }), + cartridge_branch: () => "main", + engine_set_cartridge: (args) => (args.path == null ? null : { + path: args.path, + title: args.path.split("/").at(-1).toUpperCase(), + branch: "main", + revision: "abc1234", + color: "#6a6fd1", + }), + }; + + function invoke(command, args = {}) { + calls.push({ command, args }); + if (holding.has(command)) { + holding.delete(command); + return new Promise((resolve, reject) => held.set(command, { resolve, reject })); + } + try { + const answer = respond[command] ?? defaults[command] ?? (() => null); + return Promise.resolve(answer(args)); + } catch (error) { + return Promise.reject(error); + } + } + + const windowListeners = new Map(); + const window = { + innerWidth: 1280, + innerHeight: 800, + __TAURI__: { core: { invoke } }, + matchMedia: () => ({ matches: false, addEventListener() {} }), + addEventListener(type, handler) { + if (!windowListeners.has(type)) windowListeners.set(type, []); + windowListeners.get(type).push(handler); + }, + setTimeout(handler, delay = 0) { + timerId += 1; + timers.push({ id: timerId, at: now + delay, handler }); + return timerId; + }, + clearTimeout(id) { + const index = timers.findIndex((timer) => timer.id === id); + if (index >= 0) timers.splice(index, 1); + }, + requestAnimationFrame(handler) { frames.push(handler); }, + }; + + const previous = Object.fromEntries( + ["window", "document", "localStorage", "HTMLElement", "Element"].map((name) => [ + name, + Object.getOwnPropertyDescriptor(globalThis, name), + ]), + ); + const define = (name, value) => Object.defineProperty(globalThis, name, { + value, + configurable: true, + writable: true, + }); + define("window", window); + define("document", document); + define("localStorage", localStorage); + define("HTMLElement", FakeElement); + define("Element", FakeElement); + + scenarios += 1; + await import(new URL(`../src/main.js?scenario=${scenarios}`, import.meta.url)); + + async function flush() { + for (let round = 0; round < 8; round += 1) await settle(); + } + + const shell = { + calls, + storage: localStorage, + el: (id) => document.getElementById(id), + calledWith(command, predicate = () => true) { + return calls.filter((call) => call.command === command && predicate(call.args ?? {})); + }, + /** Settles a held call; `value` may be an Error to reject it. */ + async answer(command, value) { + const call = held.get(command); + if (!call) throw new Error(`${command} is not being held`); + held.delete(command); + if (value instanceof Error) call.reject(value.message); + else call.resolve(value); + await flush(); + }, + isHeld: (command) => held.has(command), + /** Holds the next call to `command` open until `answer(command, ...)`. */ + hold(command) { holding.add(command); }, + async key(code, key = code) { + for (const handler of windowListeners.get("keydown") ?? []) { + handler(fakeEvent(document.body, { code, key })); + } + for (const handler of windowListeners.get("keyup") ?? []) { + handler(fakeEvent(document.body, { code, key })); + } + await flush(); + }, + async dispatch(element, type, init) { + const event = element.dispatch(type, init); + await flush(); + return event; + }, + /** Runs queued animation frames (twice covers waitForPaint). */ + async paint(rounds = 2) { + for (let round = 0; round < rounds; round += 1) { + const due = frames; + frames = []; + for (const handler of due) handler(now); + await flush(); + } + }, + /** Advances the manual clock, firing due timers in order. */ + async advance(milliseconds) { + const until = now + milliseconds; + for (;;) { + timers.sort((left, right) => left.at - right.at); + if (!timers.length || timers[0].at > until) break; + const timer = timers.shift(); + now = timer.at; + timer.handler(); + await flush(); + } + now = until; + await flush(); + }, + get powered() { return document.getElementById("power-switch").classList.contains("on"); }, + get loaded() { return document.getElementById("cart-back").classList.contains("loaded"); }, + get trayOpen() { return document.getElementById("cart-tray").getAttribute("aria-hidden") === "false"; }, + /** Every message surface's current text, joined. */ + get messages() { + return ["device-message", "tray-error", "battery-tray-error"] + .map((id) => document.getElementById(id).textContent) + .filter(Boolean) + .join(" | "); + }, + rackCard: (index = 0) => document.getElementById("tray-carts").children[index], + rackAction: (name) => document.getElementById("tray-carts").children + .find((card) => card.classList.contains(name)), + close() { + for (const [name, descriptor] of Object.entries(previous)) { + if (descriptor) Object.defineProperty(globalThis, name, descriptor); + else delete globalThis[name]; + } + }, + }; + await flush(); + return shell; +} + +/** A saved rack with one or more repositories, as localStorage stores it. */ +export function rackStorage(paths, { current = null, provider = "codex" } = {}) { + const storage = { + "cqa-repo-carts": JSON.stringify(paths.map((path) => ({ + path, + title: path.split("/").at(-1).toUpperCase(), + branch: "main", + revision: "abc1234", + color: "#6a6fd1", + }))), + }; + if (current) storage["cqa-cart-id"] = current; + if (provider) storage["cqa-ai-provider"] = provider; + return storage; +}