From 07e5c1f710c63806733772b3ec7e417860d2ddb5 Mon Sep 17 00:00:00 2001 From: Jason Skrzypek Date: Fri, 25 Sep 2026 09:57:02 -0400 Subject: [PATCH 01/60] Pin LF checkouts so source contracts pass on Windows core.autocrlf=true checkouts gave the Node contract tests CRLF sources, and their multiline patterns failed (npm test could not pass on Windows). Pin text to LF on every platform and mark the embedded .rgb/.rgba plates and other assets binary so content sniffing can never rewrite their bytes. Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .gitattributes 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 From 4067e076f9f985643e1dad14edd11152475339b9 Mon Sep 17 00:00:00 2001 From: Jason Skrzypek Date: Fri, 25 Sep 2026 10:05:33 -0400 Subject: [PATCH 02/60] Add the shared learning model for pedagogy-first play Introduce learning.rs, a Tauri- and Bevy-free contract for concept lenses, per-choice rationales, answer evidence, per-lens mastery thresholds, the lesson journal, and a deterministic choice-presentation order that spreads provider-first answers uniformly and moves every choice on each retry. QuizQuestion now carries its lens, rationales, and review flag; CartridgeSpec carries the lesson journal and mastery; the answer recorder receives full AnswerEvidence instead of bare question text. Behavior is unchanged until the question pipeline and engine consume the new fields. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/engine.rs | 60 +++++-- src-tauri/src/learning.rs | 321 ++++++++++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 9 +- 3 files changed, 376 insertions(+), 14 deletions(-) create mode 100644 src-tauri/src/learning.rs diff --git a/src-tauri/src/engine.rs b/src-tauri/src/engine.rs index 08892ee..110e387 100644 --- a/src-tauri/src/engine.rs +++ b/src-tauri/src/engine.rs @@ -10,6 +10,7 @@ use bevy::prelude::*; use crate::codequest::{CodeQuestConfig, GameType, VisualTemplate}; use crate::external_tools; use crate::font5x7::{glyph, GLYPH_ADVANCE, GLYPH_WIDTH, LINE_HEIGHT}; +use crate::learning::{AnswerEvidence, Concept, Lesson, Mastery}; use crate::scene_machine::{ SceneEvent, SceneHandler, SceneMachine, SceneMachineDefinition, SceneSignal, }; @@ -405,16 +406,24 @@ pub struct QuestSpec { pub command: String, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Default)] +#[allow(dead_code)] // SCAFFOLD: remove once lesson review reads rationales. 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, + /// True when this question returns because the player previously missed it. + pub review: bool, } pub type QuestionLoader = Arc Vec + Send + Sync + 'static>; -pub type AnsweredQuestionRecorder = Arc; +pub type AnsweredQuestionRecorder = Arc; pub fn quiz_question_fits(question: &str, choices: &[String], answer: usize) -> bool { !question.trim().is_empty() @@ -447,6 +456,7 @@ pub struct RepositoryProvenance { } #[derive(Clone, Debug)] +#[allow(dead_code)] // SCAFFOLD: remove once the Codex reads lessons and mastery. pub struct CartridgeSpec { pub id: String, pub title: String, @@ -457,6 +467,10 @@ pub struct CartridgeSpec { pub quests: Vec, pub questions: Vec, pub question_batch_ends: Vec, + /// The player's lesson journal for this cartridge, oldest first. + pub lessons: Vec, + /// Per-lens mastery evidence accumulated across launches. + pub mastery: Mastery, } impl CartridgeSpec { @@ -574,7 +588,7 @@ enum EngineEffect { }, RecordAnsweredQuestion { cartridge_id: String, - question: String, + evidence: AnswerEvidence, }, } @@ -1541,10 +1555,16 @@ fn handle_press(state: &mut GameState, effects: &mut Effects, button: Button) { Button::A => { let answered = state.cartridge.as_ref().and_then(|cart| { cart.questions.get(run.question).map(|question| { - (cart.id.clone(), question.question.clone(), question.answer) + ( + cart.id.clone(), + question.question.clone(), + question.answer, + question.concept, + question.review, + ) }) }); - let answer = answered.as_ref().map_or(0, |(_, _, answer)| *answer); + let answer = answered.as_ref().map_or(0, |answered| answered.2); let correct = run.selected == answer; if correct { run.streak += 1; @@ -1554,10 +1574,15 @@ fn handle_press(state: &mut GameState, effects: &mut Effects, button: Button) { run.streak = 0; } run.feedback = Some((correct, QUIZ_FEEDBACK_TICKS)); - if let Some((cartridge_id, question, _)) = answered { + if let Some((cartridge_id, question, _, concept, review)) = answered { effects.0.push_back(EngineEffect::RecordAnsweredQuestion { cartridge_id, - question, + evidence: AnswerEvidence { + question, + concept, + correct, + review, + }, }); } } @@ -3112,8 +3137,8 @@ fn handle_effect( } EngineEffect::RecordAnsweredQuestion { cartridge_id, - question, - } => answered_question_recorder(cartridge_id, question), + evidence, + } => answered_question_recorder(cartridge_id, evidence), } } @@ -3194,7 +3219,7 @@ fn run_quest( }); } -fn wrap_text(text: &str, max_chars: usize) -> Vec { +pub(crate) fn wrap_text(text: &str, max_chars: usize) -> Vec { let mut lines = Vec::new(); let mut current = String::new(); for word in text.split_whitespace() { @@ -3285,8 +3310,11 @@ mod tests { question: "WHO OWNS THE GAME LOOP?".into(), choices: vec!["BEVY".into(), "CSS".into(), "WEBKIT".into(), "HTML".into()], answer: 0, + ..Default::default() }], question_batch_ends: Vec::new(), + lessons: Vec::new(), + mastery: Mastery::new(), } } @@ -3476,8 +3504,10 @@ mod tests { effect, EngineEffect::RecordAnsweredQuestion { cartridge_id, - question, - } if cartridge_id == "/tmp/engine-test" && question == "WHO OWNS THE GAME LOOP?" + evidence, + } if cartridge_id == "/tmp/engine-test" + && evidence.question == "WHO OWNS THE GAME LOOP?" + && evidence.correct ))); } @@ -4714,6 +4744,7 @@ mod tests { "A COMMAND".into(), ], answer: 0, + ..Default::default() }], }, ); @@ -4751,6 +4782,7 @@ mod tests { question: "STALE".into(), choices: vec!["A".into()], answer: 0, + ..Default::default() }], }, ); @@ -4847,6 +4879,7 @@ mod tests { "HOST POINTERS".into(), ], answer: 0, + ..Default::default() }], }, ); @@ -4921,6 +4954,7 @@ mod tests { question: "WHAT ARRIVED NEXT?".into(), choices: vec!["A NEW QUESTION".into(), "NOTHING".into()], answer: 0, + ..Default::default() }], }, ); @@ -5479,6 +5513,7 @@ mod tests { question: "NEW BATCH".into(), choices: vec!["A".into()], answer: 0, + ..Default::default() }], }, ); @@ -5863,6 +5898,7 @@ mod tests { "TO COUPLE RENDERING TO CSS".into(), ], answer: 0, + ..Default::default() }; let machine = SceneMachine::new((*cartridge.machine).clone()); let mut state = GameState { diff --git a/src-tauri/src/learning.rs b/src-tauri/src/learning.rs new file mode 100644 index 0000000..4c0c67e --- /dev/null +++ b/src-tauri/src/learning.rs @@ -0,0 +1,321 @@ +//! The learning model shared by question generation, cartridge saves, and the +//! engine. +//! +//! Every CODE QUEST question assesses one conceptual *lens* on a project. Each +//! choice can carry a short rationale, so feedback explains why the correct +//! answer holds and which misconception a wrong answer reveals. Committed +//! answers become evidence, and evidence fills per-lens mastery runes at +//! explicit thresholds. Nothing here depends on Tauri or Bevy. + +// SCAFFOLD: remove once the question pipeline and engine consume this API. +#![allow(dead_code)] + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +/// Characters available to one rationale line inside the lesson panel. +pub const RATIONALE_COLUMNS: usize = 34; +/// Lines a single rationale may occupy inside the lesson panel. +pub const RATIONALE_ROWS: usize = 3; +/// Evidence counts that awaken a lens's first, second, and third mastery rune. +pub const MASTERY_THRESHOLDS: [u32; 3] = [1, 3, 5]; + +/// A durable way of understanding a software project. The lenses deepen with +/// the Oracle bond: Initiate questions establish purpose and responsibilities, +/// Adept questions connect interactions and tradeoffs, and Oracle-bound +/// questions probe invariants and ask the player to predict consequences. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum Concept { + Purpose, + Responsibility, + Interaction, + Invariant, + Tradeoff, +} + +impl Concept { + pub const ALL: [Self; 5] = [ + Self::Purpose, + Self::Responsibility, + Self::Interaction, + Self::Invariant, + Self::Tradeoff, + ]; + + /// Short uppercase label that fits native HUD rows. + pub fn label(self) -> &'static str { + match self { + Self::Purpose => "PURPOSE", + Self::Responsibility => "ROLES", + Self::Interaction => "FLOWS", + Self::Invariant => "INVARIANTS", + Self::Tradeoff => "TRADEOFFS", + } + } + + /// Lenient parser for provider output: accepts case, plural, and common + /// synonyms, and rejects anything that does not map to a lens. + pub fn parse(value: &str) -> Option { + let normalized = value.trim().to_ascii_lowercase().replace(['_', ' '], "-"); + match normalized.trim_end_matches('s') { + "purpose" | "goal" => Some(Self::Purpose), + "responsibility" | "responsibilitie" | "role" | "ownership" => { + Some(Self::Responsibility) + } + "interaction" | "flow" | "data-flow" | "collaboration" => Some(Self::Interaction), + "invariant" | "guarantee" | "constraint" => Some(Self::Invariant), + "tradeoff" | "trade-off" | "design-rationale" | "rationale" => Some(Self::Tradeoff), + _ => None, + } + } + + /// The lenses a question batch at `level` should emphasise. This mirrors + /// the Initiate (1), Adept (2-3), and Oracle-bound (4+) presentation tiers. + pub fn focus_for_level(level: u32) -> &'static [Self] { + match level { + 0 | 1 => &[Self::Purpose, Self::Responsibility], + 2 | 3 => &[Self::Interaction, Self::Tradeoff], + _ => &[Self::Invariant, Self::Tradeoff], + } + } +} + +/// What the engine learned when the player committed an answer. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct AnswerEvidence { + /// The question text exactly as generated; saves normalize it for identity. + pub question: String, + pub concept: Option, + pub correct: bool, + /// True when this attempt re-asked a question the player previously missed. + pub review: bool, +} + +/// Accumulated evidence for one lens on one cartridge. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct LensRecord { + /// Correct answers on a question's first attempt. + #[serde(default)] + pub first_try: u32, + /// Correct answers to a question the player had previously missed. + #[serde(default)] + pub redeemed: u32, + /// Wrong answers, counting every attempt. + #[serde(default)] + pub missed: u32, +} + +impl LensRecord { + /// Evidence of understanding: first-try successes plus redemptions. + pub fn evidence(&self) -> u32 { + self.first_try.saturating_add(self.redeemed) + } + + /// Number of mastery runes lit (0-3) at the declared thresholds. + pub fn stage(&self) -> usize { + MASTERY_THRESHOLDS.partition_point(|threshold| self.evidence() >= *threshold) + } + + pub fn record(&mut self, evidence: &AnswerEvidence) { + match (evidence.correct, evidence.review) { + (true, false) => self.first_try = self.first_try.saturating_add(1), + (true, true) => self.redeemed = self.redeemed.saturating_add(1), + (false, _) => self.missed = self.missed.saturating_add(1), + } + } +} + +/// Per-lens mastery for one cartridge. +pub type Mastery = BTreeMap; + +/// Records `evidence` into `mastery` when the question named its lens. +pub fn record_evidence(mastery: &mut Mastery, evidence: &AnswerEvidence) { + if let Some(concept) = evidence.concept { + mastery.entry(concept).or_default().record(evidence); + } +} + +/// One entry in the player's lesson journal: a committed question, the +/// correct answer, and the rationale that explains it. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct Lesson { + pub question: String, + pub answer: String, + /// Why the correct answer holds; empty for legacy questions without one. + pub rationale: String, + pub concept: Option, + /// True while the player's latest attempt at this question was wrong. + pub outstanding: bool, +} + +/// Whether a rationale fits the lesson panel without truncation. +pub fn rationale_fits(text: &str) -> bool { + !text.trim().is_empty() + && crate::engine::wrap_text(text, RATIONALE_COLUMNS).len() <= RATIONALE_ROWS +} + +/// Display order for a question's four choices: `order[slot]` is the source +/// choice index shown in that slot. +/// +/// Providers tend to place the correct answer first, so presenting choices in +/// source order would let position replace understanding. The base +/// permutation is derived from the question identity (uniform across the 24 +/// orderings of four choices), and each further attempt rotates it by one slot, +/// so a re-asked question always moves every choice, including the answer. +pub fn presentation_order(identity: &str, attempt: u32) -> [usize; 4] { + // FNV-1a keeps the order stable across runs, launches, and platforms. + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in identity.bytes() { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + // Decode a Lehmer code in 0..24 into a permutation of [0, 1, 2, 3]. + let mut code = (hash % 24) as usize; + let mut remaining = vec![0, 1, 2, 3]; + let mut base = [0; 4]; + for (slot, radix) in [6, 2, 1, 1].into_iter().enumerate() { + base[slot] = remaining.remove(code / radix); + code %= radix; + } + let shift = (attempt % 4) as usize; + std::array::from_fn(|slot| base[(slot + shift) % 4]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn concept_parser_accepts_provider_variants_and_rejects_unknown_lenses() { + assert_eq!(Concept::parse("Purpose"), Some(Concept::Purpose)); + assert_eq!( + Concept::parse("responsibilities"), + Some(Concept::Responsibility) + ); + assert_eq!(Concept::parse("Trade-offs"), Some(Concept::Tradeoff)); + assert_eq!(Concept::parse("INVARIANTS"), Some(Concept::Invariant)); + assert_eq!(Concept::parse("data flow"), Some(Concept::Interaction)); + assert_eq!(Concept::parse("file layout"), None); + assert_eq!(Concept::parse(""), None); + } + + #[test] + fn concept_labels_fit_a_compact_hud_row() { + for concept in Concept::ALL { + assert!(concept.label().len() <= 10, "{concept:?}"); + } + } + + #[test] + fn level_focus_deepens_with_the_oracle_bond() { + assert_eq!( + Concept::focus_for_level(1), + [Concept::Purpose, Concept::Responsibility] + ); + assert!(Concept::focus_for_level(3).contains(&Concept::Interaction)); + assert!(Concept::focus_for_level(4).contains(&Concept::Invariant)); + assert_eq!(Concept::focus_for_level(9), Concept::focus_for_level(4)); + } + + #[test] + fn lens_records_separate_first_try_success_redemption_and_misses() { + let mut mastery = Mastery::new(); + let mut evidence = AnswerEvidence { + question: "WHY?".into(), + concept: Some(Concept::Invariant), + correct: false, + review: false, + }; + record_evidence(&mut mastery, &evidence); + evidence.correct = true; + evidence.review = true; + record_evidence(&mut mastery, &evidence); + evidence.review = false; + record_evidence(&mut mastery, &evidence); + record_evidence( + &mut mastery, + &AnswerEvidence { + concept: None, + correct: true, + ..AnswerEvidence::default() + }, + ); + + let record = mastery[&Concept::Invariant]; + assert_eq!( + record, + LensRecord { + first_try: 1, + redeemed: 1, + missed: 1 + } + ); + assert_eq!(record.evidence(), 2); + assert_eq!(mastery.len(), 1); + } + + #[test] + fn mastery_runes_light_at_the_exact_declared_thresholds() { + let stage = |first_try| { + LensRecord { + first_try, + ..LensRecord::default() + } + .stage() + }; + assert_eq!( + [0, 1, 2, 3, 4, 5, 6].map(stage), + [0, 1, 1, 2, 2, 3, 3], + "runes wake at {MASTERY_THRESHOLDS:?}" + ); + } + + #[test] + fn rationales_must_be_present_and_fit_the_lesson_panel() { + assert!(rationale_fits( + "THE ENGINE OWNS RULES, SO THE SHELL CAN ONLY DRAW WHAT IT IS GIVEN." + )); + assert!(!rationale_fits(" ")); + assert!(!rationale_fits(&"TOO LONG ".repeat(20))); + } + + #[test] + fn presentation_order_is_a_stable_permutation() { + let order = presentation_order("WHO OWNS THE GAME LOOP?", 0); + let mut sorted = order; + sorted.sort_unstable(); + assert_eq!(sorted, [0, 1, 2, 3]); + assert_eq!(order, presentation_order("WHO OWNS THE GAME LOOP?", 0)); + } + + #[test] + fn presentation_order_spreads_first_listed_answers_across_every_slot() { + let mut slot_counts = [0u32; 4]; + for index in 0..400 { + let order = presentation_order(&format!("CONCEPT QUESTION {index}"), 0); + let answer_slot = order.iter().position(|source| *source == 0).unwrap(); + slot_counts[answer_slot] += 1; + } + for count in slot_counts { + assert!((60..=140).contains(&count), "{slot_counts:?}"); + } + } + + #[test] + fn every_retry_moves_every_choice_to_a_new_slot() { + for index in 0..50 { + let identity = format!("RETRIED QUESTION {index}"); + for attempt in 0..6 { + let before = presentation_order(&identity, attempt); + let after = presentation_order(&identity, attempt + 1); + for source in 0..4 { + let slot_before = before.iter().position(|s| *s == source); + let slot_after = after.iter().position(|s| *s == source); + assert_ne!(slot_before, slot_after, "{identity} attempt {attempt}"); + } + } + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b8527ce..55848bb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ mod codequest; mod engine; mod external_tools; mod font5x7; +mod learning; mod save; pub mod scene_machine; @@ -887,6 +888,7 @@ fn engine_cartridge(cartridge: Cartridge) -> Result batch_start { @@ -912,6 +914,8 @@ fn engine_cartridge(cartridge: Cartridge) -> Result Date: Fri, 25 Sep 2026 10:26:45 -0400 Subject: [PATCH 03/60] Teach through the quiz: lesson card, shuffled choices, spaced retry Committing an answer now opens a lesson card instead of auto-advancing. The four choice frames give way to one composed panel: a miss shows the player's pick in red with its rationale (the misconception it reveals), then the correct answer in green with its rationale; a success shows the answer and why it holds. "-" and "+" markers repeat the verdict without relying on hue. The trial panel footer names the question's lens with its three live mastery runes and shows A:CONTINUE once the existing 45-tick input hold ends; A or Start then continues. The legacy renderer gets the same card over its choice rows. Native layout tests cover worst-case copy (31-character choices, three 34-column rationale rows) for containment, sibling disjointness, and contrast against the panel fill, and write the oracle-lesson-correct / oracle-lesson-missed previews. Choices are drawn in learning::presentation_order, keyed by the normalized question identity and the attempt number in this run (review questions start at attempt 1). run.selected is a display slot mapped to the source choice at commit, so the provider's answer position no longer predicts the answer and every retry moves every choice. A survivable miss inserts a review copy RETRY_GAP (3) questions later, never past the current batch end, and shifts every later batch end, so a batch cannot complete before its misses are retried. Level-up still fires exactly at the shifted end; a ward-breaking miss goes to game over without scheduling a retry. A correct review shows REDEEMED unless an Insight Rune crossing takes precedence. Every commitment records lens evidence into cartridge.mastery and upserts the cartridge lesson journal by question identity, clearing `outstanding` on a later correct attempt. B during an active question now arms a 90-tick in-scene confirmation (B AGAIN:LEAVE); a second B inside the window leaves, any other button or the timeout disarms it, and B is inert on the lesson card. Existing flow tests that relied on the 45-tick auto-advance now press A after the hold, and answer by locating the shuffled answer slot. The Oracle quiz brief and README describe the new flow and resolve both open decisions. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- docs/game-design/oracle-quiz.md | 33 +- src-tauri/src/engine.rs | 1521 +++++++++++++++++++++++++------ 3 files changed, 1292 insertions(+), 266 deletions(-) diff --git a/README.md b/README.md index 0967939..9ae60c7 100644 --- a/README.md +++ b/README.md @@ -202,8 +202,10 @@ the running CODE QUEST ADVANCE build. | F1 | FRONT/BACK switch | Turn the device over | 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 diff --git a/docs/game-design/oracle-quiz.md b/docs/game-design/oracle-quiz.md index c07abc0..2bf2fc1 100644 --- a/docs/game-design/oracle-quiz.md +++ b/docs/game-design/oracle-quiz.md @@ -271,11 +271,24 @@ and the selected provider retries. 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, + 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 three questions later, + never past the current batch end, and shifts later batch ends so a batch + cannot complete before its misses are retried. 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. +- **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 and + shows `A:CONTINUE` only once input is live. A correct review attempt shows + `REDEEMED` unless an Insight Rune 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 @@ -368,7 +381,8 @@ engine does not load or play them yet. | 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. | | 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. | +| Quiz result and reward input boundaries | Implemented in this pass | 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 enforces a 60-tick hold before A/Start can leave. | +| Lesson card, shuffled choices, and spaced retry | Implemented in this pass | Committed answers show the misconception and answer rationales in a composed lesson panel; display order follows `learning::presentation_order`; misses return within the batch; mastery and the lesson journal update on every commitment. Native layout, contrast, and flow tests cover worst-case copy. | | 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. | @@ -417,9 +431,8 @@ engine does not load or play them yet. ## 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? + +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/src-tauri/src/engine.rs b/src-tauri/src/engine.rs index 110e387..e6aae9c 100644 --- a/src-tauri/src/engine.rs +++ b/src-tauri/src/engine.rs @@ -1,4 +1,4 @@ -use std::collections::{HashSet, VecDeque}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::io::{BufRead, BufReader}; use std::process::{Child, Stdio}; use std::sync::{mpsc, Arc, Mutex, RwLock}; @@ -10,7 +10,9 @@ use bevy::prelude::*; use crate::codequest::{CodeQuestConfig, GameType, VisualTemplate}; use crate::external_tools; use crate::font5x7::{glyph, GLYPH_ADVANCE, GLYPH_WIDTH, LINE_HEIGHT}; -use crate::learning::{AnswerEvidence, Concept, Lesson, Mastery}; +use crate::learning::{ + self, AnswerEvidence, Concept, Lesson, Mastery, RATIONALE_COLUMNS, RATIONALE_ROWS, +}; use crate::scene_machine::{ SceneEvent, SceneHandler, SceneMachine, SceneMachineDefinition, SceneSignal, }; @@ -37,6 +39,10 @@ const ORACLE_HERO_SPEED: i32 = 2; const ORACLE_DROP_INTERVAL: u64 = 30; const ORACLE_COLLISION_Y: i32 = 100; const QUIZ_FEEDBACK_TICKS: u16 = 45; +/// Ticks after a first B in which a second B leaves an active question. +const QUIZ_LEAVE_CONFIRM_TICKS: u16 = 90; +/// Questions answered between a miss and its review copy within one batch. +const RETRY_GAP: usize = 3; const LEVEL_UP_HOLD_TICKS: u64 = 60; const SCORE_RUNE_THRESHOLDS: [u32; 3] = [300, 900, 1_800]; const DATA_CHARGE_THRESHOLDS: [u32; 3] = [3, 6, 9]; @@ -292,6 +298,24 @@ 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, +}; +/// 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, @@ -910,13 +934,148 @@ 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 attempts at the current question's identity in this run. + attempt: u32, + /// Committed attempts per normalized question identity in this run. + attempts: HashMap, + /// True when the committed answer redeemed a previously missed question. + redeemed: bool, + /// Ticks left in which a second B leaves the run. + leave_armed: u16, +} + +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, + attempts: HashMap::new(), + redeemed: false, + leave_armed: 0, + } + } + + /// 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: `RETRY_GAP` questions +/// after the current one, but never beyond the end of the current batch, so a +/// batch cannot complete before its misses are retried. +fn retry_insertion_index(current: usize, batch_end: Option, question_count: usize) -> usize { + let batch_end = batch_end + .unwrap_or(question_count) + .clamp(current + 1, question_count.max(current + 1)); + (current + 1 + RETRY_GAP).min(batch_end) +} + +/// Inserts a review copy of the missed question at `current` and shifts every +/// batch end at or beyond the insertion point so the copy joins the current +/// batch. Returns the insertion index. +fn schedule_retry( + questions: &mut Vec, + batch_ends: &mut [usize], + current: usize, +) -> Option { + let mut review = questions.get(current)?.clone(); + review.review = true; + let batch_end = batch_ends.iter().copied().find(|end| *end > current); + let index = retry_insertion_index(current, batch_end, questions.len()); + questions.insert(index, review); + for end in batch_ends.iter_mut().filter(|end| **end >= index) { + *end += 1; + } + Some(index) +} + +/// Upserts the journal entry for a committed question, keyed by identity. A +/// later correct attempt clears the entry's outstanding flag. +fn record_lesson(lessons: &mut Vec, question: &QuizQuestion, correct: bool) { + let identity = question_identity(&question.question); + 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, + }; + match lessons + .iter_mut() + .find(|entry| question_identity(&entry.question) == identity) + { + Some(entry) => *entry = 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)] @@ -1193,20 +1352,128 @@ fn request_question_batch(state: &mut GameState, effects: &mut Effects, level: u fn begin_quiz_run(state: &mut GameState) { state.oracle_data = 0; state.oracle_bug_hits = 0; - state.quiz = Some(QuizRun { - question: 0, - completed_batches: 0, - selected: 0, - hearts: 3, - score: 0, - level: 1, - streak: 0, - leveled_up: false, - feedback: None, - }); + state.quiz = Some(QuizRun::new()); state.signal(SceneSignal::HeroReady); } +/// Derives the display order once for the question that just became current. +/// The attempt number counts this run's earlier commitments at the same +/// identity; a question that arrives flagged for review starts at attempt 1, +/// so it never reappears in the order the player first saw. +fn present_current_question(state: &mut GameState) { + let Some(run) = state.quiz.as_mut() else { + return; + }; + if run.presented == Some(run.question) { + return; + } + let Some(question) = state + .cartridge + .as_ref() + .and_then(|cartridge| cartridge.questions.get(run.question)) + else { + return; + }; + let identity = question_identity(&question.question); + let attempt = run + .attempts + .get(&identity) + .copied() + .unwrap_or(u32::from(question.review)); + run.order = if question.choices.len() == 4 { + learning::presentation_order(&identity, attempt).to_vec() + } else { + (0..question.choices.len()).collect() + }; + run.attempt = attempt; + run.presented = Some(run.question); + run.selected = 0; +} + +/// Commits the focused choice: scores it, records evidence and the lesson, +/// schedules a spaced retry for a survivable miss, and opens the lesson card. +fn commit_answer(state: &mut GameState, effects: &mut Effects) { + let (Some(run), Some(cartridge)) = (state.quiz.as_mut(), state.cartridge.as_mut()) else { + return; + }; + let Some(question) = cartridge.questions.get(run.question).cloned() else { + return; + }; + let picked = run.source_choice(run.selected, question.choices.len()); + let correct = picked == question.answer; + if correct { + run.streak += 1; + run.score = run.score.saturating_add(score_award_for_streak(run.streak)); + } else { + run.hearts = run.hearts.saturating_sub(1); + run.streak = 0; + } + run.feedback = Some((correct, QUIZ_FEEDBACK_TICKS)); + run.redeemed = correct && question.review; + run.attempts.insert( + question_identity(&question.question), + run.attempt.saturating_add(1), + ); + + let evidence = AnswerEvidence { + question: question.question.clone(), + concept: question.concept, + correct, + review: question.review, + }; + learning::record_evidence(&mut cartridge.mastery, &evidence); + record_lesson(&mut cartridge.lessons, &question, correct); + if !correct && run.hearts > 0 { + schedule_retry( + &mut cartridge.questions, + &mut state.batch_ends, + run.question, + ); + } + effects.0.push_back(EngineEffect::RecordAnsweredQuestion { + cartridge_id: cartridge.id.clone(), + evidence, + }); +} + +/// Leaves the lesson card: ends the run on a broken ward, otherwise advances +/// to the next question and completes the batch exactly at its (possibly +/// retry-shifted) end. +fn continue_after_lesson(state: &mut GameState) { + let question_count = state.question_count(); + let batch_ends = &state.batch_ends; + let Some(run) = state.quiz.as_mut() else { + return; + }; + let next_batch_end = batch_ends.get(run.completed_batches).copied(); + run.feedback = None; + run.redeemed = false; + let mut next_signal = None; + if run.hearts == 0 { + next_signal = Some(SceneSignal::HeartsEmpty); + } else { + run.question += 1; + run.selected = 0; + if next_batch_end == Some(run.question) { + run.completed_batches += 1; + run.level += 1; + run.leveled_up = true; + } + if run.leveled_up { + run.leveled_up = false; + next_signal = Some(SceneSignal::BatchComplete); + } else if run.question >= question_count { + next_signal = Some(SceneSignal::NeedsQuestion); + } + } + if let Some(signal) = next_signal { + state.signal(signal); + } + if state.screen == Screen::Quiz { + present_current_question(state); + } +} + fn cycle_index(index: &mut usize, count: usize, direction: isize) { *index = (*index as isize + direction).rem_euclid(count as isize) as usize; } @@ -1535,12 +1802,20 @@ fn handle_press(state: &mut GameState, effects: &mut Effects, button: Button) { } } Screen::Quiz => { + present_current_question(state); let Some(run) = state.quiz.as_mut() else { return; }; - if run.feedback.is_some() { + if let Some((_, hold)) = run.feedback { + // The lesson card ignores every input during the hold, then + // only A or Start continues; B cannot abandon mid-lesson. + if hold == 0 && matches!(button, Button::A | Button::Start) { + continue_after_lesson(state); + } return; } + // Any press disarms a pending leave; only a second B consumes it. + let leave_armed = std::mem::take(&mut run.leave_armed) > 0; let choice_count = state .cartridge .as_ref() @@ -1549,43 +1824,11 @@ fn handle_press(state: &mut GameState, effects: &mut Effects, button: Button) { match button { Button::Up => run.selected = (run.selected + choice_count - 1) % choice_count, Button::Down => run.selected = (run.selected + 1) % choice_count, - Button::B => { + Button::B if leave_armed => { state.signal(SceneSignal::Back); } - Button::A => { - let answered = state.cartridge.as_ref().and_then(|cart| { - cart.questions.get(run.question).map(|question| { - ( - cart.id.clone(), - question.question.clone(), - question.answer, - question.concept, - question.review, - ) - }) - }); - let answer = answered.as_ref().map_or(0, |answered| answered.2); - let correct = run.selected == answer; - if correct { - run.streak += 1; - run.score = run.score.saturating_add(score_award_for_streak(run.streak)); - } else { - run.hearts = run.hearts.saturating_sub(1); - run.streak = 0; - } - run.feedback = Some((correct, QUIZ_FEEDBACK_TICKS)); - if let Some((cartridge_id, question, _, concept, review)) = answered { - effects.0.push_back(EngineEffect::RecordAnsweredQuestion { - cartridge_id, - evidence: AnswerEvidence { - question, - concept, - correct, - review, - }, - }); - } - } + Button::B => run.leave_armed = QUIZ_LEAVE_CONFIRM_TICKS, + Button::A => commit_answer(state, effects), _ => {} } } @@ -1737,40 +1980,11 @@ fn advance_game(mut state: ResMut, mut effects: ResMut) { if let Some(level) = prefetch_level { request_question_batch(&mut state, &mut effects, level); } - let question_count = state.question_count(); - let next_batch_end = state - .quiz - .as_ref() - .and_then(|run| state.batch_ends.get(run.completed_batches).copied()); - let mut next_signal = None; if let Some(run) = state.quiz.as_mut() { - if let Some((correct, ticks)) = run.feedback.as_mut() { - let _ = correct; - *ticks = ticks.saturating_sub(1); - if *ticks == 0 { - run.feedback = None; - if run.hearts == 0 { - next_signal = Some(SceneSignal::HeartsEmpty); - } else { - run.question += 1; - run.selected = 0; - if next_batch_end == Some(run.question) { - run.completed_batches += 1; - run.level += 1; - run.leveled_up = true; - } - if run.leveled_up { - run.leveled_up = false; - next_signal = Some(SceneSignal::BatchComplete); - } else if run.question >= question_count { - next_signal = Some(SceneSignal::NeedsQuestion); - } - } - } + if let Some((_, hold)) = run.feedback.as_mut() { + *hold = hold.saturating_sub(1); } - } - if let Some(signal) = next_signal { - state.signal(signal); + run.leave_armed = run.leave_armed.saturating_sub(1); } } Screen::LevelUp if state.screen_ticks >= 180 => { @@ -1783,6 +1997,9 @@ fn advance_game(mut state: ResMut, mut effects: ResMut) { } _ => {} } + if state.screen == Screen::Quiz { + present_current_question(&mut state); + } state.question_retry_ticks = state.question_retry_ticks.saturating_sub(1); } @@ -2524,6 +2741,8 @@ fn quiz_feedback_banner(run: &QuizRun) -> String { Some((true, _)) => { if let Some(stage) = crossed_insight_stage(run) { format!("RUNE {} AWAKENS", stage.label()) + } else if run.redeemed { + "REDEEMED".into() } else if streak_multiplier(run.streak) > 1 { format!("FLOW X{}", streak_multiplier(run.streak)) } else { @@ -2535,6 +2754,7 @@ fn quiz_feedback_banner(run: &QuizRun) -> String { 1 => "WARD FRACTURES".into(), _ => "WARD STRAINED".into(), }, + None if run.leave_armed > 0 => "B AGAIN:LEAVE".into(), None => "A:ANSWER B:LEAVE".into(), } } @@ -2542,12 +2762,153 @@ fn quiz_feedback_banner(run: &QuizRun) -> String { fn quiz_feedback_color(run: &QuizRun) -> Color { match run.feedback { Some((true, _)) if crossed_insight_stage(run).is_some() => AMBER, + Some((true, _)) if run.redeemed => GREEN, Some((true, _)) => CYAN, Some((false, _)) => ward_color(run.hearts), + None if run.leave_armed > 0 => AMBER, None => MIST, } } +/// True once the lesson card's input hold has elapsed and A or Start will +/// continue. +fn lesson_is_live(run: &QuizRun) -> bool { + matches!(run.feedback, Some((_, 0))) +} + +const LESSON_CONTINUE: &str = "A:CONTINUE"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum LessonTone { + /// The player's wrong pick: the misconception it reveals. + Misconception, + MisconceptionWhy, + /// The correct answer and why it holds. + Answer, + AnswerWhy, +} + +impl LessonTone { + fn color(self) -> Color { + match self { + Self::Misconception => RED, + Self::MisconceptionWhy => MIST, + Self::Answer => GREEN, + Self::AnswerWhy => PARCH, + } + } +} + +/// One positioned line of lesson copy. +#[derive(Clone, Debug, Eq, PartialEq)] +struct LessonLine { + x: i32, + y: i32, + text: String, + tone: LessonTone, +} + +/// Composes the lesson card copy inside `area`: for a miss, the player's pick +/// with its rationale and then the correct answer with its rationale; for a +/// success, the answer with its rationale. Choice lines carry a `-` or `+` +/// marker as well as color, so the verdict never depends on hue alone. The +/// fixed rationale column is centered horizontally and the whole composition +/// vertically; legacy questions without rationales show only choice lines. +fn lesson_lines( + question: &QuizQuestion, + picked: usize, + correct: bool, + area: UiBox, +) -> Vec { + let mut blocks: Vec> = Vec::new(); + let mut block = |marker: &str, index: usize, tone: LessonTone, why: LessonTone| { + let choice = question.choices.get(index).map_or("", String::as_str); + let mut lines = vec![( + format!("{marker} {}", truncate(choice, QUIZ_CHOICE_CHARS)), + tone, + )]; + if let Some(rationale) = choice_rationale(question, index) { + lines.extend( + wrap_text(rationale, RATIONALE_COLUMNS) + .into_iter() + .take(RATIONALE_ROWS) + .map(|line| (line, why)), + ); + } + blocks.push(lines); + }; + if !correct { + block( + "-", + picked, + LessonTone::Misconception, + LessonTone::MisconceptionWhy, + ); + } + block( + "+", + question.answer, + LessonTone::Answer, + LessonTone::AnswerWhy, + ); + + let line_count = blocks.iter().map(Vec::len).sum::() as i32; + let height = line_count * LINE_HEIGHT - 1 + (blocks.len() as i32 - 1) * LESSON_BLOCK_GAP; + let column_width = text_width(&"W".repeat(RATIONALE_COLUMNS), 1); + let x = area.x + (area.width - column_width) / 2; + let mut y = area.y + (area.height - height) / 2; + let mut positioned = Vec::new(); + for block in blocks { + for (text, tone) in block { + positioned.push(LessonLine { x, y, text, tone }); + y += LINE_HEIGHT; + } + y += LESSON_BLOCK_GAP; + } + positioned +} + +fn draw_lesson_lines(frame: &mut Framebuffer, lines: &[LessonLine]) { + for line in lines { + frame.text(line.x, line.y, &line.text, line.tone.color(), 1); + } +} + +/// The trial lesson panel's copy region above its footer row. +fn trial_lesson_copy_box() -> UiBox { + UiBox { + x: TRIAL_LESSON_BOX.x + 1, + y: TRIAL_LESSON_BOX.y + 2, + width: TRIAL_LESSON_BOX.width - 2, + height: TRIAL_LESSON_BOX.height - 17, + } +} + +/// The trial lesson panel's footer row: the lens and its mastery runes on the +/// left, and the continue prompt on the right once input is live. +fn trial_lesson_footer_y() -> i32 { + TRIAL_LESSON_BOX.y + TRIAL_LESSON_BOX.height - 11 +} + +/// The legacy lesson panel's copy region inside its border. +fn quiz_lesson_copy_box() -> UiBox { + UiBox { + x: QUIZ_LESSON_BOX.x + 1, + y: QUIZ_LESSON_BOX.y + 1, + width: QUIZ_LESSON_BOX.width - 2, + height: QUIZ_LESSON_BOX.height - 2, + } +} + +/// The committed result's lesson copy, when the run is showing one. +fn current_lesson(state: &GameState, area: UiBox) -> Option> { + let run = state.quiz.as_ref()?; + let (correct, _) = run.feedback?; + let question = state.cartridge.as_ref()?.questions.get(run.question)?; + let picked = run.source_choice(run.selected, question.choices.len()); + Some(lesson_lines(question, picked, correct, area)) +} + fn render_quiz(frame: &mut Framebuffer, state: &GameState) { if state.uses_visual_template(VisualTemplate::Trial) { render_oracle_trial(frame, state); @@ -2593,26 +2954,55 @@ fn render_quiz(frame: &mut Framebuffer, state: &GameState) { QUIZ_QUESTION_COLUMNS, QUIZ_QUESTION_ROWS, ); - for (index, choice) in question.choices.iter().take(4).enumerate() { - let y = 73 + index as i32 * 20; - let mut color = PARCH; - if run.selected == index { + if let Some(lesson) = current_lesson(state, quiz_lesson_copy_box()) { + let panel = QUIZ_LESSON_BOX; + frame.rect(panel.x, panel.y, panel.width, panel.height, VOID); + frame.outline(panel.x, panel.y, panel.width, panel.height, SKY); + draw_lesson_lines(frame, &lesson); + frame.text( + 5, + 151, + &quiz_feedback_banner(run), + quiz_feedback_color(run), + 1, + ); + if lesson_is_live(run) { + frame.text( + 235 - text_width(LESSON_CONTINUE, 1), + 151, + LESSON_CONTINUE, + CYAN, + 1, + ); + } + return; + } + let order = run.display_order(question.choices.len()); + for (slot, source) in order.into_iter().take(4).enumerate() { + let y = 73 + slot as i32 * 20; + if run.selected == slot { frame.rect(5, y - 3, 230, 14, ROYAL); frame.text(9, y, ">", GOLD, 1); } - if let Some((correct, _)) = run.feedback { - if index == question.answer { - color = GREEN; - } else if run.selected == index && !correct { - color = RED; - } - } - frame.text(21, y, &truncate(choice, QUIZ_CHOICE_CHARS), color, 1); + frame.text( + 21, + y, + &truncate(&question.choices[source], QUIZ_CHOICE_CHARS), + PARCH, + 1, + ); } - if run.feedback.is_some() { - frame.centered_text(151, &quiz_feedback_banner(run), quiz_feedback_color(run), 1); + frame.text(5, 151, "A:ANSWER", MIST, 1); + if run.leave_armed > 0 { + let prompt = quiz_feedback_banner(run); + frame.text( + 235 - text_width(&prompt, 1), + 151, + &prompt, + quiz_feedback_color(run), + 1, + ); } else { - frame.text(5, 151, "A:ANSWER", MIST, 1); frame.text(199, 151, "B:BACK", MIST, 1); } } @@ -2701,25 +3091,52 @@ fn render_oracle_trial(frame: &mut Framebuffer, state: &GameState) { QUIZ_QUESTION_ROWS, ); - for (index, choice) in question.choices.iter().take(4).enumerate() { - let y = 69 + index as i32 * 22; - let focused = run.selected == index; + if let Some(lesson) = current_lesson(state, trial_lesson_copy_box()) { + let panel = TRIAL_LESSON_BOX; + frame.rect(panel.x, panel.y, panel.width, panel.height, VOID); + frame.outline(panel.x, panel.y, panel.width, panel.height, CYAN_DIM); + draw_lesson_lines(frame, &lesson); + let column_x = lesson.first().map_or(panel.x + 3, |line| line.x); + let column_end = column_x + text_width(&"W".repeat(RATIONALE_COLUMNS), 1); + let footer_y = trial_lesson_footer_y(); + frame.rect(column_x, footer_y - 3, column_end - column_x, 1, INDIGO); + if let Some(concept) = question.concept { + let stage = cart + .mastery + .get(&concept) + .map_or(0, |record| record.stage()); + frame.text(column_x, footer_y, concept.label(), CYAN_DIM, 1); + draw_oracle_rune_meter( + frame, + column_x + text_width(concept.label(), 1) + 4, + footer_y, + stage, + CYAN, + ); + } + if lesson_is_live(run) { + frame.text( + column_end - text_width(LESSON_CONTINUE, 1), + footer_y, + LESSON_CONTINUE, + CYAN, + 1, + ); + } + return; + } + let order = run.display_order(question.choices.len()); + for (slot, source) in order.into_iter().take(4).enumerate() { + let y = 69 + slot as i32 * 22; + let focused = run.selected == slot; if focused { draw_asset_focus(frame, 23, y, 204, 20); } - let mut color = if focused { PARCH } else { MIST }; - if let Some((correct, _)) = run.feedback { - if index == question.answer { - color = GREEN; - } else if focused && !correct { - color = RED; - } - } frame.text( TRIAL_CHOICE_TEXT_X, y + 7, - &truncate(choice, QUIZ_CHOICE_CHARS), - color, + &truncate(&question.choices[source], QUIZ_CHOICE_CHARS), + if focused { PARCH } else { MIST }, 1, ); } @@ -3487,18 +3904,123 @@ mod tests { engine } - #[test] - fn committing_an_answer_records_that_question_for_future_runs() { - let mut engine = playing_quiz_engine(); - let _ = engine.take_effects(); - + fn press(engine: &mut GameEngine, button: Button) { issue( - &mut engine, + engine, EngineCommand::Input { - button: Button::A, + button, pressed: true, }, ); + issue( + engine, + EngineCommand::Input { + button, + pressed: false, + }, + ); + } + + fn engine_state(engine: &GameEngine) -> &GameState { + engine.app.world().resource::() + } + + fn current_question(engine: &GameEngine) -> QuizQuestion { + let state = engine_state(engine); + let run = state.quiz.as_ref().unwrap(); + state.cartridge.as_ref().unwrap().questions[run.question].clone() + } + + /// The display slot that currently shows the correct answer. + fn answer_slot(engine: &GameEngine) -> usize { + let question = current_question(engine); + let run = engine_state(engine).quiz.as_ref().unwrap(); + run.display_order(question.choices.len()) + .iter() + .position(|source| *source == question.answer) + .unwrap() + } + + /// Moves focus to the answer slot, or to the slot after it for a miss. + fn focus_choice(engine: &mut GameEngine, correct: bool) { + let choice_count = current_question(engine).choices.len(); + let answer = answer_slot(engine); + let target = if correct { + answer + } else { + (answer + 1) % choice_count + }; + while engine_state(engine).quiz.as_ref().unwrap().selected != target { + press(engine, Button::Down); + } + } + + fn commit(engine: &mut GameEngine, correct: bool) { + focus_choice(engine, correct); + press(engine, Button::A); + } + + /// Waits out the lesson hold and continues past the lesson card. + fn finish_lesson(engine: &mut GameEngine) { + for _ in 0..QUIZ_FEEDBACK_TICKS { + engine.update(); + } + press(engine, Button::A); + } + + fn concept_question(index: usize) -> QuizQuestion { + QuizQuestion { + question: format!("WHICH LAYER OWNS CONCERN {index}?"), + choices: vec![ + format!("THE ENGINE LAYER {index}"), + format!("THE SHELL LAYER {index}"), + format!("THE STYLE LAYER {index}"), + format!("THE BUILD LAYER {index}"), + ], + answer: 0, + concept: Some(Concept::Responsibility), + rationales: vec![ + "THE ENGINE OWNS STATE AND RULES.".into(), + "THE SHELL ONLY FORWARDS INPUT.".into(), + "STYLES DRAW THE DEVICE CASE.".into(), + "THE BUILD PACKAGES THE APP.".into(), + ], + review: false, + } + } + + /// A quiz engine playing one batch of distinct concept questions. + fn batch_quiz_engine(count: usize) -> GameEngine { + let mut engine = GameEngine::new(); + let mut cartridge = quiz_cartridge(); + cartridge.questions.clear(); + issue(&mut engine, EngineCommand::Cartridge(Some(cartridge))); + let _ = engine.take_effects(); + issue(&mut engine, EngineCommand::Power(true)); + finish_opening(&mut engine); + for button in [Button::Start, Button::A, Button::Start] { + press(&mut engine, button); + } + issue( + &mut engine, + EngineCommand::Questions { + cartridge_id: "/tmp/engine-test".into(), + questions: (0..count).map(concept_question).collect(), + }, + ); + for _ in 0..75 { + engine.update(); + } + assert_eq!(engine.screen(), Screen::Quiz); + engine + } + + #[test] + fn committing_an_answer_records_that_question_for_future_runs() { + let mut engine = playing_quiz_engine(); + let _ = engine.take_effects(); + + commit(&mut engine, true); assert!(engine.take_effects().iter().any(|effect| matches!( effect, @@ -4257,6 +4779,7 @@ mod tests { fn correct_answers_apply_the_staged_flow_multiplier_to_runtime_score() { let mut engine = playing_quiz_engine(); let expected_scores = [100, 200, 400, 600, 800, 1_100, 1_400, 1_700, 2_000]; + focus_choice(&mut engine, true); for (index, expected_score) in expected_scores.into_iter().enumerate() { issue( @@ -4295,15 +4818,10 @@ mod tests { #[test] fn quiz_review_names_rune_flow_and_ward_threshold_changes() { let mut run = QuizRun { - question: 0, - completed_batches: 0, - selected: 0, - hearts: 3, score: 400, - level: 1, streak: 3, - leveled_up: false, feedback: Some((true, QUIZ_FEEDBACK_TICKS)), + ..QuizRun::new() }; assert_eq!(quiz_feedback_banner(&run), "RUNE I AWAKENS"); @@ -4557,6 +5075,40 @@ mod tests { }, ); assert_eq!(engine.screen(), Screen::Quiz); + + // A and Start stay inert until the hold ends; the card then waits. + press(&mut engine, Button::A); + press(&mut engine, Button::Start); + assert!(engine_state(&engine) + .quiz + .as_ref() + .unwrap() + .feedback + .is_some()); + for _ in 0..QUIZ_FEEDBACK_TICKS { + engine.update(); + } + for _ in 0..300 { + engine.update(); + } + let run = engine_state(&engine).quiz.as_ref().unwrap(); + assert_eq!(run.feedback.map(|(_, hold)| hold), Some(0)); + assert_eq!(run.question, 0, "the lesson card must not auto-advance"); + for button in [Button::B, Button::B, Button::Up, Button::Down] { + press(&mut engine, button); + } + assert_eq!(engine.screen(), Screen::Quiz); + assert!(engine_state(&engine) + .quiz + .as_ref() + .unwrap() + .feedback + .is_some()); + + press(&mut engine, Button::Start); + let run = engine_state(&engine).quiz.as_ref().unwrap(); + assert!(run.feedback.is_none()); + assert_eq!(run.question, 1); } #[test] @@ -4925,13 +5477,9 @@ mod tests { state.quiz = Some(QuizRun { question: 1, completed_batches: 1, - selected: 0, - hearts: 3, score: 100, - level: 1, streak: 1, - leveled_up: false, - feedback: None, + ..QuizRun::new() }); state.transition(Screen::Oracle); } @@ -5277,94 +5825,29 @@ mod tests { #[test] fn surviving_a_complete_ai_batch_levels_up() { - let mut engine = GameEngine::new(); - let mut cartridge = quiz_cartridge(); - let question = cartridge.questions[0].clone(); - cartridge.questions.clear(); - issue(&mut engine, EngineCommand::Cartridge(Some(cartridge))); - let _ = engine.take_effects(); - issue(&mut engine, EngineCommand::Power(true)); - finish_opening(&mut engine); - for button in [Button::Start, Button::A, Button::Start] { - issue( - &mut engine, - EngineCommand::Input { - button, - pressed: true, - }, - ); - issue( - &mut engine, - EngineCommand::Input { - button, - pressed: false, - }, - ); - } - issue( - &mut engine, - EngineCommand::Questions { - cartridge_id: "/tmp/engine-test".into(), - questions: vec![question; QUESTION_BATCH_SIZE], - }, - ); - for _ in 0..75 { - engine.update(); - } - assert_eq!(engine.screen(), Screen::Quiz); + let mut engine = batch_quiz_engine(QUESTION_BATCH_SIZE); - for (index, wrong) in [true, false, false, true, false, false] + // Two misses add two review copies, so the batch closes after eight + // commitments: the six originals plus both retries. + let batch_length = QUESTION_BATCH_SIZE + 2; + for (index, wrong) in [true, false, false, true, false, false, false, false] .into_iter() .enumerate() { - if wrong { - issue( - &mut engine, - EngineCommand::Input { - button: Button::Down, - pressed: true, - }, - ); - issue( - &mut engine, - EngineCommand::Input { - button: Button::Down, - pressed: false, - }, - ); - } - issue( - &mut engine, - EngineCommand::Input { - button: Button::A, - pressed: true, - }, - ); - issue( - &mut engine, - EngineCommand::Input { - button: Button::A, - pressed: false, - }, - ); - for _ in 0..45 { - engine.update(); - } - if index + 1 < QUESTION_BATCH_SIZE { - assert_eq!(engine.screen(), Screen::Quiz); + commit(&mut engine, !wrong); + finish_lesson(&mut engine); + if index + 1 < batch_length { + assert_eq!(engine.screen(), Screen::Quiz, "commitment {index}"); } } assert_eq!(engine.screen(), Screen::LevelUp); - let run = engine - .app - .world() - .resource::() - .quiz - .as_ref() - .unwrap(); + let state = engine_state(&engine); + let run = state.quiz.as_ref().unwrap(); assert_eq!(run.level, 2); assert_eq!(run.hearts, 1); + assert_eq!(run.question, batch_length); + assert_eq!(state.batch_ends, vec![batch_length]); } #[test] @@ -5405,25 +5888,8 @@ mod tests { } for _ in 0..3 { - for button in [Button::Down, Button::A] { - issue( - &mut engine, - EngineCommand::Input { - button, - pressed: true, - }, - ); - issue( - &mut engine, - EngineCommand::Input { - button, - pressed: false, - }, - ); - } - for _ in 0..45 { - engine.update(); - } + commit(&mut engine, false); + finish_lesson(&mut engine); } assert_eq!(engine.screen(), Screen::GameOver); @@ -5431,6 +5897,10 @@ mod tests { let run = state.quiz.as_ref().unwrap(); assert_eq!(run.level, 1); assert_eq!(run.completed_batches, 0); + // The two survivable misses were scheduled for review; the final, + // ward-breaking miss was not. + assert_eq!(state.cartridge.as_ref().unwrap().questions.len(), 5); + assert_eq!(state.batch_ends, vec![5]); } #[test] @@ -5463,23 +5933,8 @@ mod tests { assert_eq!(engine.screen(), Screen::Quiz); for _ in 0..4 { - issue( - &mut engine, - EngineCommand::Input { - button: Button::A, - pressed: true, - }, - ); - issue( - &mut engine, - EngineCommand::Input { - button: Button::A, - pressed: false, - }, - ); - for _ in 0..45 { - engine.update(); - } + commit(&mut engine, true); + finish_lesson(&mut engine); } assert_eq!(engine.screen(), Screen::Quiz); @@ -5497,6 +5952,574 @@ mod tests { ))); } + #[test] + fn question_identity_matches_the_save_normalization() { + assert_eq!( + question_identity(" who owns\tthe\n game loop? "), + "WHO OWNS THE GAME LOOP?" + ); + assert_eq!( + question_identity("WHO OWNS THE GAME LOOP?"), + question_identity("who owns the game loop?") + ); + } + + #[test] + fn committing_maps_the_focused_display_slot_to_its_source_choice() { + for correct in [true, false] { + let mut engine = batch_quiz_engine(1); + let _ = engine.take_effects(); + let question = current_question(&engine); + let slot = answer_slot(&engine); + { + let run = engine_state(&engine).quiz.as_ref().unwrap(); + let mut order = run.order.clone(); + assert_eq!(run.display_order(4)[slot], question.answer); + order.sort_unstable(); + assert_eq!(order, [0, 1, 2, 3], "the display order is a permutation"); + } + + commit(&mut engine, correct); + + let run = engine_state(&engine).quiz.as_ref().unwrap(); + assert_eq!(run.feedback.map(|(result, _)| result), Some(correct)); + assert!(engine.take_effects().iter().any(|effect| matches!( + effect, + EngineEffect::RecordAnsweredQuestion { evidence, .. } + if evidence.correct == correct + && evidence.concept == Some(Concept::Responsibility) + && !evidence.review + ))); + } + } + + #[test] + fn provider_first_answers_spread_across_every_display_slot() { + let mut cartridge = quiz_cartridge(); + cartridge.questions = (0..60).map(concept_question).collect(); + let mut state = GameState { + cartridge: Some(cartridge), + quiz: Some(QuizRun::new()), + ..Default::default() + }; + let mut slot_counts = [0; 4]; + for index in 0..60 { + state.quiz.as_mut().unwrap().question = index; + present_current_question(&mut state); + let run = state.quiz.as_ref().unwrap(); + assert_eq!(run.presented, Some(index)); + assert_eq!(run.selected, 0); + let slot = run.order.iter().position(|source| *source == 0).unwrap(); + slot_counts[slot] += 1; + } + assert!( + slot_counts.iter().all(|count| *count >= 5), + "the answer must not favor a slot: {slot_counts:?}" + ); + } + + #[test] + fn retry_insertion_respects_the_gap_and_the_batch_end() { + assert_eq!(retry_insertion_index(1, Some(6), 12), 5); + assert_eq!(retry_insertion_index(4, Some(6), 12), 6); + assert_eq!(retry_insertion_index(5, Some(6), 12), 6); + assert_eq!(retry_insertion_index(0, None, 1), 1); + + let mut questions: Vec<_> = (0..12).map(concept_question).collect(); + let mut batch_ends = vec![6, 12]; + + // Mid-batch: three other questions separate the miss from its review. + assert_eq!(schedule_retry(&mut questions, &mut batch_ends, 1), Some(5)); + assert_eq!(batch_ends, vec![7, 13]); + assert_eq!(questions[5].question, questions[1].question); + assert!(questions[5].review); + assert!(!questions[1].review); + + // Near the batch end: the review becomes the batch's last question. + assert_eq!(schedule_retry(&mut questions, &mut batch_ends, 5), Some(7)); + assert_eq!(batch_ends, vec![8, 14]); + assert_eq!(questions[7].question, questions[5].question); + + // At the batch end itself: the review follows immediately. + assert_eq!(schedule_retry(&mut questions, &mut batch_ends, 7), Some(8)); + assert_eq!(batch_ends, vec![9, 15]); + assert_eq!(questions.len(), 15); + assert_eq!(questions[9].question, concept_question(6).question); + assert_eq!(schedule_retry(&mut questions, &mut batch_ends, 99), None); + } + + #[test] + fn a_missed_question_returns_in_a_new_slot_and_redeems_its_lesson() { + let mut engine = batch_quiz_engine(QUESTION_BATCH_SIZE); + let missed = current_question(&engine); + let first_slot = answer_slot(&engine); + + commit(&mut engine, false); + { + let state = engine_state(&engine); + let cartridge = state.cartridge.as_ref().unwrap(); + assert_eq!(cartridge.questions.len(), QUESTION_BATCH_SIZE + 1); + assert_eq!(state.batch_ends, vec![QUESTION_BATCH_SIZE + 1]); + assert_eq!( + cartridge.lessons, + vec![Lesson { + question: missed.question.clone(), + answer: missed.choices[0].clone(), + rationale: missed.rationales[0].clone(), + concept: Some(Concept::Responsibility), + outstanding: true, + }] + ); + assert_eq!(cartridge.mastery[&Concept::Responsibility].missed, 1); + } + finish_lesson(&mut engine); + + for _ in 0..RETRY_GAP { + assert!(!current_question(&engine).review); + commit(&mut engine, true); + finish_lesson(&mut engine); + } + + let review = current_question(&engine); + assert!(review.review); + assert_eq!(review.question, missed.question); + assert_ne!( + answer_slot(&engine), + first_slot, + "a retry must move the answer so position cannot be memorized" + ); + assert_eq!(engine_state(&engine).quiz.as_ref().unwrap().attempt, 1); + + let _ = engine.take_effects(); + commit(&mut engine, true); + let state = engine_state(&engine); + let run = state.quiz.as_ref().unwrap(); + assert!(run.redeemed); + assert_eq!(quiz_feedback_banner(run), "REDEEMED"); + let cartridge = state.cartridge.as_ref().unwrap(); + assert_eq!(cartridge.lessons.len(), 1 + RETRY_GAP); + assert!( + !cartridge.lessons[0].outstanding, + "redemption clears the miss" + ); + assert_eq!( + cartridge.mastery[&Concept::Responsibility], + learning::LensRecord { + first_try: RETRY_GAP as u32, + redeemed: 1, + missed: 1, + } + ); + assert!(engine.take_effects().iter().any(|effect| matches!( + effect, + EngineEffect::RecordAnsweredQuestion { evidence, .. } + if evidence.correct && evidence.review + ))); + } + + #[test] + fn rune_crossings_take_precedence_over_redemption() { + let mut run = QuizRun { + score: 300, + streak: 1, + redeemed: true, + feedback: Some((true, QUIZ_FEEDBACK_TICKS)), + ..QuizRun::new() + }; + assert_eq!(quiz_feedback_banner(&run), "RUNE I AWAKENS"); + run.score = 400; + assert_eq!(quiz_feedback_banner(&run), "REDEEMED"); + assert_eq!(quiz_feedback_color(&run).1, GREEN.1); + run.redeemed = false; + assert_eq!(quiz_feedback_banner(&run), "REVIEW ANSWER"); + } + + #[test] + fn leaving_an_active_question_needs_a_second_b_inside_the_window() { + let mut engine = batch_quiz_engine(2); + + press(&mut engine, Button::B); + assert_eq!(engine.screen(), Screen::Quiz); + assert_eq!( + quiz_feedback_banner(engine_state(&engine).quiz.as_ref().unwrap()), + "B AGAIN:LEAVE" + ); + + // Any other button disarms the confirmation. + press(&mut engine, Button::Down); + press(&mut engine, Button::B); + assert_eq!(engine.screen(), Screen::Quiz); + + // So does the timeout. + for _ in 0..QUIZ_LEAVE_CONFIRM_TICKS { + engine.update(); + } + assert_eq!(engine_state(&engine).quiz.as_ref().unwrap().leave_armed, 0); + press(&mut engine, Button::B); + assert_eq!(engine.screen(), Screen::Quiz); + + // B cannot arm during the lesson hold or on the lesson card. + commit(&mut engine, true); + press(&mut engine, Button::B); + for _ in 0..QUIZ_FEEDBACK_TICKS { + engine.update(); + } + press(&mut engine, Button::B); + press(&mut engine, Button::B); + assert_eq!(engine.screen(), Screen::Quiz); + assert_eq!(engine_state(&engine).quiz.as_ref().unwrap().leave_armed, 0); + + // A second B on the window's last live tick leaves the run. + press(&mut engine, Button::A); + press(&mut engine, Button::B); + for _ in 0..QUIZ_LEAVE_CONFIRM_TICKS - 3 { + engine.update(); + } + press(&mut engine, Button::B); + assert_eq!(engine.screen(), Screen::QuizMenu); + } + + fn lesson_question() -> QuizQuestion { + QuizQuestion { + question: "WHY SEPARATE GAME STATE FROM THE DEVICE SHELL?".into(), + choices: vec![ + "TO KEEP RESPONSIBILITIES CLEAR".into(), + "TO DUPLICATE RUNTIME STATE".into(), + "TO HIDE INPUT TRANSITIONS".into(), + "TO COUPLE RENDERING TO CSS".into(), + ], + answer: 0, + concept: Some(Concept::Responsibility), + rationales: vec![ + "THE ENGINE OWNS RULES AND TICKS, SO THE SHELL ONLY DRAWS FRAMES AND FORWARDS INPUT." + .into(), + "ONE OWNER KEEPS STATE TRUE; A SECOND COPY IN THE SHELL WOULD DRIFT FROM THE ENGINE." + .into(), + "INPUT EDGES ARE EXPLICIT ENGINE EVENTS; THE SPLIT DOES NOT HIDE THEM.".into(), + "THE ENGINE RENDERS CPU PIXELS; CSS ONLY STYLES THE DEVICE CASE.".into(), + ], + review: false, + } + } + + /// 31-character choices and rationales that fill all three 34-column rows. + fn worst_case_lesson_question() -> QuizQuestion { + let row = format!("{} {}", "W".repeat(16), "W".repeat(17)); + QuizQuestion { + question: "Q".repeat(QUIZ_QUESTION_COLUMNS), + choices: ["A", "B", "C", "D"] + .map(|letter| letter.repeat(QUIZ_CHOICE_CHARS)) + .to_vec(), + answer: 0, + concept: Some(Concept::Invariant), + rationales: vec![[row.as_str(), row.as_str(), row.as_str()].join(" "); 4], + review: false, + } + } + + fn lesson_state(question: QuizQuestion, correct: bool, live: bool) -> GameState { + let mut cartridge = oracle_template_cartridge(); + cartridge.questions = vec![question]; + cartridge.mastery.insert( + Concept::Responsibility, + learning::LensRecord { + first_try: 3, + ..Default::default() + }, + ); + GameState { + cartridge: Some(cartridge), + screen: Screen::Quiz, + quiz: Some(QuizRun { + hearts: if correct { 3 } else { 2 }, + score: if correct { 200 } else { 100 }, + streak: if correct { 2 } else { 0 }, + selected: if correct { 0 } else { 1 }, + feedback: Some((correct, if live { 0 } else { QUIZ_FEEDBACK_TICKS })), + ..QuizRun::new() + }), + ..Default::default() + } + } + + #[test] + fn lesson_panel_copy_stays_contained_disjoint_and_readable() { + let question = worst_case_lesson_question(); + assert!(question.rationales.iter().all(|rationale| wrap_text( + rationale, + RATIONALE_COLUMNS + ) + .len() + == RATIONALE_ROWS + && learning::rationale_fits(rationale))); + let panel = ui_box_bounds(TRIAL_LESSON_BOX); + let interior = LayoutBounds { + x: panel.x + 1, + y: panel.y + 1, + width: panel.width - 2, + height: panel.height - 2, + }; + let screen = LayoutBounds { + x: 0, + y: 0, + width: WIDTH as i32, + height: HEIGHT as i32, + }; + let question_copy = text_bounds( + 28, + 35 + (QUIZ_QUESTION_ROWS as i32 - 1) * LINE_HEIGHT, + &"Q".repeat(QUIZ_QUESTION_COLUMNS), + 1, + ); + assert!(bounds_contains(screen, panel)); + assert!(bounds_are_disjoint(panel, question_copy)); + + for (name, correct, expected_lines) in [("missed", false, 8), ("correct", true, 4)] { + let lines = lesson_lines(&question, 1, correct, trial_lesson_copy_box()); + assert_eq!(lines.len(), expected_lines, "{name}"); + let column_x = lines[0].x; + let column_end = column_x + text_width(&"W".repeat(RATIONALE_COLUMNS), 1); + let footer_y = trial_lesson_footer_y(); + let label = text_bounds(column_x, footer_y, Concept::Invariant.label(), 1); + let runes = LayoutBounds { + x: label.x + label.width + 4, + y: footer_y, + width: 19, + height: 7, + }; + let prompt = text_bounds( + column_end - text_width(LESSON_CONTINUE, 1), + footer_y, + LESSON_CONTINUE, + 1, + ); + let rule = LayoutBounds { + x: column_x, + y: footer_y - 3, + width: column_end - column_x, + height: 1, + }; + let mut children: Vec<(String, LayoutBounds)> = lines + .iter() + .map(|line| { + ( + line.text.clone(), + text_bounds(line.x, line.y, &line.text, 1), + ) + }) + .collect(); + children.extend([ + ("lens label".to_string(), label), + ("lens runes".to_string(), runes), + ("continue prompt".to_string(), prompt), + ("footer rule".to_string(), rule), + ]); + for (child_name, child) in &children { + assert!( + bounds_contains(interior, *child), + "{name} {child_name} {child:?} exceeds the lesson panel {interior:?}" + ); + } + for (index, (left_name, left)) in children.iter().enumerate() { + for (right_name, right) in &children[index + 1..] { + assert!( + bounds_are_disjoint(*left, *right), + "{name} {left_name} overlaps {right_name}" + ); + } + } + for line in &lines { + assert!( + bounds_contains( + ui_box_bounds(trial_lesson_copy_box()), + text_bounds(line.x, line.y, &line.text, 1) + ), + "{name} line `{}` leaves the copy region", + line.text + ); + } + assert_eq!(lines.last().unwrap().tone, LessonTone::AnswerWhy); + assert!(lines[0].text.starts_with(if correct { "+ " } else { "- " })); + } + + let legacy_panel = ui_box_bounds(QUIZ_LESSON_BOX); + let legacy_interior = LayoutBounds { + x: legacy_panel.x + 1, + y: legacy_panel.y + 1, + width: legacy_panel.width - 2, + height: legacy_panel.height - 2, + }; + let legacy_lines = lesson_lines(&question, 1, false, quiz_lesson_copy_box()); + for (index, line) in legacy_lines.iter().enumerate() { + let bounds = text_bounds(line.x, line.y, &line.text, 1); + assert!( + bounds_contains(legacy_interior, bounds), + "legacy {}", + line.text + ); + for other in &legacy_lines[index + 1..] { + assert!(bounds_are_disjoint( + bounds, + text_bounds(other.x, other.y, &other.text, 1) + )); + } + } + let legacy_banner = text_bounds(5, 151, "RUNE III AWAKENS", 1); + let legacy_prompt = text_bounds( + 235 - text_width(LESSON_CONTINUE, 1), + 151, + LESSON_CONTINUE, + 1, + ); + assert!(bounds_are_disjoint(legacy_banner, legacy_prompt)); + assert!(bounds_are_disjoint(legacy_panel, legacy_banner)); + assert!(bounds_contains(screen, legacy_prompt)); + let legacy_leave = text_bounds( + 235 - text_width("B AGAIN:LEAVE", 1), + 151, + "B AGAIN:LEAVE", + 1, + ); + assert!(bounds_are_disjoint( + text_bounds(5, 151, "A:ANSWER", 1), + legacy_leave + )); + assert!(bounds_contains(screen, legacy_leave)); + + for tone in [ + LessonTone::Misconception, + LessonTone::MisconceptionWhy, + LessonTone::Answer, + LessonTone::AnswerWhy, + ] { + let ratio = contrast_ratio(tone.color(), VOID); + assert!( + ratio >= 4.5, + "{tone:?} contrast {ratio:.2}:1 on the lesson panel" + ); + } + for (name, color) in [("lens", CYAN_DIM), ("continue", CYAN)] { + let ratio = contrast_ratio(color, VOID); + assert!( + ratio >= 4.5, + "{name} contrast {ratio:.2}:1 on the lesson panel" + ); + } + } + + #[test] + fn lesson_cards_render_the_misconception_and_the_answer() { + let answer_line = |state: &GameState| { + current_lesson(state, trial_lesson_copy_box()) + .unwrap() + .into_iter() + .find(|line| line.tone == LessonTone::Answer) + .unwrap() + }; + + let missed = lesson_state(lesson_question(), false, true); + let mut missed_frame = Framebuffer::default(); + render_oracle_trial(&mut missed_frame, &missed); + maybe_write_preview("oracle-lesson-missed", &missed_frame.pixels); + let missed_lines = current_lesson(&missed, trial_lesson_copy_box()).unwrap(); + assert_eq!(missed_lines[0].text, "- TO DUPLICATE RUNTIME STATE"); + assert_eq!(missed_lines.len(), 8); + let pick = &missed_lines[0]; + assert!( + color_pixels_in_region( + &missed_frame.pixels, + RED, + pick.x as usize..(pick.x + text_width(&pick.text, 1)) as usize, + pick.y as usize..pick.y as usize + 7, + ) > 0, + "the misconception is drawn in red" + ); + let answer = answer_line(&missed); + assert_eq!(answer.text, "+ TO KEEP RESPONSIBILITIES CLEAR"); + assert!( + color_pixels_in_region( + &missed_frame.pixels, + GREEN, + answer.x as usize..(answer.x + text_width(&answer.text, 1)) as usize, + answer.y as usize..answer.y as usize + 7, + ) > 0 + ); + + let correct = lesson_state(lesson_question(), true, true); + let mut correct_frame = Framebuffer::default(); + render_oracle_trial(&mut correct_frame, &correct); + maybe_write_preview("oracle-lesson-correct", &correct_frame.pixels); + let panel = TRIAL_LESSON_BOX; + let panel_x = panel.x as usize..(panel.x + panel.width) as usize; + let panel_y = panel.y as usize..(panel.y + panel.height) as usize; + assert_eq!( + color_pixels_in_region(&correct_frame.pixels, RED, panel_x.clone(), panel_y.clone()), + 0, + "a correct lesson shows no misconception" + ); + assert_eq!(answer_line(&correct).tone, LessonTone::Answer); + + let held = lesson_state(lesson_question(), true, false); + let mut held_frame = Framebuffer::default(); + render_oracle_trial(&mut held_frame, &held); + let footer_y = trial_lesson_footer_y() as usize; + assert_ne!( + frame_region(&held_frame.pixels, panel_x.clone(), footer_y..footer_y + 7), + frame_region( + &correct_frame.pixels, + panel_x.clone(), + footer_y..footer_y + 7 + ), + "A:CONTINUE appears only once input is live" + ); + + let mut legacy = lesson_state( + QuizQuestion { + rationales: Vec::new(), + concept: None, + ..lesson_question() + }, + false, + true, + ); + legacy.cartridge.as_mut().unwrap().codequest = None; + let legacy_lines = current_lesson(&legacy, quiz_lesson_copy_box()).unwrap(); + assert_eq!( + legacy_lines + .iter() + .map(|line| line.text.as_str()) + .collect::>(), + [ + "- TO DUPLICATE RUNTIME STATE", + "+ TO KEEP RESPONSIBILITIES CLEAR" + ], + "legacy questions show only the verdict and answer" + ); + let mut legacy_frame = Framebuffer::default(); + render_quiz(&mut legacy_frame, &legacy); + maybe_write_preview("quiz-lesson-legacy", &legacy_frame.pixels); + + let mut untemplated = lesson_state(lesson_question(), false, true); + untemplated.cartridge.as_mut().unwrap().codequest = None; + let mut untemplated_frame = Framebuffer::default(); + render_quiz(&mut untemplated_frame, &untemplated); + maybe_write_preview("quiz-lesson-rationales", &untemplated_frame.pixels); + assert!( + color_pixels_in_region( + &untemplated_frame.pixels, + RED, + QUIZ_LESSON_BOX.x as usize..(QUIZ_LESSON_BOX.x + QUIZ_LESSON_BOX.width) as usize, + QUIZ_LESSON_BOX.y as usize..(QUIZ_LESSON_BOX.y + QUIZ_LESSON_BOX.height) as usize, + ) > 0, + "the untemplated quiz shows the misconception too" + ); + + let worst = lesson_state(worst_case_lesson_question(), false, true); + let mut worst_frame = Framebuffer::default(); + render_oracle_trial(&mut worst_frame, &worst); + maybe_write_preview("oracle-lesson-worst-case", &worst_frame.pixels); + } + #[test] fn oracle_result_waits_for_a_safe_screen_boundary() { let mut engine = GameEngine::new(); @@ -5905,15 +6928,13 @@ mod tests { cartridge: Some(cartridge), machine: Some(machine), quiz: Some(QuizRun { - question: 0, completed_batches: 3, - selected: 0, hearts: 2, score: 420, level: 4, streak: 3, leveled_up: true, - feedback: None, + ..QuizRun::new() }), questions_loading: true, screen_ticks: 90, @@ -6086,17 +7107,7 @@ mod tests { fn oracle_progression_changes_the_sanctum_without_relying_on_level_text() { let mut state = GameState { cartridge: Some(oracle_template_cartridge()), - quiz: Some(QuizRun { - question: 0, - completed_batches: 0, - selected: 0, - hearts: 3, - score: 0, - level: 1, - streak: 0, - leveled_up: false, - feedback: None, - }), + quiz: Some(QuizRun::new()), questions_loading: true, screen_ticks: 90, ..Default::default() From 0ca60fa33ad00b34bcde088ea79d286d8730fb64 Mon Sep 17 00:00:00 2001 From: Jason Skrzypek Date: Fri, 25 Sep 2026 10:27:17 -0400 Subject: [PATCH 04/60] Credit only real copyright notices and merge duplicate authors The chronicle card credited every GPL-family repository to the Free Software Foundation (the notice at the top of the license text itself) and every Apache-2.0 repository to "[yyyy] [name of copyright owner]" from the license appendix, because the first line starting with "copyright" in LICENSE won and LICENSE was read before NOTICE. Move provenance parsing into a pure provenance module: prefer COPYRIGHT and NOTICE files over license texts (and read COPYING), reject license-steward notices inside GNU license texts, template placeholders, enumerated "(c)" clauses, and prose such as "copyright notice" or "copyright holders". Author credits now come from "shortlog -sn" and merge one person across spellings case-insensitively, ranked by total commits, so a contributor with two addresses no longer takes two of the three slots. The timeline reads root commits and the newest commit instead of formatting every commit. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/lib.rs | 82 +++------- src-tauri/src/provenance.rs | 289 ++++++++++++++++++++++++++++++++++++ 2 files changed, 312 insertions(+), 59 deletions(-) create mode 100644 src-tauri/src/provenance.rs diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 55848bb..1061f14 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ mod engine; mod external_tools; mod font5x7; mod learning; +mod provenance; mod save; pub mod scene_machine; @@ -20,6 +21,7 @@ use tauri_plugin_dialog::DialogExt; use wait_timeout::ChildExt; use codequest::{CodeQuestConfig, GameType}; +use provenance::sanitized_metadata; use scene_machine::{SceneMachineDefinition, SceneMachineTemplate}; #[derive(Serialize, Deserialize, Clone)] @@ -205,76 +207,38 @@ fn repository_revision(path: &std::path::Path) -> String { .unwrap_or_else(|| "-------".to_string()) } -fn sanitized_metadata(value: &str, max_chars: usize) -> String { - value - .chars() - .filter(|character| !character.is_control()) - .collect::() - .split_whitespace() - .collect::>() - .join(" ") - .chars() - .take(max_chars) - .collect() -} - fn explicit_copyright_notice(path: &std::path::Path) -> Option { - const NOTICE_FILES: [&str; 9] = [ - "COPYRIGHT", - "COPYRIGHT.txt", - "COPYRIGHT.md", - "LICENSE", - "LICENSE.txt", - "LICENSE.md", - "NOTICE", - "NOTICE.txt", - "NOTICE.md", - ]; - - NOTICE_FILES.iter().find_map(|name| { + provenance::NOTICE_FILES.iter().find_map(|name| { let file = std::fs::File::open(path.join(name)).ok()?; let mut bytes = Vec::new(); file.take(16 * 1024).read_to_end(&mut bytes).ok()?; - String::from_utf8_lossy(&bytes).lines().find_map(|line| { - let notice = sanitized_metadata(line, 96); - let lowercase = notice.to_ascii_lowercase(); - let declares_copyright = lowercase.starts_with("copyright ") - || lowercase.starts_with("copyright(") - || lowercase.starts_with("(c) ") - || lowercase.starts_with("spdx-filecopyrighttext:") - || notice.starts_with('©'); - declares_copyright.then_some(notice) - }) + provenance::copyright_notice_in(&String::from_utf8_lossy(&bytes)) }) } fn repository_provenance(path: &std::path::Path) -> engine::RepositoryProvenance { - let authors = git_out(path, &["shortlog", "-sne", "--all"]) - .lines() - .filter_map(|line| { - let author = line - .trim_start() - .trim_start_matches(|ch: char| ch.is_ascii_digit()) - .trim(); - let name = author - .rsplit_once(" <") - .map_or(author, |(name, _)| name) - .trim(); - let name = sanitized_metadata(name, 64); - (!name.is_empty()).then_some(name) - }) - .take(3) - .collect(); - - let years: Vec = git_out(path, &["log", "--all", "--format=%cd", "--date=format:%Y"]) - .lines() - .filter_map(|year| year.parse().ok()) - .collect(); + let authors = provenance::ranked_authors(&git_out(path, &["shortlog", "-sn", "--all"]), 3); + let years = |args: &[&str]| -> Vec { + git_out(path, args) + .lines() + .filter_map(|year| year.trim().parse().ok()) + .collect() + }; + // Root commits bound the first year and the newest commit bounds the + // latest, so large histories are not formatted commit by commit. + let first_years = years(&[ + "log", + "--all", + "--max-parents=0", + "--format=%cd", + "--date=format:%Y", + ]); + let latest_years = years(&["log", "--all", "-1", "--format=%cd", "--date=format:%Y"]); engine::RepositoryProvenance { authors, - first_year: years.iter().min().copied(), - latest_year: years.iter().max().copied(), + first_year: first_years.iter().min().copied(), + latest_year: latest_years.iter().max().copied(), copyright: explicit_copyright_notice(path), } } diff --git a/src-tauri/src/provenance.rs b/src-tauri/src/provenance.rs new file mode 100644 index 0000000..4880fe4 --- /dev/null +++ b/src-tauri/src/provenance.rs @@ -0,0 +1,289 @@ +//! Repository provenance for the chronicle card: ranked author credits and an +//! explicit copyright notice. +//! +//! Credits must never overstate ownership. Commit authors are credited as +//! authors only; a copyright line is shown only when the repository states one +//! itself. Standard license texts carry their own template or +//! license-steward notices (the FSF line at the top of every GNU license, the +//! `[yyyy] [name of copyright owner]` appendix of Apache-2.0), and those +//! describe the license document rather than the project, so they are +//! rejected. + +/// Notice files in the order they are trusted. Dedicated copyright and NOTICE +/// files state the project's own ownership; license texts come last because +/// they frequently embed template notices. +pub const NOTICE_FILES: [&str; 11] = [ + "COPYRIGHT", + "COPYRIGHT.txt", + "COPYRIGHT.md", + "NOTICE", + "NOTICE.txt", + "NOTICE.md", + "LICENSE", + "LICENSE.txt", + "LICENSE.md", + "COPYING", + "COPYING.txt", +]; + +/// Collapses whitespace, drops control characters, and bounds the length of +/// repository-supplied text before it reaches the display. +pub fn sanitized_metadata(value: &str, max_chars: usize) -> String { + value + .chars() + .filter(|character| !character.is_control()) + .collect::() + .split_whitespace() + .collect::>() + .join(" ") + .chars() + .take(max_chars) + .collect() +} + +/// Returns the first explicit copyright notice in a notice or license text. +pub fn copyright_notice_in(text: &str) -> Option { + let upper = text.to_ascii_uppercase(); + let gnu_license_text = [ + "GNU GENERAL PUBLIC LICENSE", + "GNU LESSER GENERAL PUBLIC LICENSE", + "GNU LIBRARY GENERAL PUBLIC LICENSE", + "GNU AFFERO GENERAL PUBLIC LICENSE", + "GNU FREE DOCUMENTATION LICENSE", + ] + .iter() + .any(|title| upper.contains(title)); + + text.lines().find_map(|line| { + let notice = sanitized_metadata(line, 96); + let holder = notice_holder(¬ice)?; + let steward_notice = gnu_license_text + && holder + .to_ascii_lowercase() + .contains("free software foundation"); + (!steward_notice && !is_template_holder(holder)).then_some(notice) + }) +} + +/// The holder part of a line that declares copyright, or `None` when the line +/// does not start with a copyright declaration. +fn notice_holder(notice: &str) -> Option<&str> { + let lowercase = notice.to_ascii_lowercase(); + let rest = if let Some(rest) = lowercase.strip_prefix("spdx-filecopyrighttext:") { + rest + } else if let Some(rest) = lowercase.strip_prefix("copyright") { + rest + } else if lowercase.starts_with("(c)") || notice.starts_with('©') { + // A bare "(c)" is also how licenses enumerate clauses ("(c) You must + // retain..."), so it only declares copyright when a year follows. + let rest = notice + .trim_start_matches(['(', 'c', 'C', ')', '©']) + .trim_start(); + if !rest.starts_with(|character: char| character.is_ascii_digit()) { + return None; + } + return Some(¬ice[notice.len() - rest.len()..]); + } else { + return None; + }; + let mut holder = ¬ice[notice.len() - rest.len()..]; + loop { + let trimmed = holder + .trim_start_matches(|character: char| character.is_whitespace() || character == ':'); + let trimmed = ["(c)", "(C)", "©"] + .iter() + .find_map(|symbol| trimmed.strip_prefix(symbol)) + .unwrap_or(trimmed); + if trimmed.len() == holder.len() { + break; + } + holder = trimmed; + } + // "Copyright notice", "copyright holders", or a trailing "Copyright" are + // prose about copyright, not a declaration of it. + let first_word = holder + .split(|character: char| !character.is_ascii_alphanumeric()) + .next() + .unwrap_or_default() + .to_ascii_lowercase(); + let prose = [ + "", + "notice", + "notices", + "holder", + "holders", + "owner", + "owners", + "license", + "licenses", + "law", + "laws", + "statement", + "and", + "or", + "of", + "the", + "to", + "in", + "for", + "is", + "protection", + ]; + (!prose.contains(&first_word.as_str())).then_some(holder) +} + +/// Placeholder notices from license templates, e.g. Apache-2.0's appendix or +/// the GPL's "how to apply" section. +fn is_template_holder(holder: &str) -> bool { + let lowercase = holder.to_ascii_lowercase(); + [ + "[yyyy]", + "[year]", + "", + "{yyyy}", + "{year}", + "yyyy", + "name of copyright owner", + "name of author", + "", + "[fullname]", + "", + ] + .iter() + .any(|token| lowercase.contains(token)) +} + +/// Parses `git shortlog -sn` output into at most `limit` author names ranked +/// by total commits. Names are sanitized and merged case-insensitively so one +/// person committing under several spellings or addresses holds one slot. +pub fn ranked_authors(shortlog: &str, limit: usize) -> Vec { + let mut authors: Vec<(String, u64)> = Vec::new(); + for line in shortlog.lines() { + let line = line.trim_start(); + let digits = line + .find(|character: char| !character.is_ascii_digit()) + .unwrap_or(line.len()); + let count = line[..digits].parse::().unwrap_or(0); + let author = line[digits..].trim(); + let name = author + .rsplit_once(" <") + .map_or(author, |(name, _)| name) + .trim(); + let name = sanitized_metadata(name, 64); + if name.is_empty() { + continue; + } + match authors + .iter_mut() + .find(|(known, _)| known.eq_ignore_ascii_case(&name)) + { + Some((_, total)) => *total += count, + None => authors.push((name, count)), + } + } + // Stable: equal totals keep git's first-seen order. + authors.sort_by_key(|(_, total)| std::cmp::Reverse(*total)); + authors + .into_iter() + .take(limit) + .map(|(name, _)| name) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn project_notices_are_credited_verbatim() { + for (text, expected) in [ + ( + "MIT License\n\nCopyright (c) 2020-2024 Ada Lovelace\n", + "Copyright (c) 2020-2024 Ada Lovelace", + ), + ( + "MIT License\n\n Copyright (c) Microsoft Corporation.\n", + "Copyright (c) Microsoft Corporation.", + ), + ("Copyright 2019 Google LLC\n", "Copyright 2019 Google LLC"), + ("Copyright Contoso Ltd.\n", "Copyright Contoso Ltd."), + ("© 2021 Grace Hopper\n", "© 2021 Grace Hopper"), + ("(c) 2018 Linus\n", "(c) 2018 Linus"), + ( + "SPDX-FileCopyrightText: 2023 The CODE QUEST Authors\n", + "SPDX-FileCopyrightText: 2023 The CODE QUEST Authors", + ), + ] { + assert_eq!( + copyright_notice_in(text).as_deref(), + Some(expected), + "{text}" + ); + } + } + + #[test] + fn gnu_license_texts_do_not_credit_the_license_steward() { + let gpl = " GNU GENERAL PUBLIC LICENSE\n Version 3, 29 June 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. \n Everyone is permitted to copy and distribute verbatim copies\n"; + assert_eq!(copyright_notice_in(gpl), None); + + let gpl_with_project_notice = + format!("{gpl}\n Copyright (C) \n\nCopyright (C) 2022 Real Project\n"); + assert_eq!( + copyright_notice_in(&gpl_with_project_notice).as_deref(), + Some("Copyright (C) 2022 Real Project") + ); + + // The Free Software Foundation remains creditable for its own projects. + assert_eq!( + copyright_notice_in("Copyright (C) 1989 Free Software Foundation, Inc.\n").as_deref(), + Some("Copyright (C) 1989 Free Software Foundation, Inc.") + ); + } + + #[test] + fn license_templates_and_prose_are_not_notices() { + let apache = " 4. Redistribution.\n\n (c) You must retain, in the Source form of any Derivative Works\n\n APPENDIX: How to apply the Apache License to your work.\n\n Copyright [yyyy] [name of copyright owner]\n"; + assert_eq!(copyright_notice_in(apache), None); + + for prose in [ + "The above copyright notice and this permission notice shall be included.", + "Copyright notice", + "COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"", + "Copyright and related rights waived via CC0.", + "Copyright", + "Copyright (C) ", + "Copyright (c) {yyyy} {name of copyright owner}", + ] { + assert_eq!(copyright_notice_in(prose), None, "{prose}"); + } + } + + #[test] + fn author_credits_merge_one_person_across_addresses_and_rank_by_total() { + let shortlog = " 12\tGrace Hopper\n 9\tAda Lovelace\n 8\tada lovelace\n 1\t\n 1\tKatherine Johnson\n"; + assert_eq!( + ranked_authors(shortlog, 3), + ["Ada Lovelace", "Grace Hopper", "Katherine Johnson"] + ); + assert_eq!(ranked_authors(shortlog, 1), ["Ada Lovelace"]); + } + + #[test] + fn author_credits_accept_email_shortlog_and_strip_addresses() { + let shortlog = " 3\tAda Lovelace \n 2\tAda Lovelace \n 4\tGrace Hopper \n"; + assert_eq!( + ranked_authors(shortlog, 3), + ["Ada Lovelace", "Grace Hopper"] + ); + } + + #[test] + fn metadata_is_sanitized_and_bounded() { + assert_eq!( + sanitized_metadata(" main\u{7}\n branch ", 48), + "main branch" + ); + assert_eq!(sanitized_metadata("abcdefgh", 3), "abc"); + } +} From a3746df19fdc7b99e0f258e75b032436733e748e Mon Sep 17 00:00:00 2001 From: Jason Skrzypek Date: Fri, 25 Sep 2026 10:33:16 -0400 Subject: [PATCH 05/60] Add the Oracle Codex: a lesson journal with per-lens mastery The running game now makes learning evidence visible. A new read-only `codex` scene handler shows every concept lens with three mastery runes that wake at exactly 1, 3, and 5 pieces of evidence (first-try successes plus redeemed misses) and an amber count of that lens's lessons awaiting review. Each later page rereads one journal lesson: counter, lens and its runes, the question, the correct answer, and the rationale, with REVIEW PENDING in amber for outstanding lessons. Left/Up/L and Right/Down/R page with wrap; A and Start are deliberately inactive; B emits back. An empty journal says NO LESSONS YET and explains how to fill it. The quiz menu may now emit a new `open-codex` signal. Its second option reads OPEN THE CODEX only when the current menu scene routes that signal, and otherwise keeps RETURN TO TITLE, so manifests without the route behave exactly as before; B still returns to the title. With the route, the menu subtitle summarizes the journal (LESSONS 07 REVIEW 02 / ALL CLEAR). The built-in quiz template and both CODEQUEST.toml copies add quiz-menu -> codex -> quiz-menu, a review-lessons mechanic, and a codex-frame art entry. The new `oracle-codex` visual template composites the mastery page inside the chronicle plate's archive frame and lesson pages over the trial chamber; cartridges without it get a plain NAVY/INK renderer. Native layout tests measure plate interiors, keep worst-case copy contained and disjoint, and check contrast against both panel fills and the brightest plate pixel under directly drawn copy. Rendered-frame tests cross the exact rune breakpoints. The fast validator, schema reference, design brief, and asset README list the new handler, signal, and template. The Oracle preview test now expects 15 distinct frames (13 plus two Codex layouts), and the dogfood template count is 12. Co-Authored-By: Claude Opus 5.5 --- .../scripts/validate_codequest.py | 5 +- CODEQUEST.toml | 33 +- docs/examples/CODEQUEST.toml | 33 +- docs/game-design/oracle-quiz.md | 30 +- docs/reference/codequest-toml.md | 33 +- src-tauri/assets/oracle/README.md | 1 + src-tauri/src/codequest.rs | 138 +- src-tauri/src/engine.rs | 1319 ++++++++++++++++- src-tauri/src/scene_machine.rs | 114 +- 9 files changed, 1686 insertions(+), 20 deletions(-) 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/CODEQUEST.toml b/CODEQUEST.toml index 015ceb5..950c5e2 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", "b"] +rules = ["The Codex reads the cartridge's lesson journal and mastery evidence and never changes either.", "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 redeemed misses.", "Every later page shows one lesson, oldest first: its lens, the question, the correct answer, and the rationale explaining why it holds.", "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 are deliberately inactive because the Codex has nothing to confirm.", "A lesson whose latest attempt was wrong stays marked for review until the player answers it correctly when it returns.", "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 and learned lessons LEARNED in cyan.", "Show the correct 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 mastery page and on the quiz menu."] + [[mechanics]] id = "customize-hero" summary = "Choose the hero name, path, and aura before beginning a run." @@ -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/examples/CODEQUEST.toml b/docs/examples/CODEQUEST.toml index 015ceb5..950c5e2 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", "b"] +rules = ["The Codex reads the cartridge's lesson journal and mastery evidence and never changes either.", "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 redeemed misses.", "Every later page shows one lesson, oldest first: its lens, the question, the correct answer, and the rationale explaining why it holds.", "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 are deliberately inactive because the Codex has nothing to confirm.", "A lesson whose latest attempt was wrong stays marked for review until the player answers it correctly when it returns.", "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 and learned lessons LEARNED in cyan.", "Show the correct 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 mastery page and on the quiz menu."] + [[mechanics]] id = "customize-hero" summary = "Choose the hero name, path, and aura before beginning a run." @@ -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..757c56c 100644 --- a/docs/game-design/oracle-quiz.md +++ b/docs/game-design/oracle-quiz.md @@ -131,7 +131,8 @@ 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) and an amber `!N` pending-review count; later pages reread one lesson each: lens, question, green answer, and rationale, with `REVIEW PENDING` in amber. Left/Up/L and Right/Down/R page with wrap; A/Start are 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` | @@ -217,6 +218,24 @@ 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 and Start are deliberately + inactive because the Codex is read-only. +- **Rules:** Read the cartridge's lesson journal and per-lens mastery without + changing either. Mastery evidence is first-try successes plus redeemed misses; + a lens wakes its first, second, and third rune at exactly 1, 3, and 5 + evidence, and misses never light a rune. Lessons appear oldest first; paging + wraps between the mastery page and the newest lesson. 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 reread + explanation (the correct answer plus why it holds) and shows which lens needs + attention, so the retry is informed instead of a second guess. +- **Feedback:** Pending reviews are an amber count beside the lens runes and an + amber `REVIEW PENDING` status on the lesson, never color alone. Legacy lessons + without a rationale say so explicitly. + ### `begin-from-title` - **Decision:** Accept the Oracle's invitation and enter the quiz menu. @@ -314,6 +333,7 @@ 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. | @@ -347,6 +367,7 @@ engine does not load or play them yet. | `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. | +| `codex` | Archive frame holds five lens rows and totals; lesson pages hold counter, lens runes, question, answer, and rationale in bounded panels. | Page turns are instant input-edge cuts; there is no idle animation to compete with reading. | Page and back cues remain proposed. | B returns to the menu; A/Start are intentionally inactive; paging wraps; the empty journal has explicit guidance. | Runes wake at 1/3/5 evidence per lens and pending reviews clear as misses are redeemed. | Visual template, legacy renderer, and breakpoint tests implemented; 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. | @@ -372,7 +393,8 @@ engine does not load or play them yet. | 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. | +| 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 read-only mastery-and-lesson journal; menus without the route keep `RETURN TO TITLE`. | | 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. | @@ -395,8 +417,8 @@ engine does not load or play them yet. 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 +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, diff --git a/docs/reference/codequest-toml.md b/docs/reference/codequest-toml.md index e3c0dcf..e8e2639 100644 --- a/docs/reference/codequest-toml.md +++ b/docs/reference/codequest-toml.md @@ -109,12 +109,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 +125,24 @@ 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 read-only lesson journal. 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 redeemed misses) and an amber count of +that lens's lessons awaiting review. Each later page shows one lesson, oldest +first: lens, question, correct answer, rationale, and whether a review is +pending. Left, Up, and L page back; Right, Down, and R page forward; paging +wraps between the mastery page and the newest lesson. A and Start are +deliberately inactive, 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`. + ## Mechanics A mechanic captures reusable rules and feedback. These fields are currently @@ -169,16 +188,20 @@ 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 both Codex page layouts—as native 240×160 PPM files +for visual review without packaging the application: ```bash CQA_VISUAL_PREVIEW_DIR=/tmp/codequest-previews \ diff --git a/src-tauri/assets/oracle/README.md b/src-tauri/assets/oracle/README.md index 415666c..b808b47 100644 --- a/src-tauri/assets/oracle/README.md +++ b/src-tauri/assets/oracle/README.md @@ -16,6 +16,7 @@ templates selected by `art[].template` in `CODEQUEST.toml`. | `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 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 110e387..34e91f9 100644 --- a/src-tauri/src/engine.rs +++ b/src-tauri/src/engine.rs @@ -10,7 +10,9 @@ use bevy::prelude::*; use crate::codequest::{CodeQuestConfig, GameType, VisualTemplate}; use crate::external_tools; use crate::font5x7::{glyph, GLYPH_ADVANCE, GLYPH_WIDTH, LINE_HEIGHT}; -use crate::learning::{AnswerEvidence, Concept, Lesson, Mastery}; +use crate::learning::{ + AnswerEvidence, Concept, LensRecord, Lesson, Mastery, RATIONALE_COLUMNS, RATIONALE_ROWS, +}; use crate::scene_machine::{ SceneEvent, SceneHandler, SceneMachine, SceneMachineDefinition, SceneSignal, }; @@ -316,6 +318,61 @@ 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; #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum PresentationTier { @@ -528,6 +585,7 @@ enum Screen { Quiz, LevelUp, GameOver, + Codex, QuestSelect, Battle, Victory, @@ -546,6 +604,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, @@ -952,6 +1011,8 @@ struct GameState { screen_ticks: u64, held: HashSet - diff --git a/src/main.js b/src/main.js index 10d4b4f..3acc8d8 100644 --- a/src/main.js +++ b/src/main.js @@ -5,9 +5,17 @@ import { CARTRIDGE_DRAG_THRESHOLD, MAX_CARTRIDGES, cartridgeDragIntent, + isRefusedCartridgeError, normalizeCartridges, + rackFocusIndex, upsertCartridge, } from "./cartridge-library.js"; +import { + GUIDE_MESSAGE_MAX_LENGTH, + deviceMessageText, + leavesKeyToFocusedControl, + trappedFocusTarget, +} from "./device-shell.js"; "use strict"; @@ -21,6 +29,7 @@ import { const BOOT_SKIP_DELAY_MS = 650; const TURN_DURATION_MS = 520; const POWER_REJECTION_MS = 760; + const MESSAGE_DURATION_MS = 4000; const PROVIDER_STORAGE_KEY = "cqa-ai-provider"; const PROVIDERS = Object.freeze({ codex: { label: "CODEX" }, @@ -57,6 +66,10 @@ 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 context = canvas.getContext("2d", { alpha: false }); context.imageSmoothingEnabled = false; const image = context.createImageData(WIDTH, HEIGHT); @@ -68,13 +81,16 @@ import { let trayOpen = false; let batteryTrayOpen = false; let picking = false; + let inserting = false; + let trayReturnFocus = null; let framePending = false; 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 +100,7 @@ import { let powerTransitioning = false; let powerGeneration = 0; let lastPowerFailure = ""; + let providerCheck = null; const held = Object.create(null); const swallowedByBoot = Object.create(null); @@ -148,7 +165,7 @@ import { if (powered && generation === bootGeneration) hideDeviceBoot(); } catch (error) { if (generation === bootGeneration) bootFinishing = false; - showTrayError(error); + showDeviceError(error); } } @@ -230,7 +247,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 +276,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 +299,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 +371,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 +420,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 +443,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 @@ -447,7 +515,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 +541,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 +552,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) { @@ -547,51 +616,68 @@ 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 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 hideDeviceMessage() { + if (messageTimer !== null) window.clearTimeout(messageTimer); + messageTimer = null; + if (messageSurface) messageSurface.textContent = ""; + messageSurface = null; } - function showTrayError(message) { - showTrayMessage(message); + 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 showDeviceError(message, options) { + showDeviceMessage(message, "error", options); } async function insertCartridge(value) { - if (!value || cartridge) return; + if (!value || cartridge || inserting) 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,23 +688,27 @@ 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; } @@ -637,7 +727,7 @@ import { if (trayOpen) buildTray(); } catch (error) { slot.classList.remove("ejecting"); - showTrayError(error); + showDeviceError(error); } }, 240); } @@ -651,15 +741,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 +805,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 +823,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 +881,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 (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,9 +941,18 @@ 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() { @@ -821,10 +962,8 @@ import { 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 +971,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 +1003,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 +1021,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; } @@ -904,6 +1042,11 @@ import { 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 +1090,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 +1131,7 @@ import { if (installedProvider) return; setInstalledProvider(choice.dataset.provider).then((changed) => { if (changed) closeBatteryTray(); + else if (batteryTrayOpen) focusBatteryTray(); }); }); }); @@ -994,6 +1141,7 @@ import { if (!installedProvider) return; setInstalledProvider(null).then((changed) => { if (changed) batteryOptions.querySelector("[data-provider]")?.focus(); + else if (batteryTrayOpen) focusBatteryTray(); }); }); @@ -1012,10 +1160,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(); }); @@ -1047,10 +1197,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 +1220,11 @@ import { cartridges.pop(); } cacheCartridge(cartridge); - } catch (_) { + } catch (error) { cartridge = null; localStorage.removeItem("cqa-cart-id"); - forgetCartridge(savedPath); + if (isRefusedCartridgeError(error)) forgetCartridge(savedPath); + showDeviceError(`CARTRIDGE NOT LOADED · ${deviceMessageText(error)}`); } } renderCartridge(); @@ -1083,7 +1234,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); @@ -1097,7 +1250,7 @@ import { 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/styles.css b/src/styles.css index eedeefc..ac1d20f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2428,17 +2428,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/device-shell-robustness-contract.mjs b/tests/device-shell-robustness-contract.mjs new file mode 100644 index 0000000..1b1c167 --- /dev/null +++ b/tests/device-shell-robustness-contract.mjs @@ -0,0 +1,261 @@ +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 { isRefusedCartridgeError, rackFocusIndex } from "../src/cartridge-library.js"; + +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"); + +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"); +assert.ok( + keydown.indexOf("leavesKeyToFocusedControl(event.code, isShellControl(event.target))") + < keydown.indexOf("const button = keyMap[event.code]") + && keydown.includes("leavesKeyToFocusedControl("), + "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("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(isRefusedCartridgeError("NOT A GIT REPOSITORY - CARTRIDGE REFUSED"), true); +assert.equal(isRefusedCartridgeError(new Error("NOT A GIT REPOSITORY")), true); +for (const transient of [ + "INVALID CODEQUEST.toml: expected `=`", + "DIRECTORY NOT FOUND", + "BEVY ENGINE STOPPED", + "CANNOT READ CODEQUEST.toml: denied", + null, +]) { + assert.equal(isRefusedCartridgeError(transient), false, `${transient} 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.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"\);/, +); + +console.log("Device shell robustness contract OK: focus, repeat, fault surfacing, modal trays, stable rack"); diff --git a/tests/provider-battery-contract.mjs b/tests/provider-battery-contract.mjs index eb79fc3..7eb9d0e 100644 --- a/tests/provider-battery-contract.mjs +++ b/tests/provider-battery-contract.mjs @@ -285,8 +285,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 +295,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", From 4f152e8289a72adf35d7f543045001fe8973d335 Mon Sep 17 00:00:00 2001 From: Jason Skrzypek Date: Fri, 25 Sep 2026 10:43:49 -0400 Subject: [PATCH 07/60] Test the learning loop across the quiz and the Codex One flow now pins the contract between the lesson card and the Codex: a miss is journaled as a pending review with the correct answer and its rationale, returns after the retry gap, is redeemed (clearing the pending review and recording redemption in the lens), and the Codex pages through exactly the journal the quiz wrote without changing it. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/engine.rs | 74 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/src-tauri/src/engine.rs b/src-tauri/src/engine.rs index d760d0d..c6ffca3 100644 --- a/src-tauri/src/engine.rs +++ b/src-tauri/src/engine.rs @@ -8522,4 +8522,78 @@ mod tests { let lines = wrap_text("alpha beta supercalifragilistic", 8); assert!(lines.iter().all(|line| line.chars().count() <= 8)); } + + /// The whole learning loop across the quiz and the Codex: a miss is + /// journaled as a pending review, returns after the retry gap, is redeemed, + /// and the Codex rereads the same journal and mastery the quiz wrote. + #[test] + fn a_missed_concept_is_journaled_retried_redeemed_and_reread_in_the_codex() { + let mut engine = batch_quiz_engine(QUESTION_BATCH_SIZE); + let missed = current_question(&engine); + commit(&mut engine, false); + { + let cartridge = engine_state(&engine).cartridge.as_ref().unwrap(); + let lesson = cartridge + .lessons + .iter() + .find(|lesson| lesson.question == missed.question) + .expect("committing a miss journals the lesson"); + assert!(lesson.outstanding, "a miss is pending review"); + assert_eq!(lesson.answer, missed.choices[missed.answer]); + assert_eq!(lesson.rationale, missed.rationales[missed.answer]); + assert_eq!(cartridge.mastery[&Concept::Responsibility].missed, 1); + } + finish_lesson(&mut engine); + + for _ in 0..RETRY_GAP { + assert!(!current_question(&engine).review); + commit(&mut engine, true); + finish_lesson(&mut engine); + } + let retry = current_question(&engine); + assert!(retry.review, "the miss returns after the retry gap"); + assert_eq!(retry.question, missed.question); + commit(&mut engine, true); + { + let state = engine_state(&engine); + assert!(state.quiz.as_ref().unwrap().redeemed); + let cartridge = state.cartridge.as_ref().unwrap(); + let lesson = cartridge + .lessons + .iter() + .find(|lesson| lesson.question == missed.question) + .unwrap(); + assert!(!lesson.outstanding, "redemption clears the pending review"); + let record = cartridge.mastery[&Concept::Responsibility]; + assert_eq!((record.missed, record.redeemed), (1, 1)); + } + finish_lesson(&mut engine); + + press(&mut engine, Button::B); + press(&mut engine, Button::B); + assert_eq!(engine.screen(), Screen::QuizMenu); + let journal = engine_state(&engine) + .cartridge + .as_ref() + .unwrap() + .lessons + .clone(); + if engine_state(&engine).menu_selected == 0 { + press(&mut engine, Button::Down); + } + press(&mut engine, Button::A); + assert_eq!(engine.screen(), Screen::Codex); + for _ in 0..journal.len() { + press(&mut engine, Button::Right); + let (_, lesson) = engine_state(&engine) + .codex_lesson() + .expect("every journal page shows a lesson"); + assert!(journal.contains(lesson)); + } + assert_eq!( + engine_state(&engine).cartridge.as_ref().unwrap().lessons, + journal, + "reading the Codex never changes the journal" + ); + } } From 8b9f00f96c0e2a2737525acb4d875e545dd2119f Mon Sep 17 00:00:00 2001 From: Jason Skrzypek Date: Fri, 25 Sep 2026 10:47:00 -0400 Subject: [PATCH 08/60] Give the handheld an engine-owned chip sound system Every entry in the Oracle sound ledger was still "Proposed" and the game was silent. Sound now follows the same rule as rendering: it is a pure function of observable state. A new audio module describes a constrained four-voice chip (two duty-selectable pulses, a triangle-like wave, and noise). Each tick the engine builds an AudioSnapshot of what the player can observe and the AudioDirector diffs it against the previous tick to start cues, scene-owned loops, and scripted opening beats. Gameplay code never calls audio, and new or unknown scenes are silent until the ledger names them. Notes carry the absolute engine tick they start on. Voices are monophonic and a zero-volume note is a cut, so a scene's loop ends on the exact tick the scene exits and a new cue cancels the previous cue instead of stacking. At most one cue starts per tick, chosen by priority, so one input edge can never produce two cues. Loops stay at or below chip volume 5 while every cue peaks above it. Nothing sounds while the device is off or booting. The compositions are original and cover the whole ledger: dry archival ticks on the chronicle, an opening whose motif grows from one source pulse to the four-voice Oracle cadence and tails before the title, restrained title and menu loops, per-row trait timbres and an Oracle-motif bind cadence, a Datafall ambience with two, three, and four voices by presentation tier, data, bug, charge-rune, seal-break, retry, ready, and leave cues, one commit-and-result cue per answer (rune, flow-stage, and ward variants), a last-ward heartbeat, a batch telegraph and tier-grown reward cadence inside the 60-tick hold, a defeat fall and score hold, and an Initiate replay cadence. The director runs as a Bevy system after advance_game. EngineRuntime copies its notes into a bounded 512-note queue each frame, and the new engine_audio Tauri command drains it as { tick, notes }. The shell's new speaker.js module maps ticks onto the WebAudio clock (60 ms lookahead, re-anchoring beyond 150 ms of drift), plays pulse duty through periodic waves and noise through a 15-bit LFSR buffer, and starts its audio context only on a user gesture. The device gains a knurled volume wheel on the power-switch edge of both faces, a three-LED detent meter, V to cycle MUTE, LOW, MID, and HIGH, persistence in localStorage, and a VOLUME row on the rear label. Rust tests cover silence while off or booting, one cue per input edge through scripted engine input, loop cuts on the exit tick, loops yielding to cues, tier-grown arrangements, the opening arc, cue priority and non-stacking, determinism, the queue bound, and the engine_audio payload shape. tests/device-audio-contract.mjs checks the speaker against a recording WebAudio stand-in and keeps it presentation-only. The design brief and README now describe the implemented runtime. Co-Authored-By: Claude Opus 5.5 --- README.md | 14 +- docs/game-design/oracle-quiz.md | 71 +- package.json | 2 +- src-tauri/src/audio.rs | 2073 +++++++++++++++++++++++++++++++ src-tauri/src/engine.rs | 456 ++++++- src-tauri/src/lib.rs | 9 +- src/index.html | 9 + src/main.js | 79 +- src/speaker.js | 289 +++++ src/styles.css | 88 +- tests/device-audio-contract.mjs | 277 +++++ 11 files changed, 3331 insertions(+), 36 deletions(-) create mode 100644 src-tauri/src/audio.rs create mode 100644 src/speaker.js create mode 100644 tests/device-audio-contract.mjs diff --git a/README.md b/README.md index 0967939..1f10f7a 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,17 @@ 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. + +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. ## Install @@ -200,6 +209,7 @@ the running CODE QUEST ADVANCE build. | 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 diff --git a/docs/game-design/oracle-quiz.md b/docs/game-design/oracle-quiz.md index c07abc0..88098d5 100644 --- a/docs/game-design/oracle-quiz.md +++ b/docs/game-design/oracle-quiz.md @@ -101,8 +101,8 @@ difficulty: 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 @@ -320,38 +320,51 @@ and the selected provider retries. | `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. | +| `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, and the engine +now owns their runtime. `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). Unknown scenes are silent until the ledger names them. +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`, `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. | 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; presses that change nothing 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, wrong, low-ward, flow-stage, 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 (rune awaken over flow-stage over correct; ward break over low-ward over wrong); a heartbeat bed runs on the last ward; the review lock is silent; 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. | +| `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, 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. | 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; audio implemented/tested. | +| `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. | Cursor, one commit-and-result cue, last-ward heartbeat, question reveal. | 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 implemented/tested. | +| `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. | Batch telegraph, tier-grown reward cadence, tier continuation cue. | 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 implemented/tested. | +| `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 fall, score-hold chord, Initiate replay cadence. | 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 implemented/tested. | ## Runtime traceability @@ -359,7 +372,7 @@ 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 audio art entries remain metadata (sound is engine-owned per trusted handler); 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. | | 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. | @@ -373,8 +386,8 @@ engine does not load or play them yet. | 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. | +| 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 @@ -399,8 +412,10 @@ engine does not load or play them yet. templates cover all thirteen 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. +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. **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. diff --git a/package.json b/package.json index 856bca7..386d86f 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/packaging-contract.mjs && node tests/device-audio-contract.mjs", "tauri": "tauri" }, "devDependencies": { diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs new file mode 100644 index 0000000..e3f7d49 --- /dev/null +++ b/src-tauri/src/audio.rs @@ -0,0 +1,2073 @@ +//! 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, + /// 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, +} + +/// 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, + 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), + Confirm, + Cancel, + Unavailable, + Trait { row: usize, value: usize }, + BeginRun, + OracleEnter, + DataCollect(u32), + ChargeRune(usize), + BugHit, + SealBreak(usize), + Retry, + Ready, + Leave, + QuestionReveal, + Cursor(usize), + Correct, + FlowUp(u32), + RuneAwaken(usize), + 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::Cursor(_) | Self::Trait { .. } | Self::QuestionReveal => 2, + Self::DataCollect(_) => 3, + Self::BugHit => 4, + Self::Retry => 5, + Self::Ready | Self::ChargeRune(_) => 6, + Self::SealBreak(_) => 7, + Self::Correct | Self::Wrong => 8, + Self::FlowUp(_) | Self::LowWard => 9, + Self::RuneAwaken(_) | Self::WardBreak => 10, + 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)] + } + 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) + } + 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 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::QuestSelect, Scene::Battle) + | (Scene::Victory | Scene::Defeat, _) => Some(Cue::Confirm), + (Scene::QuizMenu | Scene::QuestSelect, Scene::Title) + | (Scene::CharacterCreation | Scene::Quiz, 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::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: the rarest threshold it crossed wins. + let result = match run.phase { + AnswerPhase::Correct if run.insight > previous.insight => Cue::RuneAwaken(run.insight), + AnswerPhase::Correct if run.multiplier > previous.multiplier => { + Cue::FlowUp(run.multiplier) + } + AnswerPhase::Correct => 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)); + } +} + +/// 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 => 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::Wrong, + Cue::LowWard, + Cue::WardBreak, + Cue::BatchComplete, + Cue::Defeat, + Cue::Replay, + Cue::Victory, + ]; + for index in 0..6 { + cues.extend([ + Cue::Navigate(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::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 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); + } + } +} diff --git a/src-tauri/src/engine.rs b/src-tauri/src/engine.rs index 110e387..8d02b9a 100644 --- a/src-tauri/src/engine.rs +++ b/src-tauri/src/engine.rs @@ -7,6 +7,10 @@ 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}; @@ -1231,7 +1235,11 @@ impl GameEngine { .init_resource::() .init_resource::() .init_resource::() - .add_systems(Update, (apply_commands, advance_game, render).chain()); + .init_resource::() + .add_systems( + Update, + (apply_commands, advance_game, direct_audio, render).chain(), + ); let mut engine = Self { app }; engine.update(); engine @@ -1262,6 +1270,11 @@ impl GameEngine { .collect() } + /// Notes the audio director emitted since the last call, plus the tick. + fn take_audio(&mut self) -> AudioBatch { + self.app.world_mut().resource_mut::().drain() + } + #[cfg(test)] fn screen(&self) -> Screen { self.app.world().resource::().screen @@ -1278,6 +1291,7 @@ impl Default for GameEngine { pub struct EngineRuntime { sender: mpsc::Sender, frame: Arc>>, + audio: Arc>, } impl EngineRuntime { @@ -1288,6 +1302,8 @@ impl EngineRuntime { let (sender, receiver) = mpsc::channel(); let frame = Arc::new(RwLock::new(vec![0; FRAME_BYTES])); let shared_frame = Arc::clone(&frame); + let audio = Arc::new(Mutex::new(AudioQueue::default())); + let shared_audio = Arc::clone(&audio); let engine_sender = sender.clone(); let child = Arc::new(Mutex::new(None)); let running_child = Arc::clone(&child); @@ -1314,6 +1330,9 @@ impl EngineRuntime { if let Ok(mut target) = shared_frame.write() { target.copy_from_slice(engine.frame()); } + if let Ok(mut queue) = shared_audio.lock() { + queue.append(engine.take_audio()); + } next_frame += FRAME_TIME; if let Some(remaining) = next_frame.checked_duration_since(Instant::now()) { thread::sleep(remaining); @@ -1324,7 +1343,11 @@ impl EngineRuntime { }) .expect("failed to start Bevy engine thread"); - Self { sender, frame } + Self { + sender, + frame, + audio, + } } pub fn set_power(&self, powered: bool) -> Result<(), String> { @@ -1354,6 +1377,14 @@ impl EngineRuntime { .unwrap_or_else(|_| vec![0; FRAME_BYTES]) } + /// Takes every note the engine has emitted since the last drain. + pub fn drain_audio(&self) -> AudioBatch { + self.audio + .lock() + .map(|mut queue| queue.drain()) + .unwrap_or_default() + } + fn send(&self, command: EngineCommand) -> Result<(), String> { self.sender .send(command) @@ -1361,6 +1392,99 @@ impl EngineRuntime { } } +/// Samples the observable game state the audio director listens to. Sound is +/// derived from these snapshots only; gameplay code never calls audio. +fn audio_snapshot(state: &GameState) -> AudioSnapshot { + let scene = match state.screen { + Screen::Off => AudioScene::Off, + Screen::Boot => AudioScene::Boot, + Screen::Copyright => AudioScene::Copyright, + Screen::OpeningFanfare => AudioScene::Opening(match state.opening_beat() { + OpeningBeat::Legacy => audio::OpeningBeat::Legacy, + OpeningBeat::SourceEmber => audio::OpeningBeat::SourceEmber, + OpeningBeat::ArchiveAnswer => audio::OpeningBeat::ArchiveAnswer, + OpeningBeat::MemoryVault => audio::OpeningBeat::MemoryVault, + OpeningBeat::Convergence => audio::OpeningBeat::Convergence, + OpeningBeat::OracleAwakening => audio::OpeningBeat::OracleAwakening, + }), + Screen::Title => AudioScene::Title, + Screen::QuizMenu => AudioScene::QuizMenu, + Screen::CharacterCreation => AudioScene::CharacterCreation, + Screen::Oracle => AudioScene::Oracle, + Screen::Quiz => AudioScene::Quiz, + Screen::LevelUp => AudioScene::LevelUp, + Screen::GameOver => AudioScene::GameOver, + Screen::QuestSelect => AudioScene::QuestSelect, + Screen::Battle => AudioScene::Battle, + Screen::Victory => AudioScene::Victory, + Screen::Defeat => AudioScene::Defeat, + }; + let held = state.held.iter().fold(0, |bits, button| { + bits | match button { + Button::Up => pad::UP, + Button::Down => pad::DOWN, + Button::Left => pad::LEFT, + Button::Right => pad::RIGHT, + Button::A => pad::A, + Button::B => pad::B, + Button::Start => pad::START, + Button::Select => pad::SELECT, + Button::L => pad::L, + Button::R => pad::R, + } + }); + // Mirrors the Oracle's truthful status line. + let questions = if state.has_unanswered_question() { + QuestionStatus::Ready + } else if state.questions_loading { + QuestionStatus::Writing + } else if state.question_retry_ticks > 0 { + QuestionStatus::Retrying + } else { + QuestionStatus::Contacting + }; + AudioSnapshot { + powered: state.powered, + scene, + scene_ticks: state.screen_ticks, + held, + menu_selected: state.menu_selected, + hero_row: state.hero_row, + hero_name: state.hero_name, + hero_class: state.hero_class, + hero_style: state.hero_style, + quest_selected: state.quest_selected, + run: state.quiz.as_ref().map(|run| RunAudio { + question: run.question, + selected: run.selected, + phase: match run.feedback { + None => AnswerPhase::Choosing, + Some((true, _)) => AnswerPhase::Correct, + Some((false, _)) => AnswerPhase::Wrong, + }, + hearts: run.hearts, + multiplier: streak_multiplier(run.streak), + insight: InsightStage::from_score(run.score).index(), + level: run.level, + completed_batches: run.completed_batches, + }), + data: state.oracle_data, + data_stage: threshold_stage(state.oracle_data, &DATA_CHARGE_THRESHOLDS), + bugs: state.oracle_bug_hits, + breach_stage: threshold_stage(state.oracle_bug_hits, &BUG_BREACH_THRESHOLDS), + questions, + tier: match state.presentation_tier() { + PresentationTier::Initiate => Tier::Initiate, + PresentationTier::Adept => Tier::Adept, + PresentationTier::OracleBound => Tier::OracleBound, + }, + } +} + +fn direct_audio(state: Res, mut audio: ResMut) { + audio.observe(&audio_snapshot(&state)); +} + fn apply_commands( mut inbox: ResMut, mut state: ResMut, @@ -6170,6 +6294,334 @@ mod tests { assert_ne!(first.frame(), second.frame()); } + fn last_cue(engine: &GameEngine) -> Option { + engine.app.world().resource::().last_cue() + } + + /// Presses and releases `button`, returning the cue each edge started. + fn tap_cues(engine: &mut GameEngine, button: Button) -> [Option; 2] { + [true, false].map(|pressed| { + issue(engine, EngineCommand::Input { button, pressed }); + last_cue(engine) + }) + } + + #[test] + fn every_input_edge_starts_at_most_one_distinct_cue() { + use audio::Cue; + let mut engine = GameEngine::new(); + issue( + &mut engine, + EngineCommand::Cartridge(Some(quiz_cartridge())), + ); + issue(&mut engine, EngineCommand::Power(true)); + finish_opening(&mut engine); + + let script = [ + (Button::Start, Some(Cue::Confirm)), + (Button::Down, Some(Cue::Navigate(1))), + (Button::Up, Some(Cue::Navigate(0))), + (Button::Left, Some(Cue::Unavailable)), + (Button::A, Some(Cue::Confirm)), + (Button::Right, Some(Cue::Trait { row: 0, value: 1 })), + (Button::Down, Some(Cue::Navigate(1))), + (Button::A, Some(Cue::Trait { row: 1, value: 1 })), + (Button::Down, Some(Cue::Navigate(2))), + (Button::Left, Some(Cue::Trait { row: 2, value: 4 })), + (Button::Down, Some(Cue::Navigate(3))), + (Button::Right, Some(Cue::Unavailable)), + (Button::Start, Some(Cue::BeginRun)), + (Button::A, Some(Cue::Unavailable)), + (Button::Left, None), + ]; + for (button, expected) in script { + let [press, release] = tap_cues(&mut engine, button); + assert_eq!(press, expected, "{button:?} on {:?}", engine.screen()); + assert_eq!(release, None, "releasing {button:?} must stay silent"); + } + assert_eq!(engine.screen(), Screen::Oracle); + + let mut arrival = Vec::new(); + for _ in 0..75 { + engine.update(); + arrival.extend(last_cue(&engine)); + } + assert_eq!(engine.screen(), Screen::Quiz); + // The first falling shard lands on the hero before the vision arrives. + assert_eq!(arrival, [Cue::DataCollect(1), Cue::QuestionReveal]); + + for (button, expected) in [ + (Button::Down, Some(Cue::Cursor(1))), + (Button::Up, Some(Cue::Cursor(0))), + (Button::Right, Some(Cue::Unavailable)), + (Button::A, Some(Cue::Correct)), + // The answer review ignores input, and so does the speaker. + (Button::Down, None), + (Button::B, None), + ] { + let [press, release] = tap_cues(&mut engine, button); + assert_eq!(press, expected, "{button:?} in the quiz"); + assert_eq!(release, None); + } + } + + #[test] + fn engine_audio_is_silent_while_off_or_booting() { + let mut engine = GameEngine::new(); + issue( + &mut engine, + EngineCommand::Cartridge(Some(quiz_cartridge())), + ); + for _ in 0..60 { + engine.update(); + } + issue(&mut engine, EngineCommand::Power(true)); + issue( + &mut engine, + EngineCommand::Input { + button: Button::Start, + pressed: true, + }, + ); + for _ in 0..180 { + engine.update(); + } + assert_eq!(engine.screen(), Screen::Boot); + let silent = engine.take_audio(); + assert!(silent.notes.is_empty(), "{:?}", silent.notes); + assert!(silent.tick > 240, "the engine tick advances while silent"); + + issue(&mut engine, EngineCommand::BootComplete); + for _ in 0..60 { + engine.update(); + } + let chronicle = engine.take_audio(); + assert!(chronicle.notes.iter().any(|note| !note.is_cut())); + assert!(chronicle + .notes + .iter() + .all(|note| note.tick > silent.tick && note.tick <= chronicle.tick)); + + // Power loss may only cut voices, never start one. + issue(&mut engine, EngineCommand::Power(false)); + for _ in 0..120 { + engine.update(); + } + assert!(engine.take_audio().notes.iter().all(|note| note.is_cut())); + } + + #[test] + fn datafall_loop_is_cut_on_the_tick_b_leaves_the_oracle() { + let mut engine = waiting_oracle_engine(); + let _ = engine.take_audio(); + for _ in 0..157 { + engine.update(); + } + let before = engine.take_audio(); + assert!(before.notes.iter().any(|note| !note.is_cut())); + + issue( + &mut engine, + EngineCommand::Input { + button: Button::B, + pressed: true, + }, + ); + assert_eq!(engine.screen(), Screen::QuizMenu); + assert_eq!(last_cue(&engine), Some(audio::Cue::Leave)); + let exit = engine.take_audio(); + let tails = before + .notes + .iter() + .filter(|note| !note.is_cut() && note.tick + u64::from(note.duration_ticks) > exit.tick) + .collect::>(); + assert!( + !tails.is_empty(), + "the exit should interrupt the Datafall loop" + ); + for tail in tails { + assert!( + exit.notes + .iter() + .any(|note| note.voice == tail.voice && note.tick == exit.tick), + "{:?} carried the Datafall loop into the menu", + tail.voice + ); + } + } + + #[test] + fn oracle_retry_and_ready_states_are_audible_once() { + let mut engine = waiting_oracle_engine(); + issue( + &mut engine, + EngineCommand::Questions { + cartridge_id: "/tmp/engine-test".into(), + questions: Vec::new(), + }, + ); + assert_eq!(last_cue(&engine), Some(audio::Cue::Retry)); + engine.update(); + assert_eq!(last_cue(&engine), None); + + issue( + &mut engine, + EngineCommand::Questions { + cartridge_id: "/tmp/engine-test".into(), + questions: quiz_cartridge().questions, + }, + ); + assert_eq!(last_cue(&engine), Some(audio::Cue::Ready)); + } + + #[test] + fn audio_snapshot_mirrors_the_observable_run_state() { + let mut state = GameState { + powered: true, + screen: Screen::Oracle, + screen_ticks: 12, + questions_loading: true, + oracle_data: 6, + oracle_bug_hits: 1, + ..GameState::default() + }; + state.held.insert(Button::Left); + state.held.insert(Button::A); + state.quiz = Some(QuizRun { + question: 7, + completed_batches: 1, + selected: 2, + hearts: 1, + score: 900, + level: 4, + streak: 3, + leveled_up: false, + feedback: Some((false, 10)), + }); + let snapshot = audio_snapshot(&state); + assert_eq!(snapshot.scene, AudioScene::Oracle); + assert_eq!(snapshot.held, pad::LEFT | pad::A); + assert_eq!((snapshot.data_stage, snapshot.breach_stage), (2, 1)); + assert_eq!(snapshot.questions, QuestionStatus::Writing); + assert_eq!(snapshot.tier, Tier::OracleBound); + assert_eq!( + snapshot.run, + Some(RunAudio { + question: 7, + selected: 2, + phase: AnswerPhase::Wrong, + hearts: 1, + multiplier: 2, + insight: 2, + level: 4, + completed_batches: 1, + }) + ); + + state.powered = false; + state.screen = Screen::Off; + state.quiz = None; + let off = audio_snapshot(&state); + assert_eq!( + (off.powered, off.scene, off.tier), + (false, AudioScene::Off, Tier::Initiate) + ); + } + + #[test] + fn oracle_opening_soundtrack_grows_from_archival_ticks_to_the_full_cadence() { + let mut engine = GameEngine::new(); + issue( + &mut engine, + EngineCommand::Cartridge(Some(oracle_template_cartridge())), + ); + issue(&mut engine, EngineCommand::Power(true)); + issue(&mut engine, EngineCommand::BootComplete); + assert_eq!(engine.screen(), Screen::Copyright); + let _ = engine.take_audio(); + + // Copyright, then the five story scenes, then a breath of title. + let mut sections = Vec::new(); + for (expected, ticks) in [ + (Screen::Copyright, 180), + (Screen::OpeningFanfare, 96), + (Screen::OpeningFanfare, 66), + (Screen::OpeningFanfare, 66), + (Screen::OpeningFanfare, 66), + (Screen::OpeningFanfare, 66), + ] { + assert_eq!(engine.screen(), expected); + for _ in 0..ticks { + engine.update(); + } + sections.push(engine.take_audio().notes); + } + assert_eq!(engine.screen(), Screen::Title); + for _ in 0..120 { + engine.update(); + } + sections.push(engine.take_audio().notes); + + let voices = |notes: &[audio::Note]| { + notes + .iter() + .filter(|note| !note.is_cut()) + .map(|note| note.voice) + .collect::>() + .len() + }; + let peak = |notes: &[audio::Note]| notes.iter().map(|note| note.volume).max().unwrap_or(0); + assert_eq!(voices(§ions[0]), 1, "the chronicle keeps near silence"); + assert!(peak(§ions[0]) <= 4); + let story = sections[1..6] + .iter() + .map(|notes| voices(notes)) + .collect::>(); + assert_eq!(story[0], 1, "the source ember is a single pulse"); + assert!(story.windows(2).all(|pair| pair[0] <= pair[1]), "{story:?}"); + assert_eq!(story[4], 4, "the Oracle crescendo is the full arrangement"); + assert!(sections[1..5] + .iter() + .all(|notes| peak(notes) < peak(§ions[5]))); + assert!( + sections[6] + .iter() + .all(|note| note.volume <= audio::AMBIENCE_MAX_VOLUME), + "the title settles into its restrained loop" + ); + + if let Ok(directory) = std::env::var("CQA_AUDIO_DUMP_DIR") { + let notes = sections.concat(); + std::fs::create_dir_all(&directory).expect("dump directory should be writable"); + std::fs::write( + std::path::Path::new(&directory).join("oracle-opening.json"), + serde_json::to_string(¬es).expect("notes should serialize"), + ) + .expect("dump should be writable"); + } + } + + #[test] + fn scripted_play_produces_identical_note_streams() { + let play = || { + let mut engine = playing_quiz_engine(); + issue( + &mut engine, + EngineCommand::Input { + button: Button::A, + pressed: true, + }, + ); + for _ in 0..60 { + engine.update(); + } + engine.take_audio() + }; + let first = play(); + assert!(first.notes.iter().filter(|note| !note.is_cut()).count() > 10); + assert_eq!(first, play()); + } + #[test] fn boot_waits_for_device_firmware() { let mut engine = GameEngine::new(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 55848bb..7643c39 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ +mod audio; mod codequest; mod engine; mod external_tools; @@ -1005,6 +1006,11 @@ fn engine_frame(state: State) -> tauri::ipc::Response { tauri::ipc::Response::new(state.0.frame()) } +#[tauri::command] +fn engine_audio(state: State) -> audio::AudioBatch { + state.0.drain_audio() +} + #[tauri::command] fn app_revision() -> &'static str { env!("CQA_APP_REVISION") @@ -1069,7 +1075,8 @@ pub fn run() { engine_power, engine_finish_boot, engine_input, - engine_frame + engine_frame, + engine_audio ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/index.html b/src/index.html index 934303c..ac2c6b1 100644 --- a/src/index.html +++ b/src/index.html @@ -71,6 +71,13 @@
START
+
+
@@ -115,6 +122,7 @@ D-PADARROW KEYSAD BSSTARTENTER SELECTSHIFTL / RA / F + VOLUMEV @@ -178,6 +186,7 @@
+
diff --git a/src/main.js b/src/main.js index 10d4b4f..93b0b70 100644 --- a/src/main.js +++ b/src/main.js @@ -1,6 +1,7 @@ /* 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, @@ -8,6 +9,13 @@ import { normalizeCartridges, upsertCartridge, } from "./cartridge-library.js"; +import { + VOLUME_LABELS, + VOLUME_LEVELS, + createSpeaker, + nextVolume, + stepVolume, +} from "./speaker.js"; "use strict"; @@ -57,6 +65,9 @@ import { const rearPowerSwitch = $("rear-power-switch"); const powerSwitches = [powerSwitch, rearPowerSwitch]; const powerLed = document.querySelector(".power-led"); + 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); @@ -69,6 +80,7 @@ import { let batteryTrayOpen = false; let picking = false; let framePending = false; + let audioPending = false; let bootTimer = null; let bootStartedAt = 0; let bootFinishing = false; @@ -103,7 +115,37 @@ 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; + } + } + + 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(); if (!framePending) { framePending = true; try { @@ -900,6 +942,10 @@ 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; @@ -1003,6 +1049,35 @@ 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) { + wheel.addEventListener("pointerdown", (event) => { + event.preventDefault(); + event.stopPropagation(); + setVolume(nextVolume(speaker.volume)); + }); + wheel.addEventListener("wheel", (event) => { + event.preventDefault(); + if (event.deltaY !== 0) setVolume(stepVolume(speaker.volume, -event.deltaY)); + }, { 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(); @@ -1037,6 +1112,7 @@ import { async function initialize() { fit(); + renderVolume(speaker.volume); setShellBackVisible(false); rearSerial.textContent = await invoke("app_revision"); installedProvider = normalizeProvider(localStorage.getItem(PROVIDER_STORAGE_KEY)); @@ -1092,6 +1168,7 @@ import { } return async (command, args) => { if (command === "engine_frame") return frame.buffer; + if (command === "engine_audio") return { tick: 0, notes: [] }; if (command === "app_revision") return "0000000"; if (["engine_power", "engine_finish_boot", "engine_input", "engine_set_ai_provider"].includes(command)) return null; if (command === "verify_ai_provider") return { provider: args?.provider, ready: true }; diff --git a/src/speaker.js b/src/speaker.js new file mode 100644 index 0000000..83bb1ea --- /dev/null +++ b/src/speaker.js @@ -0,0 +1,289 @@ +/* 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; +/** Notes are scheduled this far ahead so polling jitter never clips them. */ +export const SCHEDULE_LEAD_SECONDS = 0.06; +/** The tick clock re-anchors when it drifts further than this from real time. */ +export const MAX_DRIFT_SECONDS = 0.15; +/** Notes later than this are dropped instead of played out of time. */ +const MAX_LATENESS_SECONDS = 0.1; +const RELEASE_SECONDS = 0.004; + +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 + * slightly in the future; large drift (a stalled tab, a slow engine) anchors + * it again instead of scheduling a burst of stale notes. */ +export function createTickClock({ + lead = SCHEDULE_LEAD_SECONDS, + maxDrift = MAX_DRIFT_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) { + const target = now + lead; + if (anchorTick !== null && Math.abs(timeFor(engineTick) - target) <= maxDrift) return false; + anchorTick = engineTick; + anchorTime = target; + return true; + }, + timeFor, + reset() { + anchorTick = null; + }, + }; +} + +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(); + 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; + } + + function stopVoice(voice, at) { + const playing = sounding.get(voice); + if (!playing) return; + sounding.delete(voice); + if (playing.end <= at) return; + 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. */ + } + } + + function createSource(voice, note, start, end) { + const slide = Number(note.slide) || 0; + if (voice === "noise") { + const source = context.createBufferSource(); + source.buffer = noise(); + source.loop = true; + const rate = (pitch) => Math.min(2, Math.max(0.0625, 2 ** ((pitch - 84) / 12))); + source.playbackRate.setValueAtTime(rate(note.pitch), start); + if (slide) source.playbackRate.exponentialRampToValueAtTime(rate(note.pitch + slide), end); + return source; + } + 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; + } + + 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); + // A silent note is a cut: the voice simply stops. + if (volumeStep === 0 || ticks === 0) return; + + 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 = createSource(voice, note, start, end); + source.connect(envelope); + envelope.connect(gate); + gate.connect(master); + source.start(start); + source.stop(end + RELEASE_SECONDS); + source.onended = () => { + if (sounding.get(voice)?.source === source) sounding.delete(voice); + gate.disconnect(); + }; + sounding.set(voice, { source, gate, end }); + } + + 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; + } + const now = context.currentTime; + clock.sync(Number(batch.tick) || 0, now); + let scheduled = 0; + 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) continue; + scheduleNote(note, Math.max(due, now)); + if (note.volume > 0 && note.durationTicks > 0) 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..8b1b374 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; } diff --git a/tests/device-audio-contract.mjs b/tests/device-audio-contract.mjs new file mode 100644 index 0000000..2766fe1 --- /dev/null +++ b/tests/device-audio-contract.mjs @@ -0,0 +1,277 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +import { + DEFAULT_VOLUME, + MAX_DRIFT_SECONDS, + SCHEDULE_LEAD_SECONDS, + VOLUME_LEVELS, + VOLUME_STORAGE_KEY, + createSpeaker, + createTickClock, + midiToHz, + nextVolume, + normalizeVolume, + pulseHarmonics, + stepVolume, +} from "../src/speaker.js"; + +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 }; + } + 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(MAX_DRIFT_SECONDS, 0.15, "Re-anchoring waits for 150 ms of drift"); +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); + +/* ---------- 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"); + +/* ---------- 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"); + +// 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"); + +/* ---------- Mute and persistence ---------- */ + +assert.equal(speaker.setVolume("mute"), "mute"); +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); + +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", +); +assert.match(adapter, /event\.code === "KeyV"[\s\S]*?nextVolume\(speaker\.volume\)/, "V cycles the volume wheel"); +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(css, /prefers-reduced-motion:[\s\S]*?\.volume-knurl/, "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"); From 4ca8249d193e618e9bd8a86de3b70529c2f063ee Mon Sep 17 00:00:00 2001 From: Jason Skrzypek Date: Fri, 25 Sep 2026 10:48:31 -0400 Subject: [PATCH 09/60] Honor reduced motion inside the framebuffer The shell's CSS already respected prefers-reduced-motion, but the engine kept blinking prompts, bobbing heroes, pulsing sigils, twinkling constellations, and scrolling starfields, which the brief left as the one unfinished accessibility item. The shell now forwards the host preference (at startup and on change) through a new engine_set_reduced_motion command. Decorative motion reads a motion clock that freezes at the resting composition, settling motion jumps to its end state, and blinking prompts stay lit. Scene timing, input edges, Datafall gameplay, and data are unchanged: a test drives both modes and asserts frozen title and atelier frames plus an identical 720-tick scene timeline. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/engine.rs | 149 ++++++++++++++++++++++++---- src-tauri/src/lib.rs | 6 ++ src/main.js | 14 ++- tests/device-animation-contract.mjs | 15 ++- 4 files changed, 161 insertions(+), 23 deletions(-) diff --git a/src-tauri/src/engine.rs b/src-tauri/src/engine.rs index c6ffca3..904e92b 100644 --- a/src-tauri/src/engine.rs +++ b/src-tauri/src/engine.rs @@ -638,6 +638,7 @@ impl From for Screen { #[derive(Clone, Debug)] enum EngineCommand { Power(bool), + ReducedMotion(bool), AiProvider(Option), BootComplete, Cartridge(Option), @@ -1161,6 +1162,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, @@ -1193,6 +1196,7 @@ impl Default for GameState { fn default() -> Self { Self { powered: false, + reduced_motion: false, ai_provider: None, cartridge: None, machine: None, @@ -1227,6 +1231,32 @@ impl GameState { self.ai_provider.as_deref().unwrap_or("AI") } + /// Clock for decorative motion (bobbing, pulsing, twinkling, scrolling). + /// Reduced motion freezes it at the resting composition, while scene timing, + /// input, and data keep using `screen_ticks`. + fn motion_ticks(&self) -> u64 { + if self.reduced_motion { + 0 + } else { + self.screen_ticks + } + } + + /// Progress of a settling motion capped at `cap` ticks; reduced motion shows + /// its settled end state immediately. + fn settled_ticks(&self, cap: u64) -> u64 { + if self.reduced_motion { + cap + } else { + self.screen_ticks.min(cap) + } + } + + /// Whether a blinking prompt is lit this tick; reduced motion keeps it lit. + fn blink_lit(&self, period: u64) -> bool { + self.reduced_motion || (self.screen_ticks / period).is_multiple_of(2) + } + fn ai_provider_status(&self, status: &str) -> String { format!("{}:{status}", self.ai_provider_name()) } @@ -1700,6 +1730,10 @@ impl EngineRuntime { Self { sender, frame } } + pub fn set_reduced_motion(&self, reduced: bool) -> Result<(), String> { + self.send(EngineCommand::ReducedMotion(reduced)) + } + pub fn set_power(&self, powered: bool) -> Result<(), String> { self.send(EngineCommand::Power(powered)) } @@ -1749,6 +1783,7 @@ fn apply_commands( effects.0.push_back(EngineEffect::AbortQuest); state.transition(if powered { Screen::Boot } else { Screen::Off }); } + EngineCommand::ReducedMotion(reduced) => state.reduced_motion = reduced, EngineCommand::AiProvider(provider) => { state.ai_provider = provider.map(|name| name.to_ascii_uppercase()); } @@ -2156,7 +2191,7 @@ fn render_boot(frame: &mut Framebuffer, state: &GameState) { frame.centered_text_box(GATEWAY_TITLE_TOP_BOX, "CODE QUEST", PARCH, 2); frame.centered_text_box(GATEWAY_TITLE_BOTTOM_BOX, "ADVANCE", AMBER, 1); frame.centered_text_box(GATEWAY_SIGNATURE_BOX, "REPOSITORY ORACLE", CYAN_DIM, 1); - if !state.has_game() && state.screen_ticks > 50 && (state.screen_ticks / 30).is_multiple_of(2) { + if !state.has_game() && state.screen_ticks > 50 && state.blink_lit(30) { frame.centered_text_box(GATEWAY_PROMPT_BOX, "INSERT CARTRIDGE", PARCH, 1); } } @@ -2244,7 +2279,7 @@ fn render_oracle_chronicle(frame: &mut Framebuffer, state: &GameState) { frame.centered_text_in(42, 126, 156, &truncate(&history, 24), MIST, 1); } } - if state.can_signal(SceneSignal::Continue) && (state.screen_ticks / 20).is_multiple_of(2) { + if state.can_signal(SceneSignal::Continue) && state.blink_lit(20) { frame.rect(74, 141, 92, 16, VOID); frame.outline(74, 141, 92, 16, CYAN_DIM); frame.centered_text_in(74, 145, 92, "A / START:SKIP", MIST, 1); @@ -2282,7 +2317,7 @@ fn render_oracle_title(frame: &mut Framebuffer, state: &GameState) { frame.centered_text_box(GATEWAY_TITLE_BOTTOM_BOX, &truncate(line, 19), AMBER, 1); } frame.centered_text_box(GATEWAY_SIGNATURE_BOX, "REPOSITORY ORACLE", CYAN, 1); - if state.has_game() && (state.screen_ticks / 30).is_multiple_of(2) { + if state.has_game() && state.blink_lit(30) { frame.centered_text_box(GATEWAY_PROMPT_BOX, "PRESS START", PARCH, 1); } } @@ -2336,7 +2371,7 @@ fn render_copyright(frame: &mut Framebuffer, state: &GameState) { }; frame.centered_text(129, &history, GOLD, 1); } - if state.can_signal(SceneSignal::Continue) && (state.screen_ticks / 20).is_multiple_of(2) { + if state.can_signal(SceneSignal::Continue) && state.blink_lit(20) { frame.centered_text(141, "START:SKIP", PARCH, 1); } } @@ -2387,27 +2422,33 @@ fn render_opening_fanfare(frame: &mut Framebuffer, state: &GameState) { render_oracle_awakening(frame, state); return; } + // Story beats keep their timing; only decorative motion honors reduced motion. let ticks = state.screen_ticks; + let motion = state.motion_ticks(); frame.clear(INK); for index in 0..24 { - let x = ((index * 67 + ticks as usize) % WIDTH) as i32; + let x = ((index * 67 + motion as usize) % WIDTH) as i32; let y = ((index * 43 + 17) % HEIGHT) as i32; frame.pixel(x, y, if index % 4 == 0 { GOLD } else { MIST }); } if ticks < 120 { - let travel = (ticks.min(110) as i32 * 70) / 110; + let travel = (state.settled_ticks(110) as i32 * 70) / 110; draw_code_sigil(frame, 24 + travel, 78, false); draw_code_sigil(frame, 216 - travel, 78, true); if ticks >= 100 { - let flare = ((ticks - 100) as i32 / 4).min(8); + let flare = if state.reduced_motion { + 8 + } else { + ((ticks - 100) as i32 / 4).min(8) + }; frame.rect(120 - flare, 78 - 1, flare * 2 + 1, 3, PARCH); frame.rect(119, 79 - flare, 3, flare * 2 + 1, GOLD); } frame.centered_text(132, "TWO PATHS CONVERGE", SKY, 1); } else { - draw_commit_constellation(frame, ticks); - draw_oracle_sigil(frame, 120, 78, ((ticks / 10) % 3) as i32); + draw_commit_constellation(frame, motion); + draw_oracle_sigil(frame, 120, 78, ((motion / 10) % 3) as i32); frame.centered_text(20, "HISTORY BECOMES POWER", GOLD, 1); frame.centered_text(135, "THE ORACLE OPENS", PARCH, 1); } @@ -2424,7 +2465,7 @@ fn render_title(frame: &mut Framebuffer, state: &GameState) { } frame.clear(NAVY); for index in 0..42 { - let x = ((index * 53 + state.screen_ticks as usize / 3) % WIDTH) as i32; + let x = ((index * 53 + state.motion_ticks() as usize / 3) % WIDTH) as i32; let y = ((index * 37 + 11) % HEIGHT) as i32; frame.pixel(x, y, if index % 3 == 0 { SKY } else { MIST }); } @@ -2443,7 +2484,7 @@ fn render_title(frame: &mut Framebuffer, state: &GameState) { None => "POWER OFF TO LOAD A GAME", }; frame.centered_text(91, subtitle, SKY, 1); - if state.has_game() && (state.screen_ticks / 30).is_multiple_of(2) { + if state.has_game() && state.blink_lit(30) { frame.centered_text(126, "PRESS START", PARCH, 1); } } @@ -2530,7 +2571,7 @@ fn render_character_creation(frame: &mut Framebuffer, state: &GameState) { } frame.clear(NAVY); frame.centered_text(9, "CREATE YOUR HERO", GOLD, 1); - let bob = ((state.screen_ticks / 20) % 2) as i32; + let bob = ((state.motion_ticks() / 20) % 2) as i32; draw_hero(frame, 26, 64 - bob, 1, state); let rows = [ @@ -2576,7 +2617,7 @@ fn render_oracle_atelier(frame: &mut Framebuffer, state: &GameState) { CYAN_DIM, ); frame.centered_compact_text_box(ATELIER_HEADER_BOX, "BIND YOUR CODE-SEER", AMBER); - let bob = ((state.screen_ticks / 22) % 2) as i32; + let bob = ((state.motion_ticks() / 22) % 2) as i32; draw_hero( frame, ATELIER_HERO_X, @@ -2641,7 +2682,7 @@ fn render_oracle(frame: &mut Framebuffer, state: &GameState) { } frame.clear(INK); for index in 0..30 { - let x = ((index * 71 + state.screen_ticks as usize * 2) % WIDTH) as i32; + let x = ((index * 71 + state.motion_ticks() as usize * 2) % WIDTH) as i32; frame.pixel(x, 14 + (index * 29 % 96) as i32, MIST); } frame.rect(0, 0, WIDTH as i32, 12, NAVY); @@ -2658,7 +2699,11 @@ fn render_oracle(frame: &mut Framebuffer, state: &GameState) { }; let status_width = status.chars().count() as i32 * GLYPH_ADVANCE - 1; frame.text(211 - status_width, 2, &status, SKY, 1); - let phase = (state.screen_ticks % 45) / 15; + let phase = if state.reduced_motion { + 2 + } else { + (state.screen_ticks % 45) / 15 + }; frame.text(216, 2, &".".repeat(phase as usize + 1), GOLD, 1); for drop in &state.oracle_drops { match drop.kind { @@ -3297,10 +3342,10 @@ fn render_level_up(frame: &mut Framebuffer, state: &GameState) { } frame.clear(NAVY); frame.outline(8, 8, 224, 144, PLUM); - let pulse = ((state.screen_ticks / 10) % 3) as i32; + let pulse = ((state.motion_ticks() / 10) % 3) as i32; draw_oracle_sigil(frame, 120, 72, pulse); frame.centered_text(22, "LEVEL UP!", GOLD, 2); - let rise = (state.screen_ticks.min(45) / 5) as i32; + let rise = (state.settled_ticks(45) / 5) as i32; draw_hero(frame, 106, 91 - rise, 1, state); if let Some(run) = state.quiz.as_ref() { frame.centered_text(116, &format!("LEVEL {}", run.level), PARCH, 1); @@ -3355,7 +3400,7 @@ fn render_oracle_ascension(frame: &mut Framebuffer, state: &GameState) { }, 2, ); - let rise = (state.screen_ticks.min(45) / 5) as i32; + let rise = (state.settled_ticks(45) / 5) as i32; draw_hero(frame, 108, 105 - rise, 1, state); if let Some(run) = state.quiz.as_ref() { frame.rect( @@ -3442,7 +3487,7 @@ fn render_game_over(frame: &mut Framebuffer, state: &GameState) { } else { frame.centered_text(70, "NO QUESTIONS FOUND", GOLD, 1); } - if (state.screen_ticks / 30).is_multiple_of(2) { + if state.blink_lit(30) { frame.centered_text(143, "A/B/START:MENU", PARCH, 1); } } @@ -3523,7 +3568,7 @@ fn render_oracle_aftermath(frame: &mut Framebuffer, state: &GameState) { 1, ); } - if (state.screen_ticks / 30).is_multiple_of(2) { + if state.blink_lit(30) { frame.centered_text_in( AFTERMATH_CONTENT_BOX.x, 126, @@ -3838,7 +3883,7 @@ fn render_battle(frame: &mut Framebuffer, state: &GameState) { frame.rect(0, 0, WIDTH as i32, 69, INK); frame.text(6, 5, &truncate(&state.active_boss, 37), RED, 1); draw_crab(frame, 34, 45, 1); - draw_boss(frame, 183, 29, state.screen_ticks, 1); + draw_boss(frame, 183, 29, state.motion_ticks(), 1); frame.rect(0, 68, WIDTH as i32, 2, SKY); frame.outline(4, 75, 232, 68, MIST); for (index, (line, stderr)) in state.logs.iter().rev().take(7).rev().enumerate() { @@ -8523,6 +8568,68 @@ mod tests { assert!(lines.iter().all(|line| line.chars().count() <= 8)); } + #[test] + fn reduced_motion_freezes_decorative_motion_but_keeps_scene_timing() { + fn sampled(engine: &mut GameEngine, samples: usize, spacing: usize) -> Vec> { + (0..samples) + .map(|_| { + for _ in 0..spacing { + engine.update(); + } + engine.frame().to_vec() + }) + .collect() + } + let animates = |frames: &[Vec]| frames.windows(2).any(|pair| pair[0] != pair[1]); + + for reduced in [false, true] { + let mut engine = GameEngine::new(); + issue(&mut engine, EngineCommand::ReducedMotion(reduced)); + issue( + &mut engine, + EngineCommand::Cartridge(Some(oracle_template_cartridge())), + ); + issue(&mut engine, EngineCommand::Power(true)); + finish_opening(&mut engine); + let title = sampled(&mut engine, 4, 17); + press(&mut engine, Button::Start); + press(&mut engine, Button::A); + assert_eq!(engine.screen(), Screen::CharacterCreation); + let atelier = sampled(&mut engine, 4, 23); + + assert_eq!( + animates(&title), + !reduced, + "title prompt (reduced={reduced})" + ); + assert_eq!(animates(&atelier), !reduced, "hero bob (reduced={reduced})"); + } + + let scene_timeline = |reduced: bool| { + let mut engine = GameEngine::new(); + issue(&mut engine, EngineCommand::ReducedMotion(reduced)); + issue( + &mut engine, + EngineCommand::Cartridge(Some(oracle_template_cartridge())), + ); + issue(&mut engine, EngineCommand::Power(true)); + issue(&mut engine, EngineCommand::BootComplete); + (0..720) + .map(|_| { + engine.update(); + engine.screen() + }) + .collect::>() + }; + let timeline = scene_timeline(true); + assert_eq!( + timeline, + scene_timeline(false), + "motion never changes timing" + ); + assert_eq!(timeline.last(), Some(&Screen::Title)); + } + /// The whole learning loop across the quiz and the Codex: a miss is /// journaled as a pending review, returns after the retry gap, is redeemed, /// and the Codex rereads the same journal and mastery the quiz wrote. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1061f14..a644efc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -964,6 +964,11 @@ fn engine_input(state: State, button: String, pressed: bool) -> Res state.0.input(button, pressed) } +#[tauri::command] +fn engine_set_reduced_motion(state: State, reduced: bool) -> Result<(), String> { + state.0.set_reduced_motion(reduced) +} + #[tauri::command] fn engine_frame(state: State) -> tauri::ipc::Response { tauri::ipc::Response::new(state.0.frame()) @@ -1033,6 +1038,7 @@ pub fn run() { engine_power, engine_finish_boot, engine_input, + engine_set_reduced_motion, engine_frame ]) .run(tauri::generate_context!()) diff --git a/src/main.js b/src/main.js index 3acc8d8..016c2bd 100644 --- a/src/main.js +++ b/src/main.js @@ -1185,9 +1185,21 @@ 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(); 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); @@ -1246,7 +1258,7 @@ import { return async (command, args) => { if (command === "engine_frame") return frame.buffer; 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"); 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"); From d2729b1e4a1042dc227a34fbb32e61478a4c4bc7 Mon Sep 17 00:00:00 2001 From: Jason Skrzypek Date: Fri, 25 Sep 2026 10:48:41 -0400 Subject: [PATCH 10/60] Teach from payload v2 questions, richer briefs, and durable progress Move the question domain out of lib.rs into a Tauri-free questions module: the saved-question model, the acceptance policy, prompt construction, provider-response parsing, batch persistence, and learner progress. lib.rs keeps the commands, provider plumbing, cartridge building, and wiring. Generated questions now use payload v2: a concept lens plus a "why" for every choice. Legacy saves with plain-string choices still deserialize and play without rationales. Each generated question must name a lens that learning::Concept::parse accepts and carry location-free rationales that fit the lesson panel; failures drop individually so mixed batches survive. Provider text is normalized to renderable uppercase ASCII, and parsing finds the question array inside prose or fences and salvages finished questions from a truncated reply. The anti-trivia filter now matches phrases and word shapes instead of bare nouns, so questions such as WHY WRITE THE SAVE FILE ATOMICALLY? pass while which-file, where-does, counts, commits, branches, authors, recency, versions, hashes, paths, and real file names stay rejected. A new repo_context module builds a deterministic, budgeted project brief from the README, design notes, a CODEQUEST.toml summary, and doc-comment and signature skeletons of up to 30 components spread across the tree. Excerpts are numbered, never named; path-like words and tracked file and directory names are redacted; vendored, generated, test, binary, huge, and symlinked files are skipped. Prompt v2 adds lens focus, PREDICT transfer questions from level 4, misconception distractors and rationales, varied answer positions, the weakest lens, and previously asked stems, and is bounded to fit a Windows command line. Progress keeps answered_questions as retired questions and adds missed_questions and per-lens mastery. A wrong answer no longer retires a question: misses return as reviews in later runs and launches until redeemed. Cartridge loads now supply the lesson journal and mastery. Saves gain an atomic read-modify-write update under the save lock so batch appends and answer records cannot lose each other, and answers are recorded on a dedicated writer thread instead of the engine loop. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/lib.rs | 749 +++++-------- src-tauri/src/questions.rs | 1938 +++++++++++++++++++++++++++++++++ src-tauri/src/repo_context.rs | 1678 ++++++++++++++++++++++++++++ src-tauri/src/save.rs | 269 ++++- 4 files changed, 4086 insertions(+), 548 deletions(-) create mode 100644 src-tauri/src/questions.rs create mode 100644 src-tauri/src/repo_context.rs diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 55848bb..6d34d0f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,13 +4,14 @@ mod engine; mod external_tools; mod font5x7; mod learning; +mod questions; +mod repo_context; mod save; pub mod scene_machine; -use std::collections::HashSet; use std::io::Read; use std::process::{Command, Output, Stdio}; -use std::sync::{Arc, RwLock}; +use std::sync::{mpsc, Arc, RwLock}; use std::thread; use std::time::Duration; @@ -20,6 +21,7 @@ use tauri_plugin_dialog::DialogExt; use wait_timeout::ChildExt; use codequest::{CodeQuestConfig, GameType}; +use questions::QQuestion; use scene_machine::{SceneMachineDefinition, SceneMachineTemplate}; #[derive(Serialize, Deserialize, Clone)] @@ -425,15 +427,6 @@ fn cartridge_branch(path: String) -> Result { Ok(repository_branch(&canon)) } -struct QuizFile { - path: String, - size: u64, -} - -struct QuizData { - files: Vec, -} - fn git_out(path: &std::path::Path, args: &[&str]) -> String { git_repo_command(path) .args(args) @@ -444,230 +437,19 @@ fn git_out(path: &std::path::Path, args: &[&str]) -> String { .unwrap_or_default() } -fn quiz_data(path: String) -> Result { - let repo = std::path::Path::new(&path); - if !is_git_repo(repo) { - return Err("NOT A GIT REPOSITORY".to_string()); - } - let mut files: Vec = git_out(repo, &["ls-files"]) - .lines() - .take(400) - .map(|f| QuizFile { - path: f.to_string(), - size: std::fs::metadata(repo.join(f)) - .map(|m| m.len()) - .unwrap_or(0), - }) - .collect(); - files.retain(|f| !f.path.is_empty()); - Ok(QuizData { files }) -} - -#[derive(Serialize, Deserialize, Clone)] -struct QQuestion { - q: String, - choices: Vec, - answer: usize, -} - -const AI_QUESTION_BATCHES_KEY: &str = "ai.question_batches"; -const LEGACY_CLAUDE_QUESTION_BATCHES_KEY: &str = "claude.question_batches"; -const QUIZ_PROGRESS_KEY: &str = "quiz.progress"; - -#[derive(Serialize, Deserialize, Clone)] -struct SavedQuestionBatch { - level: u32, - questions: Vec, -} - -#[derive(Default, Serialize, Deserialize)] -struct SavedQuizProgress { - #[serde(default)] - answered_questions: Vec, -} - -fn question_identity(question: &str) -> String { - question - .split_whitespace() - .collect::>() - .join(" ") - .to_ascii_uppercase() -} - -fn question_is_acceptable(question: &QQuestion) -> bool { - const FACT_TRIVIA_MARKERS: [&str; 27] = [ - "WHICH FILE", - "WHAT FILE", - "WHERE DOES", - "HOW MANY", - "FILE NAME", - "FILENAME", - "FILE", - "FILES", - "DIRECTORY", - "DIRECTORIES", - "FOLDER", - "FOLDERS", - "PATH", - "PATHS", - "EXTENSION", - "EXTENSIONS", - "README", - "COMMIT", - "COMMITS", - "BRANCH", - "AUTHOR", - "AUTHORS", - "CONTRIBUTOR", - "CONTRIBUTORS", - "LATEST", - "MOST RECENT", - "BYTES", - ]; - const FILE_EXTENSIONS: [&str; 14] = [ - ".RS", ".JS", ".MJS", ".TS", ".TSX", ".PY", ".GO", ".RB", ".JAVA", ".C", ".CPP", ".SH", - ".CSS", ".HTML", - ]; - - if !engine::quiz_question_fits(&question.q, &question.choices, question.answer) { - return false; - } - let content = std::iter::once(question.q.as_str()) - .chain(question.choices.iter().map(String::as_str)) - .collect::>() - .join(" ") - .to_ascii_uppercase(); - let normalized = content - .chars() - .map(|character| { - if character.is_ascii_alphanumeric() { - character - } else { - ' ' - } - }) - .collect::() - .split_whitespace() - .collect::>() - .join(" "); - let padded = format!(" {normalized} "); - !FACT_TRIVIA_MARKERS - .iter() - .any(|marker| padded.contains(&format!(" {marker} "))) - && !FILE_EXTENSIONS - .iter() - .any(|extension| content.contains(extension)) - && !content.contains('/') - && !content.contains('\\') -} +/// Upper bound on tracked paths considered for the generation brief. +const MAX_TRACKED_FILES: usize = 20_000; -fn retain_acceptable_questions(questions: Vec) -> Vec { - questions - .into_iter() - .filter(question_is_acceptable) +/// Tracked paths, `/`-separated and relative to the repository root. +fn tracked_files(path: &std::path::Path) -> Vec { + git_out(path, &["ls-files", "-z"]) + .split('\0') + .filter(|file| !file.is_empty()) + .take(MAX_TRACKED_FILES) + .map(str::to_string) .collect() } -fn accepted_question_batch( - questions: Vec, - expected_count: usize, -) -> Option> { - let valid = retain_acceptable_questions(questions) - .into_iter() - .take(expected_count) - .collect::>(); - (!valid.is_empty()).then_some(valid) -} - -fn load_saved_question_batches(path: &std::path::Path) -> Result, String> { - let save = save::SaveFile::open_or_create(path)?; - let mut batches = save - .get::>(AI_QUESTION_BATCHES_KEY) - .unwrap_or_default(); - batches.extend( - save.get::>(LEGACY_CLAUDE_QUESTION_BATCHES_KEY) - .unwrap_or_default(), - ); - Ok(batches - .into_iter() - .filter(|batch| { - batch.level > 0 - && !batch.questions.is_empty() - && batch.questions.iter().all(question_is_acceptable) - }) - .collect()) -} - -fn load_answered_questions(path: &std::path::Path) -> Result, String> { - let save = save::SaveFile::open_or_create(path)?; - Ok(save - .get::(QUIZ_PROGRESS_KEY) - .unwrap_or_default() - .answered_questions - .into_iter() - .map(|question| question_identity(&question)) - .collect()) -} - -fn persist_answered_question(path: &std::path::Path, question: &str) -> Result<(), String> { - let mut save = save::SaveFile::open_or_create(path)?; - let mut progress = save - .get::(QUIZ_PROGRESS_KEY) - .unwrap_or_default(); - let identity = question_identity(question); - if identity.is_empty() - || progress - .answered_questions - .iter() - .any(|answered| question_identity(answered) == identity) - { - return Ok(()); - } - progress.answered_questions.push(question.to_string()); - save.set(QUIZ_PROGRESS_KEY, &progress) -} - -fn persist_ai_question_batch( - path: &std::path::Path, - level: u32, - questions: &[QQuestion], -) -> Result<(), String> { - if level == 0 || questions.is_empty() || !questions.iter().all(question_is_acceptable) { - return Err("INVALID AI QUESTION BATCH".to_string()); - } - let mut save = save::SaveFile::open_or_create(path)?; - let mut batches = save - .get::>(AI_QUESTION_BATCHES_KEY) - .unwrap_or_default(); - batches.push(SavedQuestionBatch { - level, - questions: questions.to_vec(), - }); - save.set(AI_QUESTION_BATCHES_KEY, &batches) -} - -fn gather_quiz_data(path: &std::path::Path) -> Result { - quiz_data(path.to_string_lossy().to_string()) -} - -fn text_excerpt(path: &std::path::Path, max_lines: usize) -> String { - std::fs::read_to_string(path) - .map(|t| t.lines().take(max_lines).collect::>().join("\n")) - .unwrap_or_default() -} - -fn ai_question_prompt( - project_name: &str, - level: u32, - count: usize, - documentation: &str, - implementation_excerpts: &str, -) -> String { - format!( - "You write questions for a retro handheld quiz game about a software project. Generate exactly {count} multiple-choice questions at difficulty level {level} (1=purpose and responsibilities, 3=component interactions and tradeoffs, 5=subtle invariants and design rationale).\n\nCONCEPTS ONLY: test the project's architecture, purpose, domain model, component responsibilities, interactions, invariants, tradeoffs, design rationale, or enduring behavior. Every question must still make sense if the project were reorganized and all implementation locations changed.\n\nNEVER ask about file names, paths, directories, or extensions; where code lives; repository structure; counts, sizes, or lines; dates or times; branches or commits; authors or contributors; ordering or recency; or any other state-in-time fact. Never use those facts as choices.\n\nDISPLAY LIMITS: each question must wrap into at most 4 lines of 31 characters. Each choice must be at most 31 characters. Return exactly 4 non-empty, distinct choices and exactly one correct answer. Wrong choices must be plausible concepts. Do not truncate words or sentences. Do not repeat questions.\n\nRespond with ONLY a JSON array, no prose and no code fences: [{{\"q\":\"...\",\"choices\":[\"a\",\"b\",\"c\",\"d\"],\"answer\":0}}]\n\nPROJECT: {project_name}\nPROJECT DOCUMENTATION:\n{documentation}\nANONYMIZED IMPLEMENTATION EXCERPTS:\n{implementation_excerpts}", - ) -} - const AI_PROBE_PROMPT: &str = "Reply with exactly CODEQUEST_READY and nothing else. Do not inspect files or use tools."; @@ -786,47 +568,27 @@ fn ai_questions( count: usize, active_provider: Option, ) -> Result, String> { - let d = gather_quiz_data(path)?; + if !is_git_repo(path) { + return Err("NOT A GIT REPOSITORY".to_string()); + } + let provider = active_provider.ok_or_else(|| "AI BATTERIES NOT VERIFIED".to_string())?; let name = path .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_default(); - // The file inventory is used only to select representative source excerpts. - // Paths and repository metadata are deliberately withheld from the provider so the - // resulting questions focus on enduring concepts rather than file trivia. - let mut files: Vec<&QuizFile> = d.files.iter().collect(); - files.sort_by_key(|file| std::cmp::Reverse(file.size)); - let readme = text_excerpt(&path.join("README.md"), 40); - let src_exts = [ - ".rs", ".js", ".ts", ".py", ".go", ".java", ".c", ".cpp", ".rb", ".sh", ".css", ".html", - ]; - let mut excerpts = String::new(); - let mut used = 0; - for f in files.iter() { - if used >= 2 || f.size > 200_000 { - continue; - } - if src_exts.iter().any(|e| f.path.ends_with(e)) { - excerpts.push_str("\n--- IMPLEMENTATION EXCERPT ---\n"); - excerpts.push_str(&text_excerpt(&path.join(&f.path), 50)); - excerpts.push('\n'); - used += 1; - } - } - let prompt = ai_question_prompt(&name, level, count, &readme, &excerpts); - let provider = active_provider.ok_or_else(|| "AI BATTERIES NOT VERIFIED".to_string())?; + // The brief describes the project through anonymized documentation and + // component skeletons. Paths and repository metadata are withheld from the + // provider so questions focus on enduring concepts rather than file trivia. + let brief = repo_context::project_brief(path, &tracked_files(path)); + let learner = questions::load_learner_state(path).unwrap_or_default(); + let prompt = questions::bounded_ai_question_prompt(&name, level, count, &brief, &learner); let cmd = ai_prompt_command(provider, &prompt); let out = command_output_with_timeout(cmd, Duration::from_secs(120), provider.name())?; if !out.status.success() { return Err(format!("{} CALL FAILED", provider.name())); } let result = ai_response_text(provider, &out.stdout)?; - let start = result.find('[').ok_or("NO JSON IN RESPONSE")?; - let end = result.rfind(']').ok_or("NO JSON IN RESPONSE")?; - let parsed: Vec = serde_json::from_str(&result[start..=end]) - .map_err(|_| "UNPARSEABLE QUESTIONS".to_string())?; - accepted_question_batch(parsed, count) - .ok_or_else(|| "INCOMPLETE OR INVALID QUESTIONS".to_string()) + questions::parse_generated_questions(&result, count) } fn generate_and_save_questions( @@ -836,7 +598,7 @@ fn generate_and_save_questions( provider: AiProvider, ) -> Result, String> { let questions = ai_questions(path, level, count, Some(provider))?; - persist_ai_question_batch(path, level, &questions)?; + questions::persist_ai_question_batch(path, level, &questions)?; Ok(questions) } @@ -856,6 +618,46 @@ where generate(path, level, count, provider).unwrap_or_default() } +/// The engine's question loader: a fresh verified batch, without questions +/// the player has already retired. Missed questions stay playable. +fn load_new_questions_with( + path: &std::path::Path, + level: u32, + count: usize, + provider_state: &AiProviderState, + generate: &F, +) -> Vec +where + F: Fn(&std::path::Path, u32, usize, AiProvider) -> Result, String>, +{ + let generated = load_verified_question_batch_with(path, level, count, provider_state, generate); + let retired = questions::load_retired_questions(path).unwrap_or_default(); + questions::playable_new_questions(generated, &retired) +} + +/// Builds the answer recorder the engine calls when a player commits an +/// answer. The engine calls it from its fixed-rate loop, and a durable save +/// write would stall frames there, so one writer thread applies evidence in +/// commit order. The writer stops once the returned recorder is dropped. +fn answer_recorder_with(persist: F) -> (engine::AnsweredQuestionRecorder, thread::JoinHandle<()>) +where + F: Fn(&std::path::Path, &learning::AnswerEvidence) + Send + 'static, +{ + let (sender, receiver) = mpsc::channel::<(String, learning::AnswerEvidence)>(); + let writer = thread::Builder::new() + .name("cqa-save-writer".into()) + .spawn(move || { + for (path, evidence) in receiver { + persist(std::path::Path::new(&path), &evidence); + } + }) + .expect("failed to start save writer thread"); + let recorder: engine::AnsweredQuestionRecorder = Arc::new(move |path, evidence| { + let _ = sender.send((path, evidence)); + }); + (recorder, writer) +} + fn engine_cartridge(cartridge: Cartridge) -> Result { let mode = if cartridge.mode == "custom" { engine::CartridgeMode::Custom @@ -873,28 +675,7 @@ fn engine_cartridge(cartridge: Cartridge) -> Result batch_start { - question_batch_ends.push(questions.len()); - } - } + let saved = questions::load_cartridge_questions(std::path::Path::new(&cartridge.path))?; Ok(engine::CartridgeSpec { id: cartridge.path, @@ -912,10 +693,10 @@ fn engine_cartridge(cartridge: Cartridge) -> Result QQuestion { - QQuestion { - q: text.to_string(), - choices: choices.iter().map(|choice| (*choice).to_string()).collect(), - answer: 0, - } - } - #[test] #[ignore = "child-process fixture for the timeout test"] fn slow_command_fixture() { @@ -1557,43 +1319,6 @@ mod question_policy_tests { remove_temporary_repo(repo); } - #[test] - fn accepted_ai_questions_are_conceptual_and_fit_the_quiz_layout() { - let conceptual = question( - "WHY SEPARATE GAME STATE FROM THE UI?", - &[ - "TO KEEP RESPONSIBILITIES CLEAR", - "TO HIDE FAILURES", - "TO COUPLE COMPONENTS", - "TO DUPLICATE STATE", - ], - ); - assert!(question_is_acceptable(&conceptual)); - - let file_trivia = question( - "WHICH FILE OWNS GAME STATE?", - &["engine.rs", "main.js", "styles.css", "README.md"], - ); - assert!(!question_is_acceptable(&file_trivia)); - - let state_trivia = question( - "HOW MANY FILES ARE IN THE REPOSITORY?", - &["ONE", "TWO", "THREE", "FOUR"], - ); - assert!(!question_is_acceptable(&state_trivia)); - - let overflowing = question( - &"CONCEPTUAL WORD ".repeat(40), - &[ - "THIS CHOICE IS LONGER THAN THE DISPLAY CAN POSSIBLY SHOW", - "SECOND", - "THIRD", - "FOURTH", - ], - ); - assert!(!question_is_acceptable(&overflowing)); - } - #[test] fn quiz_cartridges_have_no_preloaded_questions() { let repo = temporary_git_repo(); @@ -1603,46 +1328,6 @@ mod question_policy_tests { remove_temporary_repo(repo); } - #[test] - fn cartridge_reload_combines_current_and_legacy_ai_batches() { - let repo = temporary_git_repo(); - let mut save = save::SaveFile::open_or_create(&repo).unwrap(); - let current = SavedQuestionBatch { - level: 2, - questions: vec![question( - "WHY KEEP THE DEVICE SHELL THIN?", - &[ - "TO CENTRALIZE GAME RULES", - "TO DUPLICATE GAME STATE", - "TO HIDE ENGINE OUTPUT", - "TO BYPASS THE ENGINE", - ], - )], - }; - let legacy = SavedQuestionBatch { - level: 1, - questions: vec![question( - "WHAT SHOULD OWN GAMEPLAY STATE?", - &[ - "THE GAME ENGINE", - "THE DEVICE SHELL", - "THE STYLES", - "THE VIEW", - ], - )], - }; - save.set(AI_QUESTION_BATCHES_KEY, &[current]).unwrap(); - save.set(LEGACY_CLAUDE_QUESTION_BATCHES_KEY, &[legacy]) - .unwrap(); - - let batches = load_saved_question_batches(&repo).unwrap(); - - assert_eq!(batches.len(), 2); - assert_eq!(batches[0].level, 2); - assert_eq!(batches[1].level, 1); - remove_temporary_repo(repo); - } - #[test] fn cartridge_reload_restores_legacy_claude_batches() { let repo = temporary_git_repo(); @@ -1754,49 +1439,28 @@ mod question_policy_tests { remove_temporary_repo(repo); } - #[test] - fn answered_question_progress_is_namespaced_and_idempotent() { - let repo = temporary_git_repo(); - let mut save = save::SaveFile::open_or_create(&repo).unwrap(); - save.set( - AI_QUESTION_BATCHES_KEY, - &serde_json::json!([{ "level": 1, "questions": [] }]), - ) - .unwrap(); - - persist_answered_question(&repo, "WHAT OWNS GAMEPLAY STATE?").unwrap(); - persist_answered_question(&repo, " what owns gameplay state? ").unwrap(); - - let reloaded = save::SaveFile::open_or_create(&repo).unwrap(); - let progress = reloaded - .get::(QUIZ_PROGRESS_KEY) - .unwrap(); - assert_eq!(progress.answered_questions, ["WHAT OWNS GAMEPLAY STATE?"]); - assert!(reloaded - .get::(AI_QUESTION_BATCHES_KEY) - .is_some()); - - remove_temporary_repo(repo); + fn answer(question: &str, concept: Option, correct: bool) -> AnswerEvidence { + AnswerEvidence { + question: question.to_string(), + concept, + correct, + review: false, + } } #[test] - fn duplicate_choices_are_rejected() { - let ambiguous = question( - "WHAT SHOULD OWN GAMEPLAY STATE?", + fn cartridge_reload_brings_missed_questions_back_for_review_with_lessons() { + let repo = temporary_git_repo(); + let explained = question( + "WHY DOES THE ENGINE OWN STATE?", &[ - "THE GAME ENGINE", - "THE GAME ENGINE", - "THE VIEW", - "THE DEVICE SHELL", + "ONE OWNER KEEPS RULES FIXED", + "THE SHELL IS TOO SLOW", + "STYLES CANNOT HOLD STATE", + "THE VIEW IS REPLACEABLE", ], ); - - assert!(!question_is_acceptable(&ambiguous)); - } - - #[test] - fn invalid_ai_results_are_rejected_instead_of_truncated() { - let valid = question( + let retired = question( "WHAT SHOULD OWN GAMEPLAY STATE?", &[ "THE GAME ENGINE", @@ -1805,29 +1469,96 @@ mod question_policy_tests { "THE VIEW", ], ); - let file_trivia = question( - "WHICH FILE DEFINES THE ENGINE?", - &["engine.rs", "main.js", "styles.css", "README.md"], + questions::persist_ai_question_batch(&repo, 1, &[retired.clone(), explained.clone()]) + .unwrap(); + questions::persist_answer_evidence( + &repo, + &answer(&retired.q, Some(Concept::Responsibility), true), + ) + .unwrap(); + questions::persist_answer_evidence( + &repo, + &answer(&explained.q, Some(Concept::Responsibility), false), + ) + .unwrap(); + + let spec = engine_cartridge(build_cartridge(&repo).unwrap()).unwrap(); + + assert_eq!( + spec.questions.len(), + 1, + "correct answers retire, misses return" ); - let overflowing = question( - "WHY KEEP OUTPUT WITHIN A FIXED PRESENTATION BOUNDARY?", - &[ - "THIS RESPONSE CANNOT FIT IN THE AVAILABLE CHOICE ROW", - "SECOND", - "THIRD", - "FOURTH", - ], + let review = &spec.questions[0]; + assert_eq!(review.question, explained.q); + assert!(review.review); + assert_eq!(review.concept, Some(Concept::Responsibility)); + assert_eq!(review.rationales, explained.rationales()); + assert_eq!(review.choices, explained.choice_texts()); + assert_eq!(spec.question_batch_ends, vec![1]); + assert_eq!( + spec.lessons + .iter() + .map(|lesson| (lesson.question.as_str(), lesson.outstanding)) + .collect::>(), + [(retired.q.as_str(), false), (explained.q.as_str(), true)] ); + assert_eq!(spec.lessons[1].answer, "ONE OWNER KEEPS RULES FIXED"); + assert!(!spec.lessons[1].rationale.is_empty()); + let lens = spec.mastery[&Concept::Responsibility]; + assert_eq!((lens.first_try, lens.missed), (1, 1)); - let accepted = retain_acceptable_questions(vec![valid.clone(), file_trivia, overflowing]); - assert_eq!(accepted.len(), 1); - assert_eq!(accepted[0].q, valid.q); - assert_eq!(accepted[0].choices, valid.choices); + remove_temporary_repo(repo); } #[test] - fn valid_questions_survive_a_mixed_ai_batch() { - let valid = question( + fn a_wrong_answer_no_longer_retires_a_question() { + let repo = temporary_git_repo(); + save::SaveFile::open_or_create(&repo) + .unwrap() + .set("claude.question_batches", &serde_json::json!([{ + "level": 1, + "questions": [{ + "q": "WHAT SHOULD OWN GAMEPLAY STATE?", + "choices": ["THE GAME ENGINE", "THE DEVICE SHELL", "THE STYLES", "THE VIEW"], + "answer": 0 + }] + }])) + .unwrap(); + + questions::persist_answer_evidence( + &repo, + &answer("WHAT SHOULD OWN GAMEPLAY STATE?", None, false), + ) + .unwrap(); + let missed = engine_cartridge(build_cartridge(&repo).unwrap()).unwrap(); + assert_eq!(missed.questions.len(), 1); + assert!(missed.questions[0].review); + assert!( + missed.questions[0].rationales.is_empty(), + "legacy questions have no rationales" + ); + assert!(missed.lessons[0].outstanding); + + questions::persist_answer_evidence( + &repo, + &answer("WHAT SHOULD OWN GAMEPLAY STATE?", None, true), + ) + .unwrap(); + let redeemed = engine_cartridge(build_cartridge(&repo).unwrap()).unwrap(); + assert!(redeemed.questions.is_empty()); + assert!(!redeemed.lessons[0].outstanding); + + remove_temporary_repo(repo); + } + + #[test] + fn the_question_loader_maps_new_batches_and_skips_only_retired_questions() { + let repo = temporary_git_repo(); + let providers = AiProviderState::default(); + providers.select(Some(AiProvider::Claude)).unwrap(); + providers.mark_verified(AiProvider::Claude).unwrap(); + let retired = question( "WHAT SHOULD OWN GAMEPLAY STATE?", &[ "THE GAME ENGINE", @@ -1836,69 +1567,91 @@ mod question_policy_tests { "THE VIEW", ], ); - let file_trivia = question( - "WHICH FILE DEFINES THE ENGINE?", - &["engine.rs", "main.js", "styles.css", "README.md"], + let missed = question( + "WHY KEEP THE DEVICE SHELL THIN?", + &[ + "TO CENTRALIZE GAME RULES", + "TO DUPLICATE GAME STATE", + "TO HIDE ENGINE OUTPUT", + "TO BYPASS THE ENGINE", + ], ); + questions::persist_answer_evidence(&repo, &answer(&retired.q, None, true)).unwrap(); + questions::persist_answer_evidence(&repo, &answer(&missed.q, None, false)).unwrap(); + let batch = vec![retired, missed.clone()]; + let generate = |_: &std::path::Path, _: u32, _: usize, _: AiProvider| Ok(batch.clone()); + + let loaded = load_new_questions_with(&repo, 2, 6, &providers, &generate); + + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].question, missed.q); + assert_eq!(loaded[0].concept, missed.lens()); + assert_eq!(loaded[0].rationales, missed.rationales()); + assert!(!loaded[0].review); - let accepted = accepted_question_batch(vec![valid.clone(), file_trivia.clone()], 2) - .expect("the valid question should remain playable"); - assert_eq!(accepted.len(), 1); - assert_eq!(accepted[0].q, valid.q); - assert!(accepted_question_batch(vec![file_trivia], 1).is_none()); + remove_temporary_repo(repo); } #[test] - fn generated_ai_batch_is_saved_without_replacing_other_game_data() { + fn the_answer_recorder_persists_evidence_in_commit_order_off_the_engine_thread() { let repo = temporary_git_repo(); - let save_path = save::path_for(&repo); - let mut save = save::SaveFile::open_or_create(&repo).unwrap(); - save.set("quest.progress", &serde_json::json!({ "bosses": 2 })) - .unwrap(); - let generated = vec![question( - "WHAT SHOULD OWN GAMEPLAY STATE?", - &[ - "THE GAME ENGINE", - "THE DEVICE SHELL", - "THE STYLES", - "THE VIEW", - ], - )]; + let engine_thread = thread::current().id(); + let writer_threads = Arc::new(std::sync::Mutex::new(Vec::new())); + let seen = Arc::clone(&writer_threads); + let (recorder, writer) = answer_recorder_with(move |path, evidence| { + seen.lock().unwrap().push(thread::current().id()); + questions::persist_answer_evidence(path, evidence).unwrap(); + }); + let cartridge = repo.to_string_lossy().to_string(); + let evidence = |correct| AnswerEvidence { + review: correct, + ..answer( + "WHY WRITE SAVES ATOMICALLY?", + Some(Concept::Invariant), + correct, + ) + }; - persist_ai_question_batch(&repo, 3, &generated).unwrap(); + recorder(cartridge.clone(), evidence(false)); + recorder(cartridge, evidence(true)); + drop(recorder); + writer.join().unwrap(); - let reloaded = save::SaveFile::open_or_create(&repo).unwrap(); - assert_eq!( - reloaded.get::("quest.progress"), - Some(serde_json::json!({ "bosses": 2 })) + let progress = questions::load_quiz_progress(&repo).unwrap(); + assert_eq!(progress.answered_questions, ["WHY WRITE SAVES ATOMICALLY?"]); + assert!( + progress.missed_questions.is_empty(), + "the redemption was applied last" ); - let batches = reloaded - .get::>(AI_QUESTION_BATCHES_KEY) - .unwrap(); - assert_eq!(batches.len(), 1); - assert_eq!(batches[0].level, 3); - assert_eq!(batches[0].questions[0].q, generated[0].q); + let lens = progress.mastery[&Concept::Invariant]; + assert_eq!((lens.redeemed, lens.missed), (1, 1)); + assert!(writer_threads + .lock() + .unwrap() + .iter() + .all(|thread| *thread != engine_thread)); - std::fs::remove_file(save_path).unwrap(); remove_temporary_repo(repo); } #[test] - fn ai_prompt_requests_only_concepts_that_fit_the_display() { - let prompt = ai_question_prompt( - "DEMO PROJECT", - 3, - 12, - "A project that separates its engine from its device shell.", - "The engine owns state. The adapter translates platform operations.", - ); + fn generation_briefs_come_from_tracked_files() { + let repo = temporary_git_repo(); + std::fs::write(repo.join("README.md"), "# Demo\n\nA tracked overview.\n").unwrap(); + std::fs::write(repo.join("untracked.md"), "# Untracked\n").unwrap(); + let add = external_tools::git_command() + .arg("-C") + .arg(&repo) + .args(["add", "README.md"]) + .status() + .unwrap(); + assert!(add.success()); - assert!(prompt.contains("CONCEPTS ONLY")); - assert!(prompt.contains("NEVER ask about file names, paths, directories, or extensions")); - assert!(prompt.contains("still make sense if the project were reorganized")); - assert!(prompt.contains("at most 4 lines of 31 characters")); - assert!(prompt.contains("at most 31 characters")); - assert!(!prompt.contains("FILES:")); - assert!(!prompt.contains("COMMIT MESSAGES")); + let canon = std::fs::canonicalize(&repo).unwrap(); + assert_eq!(tracked_files(&canon), ["README.md"]); + let brief = repo_context::project_brief(&canon, &tracked_files(&canon)); + assert!(brief.contains("A tracked overview.")); + + remove_temporary_repo(repo); } } diff --git a/src-tauri/src/questions.rs b/src-tauri/src/questions.rs new file mode 100644 index 0000000..bc5d6fe --- /dev/null +++ b/src-tauri/src/questions.rs @@ -0,0 +1,1938 @@ +//! The question domain: the saved-question model, the acceptance policy that +//! keeps questions conceptual and displayable, provider prompt construction, +//! provider-response parsing, and the learner progress kept in cartridge saves. +//! +//! Generated questions use payload v2, where every choice carries a short +//! rationale and every question names its conceptual lens. Saves written by +//! earlier builds hold plain-string choices without a lens; those legacy +//! questions still load and play, just without explanations. Nothing here +//! depends on Tauri. + +use std::collections::HashSet; +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use crate::learning::{self, AnswerEvidence, Concept, Lesson, Mastery}; +use crate::{engine, save}; + +pub(crate) const AI_QUESTION_BATCHES_KEY: &str = "ai.question_batches"; +pub(crate) const LEGACY_CLAUDE_QUESTION_BATCHES_KEY: &str = "claude.question_batches"; +pub(crate) const QUIZ_PROGRESS_KEY: &str = "quiz.progress"; +/// Previously generated stems the prompt lists so a provider does not repeat +/// them. +pub(crate) const MAX_AVOIDED_STEMS: usize = 24; +/// Upper bound for a whole generation prompt. Prompts travel as one command +/// line argument, and Windows limits a command line to 32767 UTF-16 units, so +/// this leaves room for the program path, the other arguments, and quoting. +pub(crate) const MAX_PROMPT_CHARS: usize = 30_000; + +/// One answer choice. Payload v2 pairs the text with `why`: for the correct +/// choice, why it holds; for a distractor, the misconception it represents. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(untagged)] +pub(crate) enum QChoice { + /// Legacy saves: the choice text alone. + Plain(String), + Explained { + text: String, + #[serde(default)] + why: String, + }, +} + +impl QChoice { + pub(crate) fn text(&self) -> &str { + match self { + Self::Plain(text) | Self::Explained { text, .. } => text, + } + } + + pub(crate) fn why(&self) -> Option<&str> { + match self { + Self::Plain(_) => None, + Self::Explained { why, .. } => Some(why), + } + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub(crate) struct QQuestion { + pub(crate) q: String, + /// The lens this question assesses, as a provider spelled it; see + /// [`QQuestion::lens`]. Absent from legacy saves. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) concept: Option, + pub(crate) choices: Vec, + pub(crate) answer: usize, +} + +impl QQuestion { + pub(crate) fn choice_texts(&self) -> Vec { + self.choices + .iter() + .map(|choice| choice.text().to_string()) + .collect() + } + + pub(crate) fn lens(&self) -> Option { + self.concept.as_deref().and_then(Concept::parse) + } + + fn has_plain_choices(&self) -> bool { + self.choices + .iter() + .all(|choice| matches!(choice, QChoice::Plain(_))) + } + + fn has_explained_choices(&self) -> bool { + self.choices + .iter() + .all(|choice| matches!(choice, QChoice::Explained { .. })) + } + + /// One rationale per choice in choice order, or none for legacy questions. + pub(crate) fn rationales(&self) -> Vec { + if self.choices.is_empty() || !self.has_explained_choices() { + return Vec::new(); + } + self.choices + .iter() + .map(|choice| choice.why().unwrap_or_default().to_string()) + .collect() + } + + pub(crate) fn quiz_question(&self, review: bool) -> engine::QuizQuestion { + engine::QuizQuestion { + question: self.q.clone(), + choices: self.choice_texts(), + answer: self.answer, + concept: self.lens(), + rationales: self.rationales(), + review, + } + } + + fn lesson(&self, outstanding: bool) -> Lesson { + let correct = self.choices.get(self.answer); + Lesson { + question: self.q.clone(), + answer: correct.map(QChoice::text).unwrap_or_default().to_string(), + rationale: correct + .and_then(QChoice::why) + .unwrap_or_default() + .to_string(), + concept: self.lens(), + outstanding, + } + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub(crate) struct SavedQuestionBatch { + pub(crate) level: u32, + pub(crate) questions: Vec, +} + +/// Learner progress for one cartridge, stored under [`QUIZ_PROGRESS_KEY`]. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub(crate) struct SavedQuizProgress { + /// Retired questions: answered correctly, or recorded by earlier builds + /// that retired every committed answer. They are never asked again. + #[serde(default)] + pub(crate) answered_questions: Vec, + /// Questions whose latest attempt was wrong. They stay playable and return + /// for review until the player redeems them. + #[serde(default)] + pub(crate) missed_questions: Vec, + #[serde(default)] + pub(crate) mastery: Mastery, +} + +impl SavedQuizProgress { + /// Applies one committed answer. The latest attempt decides where a + /// question lives, so the retired and missed lists never share a question. + pub(crate) fn record(&mut self, evidence: &AnswerEvidence) { + let identity = question_identity(&evidence.question); + if identity.is_empty() { + return; + } + let (target, other) = if evidence.correct { + (&mut self.answered_questions, &mut self.missed_questions) + } else { + (&mut self.missed_questions, &mut self.answered_questions) + }; + other.retain(|question| question_identity(question) != identity); + if !target + .iter() + .any(|question| question_identity(question) == identity) + { + target.push(evidence.question.clone()); + } + learning::record_evidence(&mut self.mastery, evidence); + } + + fn missed(&self) -> HashSet { + identities(&self.missed_questions) + } + + /// Identities that must not be asked again. + pub(crate) fn retired(&self) -> HashSet { + let missed = self.missed(); + identities(&self.answered_questions) + .into_iter() + .filter(|identity| !missed.contains(identity)) + .collect() + } +} + +fn identities(questions: &[String]) -> HashSet { + questions + .iter() + .map(|question| question_identity(question)) + .filter(|identity| !identity.is_empty()) + .collect() +} + +pub(crate) fn question_identity(question: &str) -> String { + question + .split_whitespace() + .collect::>() + .join(" ") + .to_ascii_uppercase() +} + +/// The canonical save spelling of a lens (its serde name). +fn concept_key(concept: Concept) -> &'static str { + match concept { + Concept::Purpose => "purpose", + Concept::Responsibility => "responsibility", + Concept::Interaction => "interaction", + Concept::Invariant => "invariant", + Concept::Tradeoff => "tradeoff", + } +} + +/// Extensions that make a dotted word a file name rather than prose. +const FILE_EXTENSIONS: [&str; 60] = [ + "RS", "JS", "MJS", "CJS", "JSX", "TS", "TSX", "MTS", "CTS", "PY", "PYI", "GO", "RB", "JAVA", + "KT", "KTS", "SCALA", "SWIFT", "C", "H", "CC", "CPP", "CXX", "HPP", "HH", "CS", "FS", "PHP", + "PL", "LUA", "DART", "EX", "EXS", "ERL", "HS", "SH", "BASH", "ZSH", "PS1", "BAT", "CSS", + "SCSS", "LESS", "HTML", "HTM", "VUE", "SVELTE", "MD", "RST", "TXT", "TOML", "JSON", "YAML", + "YML", "XML", "INI", "LOCK", "SQL", "PROTO", "SAV", +]; +/// Dotted technology names that read like file names but name a concept. +const TECHNOLOGY_NAMES: [&str; 8] = [ + "NODE.JS", + "VUE.JS", + "NEXT.JS", + "NUXT.JS", + "THREE.JS", + "D3.JS", + "CHART.JS", + "EXPRESS.JS", +]; +/// Configuration dotfiles that only name a location. +const DOTFILES: [&str; 12] = [ + "GITIGNORE", + "GITATTRIBUTES", + "GITMODULES", + "GITHUB", + "ENV", + "EDITORCONFIG", + "NPMRC", + "PRETTIERRC", + "ESLINTRC", + "DOCKERIGNORE", + "VSCODE", + "CARGO", +]; +/// Directory names that turn a single slash into a path (`SRC/ENGINE`), while +/// ordinary pairs such as `READ/WRITE` remain prose. +const DIRECTORY_NAMES: [&str; 30] = [ + "SRC", + "SRC-TAURI", + "LIB", + "LIBS", + "BIN", + "DOC", + "DOCS", + "TEST", + "TESTS", + "SPEC", + "APP", + "APPS", + "PKG", + "CMD", + "INTERNAL", + "SCRIPTS", + "PUBLIC", + "ASSETS", + "DIST", + "BUILD", + "TARGET", + "VENDOR", + "NODE_MODULES", + "CONFIG", + "UTILS", + "COMPONENTS", + "PACKAGES", + "CRATES", + "EXAMPLES", + "TOOLS", +]; +/// Phrases that ask where code lives, how big it is, or what the repository +/// looked like at some moment. They are matched as whole words, so ordinary +/// nouns (a save FILE, a hot PATH, the LATEST attempt, a transaction COMMIT) +/// stay available to conceptual questions. +const TRIVIA_PHRASES: [&str; 95] = [ + // Locations and structure. + "WHERE DOES", + "WHERE DO", + "WHERE IS", + "WHERE ARE", + "LIVES IN", + "LIVE IN", + "LOCATED IN", + "FILE NAME", + "FILE NAMES", + "FILENAME", + "FILENAMES", + "FILE PATH", + "FILE PATHS", + "FILE EXTENSION", + "FILE EXTENSIONS", + "FILE STRUCTURE", + "FILE LAYOUT", + "DIRECTORY STRUCTURE", + "DIRECTORY LAYOUT", + "FOLDER STRUCTURE", + "FOLDER LAYOUT", + "REPOSITORY STRUCTURE", + "REPOSITORY LAYOUT", + "REPO STRUCTURE", + "REPO LAYOUT", + "PROJECT STRUCTURE", + "PROJECT LAYOUT", + "SOURCE TREE", + "ROOT DIRECTORY", + "ROOT FOLDER", + "SUBDIRECTORY", + "SUBDIRECTORIES", + "SUBFOLDER", + "SUBFOLDERS", + "README", + // Counts and sizes. + "HOW MANY", + "HOW LARGE", + "HOW BIG", + "HOW LONG IS", + "LINES OF CODE", + "LINE COUNT", + "LINE NUMBER", + "LINE NUMBERS", + "FILE SIZE", + "FILE SIZES", + "NUMBER OF FILES", + "NUMBER OF LINES", + "KILOBYTES", + "MEGABYTES", + // Version-control history. + "COMMITS", + "COMMIT MESSAGE", + "COMMIT MESSAGES", + "COMMIT HASH", + "COMMIT HISTORY", + "COMMIT LOG", + "WHICH COMMIT", + "WHAT COMMIT", + "FIRST COMMIT", + "LAST COMMIT", + "LATEST COMMIT", + "RECENT COMMIT", + "INITIAL COMMIT", + "COMMITTER", + "COMMITTERS", + "GIT HISTORY", + "GIT LOG", + "GIT BLAME", + "WHICH BRANCH", + "WHAT BRANCH", + "CURRENT BRANCH", + "MAIN BRANCH", + "MASTER BRANCH", + "DEFAULT BRANCH", + "FEATURE BRANCH", + "BRANCH NAME", + "PULL REQUEST", + "PULL REQUESTS", + // People. + "AUTHOR", + "AUTHORS", + "CONTRIBUTOR", + "CONTRIBUTORS", + "MAINTAINER", + "MAINTAINERS", + "WHO WROTE", + "WHO CREATED", + // Recency and time. + "MOST RECENT", + "MOST RECENTLY", + "RECENTLY", + "LATEST VERSION", + "LATEST RELEASE", + "LAST MODIFIED", + "LAST UPDATED", + "WHEN WAS", + "WHEN WERE", + "WHAT YEAR", +]; +/// Nouns that make "WHICH ..." or "WHAT ..." a location question when they +/// follow within two words, as in WHICH SOURCE FILE or WHAT CONFIG FOLDER. +const LOCATION_NOUNS: [&str; 10] = [ + "FILE", + "FILES", + "FILENAME", + "DIRECTORY", + "DIRECTORIES", + "FOLDER", + "FOLDERS", + "PATH", + "PATHS", + "EXTENSION", +]; + +/// Strips surrounding quotes, brackets, and sentence punctuation from one +/// whitespace-separated word while keeping a leading dot (`.RS`, `./SRC`). +fn word_core(word: &str) -> &str { + const WRAPPERS: &[char] = &[ + '"', '\'', '`', '(', ')', '[', ']', '{', '}', '<', '>', ',', ';', ':', '!', '?', '*', + ]; + let mut core = word.trim_start_matches(WRAPPERS); + loop { + let trimmed = core.trim_end_matches(WRAPPERS).trim_end_matches('.'); + if trimmed.len() == core.len() { + return core; + } + core = trimmed; + } +} + +fn is_file_name(word: &str) -> bool { + if TECHNOLOGY_NAMES.contains(&word) { + return false; + } + let Some((stem, extension)) = word.rsplit_once('.') else { + return false; + }; + FILE_EXTENSIONS.contains(&extension) + && (stem.is_empty() + || (stem.chars().any(|c| c.is_ascii_alphanumeric()) + && stem + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.')))) +} + +fn is_path(word: &str) -> bool { + if word.contains('\\') || word.contains("://") { + return true; + } + if !word.contains('/') { + return false; + } + let segments = word.split('/').collect::>(); + segments.len() > 2 + || segments.iter().any(|segment| { + segment.is_empty() + || matches!(*segment, "." | ".." | "~") + || is_file_name(segment) + || DIRECTORY_NAMES.contains(segment) + }) +} + +/// Whether one word names a path, a file, a bare file extension, or a +/// configuration dotfile. +pub(crate) fn is_location_word(word: &str) -> bool { + let upper = word.to_ascii_uppercase(); + let core = word_core(&upper); + core.chars() + .any(|character| character.is_ascii_alphanumeric()) + && (is_path(core) + || is_file_name(core) + || core + .strip_prefix('.') + .is_some_and(|name| DOTFILES.contains(&name))) +} + +/// Whether `text` cites a location: a path, a file name, or an extension. +fn cites_location(text: &str) -> bool { + text.split_whitespace().any(is_location_word) +} + +/// Year, dotted release version, or abbreviated commit hash. +fn is_moment_word(word: &str) -> bool { + let core = word_core(word); + let year = core.len() == 4 + && core + .parse::() + .is_ok_and(|year| (1990..=2039).contains(&year)); + let version = { + let digits = core.strip_prefix('V').unwrap_or(core); + let parts = digits.split('.').collect::>(); + parts.len() >= 3 + && parts + .iter() + .all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit())) + }; + let hash = (7..=40).contains(&core.len()) + && core.bytes().all(|b| b.is_ascii_hexdigit()) + && core.bytes().any(|b| b.is_ascii_digit()) + && core.bytes().any(|b| b.is_ascii_alphabetic()); + year || version || hash +} + +/// Whether `text` asks about, or answers with, locations or state-in-time +/// facts instead of concepts. +fn cites_trivia(text: &str) -> bool { + let upper = text.to_ascii_uppercase(); + if cites_location(&upper) || upper.split_whitespace().any(is_moment_word) { + return true; + } + let words = upper + .split(|c: char| !c.is_ascii_alphanumeric()) + .filter(|word| !word.is_empty()) + .collect::>(); + let padded = format!(" {} ", words.join(" ")); + TRIVIA_PHRASES + .iter() + .any(|phrase| padded.contains(&format!(" {phrase} "))) + || words.iter().enumerate().any(|(index, word)| { + matches!(*word, "WHICH" | "WHAT") + && words[index + 1..] + .iter() + .take(2) + .any(|next| LOCATION_NOUNS.contains(next)) + }) +} + +fn renderable(text: &str) -> bool { + text.chars() + .all(|character| (' '..='~').contains(&character)) +} + +/// Question and choice text fit the quiz layout and test concepts. +fn is_displayable_and_conceptual(question: &QQuestion) -> bool { + let choices = question.choice_texts(); + engine::quiz_question_fits(&question.q, &choices, question.answer) + && !std::iter::once(&question.q) + .chain(&choices) + .any(|text| cites_trivia(text)) +} + +/// Payload v2: a lens the learning model knows and a fitting, location-free +/// rationale for every choice. +fn has_complete_rationales(question: &QQuestion) -> bool { + question.lens().is_some() + && question.has_explained_choices() + && question.choices.iter().all(|choice| { + choice + .why() + .is_some_and(|why| learning::rationale_fits(why) && !cites_location(why)) + }) +} + +/// Policy for questions read back from a save: complete v2 questions, or +/// legacy questions whose choices are plain strings. +pub(crate) fn question_is_acceptable(question: &QQuestion) -> bool { + let legacy = + question.has_plain_choices() && (question.concept.is_none() || question.lens().is_some()); + is_displayable_and_conceptual(question) && (legacy || has_complete_rationales(question)) +} + +/// Policy for newly generated questions: payload v2 is required and every +/// string must render in the handheld font. +pub(crate) fn generated_question_is_acceptable(question: &QQuestion) -> bool { + is_displayable_and_conceptual(question) + && has_complete_rationales(question) + && renderable(&question.q) + && question + .choices + .iter() + .all(|choice| renderable(choice.text()) && choice.why().is_some_and(renderable)) +} + +/// Keeps each acceptable generated question once; failing questions are +/// dropped individually so the valid ones in a mixed batch survive. +pub(crate) fn retain_acceptable_questions(questions: Vec) -> Vec { + let mut seen = HashSet::new(); + questions + .into_iter() + .filter(generated_question_is_acceptable) + .filter(|question| seen.insert(question_identity(&question.q))) + .collect() +} + +pub(crate) fn accepted_question_batch( + questions: Vec, + expected_count: usize, +) -> Option> { + let valid = retain_acceptable_questions(questions) + .into_iter() + .take(expected_count) + .collect::>(); + (!valid.is_empty()).then_some(valid) +} + +/// Folds typographic punctuation into ASCII, collapses whitespace, and +/// uppercases, matching what the handheld font can draw. +fn display_text(text: &str) -> String { + let mut ascii = String::with_capacity(text.len()); + for character in text.chars() { + match character { + '\u{2018}' | '\u{2019}' | '\u{201B}' | '\u{2032}' => ascii.push('\''), + '\u{201C}' | '\u{201D}' | '\u{2033}' => ascii.push('"'), + '\u{2010}'..='\u{2015}' | '\u{2212}' => ascii.push('-'), + '\u{2026}' => ascii.push_str("..."), + other => ascii.push(other), + } + } + ascii + .split_whitespace() + .collect::>() + .join(" ") + .to_ascii_uppercase() +} + +fn normalized_question(question: QQuestion) -> QQuestion { + let concept = question.concept.map(|concept| { + Concept::parse(&concept).map_or_else( + || concept.trim().to_string(), + |lens| concept_key(lens).to_string(), + ) + }); + QQuestion { + q: display_text(&question.q), + concept, + choices: question + .choices + .into_iter() + .map(|choice| match choice { + QChoice::Plain(text) => QChoice::Plain(display_text(&text)), + QChoice::Explained { text, why } => QChoice::Explained { + text: display_text(&text), + why: display_text(&why), + }, + }) + .collect(), + answer: question.answer, + } +} + +/// Reads the complete JSON values of an array that starts right after its +/// opening bracket, stopping at the first malformed or truncated element so a +/// response cut off mid-array still yields its finished questions. +fn leading_array_values(after_bracket: &str) -> Vec { + let mut values = Vec::new(); + let mut rest = after_bracket; + loop { + rest = rest.trim_start(); + if rest.is_empty() || rest.starts_with(']') { + return values; + } + let mut stream = serde_json::Deserializer::from_str(rest).into_iter::(); + match stream.next() { + Some(Ok(value)) => values.push(value), + _ => return values, + } + rest = rest[stream.byte_offset()..].trim_start(); + match rest.strip_prefix(',') { + Some(after_comma) => rest = after_comma, + None => return values, + } + } +} + +/// Finds the question array in free-form provider text: the first bracket +/// whose elements are question objects, ignoring prose and code fences. +fn question_values(response: &str) -> Result, String> { + let mut saw_bracket = false; + for (index, _) in response.match_indices('[') { + saw_bracket = true; + let values = leading_array_values(&response[index + 1..]); + if values.iter().any(|value| value.get("q").is_some()) { + return Ok(values); + } + } + Err(if saw_bracket { + "UNPARSEABLE QUESTIONS" + } else { + "NO JSON IN RESPONSE" + } + .to_string()) +} + +/// Parses, normalizes, and filters one provider response into at most `count` +/// acceptable questions. +pub(crate) fn parse_generated_questions( + response: &str, + count: usize, +) -> Result, String> { + let questions = question_values(response)? + .into_iter() + .filter_map(|value| serde_json::from_value::(value).ok()) + .map(normalized_question) + .collect(); + accepted_question_batch(questions, count) + .ok_or_else(|| "INCOMPLETE OR INVALID QUESTIONS".to_string()) +} + +/// Parses one saved batch leniently: a question this build cannot read or no +/// longer accepts is skipped without discarding its batch mates. +fn saved_batch(value: serde_json::Value) -> Option { + #[derive(Deserialize)] + struct StoredBatch { + level: u32, + #[serde(default)] + questions: Vec, + } + let stored = serde_json::from_value::(value).ok()?; + let questions = stored + .questions + .into_iter() + .filter_map(|question| serde_json::from_value::(question).ok()) + .filter(question_is_acceptable) + .collect::>(); + (stored.level > 0 && !questions.is_empty()).then_some(SavedQuestionBatch { + level: stored.level, + questions, + }) +} + +/// Saved batches, oldest first: batches from the legacy Claude-only key +/// predate every batch under the provider-neutral key. +pub(crate) fn load_saved_question_batches(path: &Path) -> Result, String> { + let save = save::SaveFile::open_or_create(path)?; + Ok( + [LEGACY_CLAUDE_QUESTION_BATCHES_KEY, AI_QUESTION_BATCHES_KEY] + .into_iter() + .flat_map(|key| save.get::>(key).unwrap_or_default()) + .filter_map(saved_batch) + .collect(), + ) +} + +pub(crate) fn persist_ai_question_batch( + path: &Path, + level: u32, + questions: &[QQuestion], +) -> Result<(), String> { + if level == 0 || questions.is_empty() || !questions.iter().all(generated_question_is_acceptable) + { + return Err("INVALID AI QUESTION BATCH".to_string()); + } + let batch = serde_json::to_value(SavedQuestionBatch { + level, + questions: questions.to_vec(), + }) + .map_err(|_| "COULD NOT SERIALIZE CARTRIDGE SAVE".to_string())?; + // Stored batches stay raw JSON here, so entries this build cannot parse + // are carried forward instead of being dropped by the append. + save::update( + path, + AI_QUESTION_BATCHES_KEY, + |batches: &mut Vec| batches.push(batch), + ) +} + +pub(crate) fn load_quiz_progress(path: &Path) -> Result { + Ok(save::SaveFile::open_or_create(path)? + .get::(QUIZ_PROGRESS_KEY) + .unwrap_or_default()) +} + +pub(crate) fn load_retired_questions(path: &Path) -> Result, String> { + Ok(load_quiz_progress(path)?.retired()) +} + +/// Records one committed answer: progress lists and lens mastery update in a +/// single atomic save update. +pub(crate) fn persist_answer_evidence( + path: &Path, + evidence: &AnswerEvidence, +) -> Result<(), String> { + save::update( + path, + QUIZ_PROGRESS_KEY, + |progress: &mut SavedQuizProgress| progress.record(evidence), + ) +} + +/// Everything a cartridge load hands the engine from the save. +#[derive(Debug, Default)] +pub(crate) struct CartridgeQuestions { + pub(crate) questions: Vec, + pub(crate) batch_ends: Vec, + pub(crate) lessons: Vec, + pub(crate) mastery: Mastery, +} + +/// Builds the playable queue and lesson journal from saved batches (oldest +/// first). Retired questions leave the queue; missed questions stay in it as +/// reviews. Every recorded question becomes one journal lesson. A question +/// repeated across batches is queued and journaled once. +pub(crate) fn cartridge_questions( + batches: Vec, + progress: SavedQuizProgress, +) -> CartridgeQuestions { + let retired = progress.retired(); + let missed = progress.missed(); + let mut seen = HashSet::new(); + let mut loaded = CartridgeQuestions { + mastery: progress.mastery, + ..CartridgeQuestions::default() + }; + for batch in batches { + let batch_start = loaded.questions.len(); + for question in batch.questions { + let identity = question_identity(&question.q); + if !seen.insert(identity.clone()) { + continue; + } + let outstanding = missed.contains(&identity); + if outstanding || retired.contains(&identity) { + loaded.lessons.push(question.lesson(outstanding)); + } + if !retired.contains(&identity) { + loaded.questions.push(question.quiz_question(outstanding)); + } + } + if loaded.questions.len() > batch_start { + loaded.batch_ends.push(loaded.questions.len()); + } + } + loaded +} + +pub(crate) fn load_cartridge_questions(path: &Path) -> Result { + Ok(cartridge_questions( + load_saved_question_batches(path)?, + load_quiz_progress(path)?, + )) +} + +/// Maps a freshly generated batch to engine questions, leaving out any the +/// player already retired. +pub(crate) fn playable_new_questions( + questions: Vec, + retired: &HashSet, +) -> Vec { + questions + .into_iter() + .filter(|question| !retired.contains(&question_identity(&question.q))) + .map(|question| question.quiz_question(false)) + .collect() +} + +/// What the next batch should know about the player. +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct LearnerState { + /// The lens with the most misses relative to its evidence. + pub(crate) weakest: Option, + /// Normalized stems of previously generated questions, newest first. + pub(crate) asked: Vec, +} + +/// The lens the player misses most relative to their evidence for it; ties go +/// to the lens with more misses, then to the earlier lens. +pub(crate) fn weakest_lens(mastery: &Mastery) -> Option { + let mut weakest: Option<(Concept, u64, u64)> = None; + for (concept, record) in mastery { + let missed = u64::from(record.missed); + if missed == 0 { + continue; + } + let attempts = missed + u64::from(record.evidence()); + let weaker = weakest.is_none_or(|(_, best_missed, best_attempts)| { + let (left, right) = (missed * best_attempts, best_missed * attempts); + left > right || (left == right && missed > best_missed) + }); + if weaker { + weakest = Some((*concept, missed, attempts)); + } + } + weakest.map(|(concept, _, _)| concept) +} + +pub(crate) fn learner_state( + batches: &[SavedQuestionBatch], + progress: &SavedQuizProgress, +) -> LearnerState { + let mut seen = HashSet::new(); + let asked = batches + .iter() + .rev() + .flat_map(|batch| batch.questions.iter().rev()) + .map(|question| question_identity(&question.q)) + .filter(|identity| !identity.is_empty() && seen.insert(identity.clone())) + .take(MAX_AVOIDED_STEMS) + .collect(); + LearnerState { + weakest: weakest_lens(&progress.mastery), + asked, + } +} + +pub(crate) fn load_learner_state(path: &Path) -> Result { + Ok(learner_state( + &load_saved_question_batches(path)?, + &load_quiz_progress(path)?, + )) +} + +/// Questions in a batch that must use the level's focus lenses: two thirds, +/// rounded up (4 of 6). +fn focus_minimum(count: usize) -> usize { + (count * 2).div_ceil(3) +} + +fn lens_guide(concept: Concept) -> &'static str { + match concept { + Concept::Purpose => "what the project or a component is for and whom it serves", + Concept::Responsibility => "which component owns a duty, and what it must not do", + Concept::Interaction => "how components collaborate, hand off data, or react to events", + Concept::Invariant => "a guarantee that must always hold, and what protects it", + Concept::Tradeoff => "a design choice, what it buys, and what it costs", + } +} + +pub(crate) fn ai_question_prompt( + project_name: &str, + level: u32, + count: usize, + project_brief: &str, + learner: &LearnerState, +) -> String { + let tier = match level { + 0 | 1 => "INITIATE: establish what the project is for and which component owns each duty", + 2 | 3 => "ADEPT: connect how components interact and why the design accepts its tradeoffs", + _ => "ORACLE-BOUND: probe the invariants the design protects and predict consequences", + }; + let lenses = Concept::ALL + .iter() + .map(|concept| format!("- {}: {}", concept_key(*concept), lens_guide(*concept))) + .collect::>() + .join("\n"); + let lens_names = Concept::ALL + .iter() + .map(|concept| concept_key(*concept)) + .collect::>() + .join("|"); + let focus = Concept::focus_for_level(level) + .iter() + .map(|concept| concept_key(*concept)) + .collect::>() + .join(" or "); + let focus_count = focus_minimum(count); + let transfer = if level >= 4 { + format!( + "TRANSFER: at least {} questions must be PREDICT questions. Begin each with \"PREDICT:\", describe a plausible change or failure (a removed check, a reordered step, a crash mid-operation, a new kind of input), and ask what the design implies would happen. Answer from the project's invariants and tradeoffs, never from implementation locations.\n\n", + count.min(2) + ) + } else { + String::new() + }; + let weakest = learner.weakest.map_or_else( + || "WEAKEST LENS: none recorded yet.".to_string(), + |concept| { + let lens = concept_key(concept); + format!("WEAKEST LENS: {lens}. The player misses {lens} questions most often; include at least one {lens} question aimed at a common misconception about it.") + }, + ); + let asked = if learner.asked.is_empty() { + "(none yet)".to_string() + } else { + learner + .asked + .iter() + .map(|stem| format!("- {stem}")) + .collect::>() + .join("\n") + }; + format!( + "You write questions for a retro handheld quiz game that teaches how a software project is designed. Generate exactly {count} multiple-choice questions at difficulty level {level} ({tier}).\n\nLENSES: tag every question with exactly one concept lens:\n{lenses}\nFOCUS: at least {focus_count} of the {count} questions must use the {focus} lens.\n\n{transfer}CONCEPTS ONLY: test the project's architecture, purpose, domain model, component responsibilities, interactions, invariants, tradeoffs, design rationale, or enduring behavior. Every question must still make sense if the project were reorganized and all implementation locations changed.\n\nNEVER ask about file names, paths, directories, or extensions; where code lives; repository structure; counts, sizes, or lines; dates, times, or versions; branches or commits; authors or contributors; ordering or recency; or any other state-in-time fact. Never use those facts as choices or rationales.\n\nCHOICES: exactly 4 non-empty, distinct choices and exactly one correct answer. Each wrong choice must be a plausible misconception a newcomer to this project might really hold, similar in length and style to the correct choice; never a joke or an obviously absurd option. Vary which position holds the correct answer across the batch.\n\nRATIONALES: every choice has a \"why\" of at most 90 characters. For the correct choice, explain why it holds. For each wrong choice, name the misconception it represents and why it fails. A why must never cite file names, paths, or extensions.\n\nDISPLAY LIMITS: plain ASCII only. Each question must wrap into at most 4 lines of 31 characters. Each choice must be at most 31 characters. Do not truncate words or sentences. Do not repeat questions.\n\nLEARNER STATE:\n{weakest}\nALREADY ASKED (never repeat these or close paraphrases of them):\n{asked}\n\nRespond with ONLY a JSON array, no prose and no code fences, where N is the 0-based index of the correct choice:\n[{{\"q\":\"...\",\"concept\":\"{lens_names}\",\"choices\":[{{\"text\":\"...\",\"why\":\"...\"}},{{\"text\":\"...\",\"why\":\"...\"}},{{\"text\":\"...\",\"why\":\"...\"}},{{\"text\":\"...\",\"why\":\"...\"}}],\"answer\":N}}]\n\nPROJECT: {project_name}\n{project_brief}", + ) +} + +/// Characters `prompt` occupies on a Windows command line, where argument +/// quoting escapes every double quote and backslash. +pub(crate) fn command_line_chars(prompt: &str) -> usize { + prompt.chars().count() + prompt.matches(['"', '\\']).count() +} + +/// [`ai_question_prompt`], shortening the project brief when needed so the +/// whole prompt stays within [`MAX_PROMPT_CHARS`]. +pub(crate) fn bounded_ai_question_prompt( + project_name: &str, + level: u32, + count: usize, + project_brief: &str, + learner: &LearnerState, +) -> String { + let mut brief = project_brief; + loop { + let prompt = ai_question_prompt(project_name, level, count, brief, learner); + let overflow = command_line_chars(&prompt).saturating_sub(MAX_PROMPT_CHARS); + if overflow == 0 || brief.is_empty() { + return prompt; + } + let mut end = brief.len().saturating_sub(overflow); + while !brief.is_char_boundary(end) { + end -= 1; + } + brief = &brief[..end]; + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + + const RATIONALE: &str = "THIS NAMES THE MODEL THE DESIGN DEPENDS ON."; + + /// A complete payload v2 question with `answer` 0. + pub(crate) fn question(text: &str, choices: &[&str]) -> QQuestion { + QQuestion { + q: text.to_string(), + concept: Some("responsibility".to_string()), + choices: choices + .iter() + .map(|choice| QChoice::Explained { + text: (*choice).to_string(), + why: RATIONALE.to_string(), + }) + .collect(), + answer: 0, + } + } + + fn legacy_question(text: &str, choices: &[&str]) -> QQuestion { + QQuestion { + q: text.to_string(), + concept: None, + choices: choices + .iter() + .map(|choice| QChoice::Plain((*choice).to_string())) + .collect(), + answer: 0, + } + } + + fn engine_state_question() -> QQuestion { + question( + "WHAT SHOULD OWN GAMEPLAY STATE?", + &[ + "THE GAME ENGINE", + "THE DEVICE SHELL", + "THE STYLES", + "THE VIEW", + ], + ) + } + + fn temporary_cartridge_path() -> std::path::PathBuf { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!( + "codequest-questions-test-{}-{unique}", + std::process::id() + )) + } + + fn remove_save(path: &Path) { + let _ = std::fs::remove_file(save::path_for(path)); + } + + fn evidence(question: &str, concept: Option, correct: bool) -> AnswerEvidence { + AnswerEvidence { + question: question.to_string(), + concept, + correct, + review: false, + } + } + + const LEGACY_BATCHES: &str = r#"[ + { + "level": 1, + "questions": [{ + "q": "WHAT SHOULD OWN GAMEPLAY STATE?", + "choices": [ + "THE GAME ENGINE", + "THE DEVICE SHELL", + "THE STYLES", + "THE VIEW" + ], + "answer": 0 + }] + }, + { + "level": 2, + "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 + }] + } + ]"#; + + const V2_RESPONSE: &str = r#"[ + { + "q": "Why does the engine own every game rule?", + "concept": "Responsibilities", + "choices": [ + {"text": "So the shell only draws frames", "why": "One owner keeps rules consistent; the shell just shows pixels."}, + {"text": "So the UI can skip the engine", "why": "Misconception: a bypass would split the rules across layers."}, + {"text": "To make the shell faster", "why": "Misconception: speed is not why rules live in one place."}, + {"text": "To store rules in the styles", "why": "Misconception: presentation cannot enforce game rules."} + ], + "answer": 0 + } + ]"#; + + #[test] + fn legacy_saved_questions_still_deserialize_and_play_without_rationales() { + let batches: Vec = serde_json::from_str(LEGACY_BATCHES).unwrap(); + let legacy = &batches[0].questions[0]; + assert!(legacy.has_plain_choices()); + assert!(question_is_acceptable(legacy)); + assert!( + !generated_question_is_acceptable(legacy), + "new generations must use payload v2" + ); + + let playable = legacy.quiz_question(false); + assert_eq!(playable.question, "WHAT SHOULD OWN GAMEPLAY STATE?"); + assert_eq!(playable.choices[0], "THE GAME ENGINE"); + assert_eq!(playable.concept, None); + assert!(playable.rationales.is_empty()); + + let saved_again = serde_json::to_value(&batches).unwrap(); + assert_eq!( + saved_again, + serde_json::from_str::(LEGACY_BATCHES).unwrap(), + "legacy questions round-trip unchanged" + ); + } + + #[test] + fn payload_v2_maps_rationales_in_choice_order_and_normalizes_the_lens() { + let questions = parse_generated_questions(V2_RESPONSE, 6).unwrap(); + assert_eq!(questions.len(), 1); + let generated = &questions[0]; + assert_eq!(generated.q, "WHY DOES THE ENGINE OWN EVERY GAME RULE?"); + assert_eq!(generated.concept.as_deref(), Some("responsibility")); + + let playable = generated.quiz_question(true); + assert_eq!(playable.concept, Some(Concept::Responsibility)); + assert_eq!(playable.choices.len(), 4); + assert_eq!(playable.rationales.len(), 4); + assert_eq!(playable.choices[1], "SO THE UI CAN SKIP THE ENGINE"); + assert!(playable.rationales[1].starts_with("MISCONCEPTION: A BYPASS")); + assert!(playable.review); + + let saved = serde_json::to_value(generated).unwrap(); + assert_eq!(saved["concept"], "responsibility"); + assert_eq!( + saved["choices"][0]["text"], + "SO THE SHELL ONLY DRAWS FRAMES" + ); + } + + #[test] + fn generated_questions_need_a_known_lens_and_a_fitting_why_for_every_choice() { + assert!(generated_question_is_acceptable(&engine_state_question())); + + let mut no_lens = engine_state_question(); + no_lens.concept = None; + assert!(!generated_question_is_acceptable(&no_lens)); + + let mut unknown_lens = engine_state_question(); + unknown_lens.concept = Some("file layout".into()); + assert!(!generated_question_is_acceptable(&unknown_lens)); + + let mut plain_choice = engine_state_question(); + plain_choice.choices[2] = QChoice::Plain("THE STYLES".into()); + assert!(!generated_question_is_acceptable(&plain_choice)); + assert!( + !question_is_acceptable(&plain_choice), + "half-explained saves are not legacy" + ); + + let mut missing_why = engine_state_question(); + missing_why.choices[1] = QChoice::Explained { + text: "THE DEVICE SHELL".into(), + why: String::new(), + }; + assert!(!generated_question_is_acceptable(&missing_why)); + + let mut long_why = engine_state_question(); + long_why.choices[3] = QChoice::Explained { + text: "THE VIEW".into(), + why: "A RATIONALE THAT RUNS ON ".repeat(8), + }; + assert!(!generated_question_is_acceptable(&long_why)); + + let mut located_why = engine_state_question(); + located_why.choices[0] = QChoice::Explained { + text: "THE GAME ENGINE".into(), + why: "IT IS DEFINED IN ENGINE.RS UNDER SRC-TAURI/SRC.".into(), + }; + assert!(!generated_question_is_acceptable(&located_why)); + + let mut unrenderable = engine_state_question(); + unrenderable.q = "WHAT SHOULD OWN GAMEPLAY STATE \u{2192} ENGINE?".into(); + assert!(!generated_question_is_acceptable(&unrenderable)); + } + + #[test] + fn valid_questions_survive_a_mixed_ai_batch() { + let valid = engine_state_question(); + let file_trivia = question( + "WHICH FILE DEFINES THE ENGINE?", + &["engine.rs", "main.js", "styles.css", "README.md"], + ); + let mut missing_lens = engine_state_question(); + missing_lens.q = "WHY DOES THE ENGINE OWN STATE?".into(); + missing_lens.concept = None; + let overflowing = question( + "WHY KEEP OUTPUT WITHIN A FIXED PRESENTATION BOUNDARY?", + &[ + "THIS RESPONSE CANNOT FIT IN THE AVAILABLE CHOICE ROW", + "SECOND", + "THIRD", + "FOURTH", + ], + ); + + let accepted = accepted_question_batch( + vec![ + valid.clone(), + file_trivia.clone(), + missing_lens, + overflowing, + valid.clone(), + ], + 6, + ) + .expect("the valid question should remain playable"); + assert_eq!(accepted, vec![valid], "invalid and repeated questions drop"); + assert!(accepted_question_batch(vec![file_trivia], 1).is_none()); + } + + #[test] + fn duplicate_choices_are_rejected() { + let ambiguous = question( + "WHAT SHOULD OWN GAMEPLAY STATE?", + &[ + "THE GAME ENGINE", + "THE GAME ENGINE", + "THE VIEW", + "THE DEVICE SHELL", + ], + ); + + assert!(!question_is_acceptable(&ambiguous)); + assert!(!generated_question_is_acceptable(&ambiguous)); + } + + #[test] + fn conceptual_questions_may_use_ordinary_nouns_that_once_looked_like_trivia() { + let conceptual = [ + "WHY WRITE THE SAVE FILE ATOMICALLY?", + "WHY DOES THE LATEST ATTEMPT DECIDE REVIEW?", + "WHAT DOES THE HOT PATH AVOID?", + "WHY USE A READ/WRITE LOCK?", + "WHEN DOES AN ANSWER COMMIT?", + "WHY SHOW THE BRANCH ON THE LABEL?", + "WHY SEND FRAMES AS RAW BYTES?", + "WHY PICK A DIRECTORY, NOT A FILE?", + "WHY BUILD THE SHELL ON NODE.JS?", + "WHY NOT CACHE STATE...CRASHES?", + "WHAT DO BROWSER EXTENSIONS ADD?", + "WHY KEEP OLDEST LESSONS FIRST?", + "WHY DOES THE SERVER TRUST INPUT?", + ]; + for text in conceptual { + let candidate = question( + text, + &[ + "TO KEEP ONE OWNER", + "TO DUPLICATE STATE", + "TO HIDE FAILURES", + "TO SKIP VALIDATION", + ], + ); + assert!(generated_question_is_acceptable(&candidate), "{text}"); + } + + let conceptual_choices = question( + "WHY WRITE THE SAVE ATOMICALLY?", + &[ + "A CRASH CANNOT TEAR THE FILE", + "IT MAKES FILES SMALLER", + "IT SKIPS THE LATEST WRITE", + "IT KEEPS THE PATH SHORT", + ], + ); + assert!(generated_question_is_acceptable(&conceptual_choices)); + } + + #[test] + fn location_and_state_in_time_trivia_is_still_rejected() { + let choices = [ + "THE GAME ENGINE", + "THE DEVICE SHELL", + "THE STYLES", + "THE VIEW", + ]; + let trivia = [ + "WHICH FILE OWNS GAME STATE?", + "WHAT SOURCE FILE OWNS INPUT?", + "WHICH CONFIG FOLDER HOLDS KEYS?", + "WHERE DOES THE RENDERER LIVE?", + "HOW MANY FILES ARE IN THE REPOSITORY?", + "WHAT DID THE LATEST COMMIT ADD?", + "HOW MANY COMMITS TOUCHED SAVES?", + "WHO IS THE MAIN AUTHOR?", + "WHICH BRANCH IS CHECKED OUT?", + "WHAT CHANGED MOST RECENTLY?", + "WHAT DOES ENGINE.RS OWN?", + "WHAT DOES SRC/ENGINE OWN?", + "WHAT DOES ./BUILD DO?", + "WHY DOES MAIN.C EXIST?", + "WHAT DOES DEPLOY.SH RUN?", + "WHY ARE .RS SOURCES PREFERRED?", + "WHAT DOES .GITIGNORE HIDE?", + "WHAT CHANGED IN 2024?", + "WHAT DID V0.2.4 SHIP?", + "WHAT DID 4067E07 CHANGE?", + "WHAT DOES THE README PROMISE?", + "WHAT IS THE PROJECT STRUCTURE?", + ]; + for text in trivia { + assert!( + !generated_question_is_acceptable(&question(text, &choices)), + "{text}" + ); + assert!( + !question_is_acceptable(&legacy_question(text, &choices)), + "{text}" + ); + } + + let file_answers = question( + "WHAT OWNS GAME STATE?", + &["engine.rs", "main.js", "styles.css", "README.md"], + ); + assert!(!generated_question_is_acceptable(&file_answers)); + let path_answers = question( + "WHAT OWNS GAME STATE?", + &["SRC/ENGINE", "THE SHELL", "THE STYLES", "THE VIEW"], + ); + assert!(!generated_question_is_acceptable(&path_answers)); + } + + #[test] + fn accepted_ai_questions_are_conceptual_and_fit_the_quiz_layout() { + let conceptual = question( + "WHY SEPARATE GAME STATE FROM THE UI?", + &[ + "TO KEEP RESPONSIBILITIES CLEAR", + "TO HIDE FAILURES", + "TO COUPLE COMPONENTS", + "TO DUPLICATE STATE", + ], + ); + assert!(question_is_acceptable(&conceptual)); + assert!(generated_question_is_acceptable(&conceptual)); + + let file_trivia = question( + "WHICH FILE OWNS GAME STATE?", + &["engine.rs", "main.js", "styles.css", "README.md"], + ); + assert!(!question_is_acceptable(&file_trivia)); + + let state_trivia = question( + "HOW MANY FILES ARE IN THE REPOSITORY?", + &["ONE", "TWO", "THREE", "FOUR"], + ); + assert!(!question_is_acceptable(&state_trivia)); + + let overflowing = question( + &"CONCEPTUAL WORD ".repeat(40), + &[ + "THIS CHOICE IS LONGER THAN THE DISPLAY CAN POSSIBLY SHOW", + "SECOND", + "THIRD", + "FOURTH", + ], + ); + assert!(!question_is_acceptable(&overflowing)); + } + + #[test] + fn invalid_ai_results_are_rejected_instead_of_truncated() { + let valid = engine_state_question(); + let file_trivia = question( + "WHICH FILE DEFINES THE ENGINE?", + &["engine.rs", "main.js", "styles.css", "README.md"], + ); + let overflowing = question( + "WHY KEEP OUTPUT WITHIN A FIXED PRESENTATION BOUNDARY?", + &[ + "THIS RESPONSE CANNOT FIT IN THE AVAILABLE CHOICE ROW", + "SECOND", + "THIRD", + "FOURTH", + ], + ); + + let accepted = retain_acceptable_questions(vec![valid.clone(), file_trivia, overflowing]); + assert_eq!(accepted, vec![valid]); + } + + #[test] + fn provider_responses_are_found_inside_prose_fences_and_truncation() { + let wrapped = format!( + "Here are the questions [as requested]:\n```json\n{V2_RESPONSE}\n```\nLet me know [if] you need more." + ); + assert_eq!(parse_generated_questions(&wrapped, 6).unwrap().len(), 1); + + let second = V2_RESPONSE + .trim() + .trim_start_matches('[') + .trim_end_matches(']') + .replace("engine own every game rule", "shell stay free of rules"); + let truncated = format!( + "[{},{second}, {{\"q\": \"WHY IS THIS CUT", + V2_RESPONSE + .trim() + .trim_start_matches('[') + .trim_end_matches(']') + ); + let salvaged = parse_generated_questions(&truncated, 6).unwrap(); + assert_eq!( + salvaged.len(), + 2, + "finished questions survive a cut-off reply" + ); + assert_eq!(salvaged[1].q, "WHY DOES THE SHELL STAY FREE OF RULES?"); + + let typographic = V2_RESPONSE.replace("Why does", "\u{201C}Why\u{201D} does"); + assert_eq!( + parse_generated_questions(&typographic, 6).unwrap()[0].q, + "\"WHY\" DOES THE ENGINE OWN EVERY GAME RULE?" + ); + + assert_eq!( + parse_generated_questions("no questions today", 6).unwrap_err(), + "NO JSON IN RESPONSE" + ); + assert_eq!( + parse_generated_questions("[not json]", 6).unwrap_err(), + "UNPARSEABLE QUESTIONS" + ); + let legacy_only = r#"[{"q":"WHAT OWNS STATE?","choices":["A","B","C","D"],"answer":0}]"#; + assert_eq!( + parse_generated_questions(legacy_only, 6).unwrap_err(), + "INCOMPLETE OR INVALID QUESTIONS" + ); + } + + #[test] + fn answer_evidence_retires_correct_answers_and_keeps_misses_for_review() { + let mut progress = SavedQuizProgress::default(); + let lens = Some(Concept::Invariant); + + progress.record(&evidence("WHY WRITE SAVES ATOMICALLY?", lens, false)); + progress.record(&evidence(" why write saves atomically? ", lens, false)); + assert_eq!(progress.missed_questions, ["WHY WRITE SAVES ATOMICALLY?"]); + assert!(progress.answered_questions.is_empty()); + assert!(progress.retired().is_empty(), "a miss never retires"); + + progress.record(&AnswerEvidence { + review: true, + ..evidence("WHY WRITE SAVES ATOMICALLY?", lens, true) + }); + assert!(progress.missed_questions.is_empty()); + assert_eq!(progress.answered_questions, ["WHY WRITE SAVES ATOMICALLY?"]); + assert_eq!( + progress.mastery[&Concept::Invariant], + learning::LensRecord { + first_try: 0, + redeemed: 1, + missed: 2 + } + ); + + progress.record(&evidence("", lens, true)); + assert_eq!(progress.answered_questions.len(), 1); + } + + #[test] + fn answered_question_progress_is_namespaced_and_idempotent() { + let path = temporary_cartridge_path(); + let mut save = save::SaveFile::open_or_create(&path).unwrap(); + save.set( + AI_QUESTION_BATCHES_KEY, + &serde_json::json!([{ "level": 1, "questions": [] }]), + ) + .unwrap(); + + persist_answer_evidence(&path, &evidence("WHAT OWNS GAMEPLAY STATE?", None, true)).unwrap(); + persist_answer_evidence( + &path, + &evidence(" what owns gameplay state? ", None, true), + ) + .unwrap(); + + let progress = load_quiz_progress(&path).unwrap(); + assert_eq!(progress.answered_questions, ["WHAT OWNS GAMEPLAY STATE?"]); + assert!(progress.mastery.is_empty(), "legacy questions have no lens"); + let reloaded = save::SaveFile::open_or_create(&path).unwrap(); + assert!(reloaded + .get::(AI_QUESTION_BATCHES_KEY) + .is_some()); + + remove_save(&path); + } + + #[test] + fn progress_from_earlier_builds_loads_as_retired_questions() { + let path = temporary_cartridge_path(); + let mut save = save::SaveFile::open_or_create(&path).unwrap(); + save.set( + QUIZ_PROGRESS_KEY, + &serde_json::json!({ "answered_questions": ["WHAT SHOULD OWN GAMEPLAY STATE?"] }), + ) + .unwrap(); + + let progress = load_quiz_progress(&path).unwrap(); + assert!(progress.missed_questions.is_empty()); + assert!(progress.mastery.is_empty()); + assert!(load_retired_questions(&path) + .unwrap() + .contains("WHAT SHOULD OWN GAMEPLAY STATE?")); + + persist_answer_evidence( + &path, + &evidence("WHY KEEP THE SHELL THIN?", Some(Concept::Tradeoff), false), + ) + .unwrap(); + let stored = save::SaveFile::open_or_create(&path) + .unwrap() + .get::(QUIZ_PROGRESS_KEY) + .unwrap(); + assert_eq!( + stored, + serde_json::json!({ + "answered_questions": ["WHAT SHOULD OWN GAMEPLAY STATE?"], + "missed_questions": ["WHY KEEP THE SHELL THIN?"], + "mastery": { "tradeoff": { "first_try": 0, "redeemed": 0, "missed": 1 } } + }) + ); + + remove_save(&path); + } + + #[test] + fn concurrent_batches_and_answers_all_reach_the_save() { + let path = temporary_cartridge_path(); + std::thread::scope(|scope| { + for batch in 0..4 { + let path = &path; + scope.spawn(move || { + let mut generated = engine_state_question(); + generated.q = format!("WHY DOES LAYER {batch} OWN STATE?"); + persist_ai_question_batch(path, 1, &[generated]).unwrap(); + }); + } + for answer in 0..4 { + let path = &path; + scope.spawn(move || { + persist_answer_evidence( + path, + &evidence( + &format!("WHY IS RULE {answer} FIXED?"), + Some(Concept::Purpose), + answer % 2 == 0, + ), + ) + .unwrap(); + }); + } + }); + + assert_eq!(load_saved_question_batches(&path).unwrap().len(), 4); + let progress = load_quiz_progress(&path).unwrap(); + assert_eq!(progress.answered_questions.len(), 2); + assert_eq!(progress.missed_questions.len(), 2); + assert_eq!(progress.mastery[&Concept::Purpose].evidence(), 2); + assert_eq!(progress.mastery[&Concept::Purpose].missed, 2); + + remove_save(&path); + } + + #[test] + fn saving_a_batch_keeps_entries_this_build_cannot_read() { + let path = temporary_cartridge_path(); + let mut save = save::SaveFile::open_or_create(&path).unwrap(); + let future = serde_json::json!({ "level": 9, "questions": [{ "format": 3 }] }); + save.set(AI_QUESTION_BATCHES_KEY, &vec![future.clone()]) + .unwrap(); + + persist_ai_question_batch(&path, 2, &[engine_state_question()]).unwrap(); + + let stored = save::SaveFile::open_or_create(&path) + .unwrap() + .get::>(AI_QUESTION_BATCHES_KEY) + .unwrap(); + assert_eq!(stored.len(), 2); + assert_eq!(stored[0], future); + let playable = load_saved_question_batches(&path).unwrap(); + assert_eq!(playable.len(), 1, "unreadable batches are skipped on load"); + assert_eq!(playable[0].level, 2); + + remove_save(&path); + } + + #[test] + fn generated_ai_batch_is_saved_without_replacing_other_game_data() { + let path = temporary_cartridge_path(); + let mut save = save::SaveFile::open_or_create(&path).unwrap(); + save.set("quest.progress", &serde_json::json!({ "bosses": 2 })) + .unwrap(); + let generated = vec![engine_state_question()]; + + persist_ai_question_batch(&path, 3, &generated).unwrap(); + assert!(persist_ai_question_batch(&path, 3, &[legacy_question("WHY?", &[])]).is_err()); + + let reloaded = save::SaveFile::open_or_create(&path).unwrap(); + assert_eq!( + reloaded.get::("quest.progress"), + Some(serde_json::json!({ "bosses": 2 })) + ); + let batches = reloaded + .get::>(AI_QUESTION_BATCHES_KEY) + .unwrap(); + assert_eq!(batches.len(), 1); + assert_eq!(batches[0].level, 3); + assert_eq!(batches[0].questions, generated); + + remove_save(&path); + } + + #[test] + fn cartridge_reload_combines_legacy_and_current_ai_batches_oldest_first() { + let path = temporary_cartridge_path(); + let mut save = save::SaveFile::open_or_create(&path).unwrap(); + let current = SavedQuestionBatch { + level: 2, + questions: vec![question( + "WHY KEEP THE DEVICE SHELL THIN?", + &[ + "TO CENTRALIZE GAME RULES", + "TO DUPLICATE GAME STATE", + "TO HIDE ENGINE OUTPUT", + "TO BYPASS THE ENGINE", + ], + )], + }; + let legacy = SavedQuestionBatch { + level: 1, + questions: vec![legacy_question( + "WHAT SHOULD OWN GAMEPLAY STATE?", + &[ + "THE GAME ENGINE", + "THE DEVICE SHELL", + "THE STYLES", + "THE VIEW", + ], + )], + }; + save.set(AI_QUESTION_BATCHES_KEY, &[current]).unwrap(); + save.set(LEGACY_CLAUDE_QUESTION_BATCHES_KEY, &[legacy]) + .unwrap(); + + let batches = load_saved_question_batches(&path).unwrap(); + + assert_eq!(batches.len(), 2); + assert_eq!( + batches[0].level, 1, + "the legacy key holds the older batches" + ); + assert_eq!(batches[1].level, 2); + remove_save(&path); + } + + #[test] + fn cartridge_load_retires_answers_reviews_misses_and_journals_both() { + let batches: Vec = serde_json::from_str(LEGACY_BATCHES).unwrap(); + let mut explained = engine_state_question(); + explained.q = "WHY DOES THE ENGINE OWN STATE?".into(); + explained.answer = 1; + let mut unanswered = engine_state_question(); + unanswered.q = "WHY DOES THE SHELL DRAW ONLY?".into(); + let batches = [ + batches, + vec![SavedQuestionBatch { + level: 3, + questions: vec![ + explained.clone(), + unanswered.clone(), + // Repeated by the provider in a later batch. + batches_first_question(), + ], + }], + ] + .concat(); + let mut progress = SavedQuizProgress::default(); + progress.record(&evidence("WHAT SHOULD OWN GAMEPLAY STATE?", None, true)); + progress.record(&evidence("WHY KEEP THE DEVICE SHELL THIN?", None, false)); + progress.record(&evidence( + &explained.q, + Some(Concept::Responsibility), + false, + )); + + let loaded = cartridge_questions(batches, progress.clone()); + + let queued = loaded + .questions + .iter() + .map(|question| (question.question.as_str(), question.review)) + .collect::>(); + assert_eq!( + queued, + [ + ("WHY KEEP THE DEVICE SHELL THIN?", true), + ("WHY DOES THE ENGINE OWN STATE?", true), + ("WHY DOES THE SHELL DRAW ONLY?", false), + ] + ); + assert_eq!( + loaded.batch_ends, + [1, 3], + "an emptied batch leaves no boundary" + ); + assert_eq!(loaded.mastery, progress.mastery); + + assert_eq!( + loaded.lessons, + [ + Lesson { + question: "WHAT SHOULD OWN GAMEPLAY STATE?".into(), + answer: "THE GAME ENGINE".into(), + rationale: String::new(), + concept: None, + outstanding: false, + }, + Lesson { + question: "WHY KEEP THE DEVICE SHELL THIN?".into(), + answer: "TO CENTRALIZE GAME RULES".into(), + rationale: String::new(), + concept: None, + outstanding: true, + }, + Lesson { + question: explained.q.clone(), + answer: "THE DEVICE SHELL".into(), + rationale: RATIONALE.into(), + concept: Some(Concept::Responsibility), + outstanding: true, + }, + ] + ); + } + + fn batches_first_question() -> QQuestion { + let batches: Vec = serde_json::from_str(LEGACY_BATCHES).unwrap(); + batches[0].questions[0].clone() + } + + #[test] + fn new_batches_skip_only_retired_questions() { + let mut progress = SavedQuizProgress::default(); + progress.record(&evidence("WHAT SHOULD OWN GAMEPLAY STATE?", None, true)); + progress.record(&evidence("WHY DOES THE ENGINE OWN STATE?", None, false)); + let mut missed = engine_state_question(); + missed.q = "WHY DOES THE ENGINE OWN STATE?".into(); + + let playable = + playable_new_questions(vec![engine_state_question(), missed], &progress.retired()); + + assert_eq!(playable.len(), 1); + assert_eq!(playable[0].question, "WHY DOES THE ENGINE OWN STATE?"); + assert!(!playable[0].review, "a new question is not a review"); + } + + #[test] + fn learner_state_names_the_most_missed_lens_and_recent_stems() { + let record = |first_try, missed| learning::LensRecord { + first_try, + redeemed: 0, + missed, + }; + let mut progress = SavedQuizProgress::default(); + assert_eq!(weakest_lens(&progress.mastery), None); + progress.mastery.insert(Concept::Purpose, record(5, 0)); + assert_eq!( + weakest_lens(&progress.mastery), + None, + "no misses, no weakness" + ); + progress.mastery.insert(Concept::Interaction, record(1, 1)); + progress.mastery.insert(Concept::Invariant, record(0, 2)); + progress.mastery.insert(Concept::Tradeoff, record(3, 3)); + assert_eq!(weakest_lens(&progress.mastery), Some(Concept::Invariant)); + progress.mastery.insert(Concept::Invariant, record(2, 2)); + assert_eq!( + weakest_lens(&progress.mastery), + Some(Concept::Tradeoff), + "equal miss rates prefer the lens with more misses" + ); + + let batches = (0..30) + .map(|index| SavedQuestionBatch { + level: 1, + questions: vec![question( + &format!("why is rule {index} fixed?"), + &["A", "B", "C", "D"], + )], + }) + .collect::>(); + let learner = learner_state(&batches, &progress); + assert_eq!(learner.asked.len(), MAX_AVOIDED_STEMS); + assert_eq!(learner.asked[0], "WHY IS RULE 29 FIXED?"); + assert_eq!(learner.weakest, Some(Concept::Tradeoff)); + } + + #[test] + fn ai_prompt_requests_only_concepts_that_fit_the_display() { + let prompt = ai_question_prompt( + "DEMO PROJECT", + 3, + 12, + "A project that separates its engine from its device shell.", + &LearnerState::default(), + ); + + assert!(prompt.contains("CONCEPTS ONLY")); + assert!(prompt.contains("NEVER ask about file names, paths, directories, or extensions")); + assert!(prompt.contains("still make sense if the project were reorganized")); + assert!(prompt.contains("at most 4 lines of 31 characters")); + assert!(prompt.contains("at most 31 characters")); + assert!(!prompt.contains("FILES:")); + assert!(!prompt.contains("COMMIT MESSAGES")); + assert!(prompt.ends_with("A project that separates its engine from its device shell.")); + } + + #[test] + fn ai_prompt_requests_payload_v2_with_lens_focus_and_misconception_rationales() { + let prompt = ai_question_prompt("DEMO", 1, 6, "BRIEF", &LearnerState::default()); + + assert!( + prompt.contains(r#""concept":"purpose|responsibility|interaction|invariant|tradeoff""#) + ); + assert!(prompt.contains(r#""choices":[{"text":"...","why":"..."}"#)); + assert!(prompt + .contains("at least 4 of the 6 questions must use the purpose or responsibility lens")); + assert!(prompt.contains("\"why\" of at most 90 characters")); + assert!(prompt.contains("name the misconception it represents")); + assert!(prompt.contains("plausible misconception")); + assert!(prompt.contains("similar in length")); + assert!(prompt.contains("Vary which position holds the correct answer")); + assert!( + !prompt.contains("PREDICT"), + "transfer waits for the Oracle bond" + ); + + let adept = ai_question_prompt("DEMO", 3, 6, "BRIEF", &LearnerState::default()); + assert!(adept.contains("use the interaction or tradeoff lens")); + } + + #[test] + fn oracle_bound_prompts_require_predict_transfer_questions() { + let prompt = ai_question_prompt("DEMO", 4, 6, "BRIEF", &LearnerState::default()); + assert!(prompt.contains("at least 2 questions must be PREDICT questions")); + assert!(prompt.contains("Begin each with \"PREDICT:\"")); + assert!(prompt.contains("use the invariant or tradeoff lens")); + assert!( + ai_question_prompt("DEMO", 9, 6, "BRIEF", &LearnerState::default()) + .contains("PREDICT questions") + ); + } + + #[test] + fn ai_prompt_carries_learner_state() { + let fresh = ai_question_prompt("DEMO", 2, 6, "BRIEF", &LearnerState::default()); + assert!(fresh.contains("WEAKEST LENS: none recorded yet.")); + assert!(fresh.contains( + "ALREADY ASKED (never repeat these or close paraphrases of them):\n(none yet)" + )); + + let learner = LearnerState { + weakest: Some(Concept::Invariant), + asked: vec![ + "WHY WRITE SAVES ATOMICALLY?".into(), + "WHO OWNS THE GAME LOOP?".into(), + ], + }; + let prompt = ai_question_prompt("DEMO", 2, 6, "BRIEF", &learner); + assert!(prompt.contains("WEAKEST LENS: invariant.")); + assert!(prompt.contains("include at least one invariant question")); + assert!(prompt.contains("- WHY WRITE SAVES ATOMICALLY?\n- WHO OWNS THE GAME LOOP?")); + } + + #[test] + fn a_full_prompt_fits_a_windows_command_line() { + let brief = "X".repeat(crate::repo_context::BRIEF_BUDGET); + let learner = LearnerState { + weakest: Some(Concept::Responsibility), + asked: vec!["Q".repeat(4 * engine::QUIZ_QUESTION_COLUMNS); MAX_AVOIDED_STEMS], + }; + let prompt = ai_question_prompt("A PROJECT NAME OF SOME LENGTH", 4, 6, &brief, &learner); + let length = command_line_chars(&prompt); + assert!( + length <= MAX_PROMPT_CHARS, + "a full brief fits untrimmed: {length}" + ); + + let quoted = "\"\\".repeat(crate::repo_context::BRIEF_BUDGET / 2); + let bounded = bounded_ai_question_prompt("DEMO", 4, 6, "ed, &learner); + assert!(command_line_chars(&bounded) <= MAX_PROMPT_CHARS); + assert!(bounded.contains("PROJECT: DEMO\n\"\\")); + assert_eq!( + bounded_ai_question_prompt("DEMO", 1, 6, "BRIEF", &learner), + ai_question_prompt("DEMO", 1, 6, "BRIEF", &learner) + ); + } + + #[test] + fn lens_keys_match_the_learning_model_serialization() { + for concept in Concept::ALL { + assert_eq!( + serde_json::to_value(concept).unwrap(), + serde_json::json!(concept_key(concept)) + ); + assert_eq!(Concept::parse(concept_key(concept)), Some(concept)); + } + } +} diff --git a/src-tauri/src/repo_context.rs b/src-tauri/src/repo_context.rs new file mode 100644 index 0000000..f680f9c --- /dev/null +++ b/src-tauri/src/repo_context.rs @@ -0,0 +1,1678 @@ +//! A deterministic, budgeted project brief for question generation. +//! +//! The brief tells a question provider what a project is for and how it is +//! shaped without revealing where anything lives. Documentation is excerpted +//! as prose, design notes are reduced to headings and leading paragraphs, and +//! source files are reduced to their doc comments and top-level signatures. +//! Every excerpt is labelled by number, never by path or file name, and +//! path-like words inside excerpts are redacted. The same repository contents +//! always produce the same brief, and the brief never exceeds +//! [`BRIEF_BUDGET`] bytes of plain ASCII. + +use std::collections::{BTreeMap, HashSet}; +use std::path::{Component, Path}; + +use crate::{codequest, questions}; + +/// Bytes available to the whole brief. The brief travels inside a single +/// command-line prompt (see `questions::MAX_PROMPT_CHARS`), which bounds it. +pub(crate) const BRIEF_BUDGET: usize = 20 * 1024; +const SUMMARY_BUDGET: usize = 1024; +const README_BUDGET: usize = 8 * 1024; +const DESIGN_NOTES_BUDGET: usize = 5 * 1024; +const DESIGN_NOTE_BUDGET: usize = 1536; +const MAX_COMPONENTS: usize = 30; +/// The most one component may take; smaller skeletons leave their unused +/// share to larger ones. +const COMPONENT_BUDGET: usize = 1600; +const MIN_COMPONENT_BUDGET: usize = 160; +/// Design notes stop once less than this remains of their share. +const MIN_NOTE_BUDGET: usize = 160; +/// Source files read while looking for components with a usable skeleton. +const MAX_COMPONENT_READS: usize = 4 * MAX_COMPONENTS; +/// Files larger than this are generated, vendored, or data, not design. +const MAX_TEXT_BYTES: u64 = 512 * 1024; +const PARAGRAPH_CHARS: usize = 320; +const ABOUT_CHARS: usize = 300; +const DOC_SENTENCE_CHARS: usize = 100; +const SIGNATURE_CHARS: usize = 110; +/// Continuation lines joined into one multi-line signature. +const MAX_SIGNATURE_LINES: usize = 8; + +const MARKDOWN_EXTENSIONS: [&str; 5] = ["md", "markdown", "mdx", "rst", "adoc"]; +/// Directory names whose contents are third-party, generated, or build output. +const EXCLUDED_DIRECTORIES: [&str; 22] = [ + "node_modules", + "vendor", + "vendors", + "third_party", + "third-party", + "thirdparty", + "external", + "extern", + "deps", + "target", + "dist", + "build", + "out", + "obj", + "gen", + "generated", + "__generated__", + "__pycache__", + "venv", + "site-packages", + "bower_components", + "coverage", +]; +/// Directories of supporting code (tooling, packaging, samples). Their files +/// are picked only after the project's own components. +const AUXILIARY_DIRECTORIES: [&str; 22] = [ + "scripts", + "script", + "tools", + "tool", + "packaging", + "ci", + "docs", + "doc", + "examples", + "example", + "samples", + "sample", + "demo", + "demos", + "benchmarks", + "bench", + "migrations", + "deploy", + "deployment", + "infra", + "hack", + "contrib", +]; +const TEST_DIRECTORIES: [&str; 9] = [ + "test", + "tests", + "__tests__", + "spec", + "specs", + "testdata", + "fixtures", + "e2e", + "benches", +]; +/// Documents about releases, people, or policy rather than design. +const NON_DESIGN_DOCUMENTS: [&str; 20] = [ + "CHANGELOG", + "CHANGES", + "HISTORY", + "LICENSE", + "LICENCE", + "COPYING", + "CODE_OF_CONDUCT", + "SECURITY", + "NOTICE", + "AUTHORS", + "CONTRIBUTORS", + "MAINTAINERS", + "RELEASE", + "RELEASES", + "RELEASE_NOTES", + "PULL_REQUEST_TEMPLATE", + "ISSUE_TEMPLATE", + "CODEOWNERS", + "FUNDING", + "SUPPORT", +]; +const DESIGN_DOCUMENTS: [&str; 5] = ["ARCHITECTURE", "DESIGN", "OVERVIEW", "CONCEPTS", "GLOSSARY"]; +const DECISION_DIRECTORIES: [&str; 4] = ["adr", "adrs", "decisions", "decision-records"]; +const PROCEDURE_DIRECTORIES: [&str; 5] = ["runbook", "runbooks", "howto", "how-to", "playbooks"]; +/// Markdown sections about obtaining, building, or licensing a project. They +/// describe operations rather than design, so excerpts skip them. +const OPERATIONAL_SECTIONS: [&str; 20] = [ + "INSTALL", + "INSTALLATION", + "INSTALLING", + "DOWNLOAD", + "DOWNLOADS", + "BUILD", + "BUILDING", + "BUILD FROM SOURCE", + "DEVELOPMENT", + "SETUP", + "LICENSE", + "LICENSING", + "CONTRIBUTING", + "CHANGELOG", + "RELEASES", + "ACKNOWLEDGEMENTS", + "ACKNOWLEDGMENTS", + "CREDITS", + "SPONSORS", + "TABLE OF CONTENTS", +]; + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +enum Family { + Rust, + Python, + Script, + Go, + Managed, + C, + Ruby, + Shell, + Lua, + Elixir, +} + +impl Family { + fn for_extension(extension: &str) -> Option { + Some(match extension { + "rs" => Self::Rust, + "py" => Self::Python, + "js" | "mjs" | "cjs" | "jsx" | "ts" | "tsx" | "mts" | "cts" => Self::Script, + "go" => Self::Go, + "java" | "kt" | "kts" | "scala" | "swift" | "cs" | "dart" | "php" => Self::Managed, + "c" | "h" | "cc" | "cpp" | "cxx" | "hpp" | "hh" => Self::C, + "rb" => Self::Ruby, + "sh" | "bash" => Self::Shell, + "lua" => Self::Lua, + "ex" | "exs" => Self::Elixir, + _ => return None, + }) + } + + fn hash_comments(self) -> bool { + matches!(self, Self::Python | Self::Ruby | Self::Shell | Self::Elixir) + } +} + +fn file_name(path: &str) -> &str { + path.rsplit('/').next().unwrap_or(path) +} + +fn directory(path: &str) -> &str { + path.rsplit_once('/').map_or("", |(directory, _)| directory) +} + +fn extension(path: &str) -> String { + file_name(path) + .rsplit_once('.') + .map(|(_, extension)| extension.to_ascii_lowercase()) + .unwrap_or_default() +} + +fn stem(path: &str) -> String { + let name = file_name(path); + name.split_once('.') + .map_or(name, |(stem, _)| stem) + .to_ascii_uppercase() +} + +fn directories(path: &str) -> impl Iterator + '_ { + directory(path) + .split('/') + .filter(|segment| !segment.is_empty()) + .map(str::to_ascii_lowercase) +} + +fn in_directory_named(path: &str, names: &[&str]) -> bool { + directories(path).any(|segment| names.contains(&segment.as_str())) +} + +/// Hidden, vendored, generated, or build-output trees. +fn in_excluded_tree(path: &str) -> bool { + directories(path).any(|segment| segment.starts_with('.')) + || in_directory_named(path, &EXCLUDED_DIRECTORIES) +} + +fn is_generated_or_minified(path: &str) -> bool { + let name = file_name(path).to_ascii_lowercase(); + [ + ".min.", + ".bundle.", + ".generated.", + "_generated.", + ".pb.", + "_pb2.", + ".g.dart", + ".d.ts", + ] + .iter() + .any(|marker| name.contains(marker)) + || name.ends_with(".designer.cs") +} + +fn is_test(path: &str) -> bool { + let name = file_name(path).to_ascii_lowercase(); + in_directory_named(path, &TEST_DIRECTORIES) + || name.starts_with("test_") + || [".test.", ".spec.", "_test.", "_spec.", "tests."] + .iter() + .any(|marker| name.contains(marker)) + || name.ends_with("test.java") + || name.ends_with("tests.java") +} + +/// Reads a tracked regular file as text. Symlinks, oversized files, binary +/// files, and anything that is not UTF-8 are skipped, so a tracked link cannot +/// pull content from outside the repository into a prompt. +fn read_text(repo: &Path, relative: &str) -> Option { + let relative_path = Path::new(relative); + if !relative_path + .components() + .all(|component| matches!(component, Component::Normal(_))) + { + return None; + } + let path = repo.join(relative_path); + let metadata = std::fs::symlink_metadata(&path).ok()?; + if !metadata.is_file() || metadata.len() > MAX_TEXT_BYTES { + return None; + } + let bytes = std::fs::read(&path).ok()?; + if bytes.contains(&0) { + return None; + } + String::from_utf8(bytes).ok() +} + +fn tracked_size(repo: &Path, relative: &str) -> Option { + let metadata = std::fs::symlink_metadata(repo.join(relative)).ok()?; + (metadata.is_file() && metadata.len() > 0 && metadata.len() <= MAX_TEXT_BYTES) + .then_some(metadata.len()) +} + +/// Plain printable ASCII: common typographic symbols fold to ASCII, tabs +/// become spaces, and anything else outside ASCII is dropped. +fn ascii_line(line: &str) -> String { + let mut ascii = String::with_capacity(line.len()); + for character in line.chars() { + match character { + '\u{2018}' | '\u{2019}' => ascii.push('\''), + '\u{201C}' | '\u{201D}' => ascii.push('"'), + '\u{2010}'..='\u{2015}' => ascii.push('-'), + '\u{2026}' => ascii.push_str("..."), + '\u{2192}' | '\u{21D2}' => ascii.push_str("->"), + '\u{2190}' => ascii.push_str("<-"), + '\u{2194}' => ascii.push_str("<->"), + '\u{00D7}' => ascii.push('x'), + '\u{2022}' => ascii.push('-'), + '\t' => ascii.push(' '), + ' '..='~' => ascii.push(character), + _ => {} + } + } + ascii.trim_end().to_string() +} + +/// Redacts locations from excerpt text: anything shaped like a path, file +/// name, extension, or link, plus any word naming one of this repository's +/// own tracked files or directories. +#[derive(Default)] +struct Redactor { + /// Tracked file names, matched exactly (`Makefile`, `logo.svg`). + files: HashSet, + /// Lowercase tracked directory names, matched inside slashed words. + directories: HashSet, +} + +impl Redactor { + fn new(tracked: &[String]) -> Self { + let mut redactor = Self::default(); + for path in tracked { + let name = file_name(path); + if name.len() >= 3 { + redactor.files.insert(name.to_string()); + } + redactor + .directories + .extend(directories(path).filter(|segment| segment.len() >= 2)); + } + redactor + } + + fn names_tracked_location(&self, word: &str) -> bool { + let core = word + .trim_matches(|c: char| { + !c.is_ascii_alphanumeric() && !matches!(c, '.' | '/' | '_' | '-') + }) + .trim_end_matches('.'); + self.files.contains(core) + || (core.contains('/') + && core.split('/').any(|segment| { + self.files.contains(segment) + || self.directories.contains(&segment.to_ascii_lowercase()) + })) + } + + /// Replaces every location word with a placeholder. + fn redact(&self, line: &str) -> String { + let leading = line.len() - line.trim_start().len(); + let words = line + .split_whitespace() + .map(|word| { + if word.contains("://") { + "[LINK]" + } else if questions::is_location_word(word) || self.names_tracked_location(word) { + "[LOCATION]" + } else { + word + } + }) + .collect::>(); + format!("{}{}", &line[..leading], words.join(" ")) + } + + /// Printable ASCII with locations redacted. + fn clean(&self, line: &str) -> String { + self.redact(&ascii_line(line)) + } +} +/// Cuts `text` at a word boundary so it holds at most `max_chars` bytes. +fn clip(text: &str, max_chars: usize) -> String { + if text.len() <= max_chars { + return text.to_string(); + } + let mut end = max_chars; + while !text.is_char_boundary(end) { + end -= 1; + } + let clipped = &text[..end]; + clipped + .rfind(' ') + .filter(|space| *space > max_chars / 2) + .map_or(clipped, |space| &clipped[..space]) + .trim_end() + .to_string() +} + +/// Like [`clip`], but ends on a sentence when a complete one fits. +fn clip_sentences(text: &str, max_chars: usize) -> String { + let clipped = clip(text, max_chars); + if clipped.len() == text.len() { + return clipped; + } + clipped + .rfind(". ") + .filter(|end| *end > max_chars / 3) + .map_or(clipped.clone(), |end| clipped[..=end].to_string()) +} + +/// Joins whole lines while they fit in `budget` bytes. +fn fit_lines(lines: &[String], budget: usize) -> String { + let mut text = String::new(); + for line in lines { + let needed = line.len() + usize::from(!text.is_empty()); + if text.len() + needed > budget { + if text.is_empty() { + text = clip(line, budget); + } + break; + } + if !text.is_empty() { + text.push('\n'); + } + text.push_str(line); + } + text +} + +fn is_heading(line: &str) -> bool { + line.trim_start().starts_with('#') +} + +/// [`fit_lines`] for documents: an excerpt never ends on a bare heading. +fn fit_document(lines: &[String], budget: usize) -> String { + let mut text = fit_lines(lines, budget); + while let Some(last) = text.lines().last() { + if !is_heading(last) && !last.trim().is_empty() { + break; + } + let end = text.len() - last.len(); + text.truncate(end); + text.truncate(text.trim_end().len()); + } + text +} + +fn first_sentence(text: &str, max_chars: usize) -> String { + let sentence = text.find(". ").map_or(text, |end| &text[..=end]).trim(); + clip(sentence, max_chars) +} + +/// Removes link and image targets, keeping the link text. +fn strip_link_targets(line: &str) -> String { + let mut text = String::new(); + let mut rest = line; + while let Some(start) = rest.find("](") { + text.push_str(&rest[..start]); + rest = rest[start + 2..] + .find(')') + .map_or("", |end| &rest[start + 2 + end + 1..]); + } + text.push_str(rest); + text.replace("![", "").replace(['[', ']'], "") +} + +fn heading_level(trimmed: &str) -> Option<(usize, &str)> { + let level = trimmed.chars().take_while(|c| *c == '#').count(); + (level > 0).then(|| (level, trimmed[level..].trim())) +} + +fn is_table_rule(trimmed: &str) -> bool { + trimmed.starts_with('|') + && trimmed + .chars() + .all(|character| matches!(character, '|' | '-' | ':' | ' ')) +} + +/// Readable prose lines: no front matter, code fences, raw HTML, badge-only +/// lines, table rules, link targets, or operational sections. +fn markdown_prose(text: &str, redactor: &Redactor) -> Vec { + let mut lines = Vec::new(); + let mut in_fence = false; + let mut in_front_matter = text.starts_with("---"); + let mut skipped_section: Option = None; + for (index, raw) in text.lines().enumerate() { + let trimmed = raw.trim(); + if in_front_matter { + if index > 0 && trimmed == "---" { + in_front_matter = false; + } + continue; + } + if trimmed.starts_with("```") || trimmed.starts_with("~~~") { + in_fence = !in_fence; + continue; + } + if in_fence { + continue; + } + if let Some((level, title)) = heading_level(trimmed) { + if skipped_section.is_some_and(|skipped| level > skipped) { + continue; + } + let title = title.trim_end_matches(':').to_ascii_uppercase(); + skipped_section = OPERATIONAL_SECTIONS + .contains(&title.as_str()) + .then_some(level); + if skipped_section.is_some() { + continue; + } + } else if skipped_section.is_some() { + continue; + } + if trimmed.starts_with('<') + || trimmed.starts_with("[![") + || trimmed.starts_with("![") + || is_table_rule(trimmed) + { + continue; + } + let line = redactor.clean(&strip_link_targets(raw)); + if line.trim().is_empty() { + if lines.last().is_some_and(|last: &String| !last.is_empty()) { + lines.push(String::new()); + } + continue; + } + lines.push(line); + } + while lines.last().is_some_and(String::is_empty) { + lines.pop(); + } + lines +} + +/// Headings plus the first paragraph under each (and before the first). +fn markdown_outline(text: &str, redactor: &Redactor) -> Vec { + let mut outline = Vec::new(); + let mut paragraph = String::new(); + let mut wants_paragraph = true; + let flush = |paragraph: &mut String, outline: &mut Vec| { + if !paragraph.is_empty() { + outline.push(clip_sentences(paragraph, PARAGRAPH_CHARS)); + paragraph.clear(); + } + }; + for line in markdown_prose(text, redactor) { + let trimmed = line.trim(); + if is_heading(trimmed) { + flush(&mut paragraph, &mut outline); + outline.push(trimmed.to_string()); + wants_paragraph = true; + } else if trimmed.is_empty() { + if !paragraph.is_empty() { + flush(&mut paragraph, &mut outline); + wants_paragraph = false; + } + } else if wants_paragraph && paragraph.len() < PARAGRAPH_CHARS * 2 { + if !paragraph.is_empty() { + paragraph.push(' '); + } + paragraph.push_str(trimmed); + } + } + flush(&mut paragraph, &mut outline); + outline +} + +fn is_markdown(path: &str) -> bool { + MARKDOWN_EXTENSIONS.contains(&extension(path).as_str()) +} + +fn readme(repo: &Path, tracked: &[String], redactor: &Redactor) -> Option> { + let mut candidates = tracked + .iter() + .filter(|path| !path.contains('/') && stem(path) == "README") + .collect::>(); + // Prefer Markdown, then the shortest name, then byte order. + candidates.sort_by_key(|path| (!is_markdown(path), path.len(), path.as_str())); + candidates.into_iter().find_map(|path| { + let prose = markdown_prose(&read_text(repo, path)?, redactor); + (!prose.is_empty()).then_some(prose) + }) +} + +/// Design documents in reading priority: architecture-style documents, the +/// docs tree, decision records, contribution guides, then procedures. +fn design_documents(tracked: &[String]) -> Vec<&str> { + let mut documents = tracked + .iter() + .map(String::as_str) + .filter(|path| is_markdown(path) && !in_excluded_tree(path)) + .filter_map(|path| { + let name = stem(path); + let root_readme = !path.contains('/') && name == "README"; + if root_readme || NON_DESIGN_DOCUMENTS.contains(&name.as_str()) { + return None; + } + let in_docs = matches!(directories(path).next().as_deref(), Some("docs" | "doc")); + let priority = if DESIGN_DOCUMENTS.contains(&name.as_str()) { + 0 + } else if in_directory_named(path, &PROCEDURE_DIRECTORIES) { + 4 + } else if in_directory_named(path, &DECISION_DIRECTORIES) { + 2 + } else if in_docs { + 1 + } else if name == "CONTRIBUTING" { + 3 + } else { + return None; + }; + Some((priority, path.matches('/').count(), path)) + }) + .collect::>(); + documents.sort_unstable(); + documents.into_iter().map(|(_, _, path)| path).collect() +} + +fn cartridge_summary(repo: &Path, redactor: &Redactor) -> Option { + let source = read_text(repo, codequest::FILE_NAME)?; + let config = toml::from_str::(&source).ok()?; + let summary = config.get("game")?.get("summary")?.as_str()?; + let summary = redactor.clean(&summary.split_whitespace().collect::>().join(" ")); + (!summary.is_empty()).then(|| clip(&summary, SUMMARY_BUDGET)) +} + +fn indent_of(line: &str) -> usize { + line.chars() + .take_while(|character| character.is_whitespace()) + .map(|character| if character == '\t' { 4 } else { 1 }) + .sum() +} + +/// Tracks whether a scan is inside a `/* ... */` block, where continuation +/// lines start with `*` (and a leading `*` elsewhere is a dereference). +#[derive(Default)] +struct CommentScanner { + in_block: bool, +} + +impl CommentScanner { + /// Text of a comment line with its markers removed, or `None` when the + /// line is not a comment in this family. + fn text<'a>(&mut self, trimmed: &'a str, family: Family) -> Option<&'a str> { + let text = if family.hash_comments() { + trimmed.strip_prefix('#')? + } else if let Some(text) = ["///", "//!", "//"] + .iter() + .find_map(|marker| trimmed.strip_prefix(marker)) + { + text + } else if let Some(text) = ["/**", "/*"] + .iter() + .find_map(|marker| trimmed.strip_prefix(marker)) + { + self.in_block = !text.contains("*/"); + text + } else if self.in_block { + self.in_block = !trimmed.contains("*/"); + trimmed + } else { + return None; + }; + Some(text.trim_end_matches("*/").trim_start_matches('*').trim()) + } +} + +fn is_annotation(trimmed: &str, family: Family) -> bool { + match family { + Family::Rust => trimmed.starts_with("#[") || trimmed.starts_with("#!["), + Family::Python | Family::Script | Family::Managed => trimmed.starts_with('@'), + _ => false, + } +} + +fn rust_signature(trimmed: &str, indent: usize) -> bool { + let (public, mut item) = if let Some(rest) = trimmed.strip_prefix("pub(") { + (true, rest.split_once(") ").map_or("", |(_, item)| item)) + } else if let Some(rest) = trimmed.strip_prefix("pub ") { + (true, rest) + } else { + (false, trimmed) + }; + while let Some(rest) = ["async ", "const ", "unsafe ", "extern \"C\" ", "default "] + .iter() + .find_map(|qualifier| item.strip_prefix(qualifier)) + { + item = rest; + } + let kind = [ + "fn ", "struct ", "enum ", "trait ", "impl ", "impl<", "type ", "union ", + ] + .iter() + .find(|keyword| item.starts_with(**keyword)); + match kind { + Some(&"fn ") => indent == 0 || (public && indent <= 4), + Some(_) => indent == 0, + None => indent == 0 && item.starts_with("macro_rules!"), + } +} + +fn python_signature(trimmed: &str, indent: usize) -> bool { + let definition = trimmed + .strip_prefix("async ") + .unwrap_or(trimmed) + .strip_prefix("def "); + match definition { + Some(name) => { + indent == 0 || (indent <= 4 && (!name.starts_with('_') || name.starts_with("__init__"))) + } + None => indent == 0 && trimmed.starts_with("class "), + } +} + +fn script_signature(trimmed: &str, indent: usize) -> bool { + if indent != 0 || trimmed.starts_with("export {") || trimmed.starts_with("export *") { + return false; + } + let item = trimmed.strip_prefix("export ").unwrap_or(trimmed); + let item = item.strip_prefix("default ").unwrap_or(item); + if ["const ", "let ", "var "] + .iter() + .any(|keyword| item.starts_with(keyword)) + { + // Only bindings that define behavior; plain values are constants. + return item.contains("=>") || item.contains("function") || item.contains("class "); + } + trimmed.starts_with("export ") + || [ + "function ", + "async function ", + "class ", + "abstract class ", + "interface ", + "type ", + "enum ", + "declare ", + ] + .iter() + .any(|keyword| item.starts_with(keyword)) +} + +fn managed_signature(trimmed: &str, indent: usize) -> bool { + const MODIFIERS: [&str; 16] = [ + "public", + "protected", + "internal", + "open", + "abstract", + "sealed", + "final", + "static", + "override", + "data", + "inline", + "suspend", + "async", + "partial", + "readonly", + "export", + ]; + const TYPES: [&str; 10] = [ + "class", + "interface", + "enum", + "record", + "struct", + "object", + "protocol", + "extension", + "trait", + "actor", + ]; + const FUNCTIONS: [&str; 4] = ["fun", "func", "def", "function"]; + if indent > 4 || trimmed.ends_with(';') { + return false; + } + let mut words = trimmed.split_whitespace().peekable(); + let mut modified = false; + while let Some(word) = words.peek() { + if *word == "private" || *word == "fileprivate" { + return false; + } + if !MODIFIERS.contains(word) { + break; + } + modified = true; + words.next(); + } + let Some(word) = words.next() else { + return false; + }; + TYPES.contains(&word) + || FUNCTIONS.contains(&word) + || (modified && trimmed.contains('(') && !trimmed.contains(" = ")) +} + +fn c_signature(trimmed: &str, indent: usize) -> bool { + const CONTROL: [&str; 8] = [ + "return", "if", "for", "while", "else", "switch", "case", "do", + ]; + if indent != 0 || !trimmed.starts_with(|character: char| character.is_ascii_alphabetic()) { + return false; + } + if [ + "struct ", + "class ", + "enum ", + "typedef ", + "namespace ", + "union ", + "template", + ] + .iter() + .any(|keyword| trimmed.starts_with(keyword)) + { + return true; + } + let first = trimmed + .split(|character: char| !character.is_ascii_alphanumeric() && character != '_') + .next() + .unwrap_or_default(); + let before_call = trimmed.split('(').next().unwrap_or_default(); + trimmed.contains('(') && !CONTROL.contains(&first) && !before_call.contains('=') +} + +fn is_signature(line: &str, family: Family) -> bool { + let indent = indent_of(line); + let trimmed = line.trim(); + match family { + Family::Rust => rust_signature(trimmed, indent), + Family::Python => python_signature(trimmed, indent), + Family::Script => script_signature(trimmed, indent), + Family::Go => indent == 0 && (trimmed.starts_with("func ") || trimmed.starts_with("type ")), + Family::Managed => managed_signature(trimmed, indent), + Family::C => c_signature(trimmed, indent), + Family::Ruby => { + indent <= 2 + && ["def ", "class ", "module "] + .iter() + .any(|keyword| trimmed.starts_with(keyword)) + } + Family::Shell => { + indent == 0 + && (trimmed.starts_with("function ") + || trimmed.split_once("()").is_some_and(|(name, _)| { + let name = name.trim(); + !name.is_empty() + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-')) + })) + } + Family::Lua => { + indent == 0 + && (trimmed.starts_with("function ") || trimmed.starts_with("local function ")) + } + Family::Elixir => { + (indent == 0 && trimmed.starts_with("defmodule ")) + || (indent <= 2 + && ["def ", "defprotocol ", "defstruct "] + .iter() + .any(|keyword| trimmed.starts_with(keyword))) + } + } +} + +/// Whether the signature exposes the item outside its file or module. +fn is_public(signature: &str, family: Family) -> bool { + match family { + Family::Rust => signature.starts_with("pub"), + Family::Script => signature.starts_with("export"), + Family::Managed => ["public", "open", "internal", "export"] + .iter() + .any(|modifier| signature.starts_with(modifier)), + Family::Python => !signature + .trim_start_matches("async ") + .trim_start_matches("def ") + .starts_with('_'), + _ => true, + } +} + +/// Byte offset of the first `stop` character outside brackets. +fn outside_brackets(text: &str, stop: char) -> Option { + let mut depth = 0usize; + for (index, character) in text.char_indices() { + match character { + '(' | '[' | '<' => depth += 1, + ')' | ']' | '>' => depth = depth.saturating_sub(1), + _ if character == stop && depth == 0 => return Some(index), + _ => {} + } + } + None +} + +/// A signature without its body: cut at the body's opening brace (or a +/// Python colon, or a Ruby/Elixir `do`) and clipped to one compact line. +fn signature_text(header: &str, family: Family, redactor: &Redactor) -> String { + let stop = if family == Family::Python { ':' } else { '{' }; + // `->` is not a closing bracket, so hide its `>` from the depth count. + let masked = header.replace("->", "-~").replace("=>", "=~"); + let head = outside_brackets(&masked, stop).map_or(header, |end| &header[..end]); + let head = head.trim_end(); + let head = head.strip_suffix(" do").unwrap_or(head).trim_end(); + clip(&redactor.clean(head), SIGNATURE_CHARS) +} + +/// Joins a signature that continues across lines (open parentheses and no +/// body yet) into one header. +fn signature_header(lines: &[&str], start: usize) -> String { + let mut header = lines[start].trim().to_string(); + let balance = + |text: &str| text.matches('(').count() as isize - text.matches(')').count() as isize; + for next in lines.iter().skip(start + 1).take(MAX_SIGNATURE_LINES) { + if balance(&header) <= 0 || outside_brackets(&header, '{').is_some() { + break; + } + let next = next.trim(); + if !header.ends_with('(') && !next.starts_with(')') { + header.push(' '); + } + header.push_str(next); + } + header.replace(",)", ")") +} + +/// The file-level description: Rust inner doc comments, a Python module +/// docstring, or the leading comment block, unless it is a license header. +fn file_about(lines: &[&str], family: Family, redactor: &Redactor) -> Option { + let mut about = Vec::new(); + let mut in_docstring = false; + let mut comments = CommentScanner::default(); + for (index, line) in lines.iter().enumerate() { + let trimmed = line.trim(); + if in_docstring { + let (text, closed) = match trimmed.find("\"\"\"") { + Some(end) => (&trimmed[..end], true), + None => (trimmed, false), + }; + about.push(text.to_string()); + if closed { + break; + } + continue; + } + if trimmed.is_empty() + || (index == 0 && trimmed.starts_with("#!")) + || trimmed.starts_with("#![") + || trimmed.contains("coding:") + || trimmed == "\"use strict\";" + || trimmed == "'use strict';" + { + if about.is_empty() { + continue; + } + break; + } + if family == Family::Python && about.is_empty() && trimmed.starts_with("\"\"\"") { + let body = &trimmed[3..]; + match body.find("\"\"\"") { + Some(end) => { + about.push(body[..end].to_string()); + break; + } + None => { + about.push(body.to_string()); + in_docstring = true; + continue; + } + } + } + let text = if family == Family::Rust { + trimmed.strip_prefix("//!").map(str::trim) + } else { + comments.text(trimmed, family) + }; + match text { + Some(text) => about.push(text.to_string()), + None => break, + } + } + let about = about + .iter() + .map(|text| text.trim()) + .filter(|text| !text.is_empty()) + .collect::>() + .join(" "); + let lowercase = about.to_ascii_lowercase(); + if about.is_empty() + || ["copyright", "license", "spdx"] + .iter() + .any(|marker| lowercase.contains(marker)) + { + return None; + } + Some(clip_sentences(&redactor.clean(&about), ABOUT_CHARS)) +} + +/// One skeleton entry: the file description, or a signature with its doc. +struct SkeletonEntry { + /// Lower ranks survive a tight budget first: the description, then + /// documented public items, public items, documented private items, and + /// everything else. + rank: u8, + lines: Vec, +} + +impl SkeletonEntry { + fn len(&self) -> usize { + self.lines.iter().map(|line| line.len() + 1).sum() + } +} + +/// Doc comments and top-level signatures, in source order. +fn skeleton(text: &str, family: Family, redactor: &Redactor) -> Vec { + let lines = text.lines().collect::>(); + let mut skeleton = Vec::new(); + if let Some(about) = file_about(&lines, family, redactor) { + skeleton.push(SkeletonEntry { + rank: 0, + lines: vec![format!("ABOUT: {about}")], + }); + } + let mut comment = Vec::new(); + let mut comments = CommentScanner::default(); + for (index, line) in lines.iter().enumerate() { + let trimmed = line.trim(); + if trimmed.is_empty() { + comment.clear(); + continue; + } + if is_annotation(trimmed, family) || trimmed.starts_with("//!") { + continue; + } + if let Some(text) = comments.text(trimmed, family) { + if indent_of(line) <= 4 && !text.is_empty() { + comment.push(text.to_string()); + } + continue; + } + if is_signature(line, family) { + let mut doc = comment.join(" "); + if doc.is_empty() && family == Family::Python { + doc = lines[index + 1..] + .iter() + .map(|next| next.trim()) + .find(|next| !next.is_empty()) + .and_then(|next| next.strip_prefix("\"\"\"")) + .map(|next| next.trim_end_matches("\"\"\"").to_string()) + .unwrap_or_default(); + } + let doc = redactor.clean(&first_sentence(doc.trim(), DOC_SENTENCE_CHARS)); + let signature = signature_text(&signature_header(&lines, index), family, redactor); + if !signature.is_empty() { + let public = is_public(&signature, family); + let rank = match (public, doc.is_empty()) { + (true, false) => 1, + (true, true) => 2, + (false, false) => 3, + (false, true) => 4, + }; + let mut entry = Vec::new(); + if !doc.is_empty() { + entry.push(format!("// {doc}")); + } + entry.push(signature); + skeleton.push(SkeletonEntry { rank, lines: entry }); + } + } + comment.clear(); + } + skeleton +} + +/// The most informative entries that fit in `budget` bytes, in source order. +fn fit_skeleton(skeleton: &[SkeletonEntry], budget: usize) -> Vec { + let mut by_rank = (0..skeleton.len()).collect::>(); + by_rank.sort_by_key(|index| (skeleton[*index].rank, *index)); + let mut used = 0; + let mut kept = Vec::new(); + for index in by_rank { + let length = skeleton[index].len(); + if used + length <= budget { + used += length; + kept.push(index); + } + } + kept.sort_unstable(); + kept.into_iter() + .flat_map(|index| skeleton[index].lines.iter().cloned()) + .collect() +} + +fn source_family(path: &str) -> Option { + if in_excluded_tree(path) || is_generated_or_minified(path) || is_test(path) { + return None; + } + Family::for_extension(&extension(path)) +} + +/// Source files in an order that spreads across the tree: one file from each +/// directory per round, largest first within a directory. The project's own +/// directories come before tooling, packaging, samples, and shell scripts. +fn spread_sources<'a>(repo: &Path, tracked: &'a [String]) -> Vec<(bool, &'a str, Family)> { + type Files<'p> = Vec<(std::cmp::Reverse, &'p str, Family)>; + let mut by_directory = BTreeMap::<(bool, &str), Files<'a>>::new(); + for path in tracked { + let Some(family) = source_family(path) else { + continue; + }; + let Some(size) = tracked_size(repo, path) else { + continue; + }; + let auxiliary = family == Family::Shell || in_directory_named(path, &AUXILIARY_DIRECTORIES); + by_directory + .entry((auxiliary, directory(path))) + .or_default() + .push((std::cmp::Reverse(size), path.as_str(), family)); + } + for files in by_directory.values_mut() { + files.sort_unstable(); + } + let mut spread = Vec::new(); + for auxiliary in [false, true] { + let mut groups = by_directory + .iter() + .filter(|((group_is_auxiliary, _), _)| *group_is_auxiliary == auxiliary) + .map(|(_, files)| files.iter()) + .collect::>(); + loop { + let before = spread.len(); + for group in &mut groups { + if let Some((_, path, family)) = group.next() { + spread.push((auxiliary, *path, *family)); + } + } + if spread.len() == before { + break; + } + } + } + spread +} + +fn component_skeletons( + repo: &Path, + tracked: &[String], + redactor: &Redactor, +) -> Vec> { + let mut components = spread_sources(repo, tracked) + .into_iter() + .take(MAX_COMPONENT_READS) + .filter_map(|(auxiliary, path, family)| { + let skeleton = skeleton(&read_text(repo, path)?, family, redactor); + (!skeleton.is_empty()).then_some(((auxiliary, path), skeleton)) + }) + .take(MAX_COMPONENTS) + .collect::>(); + // Number the project's own components first, each group in tree order so + // related code stays adjacent; the labels reveal nothing about locations. + components.sort_unstable_by(|left, right| left.0.cmp(&right.0)); + components + .into_iter() + .map(|(_, skeleton)| skeleton) + .collect() +} + +/// Splits `total` bytes among components: each gets what it needs up to +/// [`COMPONENT_BUDGET`], and what small components leave unused flows to +/// larger ones. +fn component_allocations(needs: &[usize], total: usize) -> Vec { + let mut order = (0..needs.len()).collect::>(); + order.sort_by_key(|index| (needs[*index], *index)); + let mut allocations = vec![0; needs.len()]; + let mut remaining = total; + for (served, index) in order.into_iter().enumerate() { + let share = remaining / (needs.len() - served); + allocations[index] = needs[index].min(share).min(COMPONENT_BUDGET); + remaining -= allocations[index]; + } + allocations +} + +struct Brief { + text: String, +} + +impl Brief { + fn remaining(&self) -> usize { + BRIEF_BUDGET.saturating_sub(self.text.len()) + } + + /// Appends `heading` and `body`, or nothing when the body is empty. + fn section(&mut self, heading: &str, body: &str) -> usize { + if body.trim().is_empty() { + return 0; + } + let before = self.text.len(); + if !self.text.is_empty() { + self.text.push_str("\n\n"); + } + self.text.push_str(heading); + self.text.push('\n'); + self.text.push_str(body); + self.text.len() - before + } + + /// Bytes a section body may use under `budget` and the whole brief. + fn body_budget(&self, heading: &str, budget: usize) -> usize { + let overhead = heading.len() + 1 + if self.text.is_empty() { 0 } else { 2 }; + budget.min(self.remaining().saturating_sub(overhead)) + } +} + +/// The project brief for `repo`, built from its tracked files (paths relative +/// to the repository root, `/`-separated, as `git ls-files` reports them). +pub(crate) fn project_brief(repo: &Path, tracked: &[String]) -> String { + let redactor = Redactor::new(tracked); + let mut brief = Brief { + text: String::new(), + }; + if let Some(summary) = cartridge_summary(repo, &redactor) { + let heading = "CARTRIDGE SUMMARY:"; + let body = clip(&summary, brief.body_budget(heading, SUMMARY_BUDGET)); + brief.section(heading, &body); + } + if let Some(readme) = readme(repo, tracked, &redactor) { + let heading = "README EXCERPT:"; + let body = fit_document(&readme, brief.body_budget(heading, README_BUDGET)); + brief.section(heading, &body); + } + let mut notes_budget = DESIGN_NOTES_BUDGET; + let mut note = 0; + for path in design_documents(tracked) { + if notes_budget < MIN_NOTE_BUDGET { + break; + } + let Some(outline) = read_text(repo, path).map(|text| markdown_outline(&text, &redactor)) + else { + continue; + }; + let heading = format!("DESIGN NOTE {}:", note + 1); + let budget = brief.body_budget(&heading, DESIGN_NOTE_BUDGET.min(notes_budget)); + let used = brief.section(&heading, &fit_document(&outline, budget)); + if used > 0 { + note += 1; + notes_budget = notes_budget.saturating_sub(used); + } + } + + let components = component_skeletons(repo, tracked, &redactor); + let heading = + "COMPONENT SKELETONS (doc comments and top-level signatures; locations withheld):"; + let available = brief.body_budget(heading, BRIEF_BUDGET); + let count = components.len().min(available / MIN_COMPONENT_BUDGET); + let labels = (1..=count) + .map(|number| format!("--- COMPONENT {number} ---")) + .collect::>(); + let needs = components + .iter() + .zip(&labels) + .map(|(skeleton, label)| { + label.len() + 1 + skeleton.iter().map(SkeletonEntry::len).sum::() + }) + .collect::>(); + let mut body = Vec::new(); + for ((skeleton, label), allocation) in components + .iter() + .zip(&labels) + .zip(component_allocations(&needs, available)) + { + let lines = fit_skeleton(skeleton, allocation.saturating_sub(label.len() + 1)); + if !lines.is_empty() { + body.push(label.clone()); + body.extend(lines); + } + } + brief.section(heading, &fit_lines(&body, available)); + + if brief.text.is_empty() { + return "(no documentation or source excerpts available)".to_string(); + } + brief.text +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::external_tools; + + fn temporary_git_repo() -> std::path::PathBuf { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "codequest-context-test-{}-{unique}.cartridge", + std::process::id() + )); + std::fs::create_dir(&path).unwrap(); + let status = external_tools::git_command() + .arg("-C") + .arg(&path) + .args(["init", "--quiet"]) + .status() + .unwrap(); + assert!(status.success()); + path + } + + fn write(repo: &Path, relative: &str, contents: impl AsRef<[u8]>) { + let path = repo.join(relative); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, contents).unwrap(); + } + + fn tracked_files(repo: &Path) -> Vec { + let add = external_tools::git_command() + .arg("-C") + .arg(repo) + .args(["add", "--all", "--force"]) + .status() + .unwrap(); + assert!(add.success()); + let output = external_tools::git_command() + .arg("-C") + .arg(repo) + .args(["ls-files", "-z"]) + .output() + .unwrap(); + String::from_utf8(output.stdout) + .unwrap() + .split('\0') + .filter(|path| !path.is_empty()) + .map(str::to_string) + .collect() + } + + fn sample_repo() -> std::path::PathBuf { + let repo = temporary_git_repo(); + write( + &repo, + "README.md", + "# Tide Clock\n\n[![build](https://ci.example/badge.svg)](https://ci.example)\n\nTide Clock predicts harbor tides from lunar phase so sailors can plan departures.\nSee [the guide](docs/guide.md) or run `scripts/setup.sh`.\n\n```sh\nnpm install tide-clock\n```\n\n## Installation\n\nDownload the installer for your platform.\n\n### From source\n\nClone and build it yourself.\n\n## Why\n\n| Mode | Meaning |\n|---|---|\n| Live | Observed \u{2192} predicted |\n\nPredictions must never contradict an observed tide.\n\n## Roadmap\n", + ); + write( + &repo, + "docs/architecture.md", + "---\ntitle: internals\n---\n# Architecture\n\nThe predictor is pure; the scheduler owns every clock.\n\nA second paragraph that the outline leaves out.\n\n## Observations\n\nObserved tides override predictions until the next cycle.\n", + ); + write( + &repo, + "docs/runbooks/rotate-keys.md", + "# Rotating keys\n\nRevoke, reissue, and redeploy.\n", + ); + write( + &repo, + "CHANGELOG.md", + "# Changelog\n\n## 1.2.3\n\nFixed a bug.\n", + ); + write( + &repo, + codequest::FILE_NAME, + "schema_version = 1\n\n[game]\ntype = \"quiz\"\nsummary = \"Learn how the tide model stays honest.\"\n", + ); + write( + &repo, + "src/predictor.rs", + "//! Pure tide prediction from lunar phase.\n//! Configured by config/tides.toml at startup.\n\nuse std::f64::consts::PI;\nmod harmonics;\n\nconst CYCLE: f64 = 12.42;\n\n/// Predicts the tide height. Never reads the clock.\n#[must_use]\npub fn predict(phase: f64) -> f64 {\n phase.sin() * PI\n}\n\npub struct Tide {\n height: f64,\n}\n\nimpl Tide {\n /// Applies an observation.\n pub fn observe(&mut self, height: f64) {\n *self.last() = height;\n }\n fn secret(&self) {}\n}\n\n/// Blends two readings.\npub fn blend(\n first: f64,\n second: f64,\n) -> f64 {\n first + second\n}\n\n#[cfg(test)]\nmod tests {\n #[test]\n fn predicts() {}\n}\n", + ); + write( + &repo, + "app/scheduler.py", + "\"\"\"Owns every clock and decides when predictions refresh.\"\"\"\n\nimport time\n\nclass Scheduler:\n def tick(self, now):\n \"\"\"Advances the schedule by one step.\"\"\"\n return now\n\n def _internal(self):\n pass\n\ndef shortcut(value): return value\n", + ); + write( + &repo, + "web/view.ts", + "// Copyright (c) 2024 Someone. MIT License.\n\nimport { predict } from './predictor';\n\nexport const REFRESH_SECONDS = 30;\n\n/** Renders the next tide for sailors. */\nexport function renderTide({ height }: Reading): string {\n return `${height}`;\n}\n\nconst helper = 3;\n", + ); + write( + &repo, + "node_modules/leftpad/index.js", + "export function vendoredSecret() {}\n", + ); + write( + &repo, + "web/app.min.js", + "export function minifiedSecret(){}\n", + ); + write( + &repo, + "web/view.test.ts", + "export function testSecret() {}\n", + ); + write(&repo, "Cargo.lock", "# generated lockSecret\n"); + write(&repo, "src/blob.rs", b"pub fn binarySecret() {}\0\x01\x02"); + write( + &repo, + "src/huge.rs", + format!( + "pub fn hugeSecret() {{}}\n{}", + "// padding\n".repeat(60_000) + ), + ); + repo + } + + #[test] + fn brief_covers_docs_summary_and_component_skeletons_without_locations() { + let repo = sample_repo(); + let tracked = tracked_files(&repo); + + let brief = project_brief(&repo, &tracked); + + assert!(brief.contains("CARTRIDGE SUMMARY:\nLearn how the tide model stays honest.")); + assert!(brief.contains("README EXCERPT:\n# Tide Clock")); + assert!(brief.contains("predicts harbor tides from lunar phase")); + assert!(brief.contains("| Live | Observed -> predicted |")); + assert!(brief.contains("Predictions must never contradict an observed tide.")); + assert!( + !brief.contains("Roadmap"), + "excerpts never end on a bare heading" + ); + assert!(!brief.contains("|---|")); + for operational in [ + "Installation", + "installer", + "From source", + "Clone and build", + ] { + assert!(!brief.contains(operational), "{operational} leaked"); + } + assert!(brief.contains("DESIGN NOTE 1:\n# Architecture\nThe predictor is pure")); + assert!(brief.contains("## Observations\nObserved tides override predictions")); + assert!( + brief.contains("DESIGN NOTE 2:\n# Rotating keys"), + "procedures come last" + ); + assert!(!brief.contains("second paragraph")); + assert!(!brief.contains("Fixed a bug"), "changelogs are not design"); + + assert!(brief.contains("--- COMPONENT 1 ---")); + assert!(brief.contains("ABOUT: Pure tide prediction from lunar phase.")); + assert!(brief.contains("// Predicts the tide height.\npub fn predict(phase: f64) -> f64\n")); + assert!(brief.contains("pub struct Tide")); + assert!( + brief.contains("// Applies an observation.\npub fn observe(&mut self, height: f64)\n") + ); + assert!(brief + .contains("// Blends two readings.\npub fn blend(first: f64, second: f64) -> f64\n")); + assert!(brief.contains("ABOUT: Owns every clock and decides when predictions refresh.")); + assert!(brief.contains("class Scheduler\n")); + assert!(brief.contains("// Advances the schedule by one step.\ndef tick(self, now)\n")); + assert!(brief.contains("def shortcut(value)")); + assert!(brief.contains( + "// Renders the next tide for sailors.\nexport function renderTide({ height }: Reading): string" + )); + for hidden in [ + "secret", + "Secret", + "use std", + "mod harmonics", + "CYCLE", + "REFRESH_SECONDS", + "helper", + "fn predicts", + "Copyright", + "npm install", + "import ", + "return value", + "self.last", + ] { + assert!(!brief.contains(hidden), "{hidden} leaked into:\n{brief}"); + } + for location in [ + "predictor.rs", + "scheduler.py", + "view.ts", + "src/", + "app/", + "docs/", + "guide.md", + "setup.sh", + "tides.toml", + "node_modules", + "ci.example", + "README.md", + "runbooks", + ] { + assert!( + !brief.contains(location), + "{location} leaked into:\n{brief}" + ); + } + assert!( + brief.contains("[LOCATION]"), + "path mentions are redacted in place" + ); + assert!(brief.is_ascii()); + assert!(brief.len() <= BRIEF_BUDGET); + + std::fs::remove_dir_all(repo).unwrap(); + } + + #[test] + fn brief_is_deterministic_and_components_spread_across_the_tree() { + let repo = temporary_git_repo(); + for directory in ["alpha", "beta", "gamma"] { + for index in 0..20 { + write( + &repo, + &format!("{directory}/module_{index:02}.rs"), + format!( + "/// Part {index} of {directory}.\npub fn {directory}_part_{index}() {{}}\n{}", + "// body\n".repeat(index) + ), + ); + } + } + for index in 0..5 { + write( + &repo, + &format!("scripts/tool_{index}.py"), + format!("def tooling_{index}():\n pass\n"), + ); + } + let tracked = tracked_files(&repo); + + let brief = project_brief(&repo, &tracked); + + assert_eq!(brief, project_brief(&repo, &tracked)); + assert!(brief.contains(&format!("--- COMPONENT {MAX_COMPONENTS} ---"))); + assert!(!brief.contains(&format!("--- COMPONENT {} ---", MAX_COMPONENTS + 1))); + for directory in ["alpha", "beta", "gamma"] { + let picked = brief.matches(&format!("pub fn {directory}_part_")).count(); + assert_eq!(picked, MAX_COMPONENTS / 3, "{directory}"); + assert!( + brief.contains(&format!("pub fn {directory}_part_19()")), + "the largest file in each directory is picked first" + ); + } + assert!( + !brief.contains("tooling_"), + "tooling waits until the project's own components run out" + ); + assert!(brief.len() <= BRIEF_BUDGET); + + std::fs::remove_dir_all(repo).unwrap(); + } + + #[test] + fn large_components_get_the_budget_small_ones_leave_and_keep_public_items() { + assert_eq!( + component_allocations(&[100, 5000, 200], 3000), + [100, 1600, 200] + ); + assert_eq!( + component_allocations(&[900, 900, 900], 1500), + [500, 500, 500] + ); + + let mut source = String::new(); + for index in 0..40 { + source.push_str(&format!("fn private_helper_{index}() {{}}\n")); + } + source.push_str("/// The entry point.\npub fn entry_point() {}\n"); + let skeleton = skeleton(&source, Family::Rust, &Redactor::default()); + let fitted = fit_skeleton(&skeleton, 200); + assert_eq!( + fitted[fitted.len() - 2..], + ["// The entry point.", "pub fn entry_point()"], + "documented public items survive, still in source order" + ); + assert!(fitted.iter().map(|line| line.len() + 1).sum::() <= 200); + assert_eq!( + fitted[0], "fn private_helper_0()", + "leftover room holds private items" + ); + } + + #[test] + fn oversized_documentation_stays_within_the_brief_budget() { + let repo = temporary_git_repo(); + let paragraph = "The engine owns every rule and the shell only draws. ".repeat(4); + write( + &repo, + "README.md", + format!("# Big\n\n{}", format!("{paragraph}\n\n").repeat(400)), + ); + for index in 0..12 { + write( + &repo, + &format!("docs/note-{index:02}.md"), + format!("# Note {index}\n\n{paragraph}\n\n").repeat(30), + ); + } + for index in 0..40 { + write( + &repo, + &format!("src/part_{index:02}/mod.rs"), + format!( + "//! Part {index}.\n{}", + "pub fn a_rather_long_function_name_for_budgeting() {}\n".repeat(60) + ), + ); + } + let tracked = tracked_files(&repo); + + let brief = project_brief(&repo, &tracked); + + assert!(brief.len() <= BRIEF_BUDGET, "{}", brief.len()); + let readme_end = brief.find("DESIGN NOTE 1:").unwrap(); + assert!( + readme_end <= README_BUDGET + 64, + "the README keeps to its share" + ); + let notes = &brief[readme_end..brief.find("COMPONENT SKELETONS").unwrap()]; + assert!(notes.len() <= DESIGN_NOTES_BUDGET + 64, "{}", notes.len()); + assert!( + brief.contains("--- COMPONENT 1 ---"), + "components still get room" + ); + + std::fs::remove_dir_all(repo).unwrap(); + } + + #[test] + fn an_empty_repository_yields_a_placeholder_brief() { + let repo = temporary_git_repo(); + assert_eq!( + project_brief(&repo, &[]), + "(no documentation or source excerpts available)" + ); + std::fs::remove_dir_all(repo).unwrap(); + } + + #[cfg(unix)] + #[test] + fn tracked_symlinks_are_never_followed() { + let repo = temporary_git_repo(); + let outside = repo.with_extension("outside.rs"); + std::fs::write(&outside, "pub fn outsideSecret() {}\n").unwrap(); + std::os::unix::fs::symlink(&outside, repo.join("linked.rs")).unwrap(); + write(&repo, "README.md", "# Linked\n\nA project.\n"); + let tracked = tracked_files(&repo); + assert!(tracked.contains(&"linked.rs".to_string())); + + let brief = project_brief(&repo, &tracked); + + assert!(!brief.contains("outsideSecret")); + std::fs::remove_file(outside).unwrap(); + std::fs::remove_dir_all(repo).unwrap(); + } + + #[test] + fn redaction_keeps_prose_and_replaces_locations() { + let redactor = Redactor::new(&[ + "packaging/Containerfile".to_string(), + "Makefile".to_string(), + "assets/logo.svg".to_string(), + ]); + assert_eq!( + redactor.redact("See packaging/Containerfile, the Makefile, and logo.svg."), + "See [LOCATION] the [LOCATION] and [LOCATION]" + ); + assert_eq!( + redactor.redact("Builds on Fedora/Arch, not in the packaging stage."), + "Builds on Fedora/Arch, not in the packaging stage." + ); + assert_eq!( + redactor.redact(" Read src/engine.rs, then see https://example.com and main.c."), + " Read [LOCATION] then see [LINK] and [LOCATION]" + ); + assert_eq!( + redactor.redact("A read/write lock guards e.g. the save; see Node.js."), + "A read/write lock guards e.g. the save; see Node.js." + ); + assert_eq!(redactor.redact("let ratio = a / b;"), "let ratio = a / b;"); + } +} diff --git a/src-tauri/src/save.rs b/src-tauri/src/save.rs index 90bab0f..8bf17e0 100644 --- a/src-tauri/src/save.rs +++ b/src-tauri/src/save.rs @@ -1,16 +1,21 @@ use std::collections::BTreeMap; use std::io::Write; use std::path::{Path, PathBuf}; -use std::sync::{Mutex, OnceLock}; +use std::sync::{Mutex, MutexGuard, OnceLock, PoisonError}; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; const SCHEMA_VERSION: u32 = 1; -fn save_write_lock() -> &'static Mutex<()> { +/// Serializes every save read-modify-write in this process. The guarded value +/// carries no invariant, so a panic while it was held cannot leave anything +/// inconsistent; the lock is recovered instead of failing every later save. +fn save_write_lock() -> MutexGuard<'static, ()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(PoisonError::into_inner) } pub(crate) fn path_for(cartridge_path: &Path) -> PathBuf { @@ -46,8 +51,93 @@ fn read_document(path: &Path) -> Result { } } +/// Reads the save at `path`, creating an empty one when none exists yet. +/// Callers hold the save lock, so creation never races a concurrent writer. +fn read_or_create_document(path: &Path) -> Result { + match std::fs::metadata(path) { + Ok(_) => read_document(path), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let document = SaveDocument::default(); + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .map_err(|_| "COULD NOT CREATE CARTRIDGE SAVE".to_string())?; + serde_json::to_writer_pretty(&mut file, &document) + .map_err(|_| "COULD NOT CREATE CARTRIDGE SAVE".to_string())?; + file.write_all(b"\n") + .map_err(|_| "COULD NOT CREATE CARTRIDGE SAVE".to_string())?; + file.sync_all() + .map_err(|_| "COULD NOT CREATE CARTRIDGE SAVE".to_string())?; + Ok(document) + } + Err(_) => Err("COULD NOT READ CARTRIDGE SAVE".to_string()), + } +} + +fn persist_document(path: &Path, document: &SaveDocument) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| "COULD NOT WRITE CARTRIDGE SAVE".to_string())?; + let mut temporary = tempfile::NamedTempFile::new_in(parent) + .map_err(|_| "COULD NOT WRITE CARTRIDGE SAVE".to_string())?; + serde_json::to_writer_pretty(&mut temporary, document) + .map_err(|_| "COULD NOT WRITE CARTRIDGE SAVE".to_string())?; + temporary + .write_all(b"\n") + .map_err(|_| "COULD NOT WRITE CARTRIDGE SAVE".to_string())?; + temporary + .as_file() + .sync_all() + .map_err(|_| "COULD NOT WRITE CARTRIDGE SAVE".to_string())?; + temporary + .persist(path) + .map_err(|_| "COULD NOT WRITE CARTRIDGE SAVE".to_string())?; + Ok(()) +} + +/// Atomically read-modify-writes the value stored under `key`. +/// +/// The whole cycle holds the save lock and starts from the document on disk, +/// so concurrent updates (a question batch landing while an answer is +/// recorded, or two batches for the same cartridge) cannot lose each other the +/// way a `get` on an old snapshot followed by `set` can. A missing value starts +/// from `T::default()`. A present value that no longer deserializes as `T` is +/// reported and left untouched rather than replaced by a default. The save is +/// rewritten only when `change` actually altered the value. +pub(crate) fn update( + cartridge_path: &Path, + key: &str, + change: impl FnOnce(&mut T) -> R, +) -> Result +where + T: Serialize + DeserializeOwned + Default, +{ + let _write_guard = save_write_lock(); + let path = path_for(cartridge_path); + let mut document = read_or_create_document(&path)?; + let previous = document.data.get(key).cloned(); + let mut value = match &previous { + Some(stored) => serde_json::from_value::(stored.clone()) + .map_err(|_| "CARTRIDGE SAVE DATA IS UNREADABLE".to_string())?, + None => T::default(), + }; + let result = change(&mut value); + let next = serde_json::to_value(&value) + .map_err(|_| "COULD NOT SERIALIZE CARTRIDGE SAVE".to_string())?; + if previous.as_ref() != Some(&next) { + document.data.insert(key.to_string(), next); + persist_document(&path, &document)?; + } + Ok(result) +} + +/// A snapshot of one cartridge save, taken when it was opened. Game code +/// reads through a snapshot and writes through [`update`], which re-reads the +/// save under the lock. #[derive(Clone, Debug)] pub(crate) struct SaveFile { + #[cfg(test)] path: PathBuf, document: SaveDocument, } @@ -55,28 +145,15 @@ pub(crate) struct SaveFile { impl SaveFile { pub(crate) fn open_or_create(cartridge_path: &Path) -> Result { let path = path_for(cartridge_path); - match std::fs::read(&path) { - Ok(_) => { - let document = read_document(&path)?; - Ok(Self { path, document }) - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - let document = SaveDocument::default(); - let mut file = std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&path) - .map_err(|_| "COULD NOT CREATE CARTRIDGE SAVE".to_string())?; - serde_json::to_writer_pretty(&mut file, &document) - .map_err(|_| "COULD NOT CREATE CARTRIDGE SAVE".to_string())?; - file.write_all(b"\n") - .map_err(|_| "COULD NOT CREATE CARTRIDGE SAVE".to_string())?; - file.sync_all() - .map_err(|_| "COULD NOT CREATE CARTRIDGE SAVE".to_string())?; - Ok(Self { path, document }) - } - Err(_) => Err("COULD NOT READ CARTRIDGE SAVE".to_string()), - } + let document = { + let _write_guard = save_write_lock(); + read_or_create_document(&path)? + }; + Ok(Self { + #[cfg(test)] + path, + document, + }) } pub(crate) fn get(&self, key: &str) -> Option { @@ -87,15 +164,16 @@ impl SaveFile { .and_then(|value| serde_json::from_value(value).ok()) } + /// Replaces the value under `key`, keeping every other key as it is on + /// disk now. Tests use it to stage saves written by other builds. + #[cfg(test)] pub(crate) fn set(&mut self, key: &str, value: &T) -> Result<(), String> { - let _write_guard = save_write_lock() - .lock() - .map_err(|_| "COULD NOT WRITE CARTRIDGE SAVE".to_string())?; + let _write_guard = save_write_lock(); let value = serde_json::to_value(value) .map_err(|_| "COULD NOT SERIALIZE CARTRIDGE SAVE".to_string())?; self.document = read_document(&self.path)?; let previous = self.document.data.insert(key.to_string(), value); - if let Err(error) = self.persist() { + if let Err(error) = persist_document(&self.path, &self.document) { if let Some(previous) = previous { self.document.data.insert(key.to_string(), previous); } else { @@ -105,28 +183,6 @@ impl SaveFile { } Ok(()) } - - fn persist(&self) -> Result<(), String> { - let parent = self - .path - .parent() - .ok_or_else(|| "COULD NOT WRITE CARTRIDGE SAVE".to_string())?; - let mut temporary = tempfile::NamedTempFile::new_in(parent) - .map_err(|_| "COULD NOT WRITE CARTRIDGE SAVE".to_string())?; - serde_json::to_writer_pretty(&mut temporary, &self.document) - .map_err(|_| "COULD NOT WRITE CARTRIDGE SAVE".to_string())?; - temporary - .write_all(b"\n") - .map_err(|_| "COULD NOT WRITE CARTRIDGE SAVE".to_string())?; - temporary - .as_file() - .sync_all() - .map_err(|_| "COULD NOT WRITE CARTRIDGE SAVE".to_string())?; - temporary - .persist(&self.path) - .map_err(|_| "COULD NOT WRITE CARTRIDGE SAVE".to_string())?; - Ok(()) - } } #[cfg(test)] @@ -181,4 +237,117 @@ mod tests { std::fs::remove_file(save_path).unwrap(); } + + #[test] + fn concurrent_updates_to_shared_and_separate_keys_never_lose_each_other() { + let cartridge_path = temporary_cartridge_path(); + let writers = 6; + let writes_per_writer = 8; + + std::thread::scope(|scope| { + for writer in 0..writers { + let cartridge_path = &cartridge_path; + scope.spawn(move || { + for write in 0..writes_per_writer { + update(cartridge_path, "shared.log", |log: &mut Vec| { + log.push(writer * 100 + write); + }) + .unwrap(); + update( + cartridge_path, + &format!("writer.{writer}"), + |count: &mut u32| *count += 1, + ) + .unwrap(); + } + }); + } + }); + + let reloaded = SaveFile::open_or_create(&cartridge_path).unwrap(); + let mut log = reloaded.get::>("shared.log").unwrap(); + log.sort_unstable(); + let mut expected = (0..writers) + .flat_map(|writer| (0..writes_per_writer).map(move |write| writer * 100 + write)) + .collect::>(); + expected.sort_unstable(); + assert_eq!(log, expected); + for writer in 0..writers { + assert_eq!( + reloaded.get::(&format!("writer.{writer}")), + Some(writes_per_writer) + ); + } + + std::fs::remove_file(path_for(&cartridge_path)).unwrap(); + } + + #[test] + fn a_stale_snapshot_cannot_overwrite_an_update_to_another_key() { + let cartridge_path = temporary_cartridge_path(); + let mut stale = SaveFile::open_or_create(&cartridge_path).unwrap(); + + update( + &cartridge_path, + "quiz.progress", + |answered: &mut Vec| { + answered.push("WHY?".into()); + }, + ) + .unwrap(); + stale.set("quest.progress", &1).unwrap(); + + let reloaded = SaveFile::open_or_create(&cartridge_path).unwrap(); + assert_eq!( + reloaded.get::>("quiz.progress"), + Some(vec!["WHY?".to_string()]) + ); + assert_eq!(reloaded.get::("quest.progress"), Some(1)); + + std::fs::remove_file(path_for(&cartridge_path)).unwrap(); + } + + #[test] + fn updates_refuse_to_replace_a_value_they_cannot_read() { + let cartridge_path = temporary_cartridge_path(); + let mut save = SaveFile::open_or_create(&cartridge_path).unwrap(); + save.set("quiz.progress", &"FROM A NEWER BUILD").unwrap(); + + let error = update(&cartridge_path, "quiz.progress", |count: &mut u32| { + *count += 1; + }) + .unwrap_err(); + + assert_eq!(error, "CARTRIDGE SAVE DATA IS UNREADABLE"); + let reloaded = SaveFile::open_or_create(&cartridge_path).unwrap(); + assert_eq!( + reloaded.get::("quiz.progress").as_deref(), + Some("FROM A NEWER BUILD") + ); + + std::fs::remove_file(path_for(&cartridge_path)).unwrap(); + } + + #[test] + fn unchanged_updates_do_not_rewrite_the_save() { + let cartridge_path = temporary_cartridge_path(); + update(&cartridge_path, "quiz.progress", |count: &mut u32| { + *count = 3 + }) + .unwrap(); + // Reformat the file by hand; a rewrite would restore the canonical form. + let compact = r#"{"schema_version":1,"data":{"quiz.progress":3}}"#; + std::fs::write(path_for(&cartridge_path), compact).unwrap(); + + update(&cartridge_path, "quiz.progress", |count: &mut u32| { + *count = 3 + }) + .unwrap(); + + assert_eq!( + std::fs::read_to_string(path_for(&cartridge_path)).unwrap(), + compact + ); + std::fs::remove_file(path_for(&cartridge_path)).unwrap(); + } } From 50d68561a814d91114626127d5cd4ad05c88e24d Mon Sep 17 00:00:00 2001 From: Jason Skrzypek Date: Fri, 25 Sep 2026 10:50:23 -0400 Subject: [PATCH 11/60] Keep the question deck, batches, and runs honest across the session - Catch-up request: any powered quiz screen past boot keeps a question request in flight while the next question does not exist, so a first request that failed before battery verification is retried behind the opening, and the Oracle and level-up waits share one path. Hero creation (atelier and legacy) now shows the Oracle's real states: ready, writing, retry pending, or contacting. - A new run drops the deck prefix answered earlier in the session and re-queues, at the front and flagged for review, every consumed question whose lesson is still outstanding. Batch ends and levels are rebased. - Batches hold QUESTION_BATCH_SIZE questions: short deliveries top up the open batch (reviews count), saved batches are rebatched on insert, and a level-up only follows a full batch. The prefetch level accounts for the level-ups still ahead and never re-requests a queued level. - CartridgeSpec gains question_batch_levels (empty = unknown), kept aligned through merges, retries, and drains. - Scene-graph robustness: Oracle/quiz entry creates a fresh run when none is live, Oracle/quiz Back clears the run, quiz B works without a run, level-up requests missing questions, and its prompt and A input follow the manifest's can_signal timing instead of a fixed hold. - Question requests carry a sequence number echoed by the reply; a superseded reply is ignored without touching the loading state, and a reply during play appends to an existing pending batch. - Entering the quiz menu focuses BEGIN again. Tests updated: failed_prefetch_waits_before_requesting_ai_again now expects a level-1 top-up for its short 4-question batch, and losing_the_last_heart_at_a_batch_boundary_does_not_level_up asserts the retired deck the next run receives. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/engine.rs | 1106 ++++++++++++++++++++++++++++++++------- src-tauri/src/lib.rs | 1 + 2 files changed, 930 insertions(+), 177 deletions(-) diff --git a/src-tauri/src/engine.rs b/src-tauri/src/engine.rs index e6aae9c..4891f35 100644 --- a/src-tauri/src/engine.rs +++ b/src-tauri/src/engine.rs @@ -43,7 +43,6 @@ const QUIZ_FEEDBACK_TICKS: u16 = 45; const QUIZ_LEAVE_CONFIRM_TICKS: u16 = 90; /// Questions answered between a miss and its review copy within one batch. const RETRY_GAP: usize = 3; -const LEVEL_UP_HOLD_TICKS: u64 = 60; 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]; @@ -491,6 +490,10 @@ 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. @@ -578,6 +581,9 @@ 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), @@ -587,6 +593,8 @@ enum EngineCommand { Questions { cartridge_id: String, questions: Vec, + /// The `RequestQuestions` sequence number this reply answers. + seq: u64, }, Input { button: Button, @@ -609,6 +617,8 @@ enum EngineEffect { cartridge_id: String, level: u32, count: usize, + /// Echoed by the reply so a superseded request cannot land. + seq: u64, }, RecordAnsweredQuestion { cartridge_id: String, @@ -1118,9 +1128,19 @@ struct GameState { quest_selected: usize, quiz: Option, batch_ends: Vec, - pending_questions: Option<(String, Vec)>, + /// Generation level of each batch, parallel to `batch_ends`. + batch_levels: Vec, + /// A delivery held until a safe screen boundary: cartridge id, questions, + /// and the level they were requested at. + pending_questions: Option<(String, Vec, u32)>, questions_loading: bool, question_retry_ticks: u16, + /// Sequence number of the newest question request; only its reply lands. + question_request_seq: u64, + /// The level the newest question request asked for. + question_request_level: u32, + /// Deck prefix committed in this session; the next run drops it. + consumed_questions: usize, oracle_hero_x: i32, oracle_drops: Vec, oracle_spawned: u32, @@ -1148,9 +1168,13 @@ impl Default for GameState { quest_selected: 0, quiz: None, batch_ends: Vec::new(), + batch_levels: Vec::new(), pending_questions: None, questions_loading: false, question_retry_ticks: 0, + question_request_seq: 0, + question_request_level: 1, + consumed_questions: 0, oracle_hero_x: 104, oracle_drops: Vec::new(), oracle_spawned: 0, @@ -1177,6 +1201,16 @@ impl GameState { self.oracle_drops.clear(); self.oracle_spawned = 0; } + if screen == Screen::QuizMenu && self.screen != Screen::QuizMenu { + self.menu_selected = 0; + } + // Whatever route the scene graph takes into the Oracle or the quiz, + // they always run inside a live run. + if matches!(screen, Screen::Oracle | Screen::Quiz) + && self.quiz.as_ref().is_none_or(|run| run.hearts == 0) + { + start_quiz_run(self); + } self.screen = screen; self.screen_ticks = 0; } @@ -1244,6 +1278,75 @@ impl GameState { .is_some_and(|cartridge| cartridge.questions.get(question).is_some()) } + /// True while a run is being played: the Oracle, quiz, and level-up + /// screens with a run in hand. Every other screen precedes the next run. + fn run_is_live(&self) -> bool { + self.quiz.is_some() + && matches!(self.screen, Screen::Oracle | Screen::Quiz | Screen::LevelUp) + } + + /// Whether the question the player faces next exists: the live run's + /// current question, or anything the next run keeps from the deck. + fn has_next_question(&self) -> bool { + if self.run_is_live() { + self.has_unanswered_question() + } else { + self.question_count() > self.consumed_questions + } + } + + fn batch_start(&self, index: usize) -> usize { + index + .checked_sub(1) + .and_then(|previous| self.batch_ends.get(previous).copied()) + .unwrap_or(0) + } + + fn batch_is_full(&self, index: usize) -> bool { + self.batch_ends + .get(index) + .is_some_and(|end| end.saturating_sub(self.batch_start(index)) >= QUESTION_BATCH_SIZE) + } + + /// The level the next requested batch should be generated at: never + /// below the level the run will have reached when those questions start + /// (one level-up per full batch still ahead), and never a level already + /// queued. An open (short) last batch is topped up at its own level. + fn next_batch_level(&self) -> u32 { + let (run_level, completed) = self + .quiz + .as_ref() + .filter(|_| self.run_is_live()) + .map_or((1, 0), |run| (run.level, run.completed_batches)); + let full_ahead = (completed..self.batch_ends.len()) + .filter(|index| self.batch_is_full(*index)) + .count() as u32; + let queued = self.batch_ends.len().checked_sub(1).map_or(0, |last| { + let level = self.batch_levels.get(last).copied().unwrap_or(1); + if self.batch_is_full(last) { + level.saturating_add(1) + } else { + level + } + }); + run_level.saturating_add(full_ahead).max(queued) + } + + /// The signal a level-up continues with: back to the quiz when the next + /// question is ready, otherwise to the Oracle to wait for it. + fn level_up_signal(&self) -> SceneSignal { + if self.has_unanswered_question() { + SceneSignal::QuestionsReady + } else { + SceneSignal::NeedsQuestion + } + } + + /// Whether the scene graph accepts a level-up continuation right now. + fn level_up_can_continue(&self) -> bool { + self.can_signal(self.level_up_signal()) + } + fn presentation_tier(&self) -> PresentationTier { PresentationTier::from_level(self.quiz.as_ref().map_or(1, |run| run.level)) } @@ -1341,21 +1444,142 @@ fn request_question_batch(state: &mut GameState, effects: &mut Effects, level: u else { return; }; + let cartridge_id = cartridge.id.clone(); + state.question_request_seq = state.question_request_seq.wrapping_add(1); + state.question_request_level = level; effects.0.push_back(EngineEffect::RequestQuestions { - cartridge_id: cartridge.id.clone(), + cartridge_id, level, count: QUESTION_BATCH_SIZE, + seq: state.question_request_seq, }); state.questions_loading = true; } -fn begin_quiz_run(state: &mut GameState) { +/// Splits a deck of `len` questions into batches of `QUESTION_BATCH_SIZE`, +/// leaving any remainder as an open last batch. Each new batch takes the +/// level of the old batch holding its last question. +fn rebatch(ends: &[usize], levels: &[u32], len: usize) -> (Vec, Vec) { + let level_at = |index: usize| { + ends.iter() + .position(|end| index < *end) + .and_then(|batch| levels.get(batch)) + .or(levels.last()) + .copied() + .unwrap_or(1) + }; + let mut new_ends = Vec::new(); + let mut new_levels = Vec::new(); + let mut end = 0; + while end < len { + end = (end + QUESTION_BATCH_SIZE).min(len); + new_ends.push(end); + new_levels.push(level_at(end - 1)); + } + (new_ends, new_levels) +} + +/// Appends a delivery requested at `level`: it first tops the open last +/// batch up to `QUESTION_BATCH_SIZE` questions, so a level-up always means a +/// full batch was survived, then forms new batches from the rest. +fn append_question_batch(state: &mut GameState, questions: Vec, level: u32) { + let Some(cartridge) = state.cartridge.as_mut() else { + return; + }; + let mut incoming = questions.into_iter().peekable(); + if let Some(last) = state.batch_ends.len().checked_sub(1) { + let start = last + .checked_sub(1) + .map_or(0, |previous| state.batch_ends[previous]); + let end = state.batch_ends[last]; + if end == cartridge.questions.len() { + let room = QUESTION_BATCH_SIZE.saturating_sub(end.saturating_sub(start)); + cartridge.questions.extend(incoming.by_ref().take(room)); + state.batch_ends[last] = cartridge.questions.len(); + } + } + while incoming.peek().is_some() { + cartridge + .questions + .extend(incoming.by_ref().take(QUESTION_BATCH_SIZE)); + state.batch_ends.push(cartridge.questions.len()); + state.batch_levels.push(level); + } +} + +/// Drops the deck prefix committed in this session so a new run never +/// replays it. Consumed questions whose lesson is still outstanding (missed +/// and not redeemed) return at the front as review items, then the deck is +/// rebatched from the rebased batch boundaries. +fn retire_consumed_questions(state: &mut GameState) { + let consumed = std::mem::take(&mut state.consumed_questions); + let Some(cartridge) = state.cartridge.as_mut() else { + return; + }; + let consumed = consumed.min(cartridge.questions.len()); + if consumed == 0 { + return; + } + let retired: Vec<_> = cartridge.questions.drain(..consumed).collect(); + let outstanding: HashSet = cartridge + .lessons + .iter() + .filter(|lesson| lesson.outstanding) + .map(|lesson| question_identity(&lesson.question)) + .collect(); + let mut queued: HashSet = cartridge + .questions + .iter() + .map(|question| question_identity(&question.question)) + .collect(); + let reviews: Vec<_> = retired + .into_iter() + .filter(|question| { + let identity = question_identity(&question.question); + outstanding.contains(&identity) && queued.insert(identity) + }) + .map(|mut question| { + question.review = true; + question + }) + .collect(); + let requeued = reviews.len(); + cartridge.questions.splice(0..0, reviews); + let len = cartridge.questions.len(); + + let mut ends = Vec::new(); + let mut levels = Vec::new(); + for (index, end) in state.batch_ends.iter().enumerate() { + if *end > consumed { + ends.push(end - consumed + requeued); + levels.push(state.batch_levels.get(index).copied().unwrap_or(1)); + } + } + (state.batch_ends, state.batch_levels) = rebatch(&ends, &levels, len); +} + +/// Starts a fresh run on a deck without the previous runs' answers. +fn start_quiz_run(state: &mut GameState) { + retire_consumed_questions(state); state.oracle_data = 0; state.oracle_bug_hits = 0; state.quiz = Some(QuizRun::new()); +} + +fn begin_quiz_run(state: &mut GameState) { + start_quiz_run(state); state.signal(SceneSignal::HeroReady); } +/// Leaves the active run through the scene graph's Back route, clearing it +/// so the next run starts fresh. +fn leave_quiz_run(state: &mut GameState) { + if state.can_signal(SceneSignal::Back) { + state.quiz = None; + state.signal(SceneSignal::Back); + } +} + /// Derives the display order once for the question that just became current. /// The attempt number counts this run's earlier commitments at the same /// identity; a question that arrives flagged for review starts at attempt 1, @@ -1430,6 +1654,7 @@ fn commit_answer(state: &mut GameState, effects: &mut Effects) { run.question, ); } + state.consumed_questions = state.consumed_questions.max(run.question + 1); effects.0.push_back(EngineEffect::RecordAnsweredQuestion { cartridge_id: cartridge.id.clone(), evidence, @@ -1438,14 +1663,17 @@ fn commit_answer(state: &mut GameState, effects: &mut Effects) { /// Leaves the lesson card: ends the run on a broken ward, otherwise advances /// to the next question and completes the batch exactly at its (possibly -/// retry-shifted) end. +/// retry-shifted) end once it holds a full `QUESTION_BATCH_SIZE` questions. fn continue_after_lesson(state: &mut GameState) { let question_count = state.question_count(); - let batch_ends = &state.batch_ends; + let Some(batch) = state.quiz.as_ref().map(|run| run.completed_batches) else { + return; + }; + let batch_full = state.batch_is_full(batch); + let next_batch_end = state.batch_ends.get(batch).copied(); let Some(run) = state.quiz.as_mut() else { return; }; - let next_batch_end = batch_ends.get(run.completed_batches).copied(); run.feedback = None; run.redeemed = false; let mut next_signal = None; @@ -1455,9 +1683,22 @@ fn continue_after_lesson(state: &mut GameState) { run.question += 1; run.selected = 0; if next_batch_end == Some(run.question) { - run.completed_batches += 1; - run.level += 1; - run.leveled_up = true; + if batch_full { + run.completed_batches += 1; + run.level += 1; + run.leveled_up = true; + } else if run.question < question_count { + // A short batch with questions after it joins the next one, + // so the level-up waits for a full batch. + state.batch_ends.remove(batch); + if batch < state.batch_levels.len() { + let level = state.batch_levels.remove(batch); + if let Some(next) = state.batch_levels.get_mut(batch) { + *next = (*next).max(level); + } + } + } + // A short last batch stays open; NeedsQuestion below tops it up. } if run.leveled_up { run.leveled_up = false; @@ -1659,23 +1900,26 @@ fn apply_commands( state.quest_selected = 0; state.menu_selected = 0; state.quiz = None; - state.batch_ends = state + state.consumed_questions = 0; + (state.batch_ends, state.batch_levels) = state .cartridge .as_ref() .filter(|cartridge| cartridge.mode() == CartridgeMode::Quiz) - .map(|cartridge| cartridge.question_batch_ends.clone()) + .map(|cartridge| { + let ends = &cartridge.question_batch_ends; + let levels = if cartridge.question_batch_levels.len() == ends.len() { + cartridge.question_batch_levels.clone() + } else { + (1..=ends.len() as u32).collect() + }; + rebatch(ends, &levels, cartridge.questions.len()) + }) .unwrap_or_default(); - if state.batch_ends.is_empty() { - if let Some(batch_end) = state.cartridge.as_ref().and_then(|cartridge| { - (cartridge.mode() == CartridgeMode::Quiz && !cartridge.questions.is_empty()) - .then_some(cartridge.questions.len()) - }) { - state.batch_ends.push(batch_end); - } - } state.pending_questions = None; state.questions_loading = false; state.question_retry_ticks = 0; + // Replies to requests for the previous insert no longer land. + state.question_request_seq = state.question_request_seq.wrapping_add(1); if state.cartridge.as_ref().is_some_and(|cartridge| { cartridge.mode() == CartridgeMode::Quiz && cartridge.questions.is_empty() }) { @@ -1688,11 +1932,14 @@ fn apply_commands( EngineCommand::Questions { cartridge_id, questions, + seq, } => { let is_current_quiz = state.cartridge.as_ref().is_some_and(|cartridge| { cartridge.id == cartridge_id && cartridge.mode() == CartridgeMode::Quiz }); - if !is_current_quiz { + // A superseded request's reply must not end the newer + // request's loading state or add a batch of its own. + if !is_current_quiz || seq != state.question_request_seq { continue; } state.questions_loading = false; @@ -1701,17 +1948,20 @@ fn apply_commands( continue; } state.question_retry_ticks = 0; + let level = state.question_request_level; if state.screen == Screen::Quiz { - state.pending_questions = Some((cartridge_id, questions)); + match state.pending_questions.as_mut() { + Some((pending_id, pending, _)) if *pending_id == cartridge_id => { + pending.extend(questions); + } + _ => state.pending_questions = Some((cartridge_id, questions, level)), + } continue; } - let batch_end = state.cartridge.as_mut().map(|cartridge| { - cartridge.questions.extend(questions); - cartridge.questions.len() - }); - if let Some(batch_end) = batch_end { - state.batch_ends.push(batch_end); + if !state.run_is_live() { + retire_consumed_questions(&mut state); } + append_question_batch(&mut state, questions, level); } EngineCommand::Input { button, pressed } => { let was_held = state.held.contains(&button); @@ -1798,12 +2048,16 @@ fn handle_press(state: &mut GameState, effects: &mut Effects, button: Button) { }, Screen::Oracle => { if button == Button::B { - state.signal(SceneSignal::Back); + leave_quiz_run(state); } } Screen::Quiz => { present_current_question(state); let Some(run) = state.quiz.as_mut() else { + // Without a run there is nothing to confirm; B still leaves. + if button == Button::B { + leave_quiz_run(state); + } return; }; if let Some((_, hold)) = run.feedback { @@ -1824,23 +2078,15 @@ fn handle_press(state: &mut GameState, effects: &mut Effects, button: Button) { match button { Button::Up => run.selected = (run.selected + choice_count - 1) % choice_count, Button::Down => run.selected = (run.selected + 1) % choice_count, - Button::B if leave_armed => { - state.signal(SceneSignal::Back); - } + Button::B if leave_armed => leave_quiz_run(state), Button::B => run.leave_armed = QUIZ_LEAVE_CONFIRM_TICKS, Button::A => commit_answer(state, effects), _ => {} } } Screen::LevelUp => { - if state.screen_ticks >= LEVEL_UP_HOLD_TICKS - && matches!(button, Button::A | Button::Start) - { - let signal = if state.has_unanswered_question() { - SceneSignal::QuestionsReady - } else { - SceneSignal::NeedsQuestion - }; + if matches!(button, Button::A | Button::Start) && state.level_up_can_continue() { + let signal = state.level_up_signal(); state.signal(signal); } } @@ -1900,6 +2146,9 @@ fn advance_game(mut state: ResMut, mut effects: ResMut) { if !matches!(state.screen, Screen::Off | Screen::Boot) { state.tick_machine(); } + if state.consumed_questions > 0 && !state.run_is_live() { + retire_consumed_questions(&mut state); + } if state.screen == Screen::Oracle { let moving_left = state.held.contains(&Button::Left); let moving_right = state.held.contains(&Button::Right); @@ -1942,27 +2191,17 @@ fn advance_game(mut state: ResMut, mut effects: ResMut) { state.oracle_bug_hits = state.oracle_bug_hits.saturating_add(bug_hits); } if matches!(state.screen, Screen::Oracle | Screen::LevelUp) { - if let Some((cartridge_id, questions)) = state.pending_questions.take() { - let batch_end = if let Some(cartridge) = state.cartridge.as_mut() { - if cartridge.id == cartridge_id && !questions.is_empty() { - cartridge.questions.extend(questions); - Some(cartridge.questions.len()) - } else { - None - } - } else { - None - }; - if let Some(batch_end) = batch_end { - state.batch_ends.push(batch_end); + if let Some((cartridge_id, questions, level)) = state.pending_questions.take() { + if state + .cartridge + .as_ref() + .is_some_and(|cartridge| cartridge.id == cartridge_id) + { + append_question_batch(&mut state, questions, level); } } } match state.screen { - Screen::Oracle if !state.has_unanswered_question() && !state.questions_loading => { - let level = state.quiz.as_ref().map_or(1, |run| run.level); - request_question_batch(&mut state, &mut effects, level); - } Screen::Oracle if state.screen_ticks >= 75 && state.has_unanswered_question() => { state.signal(SceneSignal::QuestionsReady); } @@ -1970,14 +2209,11 @@ fn advance_game(mut state: ResMut, mut effects: ResMut) { state.signal(SceneSignal::NeedsQuestion); } Screen::Quiz => { - let prefetch_level = state.quiz.as_ref().and_then(|run| { - let remaining = state.question_count().saturating_sub(run.question); - (remaining <= QUESTION_BATCH_SIZE - && !state.questions_loading - && state.pending_questions.is_none()) - .then_some(run.level + 1) + let prefetch = state.quiz.as_ref().is_some_and(|run| { + state.question_count().saturating_sub(run.question) <= QUESTION_BATCH_SIZE }); - if let Some(level) = prefetch_level { + if prefetch { + let level = state.next_batch_level(); request_question_batch(&mut state, &mut effects, level); } if let Some(run) = state.quiz.as_mut() { @@ -1988,15 +2224,22 @@ fn advance_game(mut state: ResMut, mut effects: ResMut) { } } Screen::LevelUp if state.screen_ticks >= 180 => { - let signal = if state.has_unanswered_question() { - SceneSignal::QuestionsReady - } else { - SceneSignal::NeedsQuestion - }; + let signal = state.level_up_signal(); state.signal(signal); } _ => {} } + // Catch-up request: whenever the powered device shows a quiz screen and + // the next question does not exist yet, keep a request in flight. This + // recovers a first request that failed before the batteries were + // verified, and serves the Oracle and level-up waits alike. + if state.powered + && !matches!(state.screen, Screen::Off | Screen::Boot) + && !state.has_next_question() + { + let level = state.next_batch_level(); + request_question_batch(&mut state, &mut effects, level); + } if state.screen == Screen::Quiz { present_current_question(&mut state); } @@ -2399,18 +2642,30 @@ fn render_character_creation(frame: &mut Framebuffer, state: &GameState) { } frame.text(81, y, label, PARCH, 1); } - let oracle_status = if state.question_count() > 0 { - "ORACLE READY" - } else if state.questions_loading { - "ORACLE IS WRITING..." - } else { - "ORACLE WILL RETRY..." - }; - frame.centered_text(120, oracle_status, SKY, 1); + frame.centered_text(120, creation_oracle_status(state, false), SKY, 1); frame.centered_text(138, "D-PAD:EDIT A:CHOOSE", PARCH, 1); frame.centered_text(150, "START:BEGIN B:BACK", MIST, 1); } +/// Hero-creation status for the next run's first question, in the Oracle's +/// truthful states: ready, loading, a scheduled retry, or about to contact. +fn creation_oracle_status(state: &GameState, atelier: bool) -> &'static str { + match ( + state.has_next_question(), + state.questions_loading, + state.question_retry_ticks > 0, + atelier, + ) { + (true, ..) => "ORACLE READY", + (_, true, _, true) => "ORACLE IS WRITING", + (_, true, _, false) => "ORACLE IS WRITING...", + (_, _, true, true) => "VISION CLOUDY - RETRYING", + (_, _, true, false) => "ORACLE WILL RETRY...", + (.., true) => "CONTACTING ORACLE", + _ => "CONTACTING ORACLE...", + } +} + fn render_oracle_atelier(frame: &mut Framebuffer, state: &GameState) { frame.blit_rgb(ORACLE_ATELIER); frame.rect( @@ -2474,15 +2729,8 @@ fn render_oracle_atelier(frame: &mut Framebuffer, state: &GameState) { 1, ); - let oracle_status = if state.question_count() > 0 { - "ORACLE READY" - } else if state.questions_loading { - "ORACLE IS WRITING" - } else { - "VISION CLOUDY - RETRYING" - }; frame.rect(0, 143, WIDTH as i32, 17, VOID); - frame.text(5, 148, oracle_status, CYAN, 1); + frame.text(5, 148, creation_oracle_status(state, true), CYAN, 1); frame.text(174, 148, "START:BIND", MIST, 1); } @@ -3163,7 +3411,7 @@ fn render_level_up(frame: &mut Framebuffer, state: &GameState) { 1, ); } - if state.screen_ticks >= LEVEL_UP_HOLD_TICKS { + if state.level_up_can_continue() { frame.centered_text(149, "A / START:CONTINUE", MIST, 1); } else { frame.centered_text(149, "ORACLE BOND DEEPENS", MIST, 1); @@ -3265,7 +3513,7 @@ fn render_oracle_ascension(frame: &mut Framebuffer, state: &GameState) { MENU_FOOTER_BOX.height, CYAN_DIM, ); - if state.screen_ticks >= LEVEL_UP_HOLD_TICKS { + if state.level_up_can_continue() { frame.centered_text_box(MENU_FOOTER_BOX, "A / START:CONTINUE", PARCH, 1); } else { frame.centered_text_box(MENU_FOOTER_BOX, "THE NEW CREST TAKES HOLD", MIST, 1); @@ -3541,6 +3789,7 @@ fn handle_effect( cartridge_id, level, count, + seq, } => { let sender = sender.clone(); let loader = Arc::clone(question_loader); @@ -3549,6 +3798,7 @@ fn handle_effect( let _ = sender.send(EngineCommand::Questions { cartridge_id, questions, + seq, }); }); } @@ -3730,6 +3980,7 @@ mod tests { ..Default::default() }], question_batch_ends: Vec::new(), + question_batch_levels: Vec::new(), lessons: Vec::new(), mastery: Mastery::new(), } @@ -3925,6 +4176,19 @@ mod tests { engine.app.world().resource::() } + /// Answers the engine's newest question request with `questions`. + fn deliver(engine: &mut GameEngine, cartridge_id: &str, questions: Vec) { + let seq = engine_state(engine).question_request_seq; + issue( + engine, + EngineCommand::Questions { + cartridge_id: cartridge_id.into(), + questions, + seq, + }, + ); + } + fn current_question(engine: &GameEngine) -> QuizQuestion { let state = engine_state(engine); let run = state.quiz.as_ref().unwrap(); @@ -4001,12 +4265,10 @@ mod tests { for button in [Button::Start, Button::A, Button::Start] { press(&mut engine, button); } - issue( + deliver( &mut engine, - EngineCommand::Questions { - cartridge_id: "/tmp/engine-test".into(), - questions: (0..count).map(concept_question).collect(), - }, + "/tmp/engine-test", + (0..count).map(concept_question).collect(), ); for _ in 0..75 { engine.update(); @@ -5283,22 +5545,20 @@ mod tests { pressed: true, }, ); - issue( + deliver( &mut engine, - EngineCommand::Questions { - cartridge_id: "/tmp/engine-test".into(), - questions: vec![QuizQuestion { - question: "WHAT ARRIVED SAFELY?".into(), - choices: vec![ - "A QUESTION".into(), - "A KEY PRESS".into(), - "A GLITCH".into(), - "A COMMAND".into(), - ], - answer: 0, - ..Default::default() - }], - }, + "/tmp/engine-test", + vec![QuizQuestion { + question: "WHAT ARRIVED SAFELY?".into(), + choices: vec![ + "A QUESTION".into(), + "A KEY PRESS".into(), + "A GLITCH".into(), + "A COMMAND".into(), + ], + answer: 0, + ..Default::default() + }], ); assert_eq!(engine.screen(), Screen::Quiz); let unanswered = engine.frame().to_vec(); @@ -5326,17 +5586,15 @@ mod tests { second.questions[0].question = "SECOND CARTRIDGE".into(); issue(&mut engine, EngineCommand::Cartridge(Some(first))); issue(&mut engine, EngineCommand::Cartridge(Some(second))); - issue( + deliver( &mut engine, - EngineCommand::Questions { - cartridge_id: "/tmp/engine-test".into(), - questions: vec![QuizQuestion { - question: "STALE".into(), - choices: vec!["A".into()], - answer: 0, - ..Default::default() - }], - }, + "/tmp/engine-test", + vec![QuizQuestion { + question: "STALE".into(), + choices: vec!["A".into()], + answer: 0, + ..Default::default() + }], ); let state = engine.app.world().resource::(); assert_eq!( @@ -5418,22 +5676,20 @@ mod tests { } assert_eq!(engine.screen(), Screen::Oracle); - issue( + deliver( &mut engine, - EngineCommand::Questions { - cartridge_id: "/tmp/engine-test".into(), - questions: vec![QuizQuestion { - question: "WHAT SHOULD THE ENGINE OWN?".into(), - choices: vec![ - "GAMEPLAY STATE".into(), - "DEVICE STYLES".into(), - "WINDOW CHROME".into(), - "HOST POINTERS".into(), - ], - answer: 0, - ..Default::default() - }], - }, + "/tmp/engine-test", + vec![QuizQuestion { + question: "WHAT SHOULD THE ENGINE OWN?".into(), + choices: vec![ + "GAMEPLAY STATE".into(), + "DEVICE STYLES".into(), + "WINDOW CHROME".into(), + "HOST POINTERS".into(), + ], + answer: 0, + ..Default::default() + }], ); assert_eq!(engine.screen(), Screen::Quiz); } @@ -5494,17 +5750,15 @@ mod tests { } assert_eq!(engine.screen(), Screen::Oracle); - issue( + deliver( &mut engine, - EngineCommand::Questions { - cartridge_id: "/tmp/engine-test".into(), - questions: vec![QuizQuestion { - question: "WHAT ARRIVED NEXT?".into(), - choices: vec!["A NEW QUESTION".into(), "NOTHING".into()], - answer: 0, - ..Default::default() - }], - }, + "/tmp/engine-test", + vec![QuizQuestion { + question: "WHAT ARRIVED NEXT?".into(), + choices: vec!["A NEW QUESTION".into(), "NOTHING".into()], + answer: 0, + ..Default::default() + }], ); assert_eq!(engine.screen(), Screen::Quiz); let state = engine.app.world().resource::(); @@ -5678,6 +5932,7 @@ mod tests { cartridge_id, level: 1, count: 6, + .. }] if cartridge_id == "/tmp/engine-test" )); } @@ -5708,13 +5963,7 @@ mod tests { ); } assert_eq!(engine.screen(), Screen::Oracle); - issue( - &mut engine, - EngineCommand::Questions { - cartridge_id: "/tmp/engine-test".into(), - questions: Vec::new(), - }, - ); + deliver(&mut engine, "/tmp/engine-test", Vec::new()); for _ in 0..300 { engine.update(); @@ -5727,6 +5976,7 @@ mod tests { cartridge_id, level: 1, count: 6, + .. } if cartridge_id == "/tmp/engine-test" ))); } @@ -5759,18 +6009,14 @@ mod tests { for _ in 0..75 { engine.update(); } + // The four-question batch is short, so the prefetch tops it up at + // its own level instead of jumping ahead to level 2. assert!(matches!( engine.take_effects().as_slice(), - [EngineEffect::RequestQuestions { level: 2, .. }] + [EngineEffect::RequestQuestions { level: 1, .. }] )); - issue( - &mut engine, - EngineCommand::Questions { - cartridge_id: "/tmp/engine-test".into(), - questions: Vec::new(), - }, - ); + deliver(&mut engine, "/tmp/engine-test", Vec::new()); assert!(engine.take_effects().is_empty()); for _ in 0..299 { @@ -5780,7 +6026,7 @@ mod tests { engine.update(); assert!(matches!( engine.take_effects().as_slice(), - [EngineEffect::RequestQuestions { level: 2, .. }] + [EngineEffect::RequestQuestions { level: 1, .. }] )); } @@ -5876,13 +6122,7 @@ mod tests { }, ); } - issue( - &mut engine, - EngineCommand::Questions { - cartridge_id: "/tmp/engine-test".into(), - questions: vec![question; 3], - }, - ); + deliver(&mut engine, "/tmp/engine-test", vec![question; 3]); for _ in 0..75 { engine.update(); } @@ -5898,9 +6138,13 @@ mod tests { assert_eq!(run.level, 1); assert_eq!(run.completed_batches, 0); // The two survivable misses were scheduled for review; the final, - // ward-breaking miss was not. - assert_eq!(state.cartridge.as_ref().unwrap().questions.len(), 5); - assert_eq!(state.batch_ends, vec![5]); + // ward-breaking miss was not. Once the run is over, the three + // committed questions leave the deck, and the two unplayed review + // copies (all one identity) are what the next run starts with. + let questions = &state.cartridge.as_ref().unwrap().questions; + assert_eq!(questions.len(), 2); + assert!(questions.iter().all(|question| question.review)); + assert_eq!(state.batch_ends, vec![2]); } #[test] @@ -5948,6 +6192,7 @@ mod tests { cartridge_id, level: 2, count: 6, + .. } if cartridge_id == "/tmp/engine-test" ))); } @@ -6520,26 +6765,533 @@ mod tests { maybe_write_preview("oracle-lesson-worst-case", &worst_frame.pixels); } + fn request_seqs(effects: &[EngineEffect]) -> Vec<(u32, u64)> { + effects + .iter() + .filter_map(|effect| match effect { + EngineEffect::RequestQuestions { level, seq, .. } => Some((*level, *seq)), + _ => None, + }) + .collect() + } + + fn scene_spec( + id: &str, + handler: SceneHandler, + transitions: &[(SceneSignal, &str, Option)], + ) -> SceneSpec { + SceneSpec { + id: id.into(), + handler, + transitions: transitions + .iter() + .map(|(signal, target, after_ticks)| SceneTransition { + signal: *signal, + target: (*target).into(), + after_ticks: *after_ticks, + }) + .collect(), + } + } + #[test] - fn oracle_result_waits_for_a_safe_screen_boundary() { + fn a_first_request_that_fails_before_power_on_is_retried_before_the_oracle() { + let mut engine = GameEngine::new(); + let mut cartridge = quiz_cartridge(); + cartridge.questions.clear(); + issue(&mut engine, EngineCommand::Cartridge(Some(cartridge))); + assert_eq!(request_seqs(&engine.take_effects()).len(), 1); + // The unverified provider answers at once with nothing. + deliver(&mut engine, "/tmp/engine-test", Vec::new()); + issue(&mut engine, EngineCommand::Power(true)); + issue(&mut engine, EngineCommand::BootComplete); + assert!( + request_seqs(&engine.take_effects()).is_empty(), + "the retry delay still holds" + ); + assert_eq!( + creation_oracle_status(engine_state(&engine), true), + "VISION CLOUDY - RETRYING" + ); + + let mut requests = Vec::new(); + for _ in 0..300 { + engine.update(); + requests.extend(request_seqs(&engine.take_effects())); + } + + assert_ne!(engine.screen(), Screen::Oracle); + assert_eq!(requests.len(), 1, "one catch-up request before the Oracle"); + assert_eq!(requests[0].0, 1); + let state = engine_state(&engine); + assert!(state.questions_loading); + assert_eq!(creation_oracle_status(state, true), "ORACLE IS WRITING"); + assert_eq!(creation_oracle_status(state, false), "ORACLE IS WRITING..."); + } + + #[test] + fn hero_creation_status_names_the_oracles_real_state() { + let mut cartridge = quiz_cartridge(); + cartridge.questions.clear(); + let mut state = GameState { + cartridge: Some(cartridge), + ..Default::default() + }; + assert_eq!(creation_oracle_status(&state, true), "CONTACTING ORACLE"); + assert_eq!( + creation_oracle_status(&state, false), + "CONTACTING ORACLE..." + ); + state.question_retry_ticks = 12; + assert_eq!( + creation_oracle_status(&state, true), + "VISION CLOUDY - RETRYING" + ); + assert_eq!( + creation_oracle_status(&state, false), + "ORACLE WILL RETRY..." + ); + state.questions_loading = true; + assert_eq!(creation_oracle_status(&state, true), "ORACLE IS WRITING"); + state.cartridge.as_mut().unwrap().questions = vec![concept_question(0)]; + assert_eq!(creation_oracle_status(&state, true), "ORACLE READY"); + // A question the session already answered is not ready for a new run. + state.consumed_questions = 1; + assert_eq!(creation_oracle_status(&state, true), "ORACLE IS WRITING"); + } + + #[test] + fn a_new_run_drops_answered_questions_and_brings_outstanding_misses_back_first() { + let mut engine = batch_quiz_engine(QUESTION_BATCH_SIZE); + let first_questions: Vec<_> = (0..3).map(concept_question).collect(); + assert_eq!( + current_question(&engine).question, + first_questions[0].question + ); + let broken_ward_slot = { + for _ in 0..2 { + commit(&mut engine, false); + finish_lesson(&mut engine); + } + let slot = answer_slot(&engine); + commit(&mut engine, false); + finish_lesson(&mut engine); + slot + }; + assert_eq!(engine.screen(), Screen::GameOver); + + press(&mut engine, Button::A); + assert_eq!(engine.screen(), Screen::QuizMenu); + press(&mut engine, Button::A); + assert_eq!(engine.screen(), Screen::CharacterCreation); + press(&mut engine, Button::Start); + assert_eq!(engine.screen(), Screen::Oracle); + for _ in 0..75 { + engine.update(); + } + assert_eq!(engine.screen(), Screen::Quiz); + + // The ward-breaking miss had no in-run retry, so it returns first as a + // review in new slots; the two misses whose review copies were never + // reached come back through those copies, not as duplicates. + let state = engine_state(&engine); + let deck: Vec<_> = state + .cartridge + .as_ref() + .unwrap() + .questions + .iter() + .map(|question| (question.question.clone(), question.review)) + .collect(); + let expected: Vec<_> = [ + (2, true), + (3, false), + (0, true), + (1, true), + (4, false), + (5, false), + ] + .into_iter() + .map(|(index, review)| (concept_question(index).question, review)) + .collect(); + assert_eq!(deck, expected); + assert_eq!(state.batch_ends, vec![QUESTION_BATCH_SIZE]); + let run = state.quiz.as_ref().unwrap(); + assert_eq!((run.question, run.level, run.completed_batches), (0, 1, 0)); + assert_eq!(run.hearts, 3); + assert_eq!(run.attempt, 1); + assert_ne!(answer_slot(&engine), broken_ward_slot); + assert_ne!( + current_question(&engine).question, + first_questions[0].question + ); + } + + #[test] + fn short_deliveries_merge_until_a_full_batch_is_survived() { + let mut engine = waiting_oracle_engine(); + deliver( + &mut engine, + "/tmp/engine-test", + (0..4).map(concept_question).collect(), + ); + for _ in 0..75 { + engine.update(); + } + assert_eq!(engine.screen(), Screen::Quiz); + assert_eq!(engine_state(&engine).batch_ends, vec![4]); + // The open batch is topped up at its own level. + assert_eq!( + request_seqs(&engine.take_effects()) + .iter() + .map(|(level, _)| *level) + .collect::>(), + vec![1] + ); + deliver( + &mut engine, + "/tmp/engine-test", + (4..8).map(concept_question).collect(), + ); + assert!(engine_state(&engine).pending_questions.is_some()); + + for _ in 0..4 { + commit(&mut engine, true); + finish_lesson(&mut engine); + } + assert_eq!( + engine.screen(), + Screen::Oracle, + "four answers are not a batch" + ); + engine.update(); + { + let state = engine_state(&engine); + assert_eq!(state.batch_ends, vec![6, 8]); + assert_eq!(state.batch_levels, vec![1, 1]); + assert_eq!(state.quiz.as_ref().unwrap().level, 1); + } + for _ in 0..75 { + engine.update(); + } + assert_eq!(engine.screen(), Screen::Quiz); + // The new questions follow one level-up (the full first batch), so + // the prefetch asks for level 2, not a level already queued. + assert_eq!( + request_seqs(&engine.take_effects()) + .iter() + .map(|(level, _)| *level) + .collect::>(), + vec![2] + ); + + for _ in 0..2 { + commit(&mut engine, true); + finish_lesson(&mut engine); + } + assert_eq!(engine.screen(), Screen::LevelUp); + let run = engine_state(&engine).quiz.as_ref().unwrap(); + assert_eq!((run.question, run.level, run.completed_batches), (6, 2, 1)); + } + + #[test] + fn rebatching_splits_saved_batches_into_full_batches_with_their_levels() { + assert_eq!(rebatch(&[1, 7], &[2, 3], 7), (vec![6, 7], vec![3, 3])); + assert_eq!(rebatch(&[], &[], 4), (vec![4], vec![1])); + assert_eq!(rebatch(&[6, 12], &[1, 2], 12), (vec![6, 12], vec![1, 2])); + assert_eq!(rebatch(&[3], &[1], 0), (vec![], vec![])); + } + + #[test] + fn saved_batch_levels_drive_the_prefetch_while_the_run_starts_at_initiate() { + let mut engine = GameEngine::new(); + let mut cartridge = quiz_cartridge(); + cartridge.questions = (0..QUESTION_BATCH_SIZE).map(concept_question).collect(); + cartridge.question_batch_ends = vec![QUESTION_BATCH_SIZE]; + cartridge.question_batch_levels = vec![3]; + issue(&mut engine, EngineCommand::Cartridge(Some(cartridge))); + issue(&mut engine, EngineCommand::Power(true)); + finish_opening(&mut engine); + let _ = engine.take_effects(); + for button in [Button::Start, Button::A, Button::Start] { + press(&mut engine, button); + } + for _ in 0..75 { + engine.update(); + } + assert_eq!(engine.screen(), Screen::Quiz); + assert_eq!(engine_state(&engine).quiz.as_ref().unwrap().level, 1); + assert_eq!( + request_seqs(&engine.take_effects()) + .iter() + .map(|(level, _)| *level) + .collect::>(), + vec![4], + "a queued level-3 batch is not requested again" + ); + } + + #[test] + fn a_superseded_question_reply_cannot_land() { let mut engine = GameEngine::new(); + let mut cartridge = quiz_cartridge(); + cartridge.questions.clear(); issue( &mut engine, - EngineCommand::Cartridge(Some(quiz_cartridge())), + EngineCommand::Cartridge(Some(cartridge.clone())), ); - engine.app.world_mut().resource_mut::().screen = Screen::Quiz; + let first = request_seqs(&engine.take_effects()); + issue(&mut engine, EngineCommand::Cartridge(Some(cartridge))); + let second = request_seqs(&engine.take_effects()); + assert_eq!((first.len(), second.len()), (1, 1)); + assert_ne!(first[0].1, second[0].1); + + issue( + &mut engine, + EngineCommand::Questions { + cartridge_id: "/tmp/engine-test".into(), + questions: vec![concept_question(0)], + seq: first[0].1, + }, + ); + let state = engine_state(&engine); + assert!( + state.questions_loading, + "the newer request is still in flight" + ); + assert_eq!(state.question_count(), 0); + issue( &mut engine, EngineCommand::Questions { cartridge_id: "/tmp/engine-test".into(), - questions: vec![QuizQuestion { - question: "NEW BATCH".into(), - choices: vec!["A".into()], - answer: 0, - ..Default::default() - }], + questions: vec![concept_question(1)], + seq: second[0].1, }, ); + let state = engine_state(&engine); + assert!(!state.questions_loading); + assert_eq!( + state.cartridge.as_ref().unwrap().questions[0].question, + concept_question(1).question + ); + } + + #[test] + fn a_reply_during_the_quiz_appends_to_a_pending_batch() { + let mut engine = GameEngine::new(); + issue( + &mut engine, + EngineCommand::Cartridge(Some(quiz_cartridge())), + ); + { + let mut state = engine.app.world_mut().resource_mut::(); + state.screen = Screen::Quiz; + state.pending_questions = + Some(("/tmp/engine-test".into(), vec![concept_question(0)], 1)); + state.questions_loading = true; + } + deliver(&mut engine, "/tmp/engine-test", vec![concept_question(1)]); + let pending = engine_state(&engine).pending_questions.as_ref().unwrap(); + assert_eq!( + pending + .1 + .iter() + .map(|question| question.question.clone()) + .collect::>(), + vec![concept_question(0).question, concept_question(1).question] + ); + } + + #[test] + fn returning_to_the_quiz_menu_focuses_begin() { + let mut engine = playing_quiz_engine(); + press(&mut engine, Button::B); + press(&mut engine, Button::B); + assert_eq!(engine.screen(), Screen::QuizMenu); + press(&mut engine, Button::Down); + press(&mut engine, Button::A); + assert_eq!(engine.screen(), Screen::Title); + press(&mut engine, Button::A); + assert_eq!(engine.screen(), Screen::QuizMenu); + assert_eq!(engine_state(&engine).menu_selected, 0); + press(&mut engine, Button::A); + assert_eq!(engine.screen(), Screen::CharacterCreation); + } + + #[test] + fn a_scene_graph_that_skips_hero_creation_still_plays_a_fresh_run() { + use SceneHandler as H; + use SceneSignal as S; + let mut engine = GameEngine::new(); + let mut cartridge = quiz_cartridge(); + cartridge.questions = (0..QUESTION_BATCH_SIZE).map(concept_question).collect(); + cartridge.machine = Box::new( + SceneMachineDefinition::compile( + "menu", + vec![ + scene_spec( + "menu", + H::QuizMenu, + &[(S::NewRun, "oracle", None), (S::Back, "menu", None)], + ), + scene_spec( + "oracle", + H::Oracle, + &[(S::QuestionsReady, "quiz", None), (S::Back, "menu", None)], + ), + scene_spec( + "quiz", + H::ConceptQuiz, + &[ + (S::NeedsQuestion, "oracle", None), + (S::HeartsEmpty, "menu", None), + (S::Back, "menu", None), + ], + ), + ], + ) + .unwrap(), + ); + issue(&mut engine, EngineCommand::Cartridge(Some(cartridge))); + issue(&mut engine, EngineCommand::Power(true)); + issue(&mut engine, EngineCommand::BootComplete); + assert_eq!(engine.screen(), Screen::QuizMenu); + + for run in 0..2 { + press(&mut engine, Button::A); + assert_eq!(engine.screen(), Screen::Oracle, "run {run}"); + for _ in 0..75 { + engine.update(); + } + assert_eq!(engine.screen(), Screen::Quiz, "run {run}"); + let run_state = engine_state(&engine).quiz.as_ref().unwrap(); + assert_eq!((run_state.question, run_state.hearts), (0, 3)); + let _ = engine.take_effects(); + commit(&mut engine, true); + assert!(engine.take_effects().iter().any(|effect| matches!( + effect, + EngineEffect::RecordAnsweredQuestion { evidence, .. } if evidence.correct + ))); + finish_lesson(&mut engine); + press(&mut engine, Button::B); + press(&mut engine, Button::B); + assert_eq!(engine.screen(), Screen::QuizMenu, "run {run}"); + assert!(engine_state(&engine).quiz.is_none(), "Back clears the run"); + } + // The second run started on the question after the first run's answer. + assert_eq!( + engine_state(&engine).question_count(), + QUESTION_BATCH_SIZE - 2 + ); + + // Even a quiz entered without a run answers B. + { + let mut state = engine.app.world_mut().resource_mut::(); + state.machine.as_mut().unwrap().reset(); + state.signal(SceneSignal::NewRun); + state.signal(SceneSignal::QuestionsReady); + state.quiz = None; + } + assert_eq!(engine.screen(), Screen::Quiz); + press(&mut engine, Button::B); + assert_eq!(engine.screen(), Screen::QuizMenu); + } + + #[test] + fn level_up_requests_missing_questions_and_waits_for_the_manifest_hold() { + use SceneHandler as H; + use SceneSignal as S; + let mut engine = GameEngine::new(); + issue( + &mut engine, + EngineCommand::Cartridge(Some(quiz_cartridge())), + ); + let _ = engine.take_effects(); + { + let mut state = engine.app.world_mut().resource_mut::(); + state.powered = true; + state.machine = Some(SceneMachine::new( + SceneMachineDefinition::compile( + "level-up", + vec![ + scene_spec( + "level-up", + H::LevelUp, + &[(S::QuestionsReady, "quiz", Some(120))], + ), + scene_spec( + "quiz", + H::ConceptQuiz, + &[(S::BatchComplete, "level-up", None)], + ), + ], + ) + .unwrap(), + )); + state.quiz = Some(QuizRun { + question: 1, + completed_batches: 1, + level: 2, + ..QuizRun::new() + }); + state.transition(Screen::LevelUp); + } + + engine.update(); + assert_eq!(request_seqs(&engine.take_effects()).len(), 1); + deliver(&mut engine, "/tmp/engine-test", vec![concept_question(0)]); + assert!(engine_state(&engine).has_unanswered_question()); + + for _ in 0..70 { + engine.update(); + } + let early = { + let mut frame = Framebuffer::default(); + render_level_up(&mut frame, engine_state(&engine)); + frame.pixels + }; + press(&mut engine, Button::A); + assert_eq!( + engine.screen(), + Screen::LevelUp, + "A waits for the manifest's 120-tick hold" + ); + for _ in 0..50 { + engine.update(); + } + let ready = { + let mut frame = Framebuffer::default(); + render_level_up(&mut frame, engine_state(&engine)); + frame.pixels + }; + assert_ne!( + frame_region(&early, 0..WIDTH, 145..156), + frame_region(&ready, 0..WIDTH, 145..156), + "the continue prompt appears with the hold" + ); + press(&mut engine, Button::A); + assert_eq!(engine.screen(), Screen::Quiz); + } + + #[test] + fn oracle_result_waits_for_a_safe_screen_boundary() { + let mut engine = GameEngine::new(); + issue( + &mut engine, + EngineCommand::Cartridge(Some(quiz_cartridge())), + ); + engine.app.world_mut().resource_mut::().screen = Screen::Quiz; + deliver( + &mut engine, + "/tmp/engine-test", + vec![QuizQuestion { + question: "NEW BATCH".into(), + choices: vec!["A".into()], + answer: 0, + ..Default::default() + }], + ); { let state = engine.app.world().resource::(); assert_eq!( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1061f14..0dbe8d6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -878,6 +878,7 @@ fn engine_cartridge(cartridge: Cartridge) -> Result Date: Fri, 25 Sep 2026 11:01:26 -0400 Subject: [PATCH 12/60] Integrate the streams: Codex and lesson sound, level-ordered decks Finish the seams between the parallel streams: - Sound: the Codex is now a known audio scene with page-turn cues, confirm and cancel on entry and exit, the soft unavailable cue for its inactive A/Start, and no ambience under reading. A redeemed review gets its own climbing cadence (ranked below rune awakenings, above flow, matching the banner precedence), and the first B of the two-press leave sounds a distinct warning. Engine tests drive each cue through real input. - Saves: cartridge loads carry each batch's generation level to the engine's prefetch logic and queue lower-level batches first, so a new Initiate run never opens on leftover Oracle-bound questions, while the lesson journal keeps generation order. - The scaffold's temporary dead-code allows are gone: every part of the shared learning contract is now consumed. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/audio.rs | 61 ++++++++++++++++++++++++++++++++++--- src-tauri/src/engine.rs | 58 +++++++++++++++++++++++++++++++++-- src-tauri/src/learning.rs | 3 -- src-tauri/src/questions.rs | 62 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 173 insertions(+), 11 deletions(-) diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index e3f7d49..eed2fbc 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -184,6 +184,8 @@ pub enum AudioScene { Battle, Victory, Defeat, + /// The read-only Oracle Codex: page turns only, no ambience under reading. + Codex, /// A scene the sound design does not know yet. It is always silent. #[default] Unlisted, @@ -240,6 +242,10 @@ pub struct RunAudio { pub insight: usize, pub level: u32, pub completed_batches: usize, + /// The committed answer redeemed a previously missed question. + pub redeemed: bool, + /// 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 @@ -258,6 +264,8 @@ pub struct AudioSnapshot { 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, pub run: Option, pub data: u32, /// Data-charge runes lit (0-3). @@ -284,6 +292,7 @@ impl AudioSnapshot { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum Cue { Navigate(usize), + PageTurn(usize), Confirm, Cancel, Unavailable, @@ -300,6 +309,8 @@ pub enum Cue { QuestionReveal, Cursor(usize), Correct, + Redeemed, + LeaveWarning, FlowUp(u32), RuneAwaken(usize), Wrong, @@ -317,14 +328,19 @@ impl Cue { fn priority(self) -> u8 { match self { Self::Unavailable => 1, - Self::Navigate(_) | Self::Cursor(_) | Self::Trait { .. } | Self::QuestionReveal => 2, + 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::FlowUp(_) | Self::LowWard => 9, + Self::Redeemed | Self::FlowUp(_) | Self::LowWard => 9, Self::RuneAwaken(_) | Self::WardBreak => 10, Self::Confirm | Self::Cancel @@ -440,6 +456,29 @@ impl Cue { 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)] } @@ -1053,11 +1092,13 @@ fn entry_cue(before: &AudioSnapshot, after: &AudioSnapshot) -> Option { } match (before.scene, after.scene) { (Scene::Title, Scene::QuizMenu | Scene::QuestSelect) - | (Scene::QuizMenu, Scene::CharacterCreation) + | (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::QuizMenu) => Some(Cue::Cancel), + | (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), @@ -1095,6 +1136,9 @@ fn scene_event(before: &AudioSnapshot, after: &AudioSnapshot) -> Option { } } } + AudioScene::Codex if after.codex_page != before.codex_page => { + candidates.push(Cue::PageTurn(after.codex_page)); + } AudioScene::Oracle => datafall_events(before, after, &mut candidates), AudioScene::Quiz => quiz_events(before, after, &mut candidates), _ => {} @@ -1140,6 +1184,7 @@ fn quiz_events(before: &AudioSnapshot, after: &AudioSnapshot, candidates: &mut V // One result cue per commit: the rarest threshold it crossed wins. let result = match run.phase { AnswerPhase::Correct if run.insight > previous.insight => Cue::RuneAwaken(run.insight), + AnswerPhase::Correct if run.redeemed => Cue::Redeemed, AnswerPhase::Correct if run.multiplier > previous.multiplier => { Cue::FlowUp(run.multiplier) } @@ -1151,6 +1196,8 @@ fn quiz_events(before: &AudioSnapshot, after: &AudioSnapshot, candidates: &mut V 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); } } @@ -1163,7 +1210,8 @@ fn accepts_unavailable(after: &AudioSnapshot, pressed: u16) -> bool { | AudioScene::CharacterCreation | AudioScene::QuestSelect | AudioScene::LevelUp - | AudioScene::GameOver => true, + | AudioScene::GameOver + | AudioScene::Codex => true, AudioScene::Oracle => pressed & !(pad::LEFT | pad::RIGHT) != 0, AudioScene::Quiz => after .run @@ -1511,6 +1559,8 @@ mod tests { Cue::Leave, Cue::QuestionReveal, Cue::Correct, + Cue::Redeemed, + Cue::LeaveWarning, Cue::Wrong, Cue::LowWard, Cue::WardBreak, @@ -1522,6 +1572,7 @@ mod tests { for index in 0..6 { cues.extend([ Cue::Navigate(index), + Cue::PageTurn(index), Cue::Cursor(index), Cue::DataCollect(index as u32), ]); diff --git a/src-tauri/src/engine.rs b/src-tauri/src/engine.rs index 5941adf..7a92464 100644 --- a/src-tauri/src/engine.rs +++ b/src-tauri/src/engine.rs @@ -489,7 +489,6 @@ pub struct QuestSpec { } #[derive(Clone, Debug, Default)] -#[allow(dead_code)] // SCAFFOLD: remove once lesson review reads rationales. pub struct QuizQuestion { pub question: String, pub choices: Vec, @@ -538,7 +537,6 @@ pub struct RepositoryProvenance { } #[derive(Clone, Debug)] -#[allow(dead_code)] // SCAFFOLD: remove once the Codex reads lessons and mastery. pub struct CartridgeSpec { pub id: String, pub title: String, @@ -2068,7 +2066,7 @@ fn audio_snapshot(state: &GameState) -> AudioSnapshot { Screen::Battle => AudioScene::Battle, Screen::Victory => AudioScene::Victory, Screen::Defeat => AudioScene::Defeat, - Screen::Codex => AudioScene::Unlisted, + Screen::Codex => AudioScene::Codex, }; let held = state.held.iter().fold(0, |bits, button| { bits | match button { @@ -2105,6 +2103,7 @@ fn audio_snapshot(state: &GameState) -> AudioSnapshot { hero_class: state.hero_class, hero_style: state.hero_style, quest_selected: state.quest_selected, + codex_page: state.codex_page, run: state.quiz.as_ref().map(|run| RunAudio { question: run.question, selected: run.selected, @@ -2118,6 +2117,8 @@ fn audio_snapshot(state: &GameState) -> AudioSnapshot { insight: InsightStage::from_score(run.score).index(), level: run.level, completed_batches: run.completed_batches, + redeemed: run.redeemed, + leave_armed: run.leave_armed > 0, }), data: state.oracle_data, data_stage: threshold_stage(state.oracle_data, &DATA_CHARGE_THRESHOLDS), @@ -8758,6 +8759,8 @@ mod tests { insight: 2, level: 4, completed_batches: 1, + redeemed: false, + leave_armed: false, }) ); @@ -9773,6 +9776,55 @@ mod tests { assert!(lines.iter().all(|line| line.chars().count() <= 8)); } + #[test] + fn the_codex_turns_pages_audibly_and_keeps_reading_quiet() { + use audio::Cue; + let mut engine = quiz_menu_engine(journal_cartridge()); + let _ = tap_cues(&mut engine, Button::Down); + assert_eq!(tap_cues(&mut engine, Button::A)[0], Some(Cue::Confirm)); + assert_eq!(engine.screen(), Screen::Codex); + assert_eq!( + tap_cues(&mut engine, Button::Right)[0], + Some(Cue::PageTurn(1)) + ); + assert_eq!( + tap_cues(&mut engine, Button::Left)[0], + Some(Cue::PageTurn(0)) + ); + assert_eq!( + tap_cues(&mut engine, Button::A)[0], + Some(Cue::Unavailable), + "A is deliberately inactive in the Codex" + ); + for _ in 0..120 { + engine.update(); + assert_eq!(last_cue(&engine), None, "no ambience under reading"); + } + assert_eq!(tap_cues(&mut engine, Button::B)[0], Some(Cue::Cancel)); + assert_eq!(engine.screen(), Screen::QuizMenu); + } + + #[test] + fn leaving_warns_once_and_redemption_has_its_own_cadence() { + use audio::Cue; + let mut engine = batch_quiz_engine(QUESTION_BATCH_SIZE); + assert_eq!(tap_cues(&mut engine, Button::B)[0], Some(Cue::LeaveWarning)); + assert_eq!(engine.screen(), Screen::Quiz, "one B only arms the leave"); + for _ in 0..QUIZ_LEAVE_CONFIRM_TICKS { + engine.update(); + } + + commit(&mut engine, false); + finish_lesson(&mut engine); + for _ in 0..RETRY_GAP { + commit(&mut engine, true); + finish_lesson(&mut engine); + } + assert!(current_question(&engine).review); + focus_choice(&mut engine, true); + assert_eq!(tap_cues(&mut engine, Button::A)[0], Some(Cue::Redeemed)); + } + #[test] fn reduced_motion_freezes_decorative_motion_but_keeps_scene_timing() { fn sampled(engine: &mut GameEngine, samples: usize, spacing: usize) -> Vec> { diff --git a/src-tauri/src/learning.rs b/src-tauri/src/learning.rs index 4c0c67e..55e1fa6 100644 --- a/src-tauri/src/learning.rs +++ b/src-tauri/src/learning.rs @@ -7,9 +7,6 @@ //! answers become evidence, and evidence fills per-lens mastery runes at //! explicit thresholds. Nothing here depends on Tauri or Bevy. -// SCAFFOLD: remove once the question pipeline and engine consume this API. -#![allow(dead_code)] - use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; diff --git a/src-tauri/src/questions.rs b/src-tauri/src/questions.rs index b228882..7477d20 100644 --- a/src-tauri/src/questions.rs +++ b/src-tauri/src/questions.rs @@ -1693,6 +1693,68 @@ pub(crate) mod tests { remove_save(&path); } + #[test] + fn saved_batches_queue_easiest_first_while_the_journal_stays_chronological() { + let question = |text: &str| QQuestion { + q: text.into(), + ..engine_state_question() + }; + // A run ended with a prefetched Oracle-bound batch unplayed, and an + // Initiate batch was generated later. + let batches = vec![ + SavedQuestionBatch { + level: 4, + questions: vec![question("PREDICT: WHAT IF THE ENGINE LOST STATE?")], + }, + SavedQuestionBatch { + level: 1, + questions: vec![ + question("WHAT IS THE PROJECT FOR?"), + question("WHO OWNS THE RULES?"), + ], + }, + ]; + let mut progress = SavedQuizProgress::default(); + progress.record(&evidence( + "PREDICT: WHAT IF THE ENGINE LOST STATE?", + None, + false, + )); + progress.record(&evidence("WHAT IS THE PROJECT FOR?", None, false)); + + let loaded = cartridge_questions(batches, progress); + + let queued = loaded + .questions + .iter() + .map(|question| question.question.as_str()) + .collect::>(); + assert_eq!( + queued, + [ + "WHAT IS THE PROJECT FOR?", + "WHO OWNS THE RULES?", + "PREDICT: WHAT IF THE ENGINE LOST STATE?", + ], + "a new Initiate run meets Initiate questions first" + ); + assert_eq!(loaded.batch_ends, [2, 3]); + assert_eq!(loaded.batch_levels, [1, 4]); + let journal = loaded + .lessons + .iter() + .map(|lesson| lesson.question.as_str()) + .collect::>(); + assert_eq!( + journal, + [ + "PREDICT: WHAT IF THE ENGINE LOST STATE?", + "WHAT IS THE PROJECT FOR?" + ], + "the journal keeps generation order" + ); + } + #[test] fn cartridge_load_retires_answers_reviews_misses_and_journals_both() { let batches: Vec = serde_json::from_str(LEGACY_BATCHES).unwrap(); From f09c96e31ca62668f7e572534a70dec044cc7426 Mon Sep 17 00:00:00 2001 From: Jason Skrzypek Date: Fri, 25 Sep 2026 11:03:25 -0400 Subject: [PATCH 13/60] Calibrate choice length so generated batches survive validation A live run of the payload-v2 prompt against this repository (Claude Sonnet, levels 1 and 4) produced excellent lens-tagged questions with misconception rationales and PREDICT transfer questions, but only 2 of 12 were accepted: the misconception framing drew choices of 32-45 characters against the display's hard 31-character limit, which would have left the Oracle retrying most of the time. The prompt now asks for choice phrases of 2 to 5 words and at most 28 characters (headroom under the display limit), calibrates with example phrases and their exact lengths, caps questions at 100 characters including any prefix, tells the model to put nuance in the rationale rather than the choice, and asks it to count and rewrite before answering. Re-running the same live evaluation: 11 of 12 accepted (the rejection was a correctly caught location question), answers spread across all four slots, three PREDICT questions at level 4, and level focus respected (5 of 6 purpose/responsibility at level 1, 6 of 6 invariant/tradeoff at level 4). For comparison the pre-release generator accepted 8 of 12 surface-level questions and put 11 of 12 answers in the first slot. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/questions.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/questions.rs b/src-tauri/src/questions.rs index 7477d20..114d321 100644 --- a/src-tauri/src/questions.rs +++ b/src-tauri/src/questions.rs @@ -970,7 +970,7 @@ pub(crate) fn ai_question_prompt( .join("\n") }; format!( - "You write questions for a retro handheld quiz game that teaches how a software project is designed. Generate exactly {count} multiple-choice questions at difficulty level {level} ({tier}).\n\nLENSES: tag every question with exactly one concept lens:\n{lenses}\nFOCUS: at least {focus_count} of the {count} questions must use the {focus} lens.\n\n{transfer}CONCEPTS ONLY: test the project's architecture, purpose, domain model, component responsibilities, interactions, invariants, tradeoffs, design rationale, or enduring behavior. Every question must still make sense if the project were reorganized and all implementation locations changed.\n\nNEVER ask about file names, paths, directories, or extensions; where code lives; repository structure; counts, sizes, or lines; dates, times, or versions; branches or commits; authors or contributors; ordering or recency; or any other state-in-time fact. Never use those facts as choices or rationales.\n\nCHOICES: exactly 4 non-empty, distinct choices and exactly one correct answer. Each wrong choice must be a plausible misconception a newcomer to this project might really hold, similar in length and style to the correct choice; never a joke or an obviously absurd option. Vary which position holds the correct answer across the batch.\n\nRATIONALES: every choice has a \"why\" of at most 90 characters. For the correct choice, explain why it holds. For each wrong choice, name the misconception it represents and why it fails. A why must never cite file names, paths, or extensions.\n\nDISPLAY LIMITS: plain ASCII only. Each question must wrap into at most 4 lines of 31 characters. Each choice must be at most 31 characters. Do not truncate words or sentences. Do not repeat questions.\n\nLEARNER STATE:\n{weakest}\nALREADY ASKED (never repeat these or close paraphrases of them):\n{asked}\n\nRespond with ONLY a JSON array, no prose and no code fences, where N is the 0-based index of the correct choice:\n[{{\"q\":\"...\",\"concept\":\"{lens_names}\",\"choices\":[{{\"text\":\"...\",\"why\":\"...\"}},{{\"text\":\"...\",\"why\":\"...\"}},{{\"text\":\"...\",\"why\":\"...\"}},{{\"text\":\"...\",\"why\":\"...\"}}],\"answer\":N}}]\n\nPROJECT: {project_name}\n{project_brief}", + "You write questions for a retro handheld quiz game that teaches how a software project is designed. Generate exactly {count} multiple-choice questions at difficulty level {level} ({tier}).\n\nLENSES: tag every question with exactly one concept lens:\n{lenses}\nFOCUS: at least {focus_count} of the {count} questions must use the {focus} lens.\n\n{transfer}CONCEPTS ONLY: test the project's architecture, purpose, domain model, component responsibilities, interactions, invariants, tradeoffs, design rationale, or enduring behavior. Every question must still make sense if the project were reorganized and all implementation locations changed.\n\nNEVER ask about file names, paths, directories, or extensions; where code lives; repository structure; counts, sizes, or lines; dates, times, or versions; branches or commits; authors or contributors; ordering or recency; or any other state-in-time fact. Never use those facts as choices or rationales.\n\nCHOICES: exactly 4 non-empty, distinct choices and exactly one correct answer. Each wrong choice must be a plausible misconception a newcomer to this project might really hold, similar in length and style to the correct choice; never a joke or an obviously absurd option. Vary which position holds the correct answer across the batch.\n\nRATIONALES: every choice has a \"why\" of at most 90 characters. For the correct choice, explain why it holds. For each wrong choice, name the misconception it represents and why it fails. A why must never cite file names, paths, or extensions.\n\nDISPLAY LIMITS (hard: anything longer is discarded, not shortened): plain ASCII only. Each question, including any prefix, must be at most 100 characters so it wraps into 4 lines of 31. Each choice text must be a short phrase of 2 to 5 words and at most 28 characters; put nuance in its why, never in the choice text. Calibrate on these lengths: \"The headless engine\" is 19 characters, \"Refuses the cartridge\" is 21, \"Keeps the repo untouched\" is 24. Before answering, count the characters of every question and choice and rewrite any that are too long. Do not truncate words or sentences. Do not repeat questions.\n\nLEARNER STATE:\n{weakest}\nALREADY ASKED (never repeat these or close paraphrases of them):\n{asked}\n\nRespond with ONLY a JSON array, no prose and no code fences, where N is the 0-based index of the correct choice:\n[{{\"q\":\"...\",\"concept\":\"{lens_names}\",\"choices\":[{{\"text\":\"...\",\"why\":\"...\"}},{{\"text\":\"...\",\"why\":\"...\"}},{{\"text\":\"...\",\"why\":\"...\"}},{{\"text\":\"...\",\"why\":\"...\"}}],\"answer\":N}}]\n\nPROJECT: {project_name}\n{project_brief}", ) } @@ -1910,8 +1910,9 @@ pub(crate) mod tests { assert!(prompt.contains("CONCEPTS ONLY")); assert!(prompt.contains("NEVER ask about file names, paths, directories, or extensions")); assert!(prompt.contains("still make sense if the project were reorganized")); - assert!(prompt.contains("at most 4 lines of 31 characters")); - assert!(prompt.contains("at most 31 characters")); + assert!(prompt.contains("wraps into 4 lines of 31")); + assert!(prompt.contains("at most 28 characters")); + assert!(prompt.contains("count the characters of every question and choice")); assert!(!prompt.contains("FILES:")); assert!(!prompt.contains("COMMIT MESSAGES")); assert!(prompt.ends_with("A project that separates its engine from its device shell.")); From 1016c5d47443d90819a3b6ec2a318083f58f7563 Mon Sep 17 00:00:00 2001 From: Jason Skrzypek Date: Fri, 25 Sep 2026 11:07:23 -0400 Subject: [PATCH 14/60] Send prompts on stdin, bound every external call, and load cartridges off the UI thread - Provider prompts (generation and the readiness probe) are written to the CLI's stdin instead of argv: `claude -p ...` with no positional prompt and `codex exec ... -`. There is no command-line limit any more, so the prompt cap becomes a byte budget (MAX_PROMPT_BYTES) that never trims a full brief. - command_output_with_timeout takes an optional stdin payload (written from a detached thread, then closed), runs the child in its own process group (Unix) and kills the whole tree on timeout (killpg / taskkill /T /F). Pipe readers report on channels awaited only until the deadline, so a grandchild holding the pipes can no longer outlive it. - Errors keep a short sanitized reason: the io::ErrorKind on spawn failure, the first stderr line or exit code on a failed call. Generation failures are logged instead of silently dropped. - Windows discovery also finds the standalone Codex installer (%LOCALAPPDATA%\Programs\OpenAI\Codex\bin\codex.exe) and npm shims (.cmd on PATH or in %APPDATA%\npm). - engine_set_cartridge and cartridge_branch are async and do their git and save work in spawn_blocking, as pick_cartridge now does too. Loads apply in request order. Every git query goes through a 15 s timeout helper, the provenance queries are bounded by HEAD, the save is read once per load, and an add from disk reuses the picker's build instead of building twice. Co-Authored-By: Claude Opus 5.5 --- README.md | 9 +- src-tauri/src/external_tools.rs | 105 +++++- src-tauri/src/lib.rs | 620 +++++++++++++++++++++++++------- src-tauri/src/questions.rs | 83 +++-- src-tauri/src/repo_context.rs | 5 +- 5 files changed, 647 insertions(+), 175 deletions(-) diff --git a/README.md b/README.md index b7e4a7a..06163ce 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,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. @@ -106,7 +110,8 @@ 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. The reason the CLI reported, such as -`CODEX CLI UNAVAILABLE`, appears in the device's status strip and stays on the +`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. diff --git a/src-tauri/src/external_tools.rs b/src-tauri/src/external_tools.rs index 50ba498..6903035 100644 --- a/src-tauri/src/external_tools.rs +++ b/src-tauri/src/external_tools.rs @@ -55,6 +55,58 @@ pub(crate) fn codex_command() -> Command { background_command(program) } +/// Starts `command` as the leader of a new process group, so a timeout can +/// stop everything it launches with [`kill_process_tree`]. +pub(crate) fn isolate_process_tree(command: &mut Command) { + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.process_group(0); + } + #[cfg(not(unix))] + let _ = command; +} + +/// Stops `child` and the processes it launched. Wrappers such as npm shims +/// run the real CLI as a grandchild that inherits the output pipes, so +/// stopping only the direct child would leave the call running. +pub(crate) fn kill_process_tree(child: &mut std::process::Child) { + #[cfg(unix)] + { + unsafe extern "C" { + fn kill(pid: i32, signal: i32) -> i32; + } + const SIGKILL: i32 = 9; + if let Ok(group) = i32::try_from(child.id()) { + // SAFETY: kill(2) takes plain integers and touches no memory. The + // child leads its own process group (see `isolate_process_tree`), + // so the negated id names exactly the processes it started. + unsafe { + kill(-group, SIGKILL); + } + } + } + #[cfg(target_os = "windows")] + { + let taskkill = std::env::var_os("SystemRoot") + .map(|root| { + std::path::PathBuf::from(root) + .join("System32") + .join("taskkill.exe") + }) + .filter(|candidate| candidate.is_file()) + .map(std::path::PathBuf::into_os_string) + .unwrap_or_else(|| OsString::from("taskkill")); + let _ = background_command(taskkill) + .args(["/PID", &child.id().to_string(), "/T", "/F"]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + } + let _ = child.kill(); +} + pub(crate) fn quest_shell_command() -> Option { if let Some(shell) = configured_program("CQA_SHELL") { return Some(background_command(shell)); @@ -135,26 +187,55 @@ fn windows_git_executable() -> Option { }) } +/// An npm global install's `.cmd` shim, on `PATH` or in npm's default +/// prefix. Rust starts batch files through `cmd.exe`, which is safe here +/// because provider arguments are plain flags and prompts travel on stdin. #[cfg(target_os = "windows")] -fn windows_claude_executable() -> Option { - executable_on_path("claude.exe").or_else(|| { - let home = std::env::var_os("USERPROFILE").map(std::path::PathBuf::from); +fn npm_shim(name: &str) -> Option { + let file_name = format!("{name}.cmd"); + executable_on_path(&file_name).or_else(|| { + let app_data = std::env::var_os("APPDATA").map(std::path::PathBuf::from); first_installed( - home.into_iter() - .map(|directory| directory.join(".local").join("bin").join("claude.exe")), + app_data + .into_iter() + .map(|directory| directory.join("npm").join(&file_name)), ) }) } +#[cfg(target_os = "windows")] +fn windows_claude_executable() -> Option { + executable_on_path("claude.exe") + .or_else(|| { + let home = std::env::var_os("USERPROFILE").map(std::path::PathBuf::from); + first_installed( + home.into_iter() + .map(|directory| directory.join(".local").join("bin").join("claude.exe")), + ) + }) + .or_else(|| npm_shim("claude")) +} + #[cfg(target_os = "windows")] fn windows_codex_executable() -> Option { - executable_on_path("codex.exe").or_else(|| { - let home = std::env::var_os("USERPROFILE").map(std::path::PathBuf::from); - first_installed( - home.into_iter() - .map(|directory| directory.join(".local").join("bin").join("codex.exe")), - ) - }) + executable_on_path("codex.exe") + .or_else(|| { + let home = std::env::var_os("USERPROFILE").map(std::path::PathBuf::from); + let local_app_data = std::env::var_os("LOCALAPPDATA").map(std::path::PathBuf::from); + first_installed( + home.into_iter() + .map(|directory| directory.join(".local").join("bin").join("codex.exe")) + .chain(local_app_data.into_iter().map(|directory| { + directory + .join("Programs") + .join("OpenAI") + .join("Codex") + .join("bin") + .join("codex.exe") + })), + ) + }) + .or_else(|| npm_shim("codex")) } #[cfg(target_os = "windows")] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 01ef06d..d92c58b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -11,11 +11,11 @@ mod repo_context; mod save; pub mod scene_machine; -use std::io::Read; +use std::io::{self, Read, Write}; use std::process::{Command, Output, Stdio}; -use std::sync::{mpsc, Arc, RwLock}; +use std::sync::{mpsc, Arc, Mutex, RwLock}; use std::thread; -use std::time::Duration; +use std::time::{Duration, Instant}; use serde::{Deserialize, Serialize}; use tauri::State; @@ -176,35 +176,43 @@ fn git_repo_command(path: &std::path::Path) -> Command { command } -fn is_git_repo(path: &std::path::Path) -> bool { - git_repo_command(path) - .args(["rev-parse", "--is-inside-work-tree"]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) +/// Longest any one repository query may run. Provenance queries walk history, +/// and a huge repository or an unreachable network share must not hold a +/// cartridge load or a question request indefinitely. +const GIT_TIMEOUT: Duration = Duration::from_secs(15); + +fn git_output_within( + path: &std::path::Path, + args: &[&str], + timeout: Duration, +) -> Result { + let mut command = git_repo_command(path); + command.args(args); + command_output_with_timeout(command, timeout, "GIT", None) } -fn repository_branch(path: &std::path::Path) -> String { - git_repo_command(path) - .args(["symbolic-ref", "--quiet", "--short", "HEAD"]) - .output() +/// Stdout of a successful repository query, or `None` when git fails, cannot +/// start, or exceeds [`GIT_TIMEOUT`]. +fn git_stdout(path: &std::path::Path, args: &[&str]) -> Option { + git_output_within(path, args, GIT_TIMEOUT) .ok() .filter(|output| output.status.success()) - .and_then(|output| String::from_utf8(output.stdout).ok()) + .map(|output| String::from_utf8_lossy(&output.stdout).into_owned()) +} + +fn is_git_repo(path: &std::path::Path) -> bool { + git_stdout(path, &["rev-parse", "--is-inside-work-tree"]).is_some() +} + +fn repository_branch(path: &std::path::Path) -> String { + git_stdout(path, &["symbolic-ref", "--quiet", "--short", "HEAD"]) .map(|branch| sanitized_metadata(&branch, 48)) .filter(|branch| !branch.is_empty()) .unwrap_or_else(|| "DETACHED HEAD".to_string()) } fn repository_revision(path: &std::path::Path) -> String { - git_repo_command(path) - .args(["rev-parse", "--short=7", "HEAD"]) - .output() - .ok() - .filter(|output| output.status.success()) - .and_then(|output| String::from_utf8(output.stdout).ok()) + git_stdout(path, &["rev-parse", "--short=7", "HEAD"]) .map(|revision| sanitized_metadata(&revision, 12).to_ascii_lowercase()) .filter(|revision| !revision.is_empty()) .unwrap_or_else(|| "-------".to_string()) @@ -220,7 +228,9 @@ fn explicit_copyright_notice(path: &std::path::Path) -> Option { } fn repository_provenance(path: &std::path::Path) -> engine::RepositoryProvenance { - let authors = provenance::ranked_authors(&git_out(path, &["shortlog", "-sn", "--all"]), 3); + // Every query is bounded by the checked-out history rather than every ref, + // and by GIT_TIMEOUT; a query that runs out of time leaves its field empty. + let authors = provenance::ranked_authors(&git_out(path, &["shortlog", "-sn", "HEAD"]), 3); let years = |args: &[&str]| -> Vec { git_out(path, args) .lines() @@ -231,12 +241,12 @@ fn repository_provenance(path: &std::path::Path) -> engine::RepositoryProvenance // latest, so large histories are not formatted commit by commit. let first_years = years(&[ "log", - "--all", "--max-parents=0", "--format=%cd", "--date=format:%Y", + "HEAD", ]); - let latest_years = years(&["log", "--all", "-1", "--format=%cd", "--date=format:%Y"]); + let latest_years = years(&["log", "-1", "--format=%cd", "--date=format:%Y", "HEAD"]); engine::RepositoryProvenance { authors, @@ -367,24 +377,79 @@ fn build_cartridge(path: &std::path::Path) -> Result { }) } -#[tauri::command] -async fn pick_cartridge(app: tauri::AppHandle) -> Result, String> { - let Some(path) = app - .dialog() - .file() - .set_title("SELECT CARTRIDGE (GIT REPO)") - .blocking_pick_folder() - else { - return Ok(None); +/// How long a cartridge built for the folder picker stays reusable by the +/// insertion the shell makes with its path. +const PICKED_CARTRIDGE_REUSE: Duration = Duration::from_secs(30); + +/// The cartridge the folder picker just built. The shell inserts a picked +/// cartridge by path, so reusing this build keeps an add from disk from +/// walking the repository's history twice. +#[derive(Clone, Default)] +struct PickedCartridge(Arc>>); + +impl PickedCartridge { + fn offer(&self, cartridge: Cartridge) { + if let Ok(mut picked) = self.0.lock() { + *picked = Some((Instant::now(), cartridge)); + } + } + + /// The picked build for `path` if it is still fresh. Any take clears the + /// slot, so a build is reused at most once. + fn take(&self, path: &str) -> Option { + let (built, cartridge) = self.0.lock().ok()?.take()?; + (cartridge.path == path && built.elapsed() <= PICKED_CARTRIDGE_REUSE).then_some(cartridge) + } +} + +/// Cartridge loads, applied to the engine one at a time in request order. +#[derive(Default)] +struct CartridgeLoads { + picked: PickedCartridge, + order: tauri::async_runtime::Mutex<()>, +} + +/// Builds the cartridge at `path` (reusing a fresh picker build) and its +/// engine spec. Runs git and reads the save, so it belongs off the UI thread. +fn load_cartridge( + path: &str, + picked: &PickedCartridge, +) -> Result<(Cartridge, engine::CartridgeSpec), String> { + let cartridge = match picked.take(path) { + Some(cartridge) => cartridge, + None => build_cartridge(std::path::Path::new(path))?, }; - let path = path - .into_path() - .map_err(|_| "FOLDER PICKER RETURNED AN INVALID PATH".to_string())?; - build_cartridge(&path).map(Some) + let spec = engine_cartridge(cartridge.clone())?; + Ok((cartridge, spec)) } #[tauri::command] -fn cartridge_branch(path: String) -> Result { +async fn pick_cartridge( + app: tauri::AppHandle, + loads: State<'_, CartridgeLoads>, +) -> Result, String> { + let picked = loads.picked.clone(); + tauri::async_runtime::spawn_blocking(move || { + let Some(path) = app + .dialog() + .file() + .set_title("SELECT CARTRIDGE (GIT REPO)") + .blocking_pick_folder() + else { + return Ok(None); + }; + let path = path + .into_path() + .map_err(|_| "FOLDER PICKER RETURNED AN INVALID PATH".to_string())?; + let cartridge = build_cartridge(&path)?; + picked.offer(cartridge.clone()); + Ok(Some(cartridge)) + }) + .await + .map_err(|_| "CARTRIDGE LOAD FAILED".to_string())? +} + +fn repository_branch_at(path: &str) -> Result { let canon = std::fs::canonicalize(path).map_err(|_| "DIRECTORY NOT FOUND".to_string())?; if !is_git_repo(&canon) { return Err("NOT A GIT REPOSITORY - CARTRIDGE REFUSED".to_string()); @@ -392,14 +457,15 @@ fn cartridge_branch(path: String) -> Result { Ok(repository_branch(&canon)) } +#[tauri::command] +async fn cartridge_branch(path: String) -> Result { + tauri::async_runtime::spawn_blocking(move || repository_branch_at(&path)) + .await + .map_err(|_| "BRANCH UNAVAILABLE".to_string())? +} + fn git_out(path: &std::path::Path, args: &[&str]) -> String { - git_repo_command(path) - .args(args) - .output() - .ok() - .filter(|o| o.status.success()) - .map(|o| String::from_utf8_lossy(&o.stdout).to_string()) - .unwrap_or_default() + git_stdout(path, args).unwrap_or_default() } /// Upper bound on tracked paths considered for the generation brief. @@ -418,13 +484,30 @@ fn tracked_files(path: &std::path::Path) -> Vec { const AI_PROBE_PROMPT: &str = "Reply with exactly CODEQUEST_READY and nothing else. Do not inspect files or use tools."; -fn ai_prompt_command(provider: AiProvider, prompt: &str) -> Command { +/// One provider request: the CLI invocation and the prompt it reads on stdin. +/// Prompts never travel as arguments, so their size is not bound by command +/// line limits and batch-file shims never see multi-line arguments. +struct PromptCall { + command: Command, + input: Vec, +} + +fn ai_prompt_call(provider: AiProvider, prompt: &str) -> PromptCall { + PromptCall { + command: ai_prompt_command(provider), + input: prompt.as_bytes().to_vec(), + } +} + +/// The non-interactive CLI invocation for `provider`. Claude's print mode +/// reads the prompt from stdin when no prompt argument is given; Codex reads +/// its instructions from stdin when the prompt argument is `-`. +fn ai_prompt_command(provider: AiProvider) -> Command { match provider { AiProvider::Claude => { let mut command = external_tools::claude_command(); command.args([ "-p", - prompt, "--output-format", "json", "--no-session-persistence", @@ -454,7 +537,7 @@ fn ai_prompt_command(provider: AiProvider, prompt: &str) -> Command { command.args(["--model", &model]); } } - command.arg(prompt); + command.arg("-"); command } } @@ -477,54 +560,140 @@ fn ai_response_text(provider: AiProvider, stdout: &[u8]) -> Result String { + sanitized_metadata(&error.kind().to_string(), ERROR_REASON_CHARS).to_ascii_uppercase() +} + +/// Why a finished call failed: the first line the program wrote to stderr, +/// or its exit code when it wrote none. +fn exit_reason(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr) + .lines() + .map(|line| sanitized_metadata(line, ERROR_REASON_CHARS)) + .find(|line| !line.is_empty()) + .map(|line| line.to_ascii_uppercase()) + .unwrap_or_else(|| match output.status.code() { + Some(code) => format!("EXIT CODE {code}"), + None => "STOPPED BY A SIGNAL".to_string(), + }) +} + +/// Reads `pipe` to its end on a detached thread. The result arrives on the +/// returned channel, so a caller can stop waiting at a deadline instead of +/// joining a reader that a surviving grandchild keeps blocked. +fn read_to_end_in_background( + mut pipe: impl Read + Send + 'static, +) -> mpsc::Receiver>> { + let (sender, receiver) = mpsc::channel(); + thread::spawn(move || { + let mut bytes = Vec::new(); + let _ = sender.send(pipe.read_to_end(&mut bytes).map(|_| bytes)); + }); + receiver +} + +/// Runs `command` and collects its output within `timeout`. `input`, when +/// given, is written to the program's stdin, which is then closed; otherwise +/// stdin is empty. The deadline holds even for wrappers whose helpers inherit +/// the output pipes: on timeout the whole process tree is stopped and the +/// pipe readers are abandoned rather than joined. Errors name `label` and +/// carry a short reason, because the device shows them. fn command_output_with_timeout( mut command: Command, timeout: Duration, - provider_name: &str, + label: &str, + input: Option>, ) -> Result { - let unavailable = || format!("{provider_name} CLI UNAVAILABLE"); - let failed = || format!("{provider_name} CALL FAILED"); - let mut child = command + let deadline = Instant::now() + timeout; + let failed = |reason: &str| format!("{label} CALL FAILED - {reason}"); + let timed_out = || format!("{label} CALL TIMED OUT"); + let stop = |child: &mut std::process::Child| { + external_tools::kill_process_tree(child); + let _ = child.wait(); + }; + + command + .stdin(if input.is_some() { + Stdio::piped() + } else { + Stdio::null() + }) .stdout(Stdio::piped()) - .stderr(Stdio::piped()) + .stderr(Stdio::piped()); + external_tools::isolate_process_tree(&mut command); + let mut child = command .spawn() - .map_err(|_| unavailable())?; - let stdout = child.stdout.take().ok_or_else(&failed)?; - let stderr = child.stderr.take().ok_or_else(&failed)?; - let stdout_reader = thread::spawn(move || { - let mut bytes = Vec::new(); - let mut reader = stdout; - reader.read_to_end(&mut bytes).map(|_| bytes) - }); - let stderr_reader = thread::spawn(move || { - let mut bytes = Vec::new(); - let mut reader = stderr; - reader.read_to_end(&mut bytes).map(|_| bytes) - }); - - let status = match child.wait_timeout(timeout).map_err(|_| failed())? { - Some(status) => status, - None => { - let _ = child.kill(); - let _ = child.wait(); - let _ = stdout_reader.join(); - let _ = stderr_reader.join(); - return Err(format!("{provider_name} CALL TIMED OUT")); + .map_err(|error| format!("{label} CLI UNAVAILABLE - {}", io_reason(&error)))?; + let (Some(stdout), Some(stderr)) = (child.stdout.take(), child.stderr.take()) else { + stop(&mut child); + return Err(failed("NO OUTPUT PIPES")); + }; + if let (Some(mut stdin), Some(input)) = (child.stdin.take(), input) { + // A program that exits without reading its input closes the pipe; + // that write error is not the call's result, the exit status is. + // Dropping `stdin` afterwards closes it, which ends the input. + thread::spawn(move || { + let _ = stdin.write_all(&input); + }); + } + let stdout = read_to_end_in_background(stdout); + let stderr = read_to_end_in_background(stderr); + + let status = match child.wait_timeout(timeout) { + Ok(Some(status)) => status, + Ok(None) => { + stop(&mut child); + return Err(timed_out()); + } + Err(error) => { + stop(&mut child); + return Err(failed(&io_reason(&error))); } }; - let stdout = stdout_reader - .join() - .map_err(|_| failed())? - .map_err(|_| failed())?; - let stderr = stderr_reader - .join() - .map_err(|_| failed())? - .map_err(|_| failed())?; - Ok(Output { - status, - stdout, - stderr, - }) + // The program has exited, but a helper it launched may still hold the + // pipes open; its output is only awaited until the same deadline. + let collect = |pipe: &mpsc::Receiver>>| { + pipe.recv_timeout(deadline.saturating_duration_since(Instant::now())) + }; + match (collect(&stdout), collect(&stderr)) { + (Ok(Ok(stdout)), Ok(Ok(stderr))) => Ok(Output { + status, + stdout, + stderr, + }), + (Err(mpsc::RecvTimeoutError::Timeout), _) | (_, Err(mpsc::RecvTimeoutError::Timeout)) => { + // The group outlives its reaped leader while a helper holds + // the pipes, so this reaches the helper. + external_tools::kill_process_tree(&mut child); + Err(timed_out()) + } + (Ok(Err(error)), _) | (_, Ok(Err(error))) => Err(failed(&io_reason(&error))), + _ => Err(failed("OUTPUT UNREADABLE")), + } +} + +/// The assistant's reply from a finished provider call, or why it failed. +fn provider_reply(provider: AiProvider, output: &Output) -> Result { + if !output.status.success() { + return Err(format!( + "{} CALL FAILED - {}", + provider.name(), + exit_reason(output) + )); + } + ai_response_text(provider, &output.stdout) +} + +/// Sends `prompt` to `provider` on stdin and returns its reply. +fn ask_provider(provider: AiProvider, prompt: &str, timeout: Duration) -> Result { + let call = ai_prompt_call(provider, prompt); + let output = + command_output_with_timeout(call.command, timeout, provider.name(), Some(call.input))?; + provider_reply(provider, &output) } fn ai_questions( @@ -547,12 +716,7 @@ fn ai_questions( let brief = repo_context::project_brief(path, &tracked_files(path)); let learner = questions::load_learner_state(path).unwrap_or_default(); let prompt = questions::bounded_ai_question_prompt(&name, level, count, &brief, &learner); - let cmd = ai_prompt_command(provider, &prompt); - let out = command_output_with_timeout(cmd, Duration::from_secs(120), provider.name())?; - if !out.status.success() { - return Err(format!("{} CALL FAILED", provider.name())); - } - let result = ai_response_text(provider, &out.stdout)?; + let result = ask_provider(provider, &prompt, Duration::from_secs(120))?; questions::parse_generated_questions(&result, count) } @@ -580,7 +744,10 @@ where let Some(provider) = provider_state.ready_provider() else { return Vec::new(); }; - generate(path, level, count, provider).unwrap_or_default() + generate(path, level, count, provider).unwrap_or_else(|error| { + eprintln!("CODE QUEST question generation failed: {error}"); + Vec::new() + }) } /// The engine's question loader: a fresh verified batch, without questions @@ -667,12 +834,9 @@ fn engine_cartridge(cartridge: Cartridge) -> Result Result<(), String> { - let command = ai_prompt_command(provider, AI_PROBE_PROMPT); - let output = command_output_with_timeout(command, Duration::from_secs(60), provider.name())?; - if !output.status.success() { - return Err(format!("{} CALL FAILED", provider.name())); - } - let response = ai_response_text(provider, &output.stdout)?; + // The probe takes the same stdin path as generation, so a pass means + // generation requests can reach the CLI too. + let response = ask_provider(provider, AI_PROBE_PROMPT, Duration::from_secs(60))?; if response.trim() != "CODEQUEST_READY" { return Err(format!("{} READINESS CHECK FAILED", provider.name())); } @@ -711,16 +875,22 @@ async fn verify_ai_provider( } #[tauri::command] -fn engine_set_cartridge( - state: State, +async fn engine_set_cartridge( + state: State<'_, EngineState>, + loads: State<'_, CartridgeLoads>, path: Option, ) -> Result, String> { - let cartridge = path - .map(|path| build_cartridge(std::path::Path::new(&path))) - .transpose()?; - state - .0 - .set_cartridge(cartridge.clone().map(engine_cartridge).transpose()?)?; + // Loads walk git history and read the save, so they run off the UI + // thread; the order lock keeps overlapping requests applied in turn. + let _turn = loads.order.lock().await; + let picked = loads.picked.clone(); + let loaded = tauri::async_runtime::spawn_blocking(move || { + path.map(|path| load_cartridge(&path, &picked)).transpose() + }) + .await + .map_err(|_| "CARTRIDGE LOAD FAILED".to_string())??; + let (cartridge, spec) = loaded.unzip(); + state.0.set_cartridge(spec)?; Ok(cartridge) } @@ -799,6 +969,7 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_opener::init()) .manage(provider_state) + .manage(CartridgeLoads::default()) .manage(EngineState(engine::EngineRuntime::spawn( question_loader, answered_question_recorder, @@ -921,28 +1092,172 @@ mod question_policy_tests { #[test] fn each_provider_uses_a_noninteractive_ephemeral_prompt_command() { - let claude = ai_prompt_command(AiProvider::Claude, "TEST PROMPT"); - let claude_args = claude - .get_args() - .map(|arg| arg.to_string_lossy().into_owned()) - .collect::>(); + let prompt = "TEST PROMPT\nWITH \"QUOTED\" LINES"; + let arguments = |call: &PromptCall| { + call.command + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>() + }; + + let claude = ai_prompt_call(AiProvider::Claude, prompt); + let claude_args = arguments(&claude); + assert_eq!(claude_args.first().map(String::as_str), Some("-p")); assert!(claude_args .windows(2) - .any(|args| args == ["-p", "TEST PROMPT"])); + .any(|args| args == ["--output-format", "json"])); assert!(claude_args.contains(&"--no-session-persistence".to_string())); assert!(claude_args.windows(2).any(|args| args == ["--tools", ""])); - let codex = ai_prompt_command(AiProvider::Codex, "TEST PROMPT"); - let codex_args = codex - .get_args() - .map(|arg| arg.to_string_lossy().into_owned()) - .collect::>(); + let codex = ai_prompt_call(AiProvider::Codex, prompt); + let codex_args = arguments(&codex); assert_eq!(codex_args.first().map(String::as_str), Some("exec")); assert!(codex_args.contains(&"--ephemeral".to_string())); assert!(codex_args .windows(2) .any(|args| args == ["--sandbox", "read-only"])); - assert_eq!(codex_args.last().map(String::as_str), Some("TEST PROMPT")); + assert_eq!( + codex_args.last().map(String::as_str), + Some("-"), + "codex reads its instructions from stdin" + ); + + for call in [&claude, &codex] { + assert!( + arguments(call) + .iter() + .all(|arg| !arg.contains("TEST PROMPT")), + "the prompt never travels on the command line" + ); + assert_eq!(call.input, prompt.as_bytes(), "the prompt is sent on stdin"); + assert!(arguments(call) + .iter() + .all(|arg| !arg.contains(['\n', '\r']))); + } + } + + #[test] + fn failed_calls_report_a_short_reason() { + let mut missing = Command::new("codequest-missing-provider-cli"); + missing.arg("--version"); + let error = command_output_with_timeout(missing, Duration::from_secs(5), "CODEX", None) + .unwrap_err(); + assert!(error.starts_with("CODEX CLI UNAVAILABLE - "), "{error}"); + assert!(error.contains("NOT FOUND"), "{error}"); + + let mut command = Command::new(std::env::current_exe().unwrap()); + command.args([ + "--ignored", + "--exact", + "question_policy_tests::failing_provider_fixture", + "--nocapture", + ]); + let output = + command_output_with_timeout(command, Duration::from_secs(30), "CLAUDE", None).unwrap(); + let error = provider_reply(AiProvider::Claude, &output).unwrap_err(); + assert_eq!( + error, + "CLAUDE CALL FAILED - ERROR: MODEL NOT AVAILABLE FOR THIS ACCOUNT" + ); + assert!(error.len() <= "CLAUDE CALL FAILED - ".len() + ERROR_REASON_CHARS); + } + + #[test] + fn a_silent_failure_reports_its_exit_code() { + let mut command = Command::new(std::env::current_exe().unwrap()); + command.args([ + "--ignored", + "--exact", + "question_policy_tests::silent_failure_fixture", + "--nocapture", + ]); + let output = + command_output_with_timeout(command, Duration::from_secs(30), "CODEX", None).unwrap(); + assert_eq!( + provider_reply(AiProvider::Codex, &output).unwrap_err(), + "CODEX CALL FAILED - EXIT CODE 7" + ); + } + + #[test] + fn prompts_travel_on_stdin_without_a_command_line_limit() { + // Larger than Linux's 128 KiB per-argument limit and Windows' 32767 + // character command line, with the quotes and newlines that batch + // file arguments cannot carry. + let prompt = "A LINE WITH \"QUOTES\", \\ SLASHES, AND 100% SIGNS\n".repeat(4_000); + assert!(prompt.len() > 128 * 1024); + let mut command = Command::new(std::env::current_exe().unwrap()); + command.args([ + "--ignored", + "--exact", + "question_policy_tests::stdin_echo_fixture", + "--nocapture", + ]); + + let output = command_output_with_timeout( + command, + Duration::from_secs(30), + "CLAUDE", + Some(prompt.clone().into_bytes()), + ) + .unwrap(); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains(&format!("STDIN {} BYTES 4000 LINES", prompt.len())), + "{stdout}" + ); + } + + #[test] + fn git_queries_are_bounded_by_a_deadline() { + let repo = temporary_git_repo(); + let error = git_output_within(&repo, &["log", "--all"], Duration::ZERO).unwrap_err(); + assert_eq!(error, "GIT CALL TIMED OUT"); + assert!(git_output_within(&repo, &["rev-parse", "--git-dir"], GIT_TIMEOUT).is_ok()); + remove_temporary_repo(repo); + } + + #[test] + fn a_picked_cartridge_is_reused_once_by_its_own_insertion() { + let repo = temporary_git_repo(); + let other = temporary_git_repo(); + let picked = PickedCartridge::default(); + let cartridge = build_cartridge(&repo).unwrap(); + let path = cartridge.path.clone(); + + picked.offer(cartridge.clone()); + assert!( + picked + .take(&build_cartridge(&other).unwrap().path) + .is_none(), + "another path never receives the picked build" + ); + assert!(picked.take(&path).is_none(), "any take clears the slot"); + + picked.offer(cartridge); + let reused = picked.take(&path).expect("the insertion reuses the build"); + assert_eq!(reused.path, path); + assert!( + picked.take(&path).is_none(), + "a build is reused at most once" + ); + + let (loaded, spec) = load_cartridge(&path, &picked).unwrap(); + assert_eq!(loaded.path, path); + assert_eq!(spec.id, path); + + picked.offer(loaded); + if let Ok(mut slot) = picked.0.lock() { + if let Some((built, _)) = slot.as_mut() { + *built -= PICKED_CARTRIDGE_REUSE + Duration::from_secs(1); + } + } + assert!(picked.take(&path).is_none(), "a stale build is rebuilt"); + + remove_temporary_repo(other); + remove_temporary_repo(repo); } #[test] @@ -1020,6 +1335,31 @@ mod question_policy_tests { std::thread::sleep(Duration::from_secs(5)); } + #[test] + #[ignore = "child-process fixture for the stdin test"] + fn stdin_echo_fixture() { + let mut input = String::new(); + std::io::stdin().read_to_string(&mut input).unwrap(); + println!( + "STDIN {} BYTES {} LINES", + input.len(), + input.lines().count() + ); + } + + #[test] + #[ignore = "child-process fixture for the failure-reason test"] + fn failing_provider_fixture() { + eprintln!("error: model not available for this account\nsecond line"); + std::process::exit(3); + } + + #[test] + #[ignore = "child-process fixture for the failure-reason test"] + fn silent_failure_fixture() { + std::process::exit(7); + } + #[test] fn external_commands_are_stopped_at_their_deadline() { let mut command = Command::new(std::env::current_exe().unwrap()); @@ -1030,13 +1370,45 @@ mod question_policy_tests { ]); let started = std::time::Instant::now(); - let error = - command_output_with_timeout(command, Duration::from_millis(50), "CLAUDE").unwrap_err(); + let error = command_output_with_timeout(command, Duration::from_millis(50), "CLAUDE", None) + .unwrap_err(); assert_eq!(error, "CLAUDE CALL TIMED OUT"); assert!(started.elapsed() < Duration::from_secs(2)); } + #[cfg(unix)] + #[test] + fn a_timeout_stops_helpers_that_inherit_the_output_pipes() { + // Like an npm launcher: the direct child starts the real work as a + // grandchild that holds stdout and stderr open. + let mut command = Command::new("sh"); + command.args(["-c", "sleep 30 & sleep 30"]); + let started = std::time::Instant::now(); + + let error = command_output_with_timeout(command, Duration::from_millis(200), "CODEX", None) + .unwrap_err(); + + assert_eq!(error, "CODEX CALL TIMED OUT"); + assert!(started.elapsed() < Duration::from_secs(2)); + } + + #[cfg(unix)] + #[test] + fn a_helper_left_holding_the_pipes_cannot_outlive_the_deadline() { + // The direct child exits at once; its background helper keeps the + // pipes open well past the deadline. + let mut command = Command::new("sh"); + command.args(["-c", "sleep 30 &"]); + let started = std::time::Instant::now(); + + let error = command_output_with_timeout(command, Duration::from_millis(300), "CODEX", None) + .unwrap_err(); + + assert_eq!(error, "CODEX CALL TIMED OUT"); + assert!(started.elapsed() < Duration::from_secs(2)); + } + #[test] fn cartridge_loads_codequest_title_type_and_storyboard() { let repo = temporary_git_repo(); diff --git a/src-tauri/src/questions.rs b/src-tauri/src/questions.rs index b228882..61fab89 100644 --- a/src-tauri/src/questions.rs +++ b/src-tauri/src/questions.rs @@ -22,10 +22,11 @@ pub(crate) const QUIZ_PROGRESS_KEY: &str = "quiz.progress"; /// Previously generated stems the prompt lists so a provider does not repeat /// them. pub(crate) const MAX_AVOIDED_STEMS: usize = 24; -/// Upper bound for a whole generation prompt. Prompts travel as one command -/// line argument, and Windows limits a command line to 32767 UTF-16 units, so -/// this leaves room for the program path, the other arguments, and quoting. -pub(crate) const MAX_PROMPT_CHARS: usize = 30_000; +/// Upper bound, in bytes, for a whole generation prompt. Prompts travel on the +/// provider's stdin, so no command-line limit applies; this cap only keeps a +/// request proportionate. It leaves the instructions and learner state room +/// beside a brief that fills its own budget, so a full brief is never trimmed. +pub(crate) const MAX_PROMPT_BYTES: usize = 2 * crate::repo_context::BRIEF_BUDGET; /// One answer choice. Payload v2 pairs the text with `why`: for the correct /// choice, why it holds; for a distractor, the misconception it represents. @@ -711,14 +712,17 @@ fn saved_batch(value: serde_json::Value) -> Option { /// Saved batches, oldest first: batches from the legacy Claude-only key /// predate every batch under the provider-neutral key. pub(crate) fn load_saved_question_batches(path: &Path) -> Result, String> { - let save = save::SaveFile::open_or_create(path)?; - Ok( - [LEGACY_CLAUDE_QUESTION_BATCHES_KEY, AI_QUESTION_BATCHES_KEY] - .into_iter() - .flat_map(|key| save.get::>(key).unwrap_or_default()) - .filter_map(saved_batch) - .collect(), - ) + Ok(saved_question_batches(&save::SaveFile::open_or_create( + path, + )?)) +} + +fn saved_question_batches(save: &save::SaveFile) -> Vec { + [LEGACY_CLAUDE_QUESTION_BATCHES_KEY, AI_QUESTION_BATCHES_KEY] + .into_iter() + .flat_map(|key| save.get::>(key).unwrap_or_default()) + .filter_map(saved_batch) + .collect() } pub(crate) fn persist_ai_question_batch( @@ -745,9 +749,12 @@ pub(crate) fn persist_ai_question_batch( } pub(crate) fn load_quiz_progress(path: &Path) -> Result { - Ok(save::SaveFile::open_or_create(path)? - .get::(QUIZ_PROGRESS_KEY) - .unwrap_or_default()) + Ok(quiz_progress(&save::SaveFile::open_or_create(path)?)) +} + +fn quiz_progress(save: &save::SaveFile) -> SavedQuizProgress { + save.get::(QUIZ_PROGRESS_KEY) + .unwrap_or_default() } pub(crate) fn load_retired_questions(path: &Path) -> Result, String> { @@ -824,10 +831,12 @@ pub(crate) fn cartridge_questions( loaded } +/// Everything a cartridge load needs from its save, from one read of it. pub(crate) fn load_cartridge_questions(path: &Path) -> Result { + let save = save::SaveFile::open_or_create(path)?; Ok(cartridge_questions( - load_saved_question_batches(path)?, - load_quiz_progress(path)?, + saved_question_batches(&save), + quiz_progress(&save), )) } @@ -974,14 +983,9 @@ pub(crate) fn ai_question_prompt( ) } -/// Characters `prompt` occupies on a Windows command line, where argument -/// quoting escapes every double quote and backslash. -pub(crate) fn command_line_chars(prompt: &str) -> usize { - prompt.chars().count() + prompt.matches(['"', '\\']).count() -} - -/// [`ai_question_prompt`], shortening the project brief when needed so the -/// whole prompt stays within [`MAX_PROMPT_CHARS`]. +/// [`ai_question_prompt`], shortening the project brief at a character +/// boundary when needed so the whole prompt stays within +/// [`MAX_PROMPT_BYTES`]. A brief within its own budget is never shortened. pub(crate) fn bounded_ai_question_prompt( project_name: &str, level: u32, @@ -992,7 +996,7 @@ pub(crate) fn bounded_ai_question_prompt( let mut brief = project_brief; loop { let prompt = ai_question_prompt(project_name, level, count, brief, learner); - let overflow = command_line_chars(&prompt).saturating_sub(MAX_PROMPT_CHARS); + let overflow = prompt.len().saturating_sub(MAX_PROMPT_BYTES); if overflow == 0 || brief.is_empty() { return prompt; } @@ -1913,23 +1917,32 @@ pub(crate) mod tests { } #[test] - fn a_full_prompt_fits_a_windows_command_line() { + fn a_full_brief_fits_the_prompt_budget_and_oversized_briefs_are_trimmed() { let brief = "X".repeat(crate::repo_context::BRIEF_BUDGET); let learner = LearnerState { weakest: Some(Concept::Responsibility), asked: vec!["Q".repeat(4 * engine::QUIZ_QUESTION_COLUMNS); MAX_AVOIDED_STEMS], }; - let prompt = ai_question_prompt("A PROJECT NAME OF SOME LENGTH", 4, 6, &brief, &learner); - let length = command_line_chars(&prompt); + let name = "A PROJECT NAME OF SOME LENGTH"; + let prompt = ai_question_prompt(name, 4, 6, &brief, &learner); assert!( - length <= MAX_PROMPT_CHARS, - "a full brief fits untrimmed: {length}" + prompt.len() <= MAX_PROMPT_BYTES, + "a full brief fits untrimmed: {}", + prompt.len() + ); + assert_eq!( + bounded_ai_question_prompt(name, 4, 6, &brief, &learner), + prompt ); - let quoted = "\"\\".repeat(crate::repo_context::BRIEF_BUDGET / 2); - let bounded = bounded_ai_question_prompt("DEMO", 4, 6, "ed, &learner); - assert!(command_line_chars(&bounded) <= MAX_PROMPT_CHARS); - assert!(bounded.contains("PROJECT: DEMO\n\"\\")); + let oversized = "\u{e9}".repeat(MAX_PROMPT_BYTES); + let bounded = bounded_ai_question_prompt("DEMO", 4, 6, &oversized, &learner); + assert!(bounded.len() <= MAX_PROMPT_BYTES, "{}", bounded.len()); + assert!(bounded.contains("PROJECT: DEMO\n\u{e9}\u{e9}")); + assert!( + bounded.len() > MAX_PROMPT_BYTES - 4, + "trims only the excess" + ); assert_eq!( bounded_ai_question_prompt("DEMO", 1, 6, "BRIEF", &learner), ai_question_prompt("DEMO", 1, 6, "BRIEF", &learner) diff --git a/src-tauri/src/repo_context.rs b/src-tauri/src/repo_context.rs index f680f9c..eedb8ec 100644 --- a/src-tauri/src/repo_context.rs +++ b/src-tauri/src/repo_context.rs @@ -14,8 +14,9 @@ use std::path::{Component, Path}; use crate::{codequest, questions}; -/// Bytes available to the whole brief. The brief travels inside a single -/// command-line prompt (see `questions::MAX_PROMPT_CHARS`), which bounds it. +/// Bytes available to the whole brief. The brief travels inside the prompt a +/// provider reads on stdin; `questions::MAX_PROMPT_BYTES` leaves room for a +/// brief of this size beside the instructions. pub(crate) const BRIEF_BUDGET: usize = 20 * 1024; const SUMMARY_BUDGET: usize = 1024; const README_BUDGET: usize = 8 * 1024; From bef0a33331156133c2ae53a13a0326a7ad96a759 Mon Sep 17 00:00:00 2001 From: Jason Skrzypek Date: Fri, 25 Sep 2026 11:11:52 -0400 Subject: [PATCH 15/60] Document how the game teaches, the Codex, and the new modules Co-Authored-By: Claude Opus 5.5 --- README.md | 103 +++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 79 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index b7e4a7a..c8b4c64 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,47 @@ 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 three questions later, and a + batch cannot close until its misses are retried. Answering it correctly is a + *redemption*, recorded separately from a first-try success. Missed questions + also survive across launches until you redeem them; correct answers retire + their questions for good. +- **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 redemptions). The Codex + shows them with pending reviews, and pages through the lesson journal: + question, answer, and rationale. +- **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: @@ -51,6 +88,10 @@ 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. + ## Install Download the current packages from the @@ -133,11 +174,12 @@ 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 correct answer retires the +question for later runs and launches, while a miss keeps it queued for review +until you redeem it. 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 @@ -155,13 +197,21 @@ 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; 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; @@ -208,12 +258,12 @@ 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, 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 | @@ -231,13 +281,18 @@ 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/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 From 0055615606b9f57e63c7d04ad6e58d480c06f825 Mon Sep 17 00:00:00 2001 From: Jason Skrzypek Date: Fri, 25 Sep 2026 11:17:32 -0400 Subject: [PATCH 16/60] Show a live session in the README: lesson card and Codex Screenshots come from the real release build under Xvfb with questions generated by Claude for this repository: a lesson card after a deliberate miss (misconception, then answer and rationale) and the Codex page that journals it as a pending review. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 ++++++ docs/screenshots/oracle-codex-shell.png | Bin 0 -> 137700 bytes docs/screenshots/oracle-lesson-shell.png | Bin 0 -> 147374 bytes .../screenshots/oracle-live-question-shell.png | Bin 0 -> 165534 bytes 4 files changed, 6 insertions(+) create mode 100644 docs/screenshots/oracle-codex-shell.png create mode 100644 docs/screenshots/oracle-lesson-shell.png create mode 100644 docs/screenshots/oracle-live-question-shell.png diff --git a/README.md b/README.md index 390958b..8866278 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 diff --git a/docs/screenshots/oracle-codex-shell.png b/docs/screenshots/oracle-codex-shell.png new file mode 100644 index 0000000000000000000000000000000000000000..e7324844ca9da0da3e8e7433ae711e054ad0fbfa GIT binary patch literal 137700 zcmZ^KbyOQq)b<7q9;CRtYmuVCrFg00E$$R;kp%bR#kIv-C|abrOL2-9N^va?fh2E! z?;qbe-yiSEIoZiN=V^%vf6++AI5-Mj&RKN}xcj}N$eNX!|o|IXN0d)vysd25R~0OF$JQbMASgv2FH z#3W_Kq-8}P3W$ozii*DUwW$678o0XKINH7YzZ>uhY?ELb@czF$csshvQ zV2Xp&m?s0Y)s#*Amk!(g;GCL1N~p@#fSz%ej@41Wwu~_EGLI3J5LT!bYZuP)0__e! zo9g;)m^Q2%73%trY&p|`!G$AKRGDsTM!Rg(uXSeCqGNSgzNM`y7VjoedpR; zLI05nQ5>%<$E>lJs3VDcPhS{7lBcTkFJO?M#`Z2w5=wYo=DSSjYF_M3P}Ahnh^!YuDo1 z3B1zAd5RpYylUBbi(n%Y|of zze0ueblyQobNOy#Y&+fgtzQVqdoTsTEr?#QoA0d|*LG-<2twk*-D{|0jh|Qa;-uNO zGfk7%;ew@S(uFrKPip)62Fd6N%+S={t?^u(Gz>xq zUQg-EPr~3Q40qWJR}cL-FA{N6e6bK&eaeUQ*hY`Peb>AH2|Rz!#+yi74+^C@k%IdT zmQ1|43Lxbe7<-S+YId!}fexPx4bxa6MBbf0b5@;(JrcyzLsk=p ze5#r6L|Dr>#Dy)T0ovQa*4GTiXgY-Zqu;z!Fc)K_Ab6Jr>TAXA%3nGFB^C_%AclRo z1dL~3Vby}zbx9fk7r=+E%SVoglLO*PADUQGR{3n{_LK`O^ZKndxUYs(1KllM2c7Jc z;C8@VE<6Usu|BzMbtUm~Xqx>MD|--@gEUsG3zQ%V_mJ=g@Cy< zZyvpN$gc{mG3n`RQWZj0&>tj;OJW`SJ5%l1RmfOuzNxHLjEk^T@52?8b6=n9t3D`ycjPf$)vhW(Rk72*^ z3weTez1nhNHoI8=HUp%`Fd_%xSCphXJn;-D4Kn?ypy;4$C&>la1>kW{wsHBYUa{`VO+jG<6(Ry*u)(Z^u;E@@WT zm)b|r`5gNe>NR#yXf0?zhz%!AO8!yK7;q%n*hyt53B1{T>mt#}R^Z z#V=ge|8o=Vw!hTR9POms1(RT1DFN)T7g+o^W0V^gcPm_X-GO~RR)=n!GxyI$IM@y2 zXO4(GsXWmY>Mkn*csAW6W6l%;x6e=K(A|XM(JfC$NGDS`Y8e+1wSHqixu$bn{$SU{ zQ7)j%U%4=04DOME^~(9!0}CnnTy`4IgPeFWcg|u^zP2(HqHkXp6o(gKBF^_YHH%86 znJIX*h_K{yu1JPJgieKgkY}-|k|v*+;KC33^%Xp5c@1o~D3YxiomYpZyB*ZG42kdg zLLQ02W-9$@7o?8vE?&N}x$84)$7L5Agt*B^D0}bMu@y*E1Uy|1D6FJe4Q%!JU|LIO zcJPT?DB#P3!e5gtY>*Ul)zJ)}>kI0)=Uagvv{D40sBof#@x)+QGUVZ++hS2-gJt52 z)yHEi7h^YzGwH@tHe`~sFLGYdOcr=K01d%^-bn#weW=y|k^p?&V@q_NI?{f34OOnH zf_B}!ahJ}3<@!iWEu@dIxxG91)c4YHa5;3qo*xUl&@J0m3SOfsQTtCY?7gIY4hir8 z!I4k%vF0UF+L(dCQ=Kgxb*g^Ck~D~p>N2H_#dOM1Bt(UlBVKHRC?=fsv#|m)jB0&$ zsyAJy|4V);F1{3e0uSo3+xd3|O^m$zGNz)ACPqZDo=)=zJ5I8G#8W+_5}%FD0tqj< zz!9#MgROd(`_8IYin@#w$_V)Hzn1#nC z3KUUTY>}S}-vcQ~nko)#crxtAb(QbtDHAj0zLF|hN^|wY2Q)4zlH(UoKJQvyUzb$0 z1-5h0Qa^F@gfUPLx@X9iBroSeJP$9&n6;+uBIUMXcyYKwrD&)S_NlqWf^pDR+`K(3 zt}ZAj>iX~_*VHb_)&kzT@m}z+@EWB6+U9LCPITc?tH07Hr z{%7x#uGYCFk2diD?Uij~Sz>T(=;3%-WPFG^t#i)L?C|Y+XE**a9OmxtZ~pi<#-J(? zYc|jAW^9*vCi3#=3wWtwiBfjn2)R|$lr)%Co9L(mvaRn9R%RPGKMJ`5rWR6ncz_o= z;DiR_>HPGHG2W7RQa`7oII7UYI5nlV$A*n>W)a%SWXJK#&pau0eYspOExrp6eBQsh zV!|l}P5Q1$~ef5xa)8uNi@-R_>QR<@j8&Ki#CQx|@SL7GEm(Y{)Dy_22KNrF^zi4k{_8r{nY9ddy5iv)Y4p5xKcLFoxWx%*-fH_^qbK2$t&fHCWW4P5H`I4laRv6g zubW2Q4vv__ADwI)DM|zdPk43mroYW^Jom1}i;8+rL(={Y<~~O#Ar4d)Tpav+>ezy{ zPKCVVQ+6k%Uow?vro*O1Tvr-U?RMrdqQ$~cir37KCt2|G*fobC3mec)BCMjXc>Y&b z47=N(KCM<)lIEQTw~WcTFadL3@3WictmrtOnAdTR>l*|xcAnI|-ug(wP_y$cI5>EF z<7PO}n!6x}qQ8!aiVZxh>swe=rHPVMPO7Dapy01tM^~O>@QI zW$oL8k6)$P!VU6eJvRUSZCUAm%V)sb4QA`J{-B!K%NQk=C}w{dB@-`J)L=F;u_K(X z1fv4YuXXzSpK4W@WftW8EBsV8s>i2Jbt%N0o%N%<<+{N&TOIp(=ZhL39CF97ANZ1= z#jGs(6U>rR=6QoFNwF$gv1wahVf%*6%YnQbDbs!z#$=*JdI@~Q4gKfTwl_}(tG5;b z3E_sflCH~hvOYTiNCPfmUGlu^(J z?%b9X2^fxZUubfr1P&JiZcukD=&8gLO)&QQGmBTYU(&Ej(hS)PWEIVn`h;}7C4*Od(t*HA@Iz?4y|>L zZ%(JWcVIa^)5cp+py2aX1VC-_{O%08u%lH7BlWtzO9{#)2jGaD+IpNa6HbU7PM;Q?Q3xD8S`R2S1@ zQ%^6qWg5%Cj&X5r#^v0XTYcIWf8m%meQ85pnFb?fG@bC^Q(g?pmpkepUQp~D_KpZ6 z3sZZ;_lXg}Fa)Kkh~WB4GAT3cvr?llMS;YIR+#C0u8Prf)>f)Wpn>2*7dFjoHd#8k zS{K^C6`BgBrphp@S4|1YT%61xf}@IHEYUd1XMxn*1w4!d9&X4suAa^dkUAmV_mk3K z+C5NiPdYGI<;oK%Y)EU|i`?HeHs8}}p)z!*9)L$&Tngb8_V>sGt(U(J)U}&#+HOm$ z((g;b9_fERp;f^}`!C0=$BO7*mYZba!#g^-f)l=Tt+-Ryn&|l1Hp~xb#h;Xbp98wH zHpj5FNJji?4ES)Pieo$C!JAyOME)(NrQCTvpntVet>lcwb{?wog79A+>E<0(tq)Ld z-AYe7_e*)(x|<4U6<;k;skKcdOmpA+ajlNP|A+-G62%5%Ms~yREdR?Pah;g6Ph#(~ zISl7Pyg(1FQLIe3cC8FP&>j@`^>T_l*d>dsoLk9TJE=|pnN+yRBlfaw% zQLP`Fe9-}iyrjEIg+;d#jAY0G7Nj5XMIz6!eW8*fMYPy@6ci#1}4nDi@)CgWtxBe{=A|4w0Z9r9zgi4l%H(Mhx&wgn#RQZ)5 z?}Kv{_CLjY>(8*acS-@))IDtYPJt4ec3uD4)F&P$NeeDwb{w#68$QS?c>EMK$a1@z zF}rRhal4?oR!u}8nx@8X#9t{!JFXjTht2AC4AVfxSS-o^`>rqvTPf7==2KMg*_7@t z2ti3bP!h>yk$(D6slaN?itG}bj3D|G2_&(v&^KK6kva02a9Yan=Qh-#%#Ha1!Ffv@ zxTxAig!STYz;WXT(9NmBHOQ&u0sNHH+x9)CGrwy=|IOFlX!7tw*5Eu&;|k-B1&=Am zURcA?K_o>REl?1i43lqUawQ_7L`F#&@6=B*v)y_A7D&+c-r&(3l4#^^_y;I(@IEsF zX|REq#&Bb~`3%8Ayq5gwDQyqncAhk6%Yw0wgB={^ZoLMXVSzgmZC9?#9gE*KtIhB7 z1JCF}3_w=P6G66`qOlNW`d1*p>+3V%un(XW2J^-NzmfqJaUW zn+v)co0=&j6S0)K$eIXLgIu7FjVtbdUkADPa!;IU$G@9-SeW3Y6JpFhs`je(b??88 zr?>ZGe-nca3|?phH+1ZcM0O`{HZ-yNhLD8Y{V4TS621$9ET}jwUg@ioIe7L-{#aCDKS`p zZz$sS7#r%PRgX1Ew_DJH4tm0B^Iud0N|>6H569NKpO%{ao#kDO^Jjvz#;?r0&z`SP z?-%Iac=%2h{&z%g^<@K;0X_9Mxc317v^pV6iOLLUTwU;~OTWAd+KWt>*EZf7%i{P; zGXNjL;Wfm9LwabvXwNe#MYq5Df@btNaSOu@>->lJxgKkX4d7p+&t@xvK)Y_&{C>9e zaUX@4Sp&B{CoCY+I~Svwlf1u-h@<2aG}NrP_op`|1=zyM;Rq#2$ZLd#$|{UWaVDirv#>8 zpt12IX_@BA8h4px1-Mw$BM>iE_@fVlFS;0`AZ0Ds6{jpEX~yxl-p~E~uDfC%FyP^5 z=%o1nTko~kXNmTWAJzO7U+_rina(7jY)TJ2{o}JUW&6u7w|KYGzV+5)jK!3OcWy3Si;J-3k@>rTg-9npXr3b=dY0JsdpQfScDk!1GYFg9 zm_A?$E<61wlgP^r<`d?4OH`w3i_@P;I$L2@xPXVV{2AcGk8aXsPqz@O+ujX{R@ktc zo|~JW@B9q2a zXa_U{i+W5JE)t{6lom=)iG1E+)2QDeR>KX9pgZ1}UXfH88!` z(1Tr77V)A(nay4LQkUFLb_`)TV%lYGRvVz#jKq^YLe5Jk3(2aZ8k!QT3c+J!lmdJE z#}{oM4-mia+iaB~7JyjQNzdqhe0yv`dYhzq>uU z8PjKfOugm$;~?3M1^C2%)<+S?CAR()+Iytax{yq`L2u%~kT1OapILd0-P0{zc@d~Xc$ zXx6p!-U~elLCPJ;dVJkDyt&Sm7t6PrBH6G6Pb*)+16Iwhrghwm?rJ2-WYgsEJ;=g!2zbd|q%JV3EEI z%?V+>BJx_(Nm>qn**N2-9TGSO{=9nmYu)4xJ8L7?^iBHfz67^+Dqe-4!xwyZEy~M! z=MVH&Q@vt+{W zBO_3%UiX89I&wkHVyDW2X_7%_uRoSvO>B3RiCA6zY953 zq2PF*j+S}kie%HSbjQw)vz9$3g*UdSRN0kM8GMd4NF*=9*&1-yc6Ya3R>m!oE3&!s zaB_GAfEF%O0=$xMuCo{b*H~26-J1FdX7q_(xWm>#*3%JqNV?s`67uY-E@7jz_$0>&+DuVr-_*KesRjxRe<4?`&^l;s>#U z(VcD1TD3++G$!36=wI4|tVQ{oG5{8nu<&D8BnlHm7*_eqqL%3p5-TjuG3{KC4|J2l zk3nO24pS;-)S$yUxOa4C1PQe0rM9cV0AiWp!KDYTiur+~3xOn~M30u1cB4(~WsV+J z9xXkfwMx)j;m=SAyGywH{buLK6~?m9vm%rcmaLO&n??^>EOhDB1JtvDg!jTKkAW7$ zuG`}uZtrVX-v&)sO-R9c6tI{17b^20(gar>s^27-zTAmBS3RrB2dIozZH*GkU0I*S)=q0{uQHe<4?T)6_6_s*1iM^;6zqB)YOcxr1qT($HMM#jOT^TYLRDxPQ5&jo^e~?y$E|k2UmFq>ivhCZ(7I3Xk6MIDCBC+~Pe6*3VRtvqL~BqzTOUuY zoN7dEI%S$TbY0LxYk1J`WbMg?PgB-%|7HB{YQ9#5sOh6lW$QfX2?Vy#7+;%5f}}e> z;f<-{T3;ZJ{%H&yTn2>bYo+%^AD%v^AB=E%NL7h+tNuitZrtv-)`e$iEi`x2np{e> z?|Q>rl-I5f{0$}bk>WUDEi|d;o?d@6hl{m4kCV-2F&ZN-3M~D}qag`R%>8}zNlh7b zs(j!tMInSp%=z1iT?mtt$IeAH9sI3NNnQZhq&B8T&SJne&g9T~ zO3}t9P`0BIq7j&6_Ic!P!45hc&mo^5vNKm$uDEsKXp+Xd)Yx6iwz0knyUfF)f?dN{ zKb?QY)s`AdFaU)P+zW16DjdaJBFEAXFS~KhV0P${f3Ea01Br8MPRotqr3Wm&u zxk@k;Qd7r6oE9{{bWeO0!;;ryF3#Uy4x{VX>K7llp1nzPl5C8}|F&H58vuK*8LpKI z=C&Rkq7vipdnaLqh(1E(_n5z(Pds5aZ=zL0nWQNs9(UE&OELd9p21FrOVlcH3X|Tm zUHf8|IFrnijG9{3w@)TnG%p$ElNjV=*CvS!GH@BOP#W)s8} zWMt^tnTnYEG%>SL+}hu)O9N^cL*czA`zl2C@b@y94aa0LGghh|jie9odc|ILzU|n` zC>53c*x^WwL&MPPp$E}fVwUU=WbtWI`Gy~csu(1{tx@IFR^o8svAS5(p6t}~gfW!w z8s28U!mWJ?@vy^_F(U-`RK~=SK!WAi@NlB&i6>2}w%FkjB!z_mVE~+;96Xo1cA1XP z;%=Cj>h70b=F{1fr0wMwxk*5@rX=q8vL@x&_skQwupZ;p>A_~|%jSNZw`HrwsJm!X z*55T{OR)ju>73w@*uW^~w+5V^huBLFe1&X+GW-mz13B0YBIMNelwNUhF5mI#bq$ZNc_zUH0wX%;PUJAMrth*)f!d+q;(7Tcj_KsR<96d+b}j~1Uo3p{Zj`h+bdG1f4z~`y?j%ikBbF{%*lN~OiXqj{L#clej32=@c3-x4A>ffKt)-g5Mf-*s#YcLN}!tC`rUJPPD@^>7;LBi z{T?grkBun?`dbY5xc0>oCZcCL6TI>FEb;!ju8>pbP2un;^Q$^m`^TWEsgO*P^>dGV zO26+(atuKH7*NhUfBicDA+e+sc>Uh-!1lT5vc&dsgrxtXGZx73iFMed^?{U655pY6 ztm;YiDcIsF*|#=nSV=782{E#bAwVWWgwz6^xl&H_Ql)n-p`9L+ocRWF;o*%>!GLQi zc!9*<*7p~tVRXyD#+INbSJXodnPcRc+W?7-sZzuMpw2=B7Mcekv=J1YsdP$Rs%Axy`30}xr?fDj}H+P5<3>UL-rJEMT`E(Y-BQ!BF_33ETGilUL#icjT0;=fcj$UI~MLUtr&eLdJOk{@TZw3+_{8sC4 zF6?~`+udYQO81*fo|r|`7Q(h~5~&e`8_ENHE#XcCYBqQ`9aQTzOP9Yktg*fIMWJeR zA=xg#ObOs@*>RPFx1@FyyY4C zGgy+`gprmIA~`y{Rk>PbIO0i@JSu_&fJS;fcMcHq%uxM^R#s9u4c5%|# zLyVZej5AR_`2E+FIXtadf=~j!2wZ702?m~cvnP$?!JpYd>$$Az4}K9-?@%1F!(pT1 z8ZAoB2?1~v-bOzV%{qg@%}pRYa$A)>#}JtD!C9Xvw`x5@U`s6a#trfC zEHh`|I7@=-*Mg|2$%l;JJ(1{Ip%86;{%N^UdaT9i<@A{u&_Mn>;~Bj-W>$mur5Yc^ z-YGTwD8TTgk`Ec$@#Sff22iV|R1LLAWl(d5l098?Uf^6W_o z7V)0|-7l`aJSjnGjB8pzYGC1U+YGxP4mIDCJlDk1kACe~m_@O6|23PY_5kM*nJPNC zl3sjOfFj2GlP9drpx7le_}4}&+_)0Q$q!olwf&jLCQ8f{oL{>LXtSo2 z@lpMePRX_ODpJ^?^`OJ)5Oy7s%{Y?vN_TWB*5YARzYETq5u`omse7w6{U0T#$^*lg zatvzpv3mC?_nW_<$z{YD)EvW#Bq1fRpes$sFcEXnGlM=y7`qypXySB@Ixbdh5W$T9 zI-|@&h)GI=c!fB6&KRo32Er&T#D;fzqoLG70@JamPcK=Zyf+CK?Bn^HdVT_1tKQ^V z)P<~{u`#yMMK1oDzM4fxB%oBoP-25fqJ5q(3@!q8o?rc&KSJR@3L$jLo~7987pp6CCRR#;xqZ}qh_ng*2X%ur~>atD5RKi@&!!M|o#m-HX;-8RpI_pc%3oYLmr zIhsJt2sx4ex~P)oajhJg{`N1wODu=yN=c$FF3v&su9Lw}ehZi9jOZ!gHOITSdn^9X z?k%E0kT-?1!w?ubP(=>oVbvMl8_!xSi1H?#7aHGi-ZcZ`-9RKGXrpjBS=bcJR1T%u zkMw*}q0p~USZFMKXd64B8VV-(7nE8$G!Mc`^JT?)M_(Xq|6|GsUt+uZppe63 zY#M|+?*4WOcVY!ShFpu>D4XDRx;UASaFDoIJDyacTKgUPz;pnLF$F82@MYZ!<|lsU z345~t&3WzORF1XzYYeno9V@)BrOGK;UH@(}FH!D;b=d^Rxq6f%N z>r*gV=MkxB-uT~tYZ~$=AdAW&X1_}9RiH}HvTT3XpGIV2RWo zZ#v=$Hf!h<9#ZR7(lN}y9M@c7%tVyU5{Vm+I&i`f-3j^R%F|q+?Lk5LxC>3hq+;jBmdrz>Chf0R`qpLI1H8mrmnK@Uf-0Y{~>O6=~HIvJZgsAowd`5Om(@C zv(D-xTQeAOL4%KSEWRka;%hl<7O+-nY9kXdA-8KRgn#tGOA3N?$K0EI-AqwWPOx>n z=pYdzkJT5%T_7<*^iz2#K1SN&{?Ox2d(!XKeQ#aR0zNDApU&hVtheYpwo`Vr+N{c# zDJ3>Fs>dlNr`oFAx|wQxX%tx$E$kqwcZzJ_iflTFGXv|i23GtTv7cfXQ(KK3Kka-1 z(mR4%IRb0B45&RUAbKA779B9xGq_*vCS-{P`x}Si(iWuNp+(sDeCAM3;qTwb<&0^> zRy58E3jPwo2s!i<1}JBR1-+xbN|P@9N%MM1FMIj(H*JYE`?=#yLxRUs}T ziXo=&5i!SjdtV5G#qvT#$JR*D(ZFS~Zm&cnXm5Y6>RFA&;_|ZnqSl~Dr?@r6*XBU) zvGMWvggjX>sjE24m4MxK>&s@|j}Q&4f-jQHPlOF~@Ua&Td3j@$KnlzhF+C{neI&&v zR7m61tmIsm(YrhIa0=7`ed;^U+y+dnysWl(B&&4|v)L>FFd$qLT_>2KbG}5`dDrG1 z@RtGBPv)8>YE98WE02XbQ0ly^L0_FLaa5Q{4|+DbH+h6BH2fjTt(4vXZOBwF&SnhO zWw3Jeld zm}q$AV}S8XDS*U&KTzfSvj<`M0%X+COgy-0VcpfdFsn8JjoYQLL z9bnqm=lf0B_vd6MzffU=T+U01gGaDmUrp~yrQ4^7>m+zjOctmKKlCl9xCF$t$@TY# zBll}MZj&{c<$~|S&z`3Bg(uBQEneh0X5~)l5-54SZ}Jj_Hq5n0qa77it0?^CRolXa zWv1(WtmOm%>o#M64*V!9>nj@yP!>? zS8m5yWQS*s|9MQ+xwcCKdQ}bdJv!3WrNkGz?)+K(w|#*gg7Awa3Jap6WUng75V1Ut zo5_CHktGN{J!m3x;8L4&yB)FJe-b@ZtbO{cLzO}QW##SnmLU8m6ewA!1E05xcONx~ z^+MhRmH04UJ1cO3pt-ibDJk<6sV@WJL}B7(2S}T|O0(fX_n_T;(Fzxju_qqt7Y}gkaJbe`t5q zY<5IUsqRBa4(1zuqCAmPW8OzII0(I;ohbo9%n)e99$3(f)KJ!`?6lUwl7oWanL(FO z^Uje!&HV1#KkYmnuBWm|Su2mru2sA9nutp!9OVd(OE1s$lpUEj+mkrbe&CV3@{L8@ z1-KrpXOOI0aaKz@awUJadZq9i=Nh*G47s34HM%2r2V1!U4H4J96{6QLuzBCs_-Xq6 z8zo}tcurb7Rp&s6%w*p<CQc z+ekl8@AKaHUV5wuIWKstmxag_C%WVX$%rOllG2~f?s2ud7IfHCnaozHS7iOj6A-dZ zoAFO7N{HSl|65ZK8)>&yCqi`TTiyw?6TXt= z%gG^*8>%M4=myQ#r|_)e0P@H2G@zBl;ioo*O0QbQfY7j(Q&O~#l3?e)Z{(fQkWsVq z(t-Q1<%NM`V7-I$I?T+1pld;0H)9Y^1{f9I5b;n>; z$*Yb+*qH4SF@=X*_Ze(H+(%s9?bYUvZ-QhAUYrY%UPgT0Ad4eV9Wv0eXE?oCUyC8B zFHv2%x8Ph?htnp^Dv2?rSyB~>-YO-BqczwuxcPcI;si5`Iya#zQA{0=ipRQKHRp`d zWN{+2Jmg1(M>wBt@LAeJ$N}I}()7c_lau}^D?le<*|Xzo!gbLjQ{G)FcEe>MEHzfP z6WN`nVx{{11t1!P9tSLbM3@&751G{4Dr1l>)-1~abk>7ucroN zp(|_wKN{a`)ZZXI&aSVTob@xkv+?8!_e`}uIt6D2yYoRzUK`f)3oWX3^t?1zRUD+a z;URJ&FT|EhctA8X26(g@Xs(oy^un_4I!iJUMrP&PrKZ=nyerYB7w(r@lZqmO`Aq!3 zw6TM^_CUL*ehy;asWASTM_I`vhppo(!^W=oA#YB=f>5(1-gJ(@G zO`=`&I7-T2RASG&_O9#nrru$%=R3Ns-lf8pqx{q>Ek+qLZ)|#+=R@9C9e+}56nTM< z_{%XRrO1GF(?;o$ThiVG>-9xPu~uWKjA>6glOoX3~q`z%3;m;5#!Bh)>992uPRk3-=oksC8g@8>w*;XY+kdmD;Ybb3f zMUWEnV>BG1Q_lHVt%8fP<^7A>L|t&YZN*Z);HWsc`qX@_L<|&1d0l4AlTjt}ujt?@ zM*aEd!(Y(>#;7XvUyRmi;uMV0gARJqT7T%x{3{lP`6n$$302f1Oy!&K=2wm0;#s=f zlodAoS-Sja&eRw+_AK2};}{nyXH(0#+)iO;_|RuQ5ecXE^cv;FB2K)ojqqG!#Ol?& zDDTo+iWQPhy_Jctk@LUffFoOYRy73!wx4KvZwlG&zpjoR?UWh@y&wttF&O<;&e#*ua8J6q7B z7a#|FlmWvFKG&+R?QAXDBaT(prX{(XD=zJf$0!6XqT!Rn;tH-jqMxt0hpe zV07!OWhGECqhMmot7YZ>9#hPj*xKIl8bRAEW~_E&{P)A*9U|rg(+_`J+v-WXp4q9Es}G`LWM- zbLI=!KcCE7Pg}TMT0JLw-Gj9sNNC6qiH=d8%_~b<=*Dzho zI~upCUp#g(@blxIDOn)Ra*@gzwyDqR=ukv68%ntXs;3)r->19j7>3^MR8O|Nl(vY5 zdK&CeHqiB|gtQawv%IL1do9NFbI|O<&m@DC2vV-l?h=lHJ@{Nx#GJKqi}#vQfwf9=cgEgCmai+!;7oJ z2b6?U*vh9-Y;`i*$$50YSij|*;PF=+KGjy%ItvV?F%CU@sdbk7pS)}U{3agsCjJpl zZqL7J<{i-idDcEQQZHII(l_yABGL zqqkh+MQVoglWuKOYpP!#mYe9LK~xBgLY1R+D|EqJYNaE}I^bm9qVN&5XB`wOwyfcX zFFz9CmQ<^OOIRsY;3I0>;S@UQ9+XCvY5c}7KaQlBBv+!J*;EJ)KX}PcNkK-D^5@=> z@myYn6$<{dbWX$(4~P!9-K1@=zH?H3H%YFN#`FEmWd1`qmQnuirK2H;tJUEXoakS^ z994>Cb3Bg#EE73GFvJRo#R%!>BA*)WYrzg`+enxHEz}%D#T?n=`n;U7D+8V2?C^<1 zo)3F|{d!t8qHo{EHeb zPdL?}r|`gUdG|C56%#W{NmsZr7LHchdcN`cEzTgHze4vpU%mlhU1n_NXIPf=*_H)Q&kXB;3%wNP=^-L&$Dij^9*T>POR8kY=d!p!&mxH>Z>r=$2Ot(n>L` zTZhQdD-o|SkxCygHO}&`l*=3cJiWT-EtNM8B_T2CZY5lGHQ*|dn)%p9EuyWD%zJ@{ z44Zjao}X)h6>ccLn6B&l&NyPE|8@0?u?(G-lr#57mV8@}OUz1)%T3O<-WC}TKjp{O z7DS+N@Cf@Jc0?Kfr%Zglx*4lywIS}8VEc%)%n&119#wNMe{oE)-e#hmO6_G9B=D5x zNgB*>&R=EWifHt)9ot}!N{FfV;+`NY6K?tWjZ%@(a?s?joV&F~5M=#HY02Fhcx#3q z#$}aO6MNF>5pKwyydgt$K>svZfi0G@z*t2Y7ZLJ-o{J)j{>ub+s(FQppLn`_PdyxXc=&*U&`@V| zE3h_G;K4cOu}#L)AIKG%@n%&0QYp!;F(E8|0ru9fiVRU{sCsSL^)xoQF>ysl;N?e* zzN+d+d3VBGcWy%+$tz*`SH!)PHQWx=Ni}M$^8@cVO;;K~L_;4z>nuMN4)2wizfrwG zGsM9i*FrJ`d0)UYROaZ;+VXK8V#s9bu9vw{EfN!2E5Mqf<$U#s7m z2|xeRw*F3e5Naqh82h*Yi!04gg;lgWH%aHx$LF%N4T0I4V7`bt)^+EWn_Qil&pVp% zzE(-sz>(~fGx-zrck?DsX5}4aUL=NaQIgRM+vg*^SQ`>1gN6jUNo5oWZ*HGO>;+mk zWft&J*^idAy!Na%%O-jF9%LO)t^s$h!fj4|H1oRQb%~&kP=4324Lch+2J^*9f!XZA zNinR(pH&|HTK!!y#;59(AYQsmy#j{~u$)_19Pe1a5(C1)UdfP3W5 z3V5ME+Jm7Jh3@AsH^W7hno{_s=Z(9nN9+i8gYvaF+u9@+T_lYxFn%C)*bd_oj$OEC z(tlz~{~aL|Ptyvh()`q)W7ZTNIcSR48 zC9&O;Fm7Um#4gK&>p~O$O|O~TdtG5^Q{FLZB8mQuFR$6sV(U{(F5Zo$y$&bufqUes z*f0k@23MHGe%@DAHaD;M{ky|kmJO^Tm4iH53qE-8-0Zj6D1t3_bPFTd;C(iN0cFOG zl!Ot1!Wjh^Cw@NC*>d~es~+0g_IkAC)nUws?}3|Ei1j&(j0i9Jt^BVzK+)Q4Cf1V# z*G=L-u)}yk%Y!n29>Ywt==MwaxJ*jvw_BIri6(`Fglvsw2wT+IRGfWx(RMd*Z<0$L zw!z0kG|IksxN&;8xw%Qxd0}w98URx;GW^T5%`&N3VPe3)x;!_RalAij^Iqb=(PY^a zz`8s$pL(?8UqpZV1%$AWf0S+gzVfKuuV3F}I*osnYUHa4k$<)3>C>lQr`|sOdAX-d zABIUAnegZ@7p}z_yMmF}gL%JG2O5&zR}23PqjF?ozFfmV(T7T8bKDTDBke{EGq~Xt zC|Fr6WhS@5t|9s3@uUhR5w4oH;HLfCbBWs?T{b0*)v1WQ`loekMW6Fcq>rAXvfQXZ z8jJ&7Y{X#CSM$xBG3h$P-^r(;XSXw6Am?|r z65+KoVJ-thjb(W~dGaK9EG*lJ)OidmPaE3El{fBXDum@PHvhL73=tyY)xn0hpoR7| zHc{VU%SBGXdhC=sa0-<`4<9mOVnNd;@4dQ`$21}qtuDhUT#r8rnb!HArr3yEzxXX~ zANRO-=_O4aUqqZK$j$oaOEQ2i(0x$&2lGo;OYJPWs3E9w8$NJV!~j@3u8?)Md_!Q zF&x*}d2dsaa$e1@!Po<4BL!^OM^Y9ZKIY9iLaeUc9nE(h3!B?9&lj$BC=_DihPQuu zA|&j`3zYnhS8mVkC-RhF$M=i%7R1<+ym1@+&fw>tYLY#*11M1JP`O(85{3w6`EQJ3 z`oOIs0ywdx8L~u&<<6Gd?S@IkvaZ$reXD(Uy{<3neYI-lN-~521 zbUuG3Sb+NRn>Rdz`uXyYe9!t878ZtQ-tY{VB^u!(R#(GT6?!q6_xAx&cZeuFyhvO& zZRZZ>;`hxQ8xEOzWNZxx&8mV5L{V_A|KM@_c`qZHtr(5j{bLpH1cN`Qj_)aWUVd>| z&#Z@Q`3zzj&HK#Geqd*tduN;H?RhF@|3+Z&NSn3c<6DCVh?49@*wK>Y2@DP(U zhM|=m(EJ8efd~9W%k0*2+$hcJo9kep+^p27r$U9fxj6?%)$gr9tEE+~epJy%dHLWFfZZ5|g zD;;bY7^>7yjbAjqORxF(gKD4LviZ{a&3LYsiZxeaF9c8jX9xbiQK5_IcrHn^VHPh{ zF;{Y%ntZeVgG7E9D_5mhA6}X&D_OdpKU4PJ@3ixLZG)*vDo&{&>Mx`bq}wDVj?p7C z9hTj0PbgMMXOMG_54u%s{NYO%7q|2C1m_-`AW_WyoLXVxtN!@ZRJ-5#c6|H{c&wm> z8%x#*WR3_^J+qPtd(ZVeu-!G-^5E~EQ*x_+CLR+@FYRXO@82>bGxAD%`It_>(cOV} zfC|lccjZ$wU`_<|qN*i1+`LgDOIV+P{#N}mhkR5fm8wbenZgX{wB=+v}QV zf7ghi@ZtDC41_Nu?Vu=JbcS_}r1$gYPkZK@E+HN1mb4I^QhrI0UwYMbKOElNe0qC( z8t81AprWz(kb%J*8a%R4?=bhnsMh;%XE1rlNx*KHq-|=GSC#-QVENiy@5g`1;Se*3 zxt^?*>Xz_Cd>&rdBIyOSRI?N&Owj`Xnd~^ZpI|O`mLC8O+n;cw}b}O@< zEzlC5-OkRM&|fwmqhCKD=G#9ys@etWzrT(!T%8q~h~B73#bXngxHnO_HFw4Mh+G#% zM)#yglSf$3b5@Cb5sSpTn8WYwDo~)+-Cc3r4toii!)L*bbuhuN4)lU7?W*r@Z>KRHDEE!b?jz7~ps z^MGE=cdZkN5y8%9{=D4DS5MjOtHX2VFs(ZpfRqP4Y!n@T40v3wXR(BfJpIO|O^oa-AzK2sP7w8oiIzPT7 z{&9F@PWGLhfEIq22T5*UGOnJCRwDcbn^7u5FF3QobcZAZsOviYbu z=<9_fB-(uUjfS9~lc0YP_`HB%RK#Gayw9cDb;Hfq*MuIB^AFW$bQlc?7oRys`X}~q| z=a?yT#Br(V?NqTkt;mnnpKgc*wwBguoTcOYrdv-f|FgflA3;iai<0ZwgO{4laPL~;X{cam;EgN`N>eSrQ0XHRP^x;wp8}SWu$!#NV1uL-BuUh zJ;uxm_}w|*Sywbvw9(zYyE`y7HDzaOJ2pC&L$1)OQM9tMGLk9cxX|?eckd;w@DGc+ zcQPXE-`--|vl^=LC9v+^!eZ$`(;5%`ct=e4h?dqN@HpK)j6}yW;#t=pG6kN`T5F?L zX!*!cFI$d>sJ@k~Fc*Nv?&QZLoRh5v9qv?LZ#^*SP&w~5qc2|BVy`H?XyaYwi*pU` z)WN|)zT6n2RN&wKbr>@-__z5t z$}**IWzg(tcU;a`^JMVy1=wnM46K`3qPAq;n%;Vy1=`P_aU&+fUZVQ*q5G@+vYqEm z>s7zFT!|4QtkTi}ryIMarT=yfW;-@AmYuj&lidqmxOIM0y+oP9j!Qp53-gkclx)eT z`N}K$_?M*$De%|*Hd{P5_-r6sQ}XiS_IzP@u2_h(2zRsWSqP!7LXRbBNOcHZbx0c8 zT3+AxTj^eQ@rnWyqZFGk$7NnKvvh7==s}qSxLtk8<*zG#HYv1i1Z;}81Zz?BSSebF zy6&9(!oayc7I!^rt9X9@A&!B@RPwn*i}h)S&Cne0Bjd8exF@Z{uWMcX<$irNv`C43 zQZLLxb72uO&Is7rfn$)ewSxQ8hkwTHY&27haXj00ID%k~8o-xElzH+Y?Uo}(%tJL# zhA5SJA}yJ7t3I7xT*h8q@SK0g+q{<_y~X=*6iY&eIqb%Sn+D_WvkL8-mFDDgb+Lta z-Gq}<^TnvE=VWDz*$6I=*Vqg-I4`%^45ff(R%s?acphj5k*ix9p>cJ>13Cv1OL7-( zFGe|hq|Og&GqQ1vCBE@@eJ+25p;3}v9pX5q=J_vq#1!MerW~Oxv89YOwYgesR^{)_ z6n1KZm(hz?cAQPB@~(^0J=&SeKZzz!#CZVv^vQTA0*9C}ctxX<;blJ|VI_n4zwOzX z+;RZVuHaDp?HPzHo`&5}c62-;HDW1{XB#+WXe5OD&|Nf^6EaWk>WbsVqcNrL9jaWP z%SORKPt?Nz0`m}!50|0(550crBo!vGQbqT?XY(WIy|39aupDl5{yc&B`aCiayRF6C z@k)n0T)dmWaP8UH+n1pG78gnO4^M}mo>oUjlI!Yn*p+B1>r~q$TMJaXZT3Dj5q4Ux z@tZ3C8vO~o-j3)X)b%S`eH*KCz%~7!+rLwqhk@|%e7X97@5u%f9(29+bRE?{{{?nr z7N!&68FMF%kI{KkK#Csrrw0*D&DXKFM{i*8Q0if=$uFtjeGd{#n*W4iL=YcHyRNN) z_r~1dMRAu-u*SE@!bVasJ6F9xzK)L zeB7^{f4jd&IwC`$P6Ojm+i~$`VD2VJgH?B0ZDEYvL4VDII}&+XeHuwVrCVZK+;{MU zn2?y*X}QJbe0#*C!EvR==G#>gsdppRDZN|R0T03O7|i=95MG)s?jv=15l*maoAJ)B z!yfmI$;;n|4nW;+78$L77IX8hJGkeK1 zui9@fzdkSy4K_+4W|V5N8(Ud!y*^lOtul1n<$M{0;08`(sS4-eKrl`t^LN$(!CL_%icwUQ>v-={MF$ipBXf`LU{iZy;bLCO~{Da zH!G`>od3KjH`x37$g=uR27Q=`3Hq}?BzYf+0304BD-0%kWUK>5Ud1x17#XZ$olX)$ zg=f>!A|SHjig?P`ahp+IMTYsS)nXNEN80`GP7dr!s?lyNN3!B2ge9K-k$I!_ZVM&1 z@DIagsdTmX88a=n>JWEZD4~i>(&r~+$}9=reXlX*zb3zgU?V88Mza^qIAK7Zcq{9& zP$$9equahq-47Zuj5<<84_qd*r*bAoI;XqyL-W;z@3aQF88aN-YdAT+@G_J<^4j|H zfsBwTGF;ZpxcVE}z!95j!^0GjR?K{Y&3jDMYmoQ4+4h(|$rZhaK?(OWwP+un@`#06 zviok=trcvHu~pP9(0;FpmFkDd-rin|=OSCG(`rZ1UD5t*1ztbR37IBO)JCG_VH>Ig zhcW1)8Z6-Y$ks%sd4ao4W;sEW|u2y>i12P|Po;y*y%$X9Z<*b=pO+)KF{KFhAZ zy&6~ihlJ)#la*K{;Fo40NbuHgfHk*+HZik7#QiDt2WZELL4DeYPbose z4qMwITIL9uyzPaO1bKPwzGdRQp1=2EYa*{Nfy{5OI~i>(KOoW8hIj3exgE%Oy_!tw zNt$y8cjWlMshq>CKIsX8;`KWxV40KN)j+FNo%W6W#`w;Hy>4Hu&~trp1+=xB>UQIQ ze?8e*P)+_AY>KYJ$tIsrs8cEmsx!#|U*2ZNo(JLv5n{;4KKFfS@BrVK>3ui4$qF(i z!NksdO?&&ZgXZ%-_Fjv$4BxlTNQoLf;lu&QMehO7<{Dg98@vy{`&f0Zl6+862t14A zIEJkKQe+HT>dgSURN#5N>5di{Oyde6aYTT)_UovyU1V%F=11gvkIkgloYlZIybGLB zuwMzinl*9hxLR<~TUyQXUg2A+;^r3xV+%qCu%0yz{WPaqQZ7y-g ztX_8Fg_2|Pu!??(*WIbgdPrmXg1?EPVdXm*JhzAA&tWlYU!pV5$V|ZL~G$G zSHvQPyxAm{I2pH!mDc9U0J{w{aSCC+!=-n4Oogoh@ry(6w5m%_8%Q6Im<@kf=DH#Z zxls){fPVM;;<|a+Q^YFE+puXCGG$koQ;8L{at$YRX}!j`FB0WZ1!4@o>=E3Wgy zCxO5HPn3!$9cG;^YQ4pQ&s%5n9*U#cVgk>CJ^kUVMG2KBfxOq97{rn%opQiW=fj2a zs%HB>eFMk&x_Ig^_Mer94|b2pioV1n)_TdbV%W&KMnqOU&qqX(hm!jkg6@$xjQ`A3 zl`WA2qbPwVlrU{m6eoZXha)(H&WQ^fTV^^K-eAV=$}L|kkQ~WlG8`z7-UQ!saBja$ zp>=N3=s}Et0?k2}d8>eH)@8D$WSoML&MP}_?}QOseC?HG$c^ht8=o$wzV6IBFfu5 ze>b#)D~nICb5kujGWeykT`&5Q66;<874;;OU?O8P7e@Bw$mP2-Ocn*~B1iq7Rk02g z8czSgpriV7fOgl8JMeceUezGh6dy=16!W?I(_E{_Ybq)r81#Xp{h8~;LH7OS@LgHPowzbb&RK~!Xc+n888CrLrmeWVdzWSHyBi~(ql zBO*(_K`*k)ZtqOa1C2L=9ma`)u>*f^*wom;LjC^D>9c5ULSExq%bwYIp~bl$%Vo5o zx|+}@q}8%qmO{RBratA$m%iF+i%wQ8pq3H&&awjN#S0Mt`sM3Ziyx~yV&BcW27df5 z4^bEi4(WGy3UGG2mf~3qEgE8K2eT%(6(*|hh&4RXKxT{v&wzdpR zXTQwV8~m^*#C(reI2D=N_BZ9hZB&$n36G(T!e)vHomYSM-!Em_E?us!g=^{96( zy?TQdzT&Hu@=+Zl?_>KJDdATgt|0;DVqVBgCDWjpm`s=U-CkPfojsNE`i#Z*e%+); zU{~Zg5WdU70C9(nmVgAlWl7Z(zSS23HBO#~Y`dPRl_IhS?r=P;Y8C_gyj51yU%%S? zTexh$3iL^TL<-xpCOl+=*c4@bOJ~NP?oI5eWKZ10HiUQ{3KRTr@f}pNg$-_((D6w5 zl5Rz6lfshUE90j3*8aM3{;oZZ|NI@9*o6gC7NdCb@InHsQG&HiIvsP8(vvG7RY`P= zFKm2J-{f`W0ynHC5iyA1(vjAapOlQz!uN0gSkgvxw%(>k58jJNry5NsOq_s6L-dslN^hmD|eMi##mOEWH+ z>w5j|A3W^R`YNAa=vlB#q`yU*Z+YB~Z2v~kY|C;ocdO5Fna-`^oG>O@mGOK<%{^c1 zMpW}|AH$JFU9}DM)poiL zS7h@2&CN>{8-V7~N`}Vuv(d2++xPOg2>Nd*s`hCr99Mx~=d#@J*7sJP!OrmMfgrmv zh71~M)6&0$1a5SVPK!lzW{~B}IWs{Ci9*Su02U$1yd_qOn&#y{Ajc#Ya-YbEI8lU+ zTJST_7SX)@A{G|!ArD&!*g3m;*Y`9uGjoO(;6eNDR8N1Q1m*-Ok&fE1vU2aG%LM}q zpTqi*UoY$*KaKsmsNyy~BhJI9@9opf7V}ygPDdnC$EM9Zq^ECQ|FmD( z3@;<1opaFk(n|rD|DJN>+#^xQlid;@BSK262h0c7^W9zzz_n-xMnWjIHBdK%-5J`7 za%jC)v88fiPYmkeb`Cf+t1@p0Y<4eX8}|vj%iL!WJG+-7mOLAmBL*#GLaWHS#;&LA zaDUdk!+Dc(KwiZlTTA8dEMcxnxSo8o4s&wU!H&=5Cr?ly95h>aI1XiLzC9gK-ctYa z>61u_j-)zmsMxo=@}0JafexXq!bWlHcKc$_4vLDm zKZ!z?^$L>)Ypko$4tI8TKv#n~HMa94tF(CCigzOVG`~pRkMl7Go}|J?vjr!qK)B{> zjCr*;f|A>ya!&pI)2vj(uyY-JN-(EzFOrp?c(`L=^3!70ZC{F;^vc~PPZ@QDD_rrDkk@o}~ng@~|w#WBN4}S}Qt)$k=luVbF-Cq52 zgO&!&dt;I(6e`f0o^~J9S_IW|%rKI^Q{gw}tv^F#1p(vQ5K9}Nn-}P$M@8sxkw^CT z%0pr{o!d@7%`IK-fthdVme))s?KC~V_~O#^d@YQ@|0X_yA<)-xcnzo5ZJ=nV;Y*Bp zhugWnq$J|2*S=UFkF%#X>sFL0Z!0Fk-r{Pg<>0pn7WxE;GS_AWo&o*KH$?CzTT`Z2V+HHF4m`RVdOLoACMDEXDu@ z$%~D`1o0LqpV?#ia&8P|-gSw55@2{tGuL;$S+L|IxPr zNbR>j()bvlk@CKohr4NYlRNq)N+T3$&hRamb4`aFLYW3)=0aX-w_l#EE;b5e(!UnQ z77ieM)l}m08N7mI-zRIQjj0ch<|J*|v-E`i@|r)`;1`Ot1Q#DEF`Modd{Do&%35D= zR}LeUEu~0RJ1b@FJCC^@BU3o-K80yy`EGYLBWfJujvYW)vvDc#{Hut$^z*uYKGn_@ zvQYEHfq_!S`!&VurSrty-G{%c-^9Rv%<73pU{Pq$$dF)nq7wmmdcuo8?T2PZeA>4w zK{-N-vGO%9WWQ-8Pzp6J7rO*Ofh`I;$JZaJCm)wr8A5mWMl!1=wlbW$5O9+S5@TNO z(cpRHgN|8nf?7!)D*WhXZs2~YxwN!pedK(%(i6fm{}k8kL)@g~YpR4z=$`KP&kCg= z*T_A5qmCz84-0-b=XuRTq@8$T*J+s8FD6Y0Uwp?cvE!m0wi#!M$C@b7CnhJA)KzUY z_NDz~xY;9s=xL}b@4Wkd1?crtU(?lky}MnR2+*&Wuh*8PZ+?X$u=%mkx+G$qa8&C` z(MZ6Rs$#rZ#|HkuP++%^h1NaT-ye=#yN1BFs+vZ=4Xr-99;#{EPq#a6y$WM}DdtqSWCX+NyEPu9+(3-vM=)in0*>L>)J8%MFt1 zB?=zEQH(1ZNeRv~I^l)+sNzB>d(Ezs-{Ta&w+`j8cd8O7T``1Ecr zVq*p82$JVFz!QJK@UY!|v)Ey_VzJn{sp9#Ym^(C(`JigkYfCJ=`WlxVQN(kFEJdf@WZQn1uA zIOL`L&el#hTSs%|xg+5f(jY&@rTuc&plzc8gh`FZLoW<1qT?kp(V9(>O|hA8nbm1XLI!s3KbGoQ^p8v1UoaB% zLFn?NH}Aj00^8^!F;i7eL60P{m*%FGBw@)F?Ks}4=HSg*RQy~h-Wm+b#M%#vM3z0j z)kt&N%iOTP4t-$BthtW?wqoF2)JWvbDLK6PThgXz_}rUkb6>EmXB}H0c1i|-W>(34RH9Bz4h3eXHyk+T5$i(mbwzB z;D#6X=`|8f8_=9u&Es?!;$vnETqhpQTljjS2sWNrxdsEA`p1+G}LHK`mrQ_u?r^?HeQl3QHB@3>Bq8dHkp_hCLf}nqa zzmpKo=eV;vpOs4^2kF~^lIKAxcJ0q`Uvl-7Ovw)Rlkrcz_4YmkCB=Oj68im`I~qiU zgiFD9+!n~Q)XVGZMKh8UPG?_t_q`#D+cP<0sU^a7(U>Z223Z@lo)zGyC0b86@EHPx z2)z9a-iGO9lFG%ct#=rVzN`8Mhu;lcle={hKNuFzmvR_Ue^EKU_sWxh-m}D;wH{@t zHY~T<&tSJ#vg1HaEjpAx z6_aK+qrC_rVNDD)$j~e*H}X9A?sl|P4I`EghELZfOt1OFM33P6Rjr#b2F}YZ`cBZR zU$5&zs6L4;7#&O~G9nhCh*neHIY(7jI+xF4Ml>OBYxOZg4uLn74LM;`5}AvQE~s;9 zl#GRiMMWAhFDkI(q!Z(M)niOkGgqo&-`RB{<1lsEd$)ns&z$j2^K;FMi9GatEiUDv z1k>M$P&w39;ho^caV>jx^uGxXmFxtIr(>ehSE{XtJ<3H7B9ILMGPq>QQrR0*?-{I2 zm{bzFVX+4ifjhsN=XG0j!A_3<41MFO`?)&kKOKCyY3XQ*iHQ)zT<&r$thtzqWQ_KJ zdbap2@bVaYtqN`Y?S1a6Ycb}LfN8w86BCyJus4lbu*V`Ew~+L9Kln=w2^_c751-d_ND9S=Bh};p^Cn5jU^5pBV%PU~X|2T|lAN(iV5u zF4L3zvqP?Q5t&y6CY?M?B4Bs)0Wdz6$_%$?aGZ~jzR#bSAcf|kK%lh)g~ zmxE*%>I533#%Y{xcb2Wl@QV1nS^d}-BgyjGy+zi*tbNm~s(t7wUw`#O*MmORr3Fz&Zt7q&5hb83gXS zQNgGqSgb_VzK!d|!VoX?aMDTSIeQEG(?vz65*_BA1U;Tdy_W(zj6cA{6$kLos_)-H zemR1U=R+JtrB6R0v%7BHEKUFDVK7h6^!OBoTPHY709eW?Inrm->vF_G=O2U}4N>qp zC4%{bQ z0jt+X)a^S8-gywgr8$Nldfj(N(Bq?H)XDwr`3p9Kucm&7^;JgAgCU16Py zR-21r_{_IA;s_jGk<8wFOCTs%qEeKuYCqlO;4>|%+aFH>a9nB zCk*8O`OleE{R?g6c=>pzAGd2mmDi>Tn@~ucj+1a`BCE0qJ#(S6?J!};wyid*^92=< zmref^KDVT%zk7Iy_{$;PVLS4uc277T!kcUp z^K^2Eif?{pv9V?5u4Ng_OI(8;g~7VX%uF_wjP=d@#=${P``EI$@KqR(L0w-!K)`X+ zxqej(f-+t0wOGN+`%6oWk&x}oz5e*ejK~vPTDuJ^RZdwWpN#i6RUJ)2$6S94VHUh8%idCU1v+4A2bWL4sFddZAXC1P}^XNv_9ToTLjn9@owVP zFrGSWkqE&@vr|Qh8K*%o|0PbT?oVT@9v8!(>$P)5M&8s%=1#kw@KJGdFIf4pUIY1q zenJ|?+?#SJyni6k>cu4`ZOB8Q#dd@+1gi`6ER7=U%LEE+WSk?fp&aRILUZ+a2p9|o zIr+`Fe7>1W5v|}fgur-gaNxVu;3Q)C1NB~r1c9caAeKt4z?}+SMWGHG6Z@$9_yFRg zyWk>DZ+w7%Tic7+OOIeH(d^>azr$F^(^)Z9!h4w~gZs?a6)$qQ<+3rhGD!JQrcVad zPNw0#V1Cj=$lz!r*Kw!;^>Q02r#}qQN0knJX75jraZ@W)3@5!Gl{K()Yh&As3sB z`+2B>{Q5aF6gRD~`N|JWW~L$e{7Z7#bF5OWI11HH9U3@jQgb`GBz^}sk8!SopoUqK z42mHXRWwBj`}$8o(K&x)tQHXFi=RZM9Pve`6iJkMWY}?L9QJeb6+NA87Na!w`$?9* zd^Ps?0bEnvGcB{Pn|;@ZK}R|B>DZ}(1_pEG7C|t2pQW6N_z#^1f+0HauP%Y-t*Z#< z#Dxz;XZ^AQ05X5ytKLPggor1;iw(O>-!x=XMOCbggs zmN;SkdbK&}1HAKfxAoDf&b5oU^?pBQ9#Vt07|B!!!%EkG;bwW9vwS@~nl;TZY~5mh zzBjjoEHkhbO!to_LSgaQc4|W4Q=m?r(KEx9ZgueN-ywwCf*g0^qM9FysAK=RR`atB zlhU9a3vRHv0=fCk2tnQX?6)C5M$ejiESqJDSj_wgp@OHX=r?G_nHVl+QQ_Epd(A|m z-X?WUHK zk>^&8elGS+p~x4?tW9dd6Uo~n(OQ6LaNzL@EO^opyX~sJs(mwN5oCoQrkM!>FXbY) zyQJ@S$2u;EHJn1ioq=wD0{+H(Mtid%5(Mx0Hv zhTGQoDK33aG1mR3teE6g~ry^gYRIsLx-& zoLfEN21`1ZBja#njT!+Kr^R;x_Vi6?1wE?9)Ni92BH^=bbcx%?_Jp#+#dMgBzYEs` z_iY#`c$CeuD&_%=DlLpHiA;s^I$5nWp)w|}S3XYt1K?J}Q?{DQ)4ox|4Qb86{y2d! z+u!kzW5|QPCXt>05jVmrIvVBX^bXr`rR|m&b#iS`X?iqG__G0x!GC9cEa=F^3*T@7 zAYdcR+av-GL%uq2wg$adwG5L!`E_j;a9D32T1*VGZi~*(Jywxdkd=VrpoLbZ#rB)x zOi0jqO8HkfmNHA2mNv_6mL89?`q#yWNhr|5`y`|-i%y0jklyxZX=n7HJk2^~42OC6 zR5?cV%(M&H_3+<{x(%Kr|NSR>Dpecl$|6z3)yKd9o%6V9JJwHXiv$5uiv8k(ZLP}i zWuIqLwbbIh{OSINUh?YTs03=kqhZfguxK8<2+I12mX{^HhXL_LyvJb-!8hw;5F|k% zb6QP@Vp{fnoOf~IAtAuKEHtk7-3lG>{3cYXq=W%4p;$p2`7HZx5?$p(StCKL0+Ne3 zfPq|vA30QE7c+Amr!zk7LB6Ld?Zy<=vTPvMX6;=mAkC*j_I4bcOD7Fb;Oi2vT$j$L z@6GPeQ30fwa08~UHl{-I$Rq2TOjsh#IUz#uZC9ZjCi53RP*fI4*vCfNn>1M*hC@N} zrkj&Wwp!F{eJN+XXAzT`2<~n>Nt%`Wr|`yeG3?Y&oAtKMu#_aHk1m3U;K2po^3Pp` zBwUc#!dVKJiKUnHKIS`8WlwaN{lXL`nTB@x@gLK>?%mD+NAC3dkY~=k2d~isKi(yk z>+;b%0?${Auqka=_AUKZPd?3o(Vjj@Cpqd{Ru#RLadG`)uz|J7c=v}R_;yH9LECIx zo>q7n-ZBUES%x2GESArHQ)7!v8md7@%navzIa)>Afdn5dVL)%>tF`lIR11wqN!kOstBp8mden zn@a~5fln>2mX?-4>y5U|Z?YM(joEyTv}oUi+T{Jz*{T_8FzCjn4ME3>BAHG0e2>>; z))PB?@=h37kObQ#ywh_mVg2$t&ZJl625< zz`gn9)*0-nU8IS{eL!RThDhzadAkM$Ya~TMPt* zk*SlDaSo}|Q#E!oobbAamBRIVKt?JTH3vZ~%ynEHU$(cCthf(2Rox}~@L5zhG+QYb z_k4IRXp%k#O0qOUWuBjs!ZA9J%vN7`?cr?+_Td?W@jUnF(b}yZOzQQ2X?!J9m?-bB zQ)QNDy|1yd6puqmK9J~fo!>6q77h8leSi-3AEt^?5Fk?0eS0W`P zKV}NIf7W^He%3_xI45P$nVq~rD*e)*vjL#xMwK!$YsyA=0CeHjPcc)9FE=v`nv4yT|Fmlzr z?xoL%Yjf}amVgw!!UplCcZUuZsL-LRrf}G0+XLA=!-SGz72_(1$pqpdys>EDPh{WK zVd>F}Q4c(o{hX&Us>M?Xo@9=Hq4^ya^YHn5S=NyUzrtXcvUtE>515$Ly!20DwVg3K zl!m?NC!}$_O~zPsfjnmDrmNtWZq?LE6G`{Wzklat-|GB&6G6=AcN4D&jw_1`Q?0!DY5U`6j3YvfysfX_1CsD~p54YVnsw_ja?4_Be)T*=9gWm)bDGK0^PtKiE=M^f#w6!MX3V@Y;)LkZT5 zaKS#vTE-0l0&Q)hJ06_KU*N7neVIv8GhnT`l=B$OFyE5lyu>{X#0PLoi^ub1_{(}R zEwV(P^EY*rJB;U!(!lzHxGNvkYpWzEN%5%&P-1?e9Rcv;)QaaSpzQH#5MdLOmX*l{V!_e61esn!KExoQa9}2u5U4Wq9wa?+x`aN#6+pMhxI!DO!o(&NCo* zIdCf4{BB|Hy~$J|P=*X}vpk#1y&cxoJk)cjF!j34T)AJ5pBNbt#*5!wGPQ9pGcBDS zA;dbziU5}PV!VHA>7qv%d7rU(v3 z9K6KY56}8em5q4kN*8f4v}C&j*EecDy#Y8jsFy7;;GFd#DBM!E!Nw}h zEY$c3Of=m%1hSTgE#*K_FP{Xl4DiO@elI_Q;nBl~W^KNPur(1A{C658o&#*D5*OpY z?fE%mTYlsj@1}W$dLRjE2&QTT@^D((Wm){8qsdQ@k5%v!){`*MQ`txuw2CC-$JbmrUqte$3n1E_+A@DQw$G$=t<<_e`)R0B+BDeYJnep59 zO$Z2HR-xd6no_q(#=*-iw2&=z#)ExY$D=2y31}b zN^84mlSOJzAz2e8=~5k+CH@5y{s~-Jy51}vZd0bH5CeBi%BaL41JlhsW~ivN(n~D; zC1PAaJ1nmQJETn$C!hR;?&oo#L1 zgDYDMI@fBcn_6-3v!`$cwZwYs_*3NMc}Z*t9lp&ocv#CP{C(*;491M{NS z=dungNE?d$S`B+z=kYA?4#Zf;L7F$H$?}%E)ZY^9H7sQV`%L3DB`^NBR=nP}u6r~< zM#+z!nt+;`$VsE*&SdU7`CRuL&dq9RtZ+{QXh(6%VwO1vt2|>W0(7RPAM06&jPqgn zVhxXC9|e}t)$hFJpKfz61zW;8vTJ*HIN;cwzSD0GyqZN3quG*qKSh?kpgP1gIfI1Iku^|rtEXc-AO=B( z4#x@M$i0Ex--qwTK=ocru_lhrm4egDy26yzMR{L-Rw^ZwA>AmK&+F=rN}e;)KNo40 z*15l?c$=pbj>MlW3E_Mr`%E8YAI7*qf&qk(FnMtTT55Et)Af5pYNoJlf&w)qfnWRY z#6ErmWyY-_a|o7nSpa5gJC9?X7wX=-x^|LG3r&W`QE{9|NDI`!K_{-{e)aUTOvQTn zaWH(1l?itzp(4qv_&IeSg!3RlneO$=$?yUiXh>`HSeV)It(D6`v?RDWL%b-k_(U z%SP2}S!FTJq^ohF|9w_}kNyE^tnH3@icaOLvFK%BFt#^A57MN-_9VchJPCG=8{D^| zz;+cUR-G!Ex@X=etk9ll>ey@=agQstc7rh2=*H&bV>oW-$5}7{WB#mQj3vdC{~C$n z1lsN-kB|2EU0$JxrSFG8_&{}UZi~ASdOIhTq+#c2HVosc6-;)2I>hl22|jkmeYRA) z`dajJ8hOYd*c87OaWWnI??#~`kj1rPV@h*xYQY6V)JIR54!i3rcVCh&E;t8;_SzHI zurq-Z0ZLq4fi0Us)uhxAj!w=7w2J(|X&Dol{uc~+$=#7hjNo-zMip+U%@W3*m?g}` z6t7faaxM zaPP6*Z{2^_d5oG)lMb^~5kqfN6Gi{P!y%s$r=(N(URxtsStl$^%{S6gmgHqrd5tZ0 zp8ZZp4(?xhS=akMYPiF`0hfCzYe_-ULLnvvbcY zEv4DCN$l6&YEv3qU0r2Kc)^Evr|gGtf%O#X@~0b^YCW15Og{m@z5~a#0<&IJK?oaQ z3R^i}xwft~s?TuW=mjabD%;6D z6Hr%T)z3GhOyC?$FPPS6O&3r$_-mccE>B|Jjix}sEE5uEsialNPE--EaUhq6HF#US zV;qUUCdPwHlruk5-l)xGDhe@7-)axI_Off8`(aj&*Cw-og;qp`j+qe`qR(xB1W(u? zp9gE9N#tjG82&ZF=3&4yfF%+TCUKxp!qeA@(z9@fMlyN$?K(!^>zL7*SloEu5S3Os4)PFcs-7m=55n?UzF0ZL~+uQ&cW%{#g7ASf^(E+iaz{G@RHnrO!bXsA{IlEgFJS z`x!HOXVRPSYt7G0@p{U8ypio<%_0VLVjR~LN-cyEXgP77Mn}9+2oI%=kZ(zr@+2gt z^=JUI*Jj8pwoFzYWIBtHfRs&#OL_NsRSzus(`)y@?fYho!8wo*ZPenqd$u)X|8;`v zWXcv&s#8~%O+@sx<+$T6c?=h=o}7^Iu={pG`35`0>cZtMkY@GEe~@GASAIXJ-aT$R ze{eYJ3TBST$yDS^uU!ksri*#dh||I+Fv(X>#mg^HVjF;evK^N;R4)ieMxleWGia^oexTno-lIU}XSNACa1oCkJ8ZSrOmuJ5Kn)nPfnf>{f>Mh44qndgDcnM0? zPl|OUO6D6Ke0{@Y-0!=XxAvzrkBw_>Jx@1!>P=+^-v)Uz&4kabN}v2q)Epfd(I4V8 z<^8$2ao&TCyBMcM6hiX~aA-J_bsmShN56x19MxCRKBU(_geG!mB1cvOQ>MLfTHy;K ztFD{b3!esU{QQg6I6t!_v!ai;^o4+LVTzwvwTJLiH;ZV*26?0i&^US#q2d_&Nf=P6H87+pGsN zbQE&heAPG`JYZ7>rhW<_vDBL!FvC_ND$gX7^*#>Pa3Y%P0_YZiXBOn&%cxBbCb>Fi zvEc^ZD1B)TvdDdxCiC_cP;5GI~~?^o%cNo;xj`-9LgR~03ug87F2PAD>VUmU^ z&tC${!{+kJ7LVIO`(8t3GSm?P#NY`<#?OvwBG=8YB4-j+De(>uHu@h+e~j~e1R(Pu zlei02P$XQQCI5h!W9Tdu#MD9ANAQM)v~H{o%g)s1};L9q6H!Ls51fO&+aW@aTQXe1@6U4 zWCg>jAxib67-eKZu-1<8>2{BB6McVJtCUM4^IYOKJ$$ju$=KPy1q4tga{?yRp*e?@ zN4Vag3qbBm__%LPrs+SFKNVg8k|x#IJ(la|-qCW%w?@0ZNu&usaprD`0h~G2X>Wxa z9LW(FfDDE!6#0pkqJg;R;;0qkx}%lMmZIhjMG_sun&5iF!zs}UYzY3(1&&pGxkW^} zf$$%ZB0G%Wd!ZX@hdk?|D54Mon9tn``@i*tG0_mn{Wb}5N!d)WEXo7Nw3K|jXqgcR zMP~yj&=h7w-!zvyLLVlSY2^TlNj!OVk-bZE(B3?UkPakRu-8{wjz=rV9l>}cMq_)L zZ%q~0J@cTC68hY_RL$WLU~i}jjHkfe)e+*?vUK2xS=FoWEn~HVPph2oOvRNr+jqgM z__qJs*OBt>{1n5~vc*on&8L}B+C%-z`d=3DL!>)U3;CZ2ghcuCOqapS5qT0J{ewYa zCuXj#r1k$uI_sz?zpsnm85%@DK#*<;K|w-77*J^`3F%NoxK zq@)``x;tm)eZIf<-&t#Bt+`L!bI;lPvm04-Meje;9z=g963eZ*IB+LTk;G=}K?55Z zY3zF)6r~|e{3#TP?Vhr*RPg_$ccn`T$}S+yid!@6ACk37@v_jF@EQo?P!-Po()~3H z)0v|lZlxOUh+k9~{JM1VqImfQ(t~bd4~afBH6PqZR*B6)_h&phZ(nzLiM77BYMoe8 z?s`KFf6+q&-mu~O$GvgeEOpqtdF^kPvvPGl19QzGRjk{j0F;<~Q;_%_QqEj;eZiZc zH5H$R-cn|^K-e5ltU>lQ8E?D59WK$=di6?V;2{ z{{G2DC+DAW^@DSgWjy!~cM?{j{{K}dzk`-;#OX^A3dO%_Y#{WrpVDApk3c;*PWa=U zbbj%v$meWePW$KPuJ1UV;&{{VhD?7or5068xgfzv*TK#^r{d#-b1Kuj`@K86RVjb# zea#-DE-rL>#^VLV9h;B(F+RoD=aCm($P1{223-mumWUqPFE1nEdd62eE;kcqme}I^ z?nW^!c(;Qj8?N{p$E8No{SK->dX_hg=6d22&ELj?b!Z_(k^9g_Rx5rF=%11T7U zb1o$!835|uD;W5%y1enx#YD$?C{FD@R#f%rpj@f*-C$k?5e58xNuu?%a}x&Y4==fo zZ3N+Ub9Z>#TK@Y#7mgKgs<_hSd{%P_KjB3!X+QZ!K6hR=uxYEXt$yk(8EDcXB4My< zZ~FG!@li)|{J^r?IW*rCB7nyMXhZ~xu8gO-QIO)$@cT~Ai<>Y7Ggt*>-AhOh| zx%KSqv26N$y?g!vt$Vv;`4w~ZvVwLG-3D2t6*K031JpbFF|%JD0sRMH`8)WKQvTk^K2Ow&5H_XTN#bBjiHtq(`{yi}~{@@svGU!|a(t4pM8$N+vO3~grILS&ZcP;Q_&Ws|9UGQO&y5&nR8KmoA1S-W6X_gJ*zSuN>uz!FX&Y{B z^^iU;yZt3`L{g(JQsGo>cq({I(0lwB`c^~~+wVgCtXSgi=GTx`^}S*h1N>T0he#+t z+&~rO;9Si4X(^J=gsS2LRa+RxsK9%>*^}dPJ*>k<@#7=@L3Cll`tyq_LPf^Bvo|hf zDz9Ft+7Tx1%(amxo0p|sKzbnd2!7~A%!r2vQXab3WD35NL5Z6aA-);G?$Oo)s`V7l z(>b5{Gf$7?4?8>Daj9LK_5F8K`4wwH-^I8`Y6B`gg?QzQT865bkgn=pJ|#C4b(4t5ItW?Qm(@+0*E58~P0EV@@K- zQg4jbe-!n1?K71xl1rcR!ta?o)H{_am7H4c9K8<*OzChGB;ZBC!1%&bqXh^)W0j^q zsyI5WQy&~AwK_^7>iM#uUuASgifEYB5t|N zsP<<)Q+m||SC>KC^RM`ZZj{l}5AN5@F%u*7uYvY}$Aw|_5ePEV!uyeG#r?G|-?K>Y{^ijQjLEiAh5gEx{pHu#txKE)^Sng2L4j znW!+WVTH`j64wq`0EXeO#b;Mw4iH-%*X(hI7SrhYpL|Vf#ZqfqC z<9f?ct$T*X>C^*2Q-}Ub@z$FB)(Vqefz7g;yysZ4;&hM1?ajG8ZLyPgZbdnLN9Kjy zPrsr4N#A3)i7~X26}y*2r}N~XDMppM(JzHsPbdYc@s8`$TBeDIZOa9hPOQbC+ma@Q zb_zb=Ph`j|ifGV|O4b&bnTTA!`&`X>h>=PR4{}5R9&X3xPxH8no05!MtEEUgwo(@2 z82PU&L{T@V#b^)>IB`~6&l1k}n(9;fNnad{m>vm6tbeeHZyHvo&hIl%ZpgT)_xiox z-1DDx?nTZ&>g0>t2|I?0ILL4P(o4`(_VbD)JeyQsJc_xONb6>+ ztIGDNi-YJ!C^0;crtlTPveA8Ub{!ZoB*OO`w? zuA7TxO|Ia}3&@J3(igc1qmD|GeoOP{dUWG64Ge_>x!g~N&HPMW9lsJi#h9`WEvm=L zeqYPYGUj6RLpyO}>V;D5VcPGdRJ1ytT6+$)UFgdfIfnmsmYW^V6Jppc=;W_vC9o6q zBQMGhd6uM@#Ndzu8?-OLd=q6E-#}eEwsokSY`VUj)v%o~<(I1i6sQ0^g61Y=?1LE3 zf<0gMU#ks4?vb(iA=k!DQHX=17?-zH>1yu5T)Li=UR-X7qNgx6Zl=~ddjIGa&21S>m;!60x#mN9u6^$=e%+sx>rU^k<1+IE{o{=RH2e$K(5kX5WIb#ikr3T-P5l^moP^=%*6p zem5x~!s>vSe;Gow?pN;!kIHS|!>5+A4f(B9l#c-DG_m$K@e@UFJxTPvC?g3%Q9l5! zO!Zfq{dleYyJmrugB+pMDq!IrA$ZKyilU^9pn_l1WtX%-0Jia8GBlhYD77+(Vek*o zBu=*s0E_E*io}%5lnSUE)hCYuGk*qv+XpNxD>!xE)}JJ^7nQm2O7FxqFr&GuhfrvC~#SF;QdOl_sZmcuVgEJ|*P`ltn~A zH#L9(DvdH?6Jc)aNG?5XaiWC-+cGYfb0HIDw_q9r8kxYEtb-twd3c|3AFbhgalfSN#ZSmAvE8n^~NjMFE;YZM% z%6;LE6V~b2_gW>0DpI7tVD4*dhUVp~sU^MynWNc>J5yp>{2H*flUoOpY4kYliQ8_Od zF#D2qfv|3l?!78LsXKp%e^kb73(odeYhkTo?^3_SHh{LCfEy5yqkq3CJ2m#xIvvr3 z>_-Yi9!K62|J{*>kDH+($#a7I7eMGKao53*2eiGV#i4xS>)6f?1wGM%CMjb02gKRy3&cNZ0Hr(|=bb~XHf0(_dze*>>*%Sb-42rbM_krTsR^xNHNL~C zxd(Foafi$o0vx@NP6_gXDiAx)cy78k;*=z@z2>sM%khZ<>N2Y9KYsi`;s#EuXoXjP z`&7Tcdp%#`S_PY8Ba3kq)=<>Z+`h)f^&N_ng`Ucnu>%GS{LfHE~e884T22g6@D8_96!p_J%!ejnP4c>++=X_Orrk zGCkkCVH-Ee@D_7={Y`XPkS`y-2%Xbt#qP@F>RQ6sBlw=sbCQN}e(4E%BqJZkQS>sd zoklw_=0ku*&P$xGFAg71&+T5ut5cjpAoj1T|247|w|I4)wnNY1mCcjgW~Ep)-~s{O z_V>g8{$&rcaC*Dro_=Za?fKviiPb;sE^ixEv%KGJ+}ilQ==>%g6Z355Key!6QE5S6 zE?kA9-?Ts@@`3w}Dz<#C$3A?e%~hf5ma^XstMo!4FgNd80U(=!3z2U4i#2P$xB-lWdZ(JwBhD7FHfFfs zf5#N-U3a+twB{F0?g!I=y)bxdnXu5;RNJS0rve7>I-r2oJidGXT4f z`BqJ9kV?$r1l=YzC$G9x%!|Y-8DR|p>#nFXftqG)jRDcdV>a%GzQ@0m>@79%*>fhV z1INVe3zn}JVf2R3AFn#ruxW}5Z)r<)KJ{|*eO*ixnsr$j(O6z+@$@nIg<>eoOh z+)1CU-;_!T4}4F{dJU{*SNjZo>YG=4F-^XPrlKgDu3!cS$G_#spFHe;RONHg(B2|z z|2<|9*==>qcz9L!P1~Hc=XHws{0V>1y9VT?jMdjSVf3Mg3~^bnXt$~@hO#PkI%x%y zD>E&Bd}@8VNVMAJnW3Di#9x1d=4B{s$nLu*{`13@!um5RU2@bEKl}-?HZA6Ue{?>T zC(mHtD*rDU2sfCQpI!t3DIpM}pO5wn2rD>|f|)NtMAlY1HC{)XnLQP3q@p84DQ%m^ zARQ!>ldtYpVe2+F&Eqv}xWDT2ySbyy@?!r>0j1F3`g%jRZ0M8#Q1(`Wy$+kd0nScJ z#HOb!s|XeI)HOU1!_ECSs3FF1`g`O^Y-GnN`Y`@K2`FpD$(-;3L4nLT68T3e!2JCuO(Zu_U(8LPOB`4y|a&w zA=xe1Nle46I_>7-kYnz66wqCF}tKyMhYfJr^8Qzo7V=4y5gTrmD^d)%HRL? zdNPZwGVe`6A-i01uv;`V^;81}G@2@m4Vb}LN#oVT;lOKqTQY|UUytIyI-qW)(*U%7j=mZx z8Pt=QAq3K_n0iApgk{(9-E7dvav7YevJyh4_W3@$$x~`SyZ~&5n+K)dO;E(eV$>U< z$S9$kbL{Z`c>AVammKf(v&)>7_8_cm@IahuEN>44;^Aq0klvWl^q@dIfEg%+Ju-Q5 zw;$pB=2ifc@v3kZ@Z!d4yuzIdb3MdzQ&TYZk zt!~gd?{BeL>O4yZ=<@-pcvt=R=-h+n%nVs^9M{3z=?S#OIJaywn=+acfM4oxz#gm9 zc~>p`;i4O$%z?ha;|(n1|2<-~3agI32M4^3UlLxyEP6Mg&9Z3TIS+`*97lv+2Vj82 z;w2{y1E-z1a{J`G9^nAuc&-sB} z{g1ph9>di>?~eb-oX+^Gt_zqQD;|&B^}j%ay&mZ~eBg7!REO!8uxDkEYhFde7Cw$& z@q??io!t*5%}w5ca4>V&fx|}`pid;g!Vd1dbBf$xbYL`SDY)5(0^Yhge`ehGqHx{z z;>%P6MhY}+hF_xEPXx@Xkf+p1pGS?1Ys?R~s88`)a}(zQAUcsLw10V+YXEDl)O<(` zAkY`4%}+BRSPKBJMFnaCUYc*GGp9`@6k>^TS~~9OY~=k}7u8xnH#K2JACbmu>8}1M zW3F-uCS`xd(^(j%GU!3iaSnotfHbayNyisQUS9W+8y@HQ`ZOdc^n0A`qr#$ zTdND$oeVyycKqhCDoSfSH&JkGINkWZw!35W;C6^ZLZrG3-W)?X6h&=v{GReLNnjp& z!!BR`$+`PurYsc2(#pNGx<99_daVJkG$HGI|3y3UhLr+*MQtbqju0jE^_Zp zCNV(iGz0M6gi%LK={6#2BLu*1C=-Ad-vMC+af10>7!e4E%6CeHhz1-^Nf!J|7$}>a zBm-2#&1IGg8ZdaIwe+vp^mRL&GD|@Mgz3O2_tX-4b+W(Bo~f%jPexG2zI;zB36!0i z@d1~Lh@=l6P>!`Vt$Y?@WA7KWg5xFx}3lLu~-)`WCB-+PM;=GP~C@0QQIK|V#tvVyeP8pp_;t&?qIzTBSD7RTwE zhV`q-X%DAs9CPD(qCB=`GRvCR<27ZjkQ?KxZ!rf+bj>$-_39A@^Jk>Mv*^TQw=$al z4Vxg{Yv^s_)1Z2p2`Y9cyS7{c&i?-V^D~SWv9c(a#KwSoLZ<92=l1EpTW231Q_RXs z6KT~TU@R9(8TyH5l|rw;v>T?EZ%PMi7X>DDxY9(^4c5Y_h_4Qs_b=!7>FHNP<#8@g zC}Ue8prfnI9#k#4-C)Q6qDeyPr)W83VPgIB58*}=2XRcSG;ufqSm2dkmIUBqvD0)U zJ^=MU!o5I77;pW1NMjD-mWumye|Cd{C(LZhaFj3hPS@e9zwJOv>dj!_cbyqDE2W5B z96*lN+^vK{>F3u;9+r0pl)DH*<#v5qLE7I zIesl${C)Oh=V5{GYi}F9$m*WdQLQJRtk}B_$;%IP*bP?4VR58hpvOJ1M!zs-Rnp9{ zz~r~P$qUY(GQg#YRyl@8Nb4KTMRm0>pTF4P42L86=GEW-@GX9eSC5rXYYR+-eig>` ze9=3=aN50D{v^Q>^QS5N;M)YM?u>H@D+5MWW2g9e= z)AYSU-IJ6poW|Akm{@dph7*8}U-*f)o)DYD2!QALwAn$M^A0|sYy>FA=cZCuWYC&* zGz-AM89vYz+6wllg@F|BB1OO4B#o++=gaxLcpw8`bPdi^#o61%sz5q!Zr>|&YVTa+ z=b5eg(csDQ54qD~=X=uD=5hQDZykD0aA#?e8>*IMdesCod^KR%M$YyNucKN7}0Ke}iopno1iLP1~DYCb0VAjKz$Z!Gc)%B{=~%?@#Qhk2w*jMp-IDGqVbW z;BH#guc?Ld}WW%u5wvbsDrZeYu*@j{*xf*S8$I*#w7*KzhQ-|Bxl1O`OD4~fdF^XLy`@yv?IOA&0UZ<6$Oo&Ot`4|_n$f2X< zD#Xtd;6a?NZU0u4gHh>Dh-KYFm!{O)>R;%?S5AYIt{dsv3$56NehB&Ao#nKdp6Khtr;0`ZrZJ%DZCA16RRMl+%tWz`n3K*DhmXpvT`K_Km6twFUA4r z(S#uacmHS{la6XtDpjZ|YxWgH=dYI4om>>cvxD&^p&l z1Sw{NpsijSkpOc1pL`)&UDQl+a`BL#_;s1)9mJ)yfD_a&gf+IR+dEgS37;#PXV7aT)FrggY}NLa{XC&}IJn zn=llc(>yhfh}aZ!Fb_vup2~=Mc~d|YI#imNW2cT;G3|1NTB%w_Q1E=Illt?~_WGmrY9yj%!mi;c7oKPEZFc*W=Hj==q9Mp z)_N+%mRc|wfNJmW@-N%%TMO?Z7nG+SCZEMWvB*iHki|1oJU!Jf6KujB2hzO!wa4;0 zWJw5kAN^ptTpR@zu;ISc0{>2I%}s^pInD^uv%+xXE=8?S_(bXCR~>JMpSz>j_aDfT zshpDxaE_mSx44O&=@pjp&{0YEEdRX967k~AO}b3k=r__e;4i|7A4aDtt?*m65l?!x zvUfBnn%2xaS3O$8%DVVp@s7V5M4v=6ixw0H%Lu`rE8;au&{H0e8Vt5Bcq=U@i@lb7 z+Yuo+i6dUOlN`8uB<~ZCTyn&{s#$U_tE=^$|DJngdew!C zo<4l+YOYb!|9DrhYs!BjfP|XDO2VS=m6^0eGd*2qKVwmv33g?^EJgC&0j?#__vaU= zUwbR?Q^fO_9~_`${-{XI-h90YMuy+(0GCZtB5BO4IxtT|qUf64rR?m_O7`RypI#O=BLyi%pqZF)PL z?+eSz0VN*wn}N^0x5aKV-altpIrf;2bfsG2uNiTSwr+Uf>Q6sApI`3+iN}RaV1q)c z&q#xATqCx88AsJU{{+>|^%}2r1mkR0H_9iC2Gi&p|J&0gkLZ4K5Ucz=XN$Q39Bxs; z5H}+^7B@-AH`ZAAG(1~GVIar>fZw^rK8A1a5(4GBNomP2#BJGQ4*ZJ0yF$PF2Ve;Q zgR^kw7HamY9f}q_Fenz3U`7J)AAqwl3V$Kh*akWtJwsd^Z?it~Z5%*Vya}e#l=6>8 z@ThZ8-lkw??Ed97$Kaea&aemN`#5<5789#r~KQia0k6bRN&9 zI#LHPeI*(RvSfT98vaw!f+dK7`CGGJrKTLo>6A}+V3;B-J>GH+olC3`!4V8;Q&Zrm zM_s%Mtt7TkBbm2LUTnjlZJ8e2BZwFVG3svxN_C;;82+y#HlW+df=#Drh5?p`ZZJT} zKHLT6;~?uCe_{7!;79ez-c+qVEK<=h80ti2YML&hkXENI!c7*r$ z82LsX_HObB$@yC-lFz{$-76|D(Fxf?A0i(t6EOVT=~9s5Z$$eX?cQmWJI;jSdl*Ay zs%`WKr&DmJNL<(D{ApUefWgRhHid$u+ud))jgI4zZnXBU;eA3FfGd`=7i!ZMVklSt zFXM6{=tpE!KS{ZC^elwpfkg+g0`bd2U2T8#s9&JUD69F>HL_wSf}nqoSpm)Ut55~+ zoUg|KMRva&+lvgC;%T{*+n);9+y$si<2fSSz8X;G{tWhx8qL0A$fEI3cQo)2Rv2by zpJP(Q-oSi=vDdDEDpd^Qs{|dekSJ8Hjj{@(_|MAj$sY5#hajucw%IgLg$ z2u98LaAf|OTEZQ#WBVP?iy~Y=SQhO8nN(Aqp z%83Xp3~uhqDZkk~KD}#_lbNpGsO(EC2W7nEECfOZ|uuMW2O=+x#TGF@!{vX<_TrXl4Gn*W9| z?J;mvN7oau5KI%u1qLtPrX_I^g76+Dz*T7r*c1+sa@dn}6kiwRLMgDI5rTI#})f!87gfXD{*h!!a4iU8EJ+4-xBahR5f*z>3M89Xdcdahf1 zFRFR7b8X;I_y-jA`9sqr2>*nKvf3z#Q##$6+bt{%0Ao2or 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�h%h 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%0D_2->IL<9`lt&3j#tyv+PfE0z@O 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

3~!jPyYbAGOreG}Ao z_cjNHX>Mx;vWYi7<45_eO+?5lVm|h<4*$)&b1%qv;Tl~y(b&s>_e(yLH)^VFbRK`_ zoQXc^4_x=_&vgZexb2!RFusm*e;IMT)HfYs=pHPxP5P(mOAM0H%})vL@t-q%d69FA z=)%?IBxZorP$B?$bWMOvEiE{=#4)e`D`lP)AJFr|XVso$t<*o1sGGv(>?541|BKvr1lC0Gybvc;b6cDF%%h zJFjCq>)nYg=8e+SgZFz(>!mlOikM>aKU)%JAIP+%wQw-Q5P#_A5X$@2u#s1hLgj`1>=3mfKv8^*Wv1g*bwF2t@ER zMVT6tNdsWf!|w;>mXiRw2~1-^!+vQ52!Ai?I*11}ib^O~OQ*Yf9$TafOhl%W5ugDz z^uIm@mS#P_Ue#pY5|$1&$oqSYexi$a-tA+6=b?Q7&}5?^6HJCWSny&81{F{$D%RYq zN7m;F13IR+M*y6zVf-ta?{x!?dCzCOVGIpV0U!pGVA6%@%c4sgHq2au-aUnAU8Lzs zvHjsPqwj~A%Jw3pl2tX%O? z31erk2mRd==ewV4AdL?tqesJS;(mB;qzF5y7X!Rsh1$q`m>$M$I62^w1~jmd{Zg{`fNr{@=Lnf|vp zs$QBHoc~!}Re;w@O+5AH5P`RqR~;FJ7ZGYgF}M7NcrDZC?Bl(b-M4%9KNVS!el1N1 z!l^z_34k#XkXg?egEce+2s9SnDe0J`HwmnJq}-vJ5YT-3GzgCbxyF7}cq}W_V>d?| zN&BGn=e7I*!iQkR&sWT>=dOo8dX;~Zms&*O-(7k&;aCDA`jY%$8MI^= zo}ePr!wFf(BshB6uZ4MTu`S&uoc=DK_WXqNvYrBrqB2zR7u02RbbEOmx zu$Z%>0mV>yg67RRfJ=xOh~OLw3RZyqWHn1kuBjXR71*V03p(MQ5jkI zFQR*$1)mLf>Ip14szOy3SbdhfbFKrV2EK=E6SfRu%&_yyN~9^kEvXgl{JKeUWtJ&5JFbHg z4d>Tma=!d|mjg#*v#+ZV9SSn=%PD;-M|(%8rlL0vVsqrcq$UV(3WGG;5(3Z3a!dFC z!|xzBBH0w6WVaz7*qjcr&d`kVX1$<8F%&Wx7wU-~S7qNg0+Y^IJhgS=X{g-Krut^E z@yI$rxpED+1`3 ztG2_A3H& z5g}Ab~wFiw6tvIUtCP^1Q<6@OTG2R=B6s>?e&a3C!PKQ?bQQ~ zzn1vyOw^elRlxj>by8(z6hZd$IUy&8+1Gy_tL(Z6?MJTwxDT|)pwo3yfP05xaBtf! zXS|2_Itf2^>W+e*fa?rjoLp!{()xu}JoF_TK?N@7JT2M|?y-7! z^~bck)o4CMVd|&IvE*1zuDh3O;<~nh8b4(1B+3_dOKb}OL?m_?xmV69w!mp9axGmY zXic*Gw3rW)TV_+L0m>aBIZ;r5hm8RN#wK+CxdG#1ML@I74=plGgmKh+Sh7OfAyIC# zKH4$D>XcQj94OfUMDFQA9{))OnOS#$XCdYTPRQp<1*q!AqIdmvL*o$(`dF(6nmdcS z@`TymichOy^P1@iKOuxhaC+oN{4GmS+LjF~$MF#y+Sy#V>14I6en2+|^kt84)YpE3JJ zc2MM;v^f$)Y}*^OKo@dun)4Vzy1jvj0HLaE7c6`hP z3}vUcPB^{93b71y)c)RNw3@l@ImWE}zFsXB9$92@aW!croD4}X@*{J#e5TpzYBdkj z3QYbue()s@7UwzT0f5!i(Xp||BI@#ml@j7rSi-mN}Vr8zEraLGM6Xj7O zmX)lekWi}aPW~`^Vz$#i5C^yg34Q#xS&zTX&;JBG5mC=$r6RnOzdAtN+5W;Tn2HPx zQ^B+%zDKhUX89Ga=n$NknvD7&s1irE$G!W=)!BYH(kJ0S4UxJS`+73!tKziv!id8H zYGl3OtsqXB;b$^@cu(_l5KNbp6s)ye1Bzr@B5|wfhS3vaBIeV(ZP4K$eCQ|f%?4Va zL=$YT9y&G*g)_zQ)WT$=^ly+iKRO4 z?jAd^NN%G(J-&eJ1&)9EFP}{(T0bOx4yF$7=M0{+6SBn{iu8?Ppl5;qmzS;(eQ_Tv zB3NLnX!#lR0!MsL4sq_{CPqobKT?Onxz`9fiS3XV$onu6J|(@tQ!6?G$1%20jSVjy z{mQ&HhE^K+7Q2t$eQU{^nKA8j$bkp~=w;Q;rs;67ipg`2=x1i#ebtIGX2nEpo%{J13b#a<^@IDcO{4AZCrtOXtBfh92D<9klTP>F?P>JJm!&Q~A4(?gv;c>-Htr zD35q8d{mD|#CtXj|1FL``m8Ihj$l5@AB2BSII6|FS62*7s3I*g`+m+3%pWW^)^Fi^ zw1l8A2o(I9AuqC-gIaGvzX;&)2~(jmVIY^(<=4v7Og@Woqd&mmG6#r_q}*NIetd~txSZKz^?HEg4rXo%L7v@)D>&bCD(JG=2fe-&&(%OUn zXpY^iBs(rw*7cE2=lEbGNtz6BVj^yw3qbJIbJ9c(PlDicOp$H*uIt}9&0RRThit%` z3>9(YWs)~|4BE0J**F^BH@Z_2Vs~*QlYoCgTlmKv3YTeIGH{Y0wvD(&C2djm!9|j5 zg=Oe@8~~GBvK!#piUDLcKF?=;`ne!PLJtjAik9mln#o6G?BoHclFD#_MYOGe?D|g? zv%uQPCjqf-J`0) zt5xLZI}p50{TO+3z3N8+BII}v{_~|jd{AxD8On)9!y6Z?pQ6{cz6cdQcKT{#mK7w* zusWX{CLG`S!P!f`5LL+^mDJQ*nEY90_dDK?{LR~%-!7ULa?`sSF#fc4eHLZnt{q3C z|6PSk^0ZMzNP2BZE$J~SD&YS8K%ImGY(I8Ej{nJD4)j%`)?csa0$d+`s8L z(Fn>Q}o9MTS9a4)MeE2ozQEM&gq@K#)pIDJ!PmiQ5S}%$Oj& z?KtaFyh9gX5fEQ@63)I}lg=6r_4pgvjx6qf49j;4Fr|L>tJz1W+1FjL9TbK;`0W8uR3VCs2d@YRmtPh zvWcNrbsQvljl}Btl-(XdBt?5SYvc-cn-Ls8pFJ*RJ!B0RY4Du=CIn9n`AryAUj@GvR0hukJ>zQq}UUx)c!m(BN?$4_C zF}KaGyD~ikS?}5kkAhynr_71~ft9>WH+Yl}K{ER$gTe_xGJrZZ)Ps2+{H9WJ%X`h;SV7-+^D!YxO+MeXd^qDwz*GSxo2c z<{gb&{EzQd7H~-?@BdCq8jX-$to|4dL6^${&2=b;+A$5Nmsn4@kJxTlmitBVa`I&4 zkqK0mQIz>xa537gou=HW|0~RQ5Z&YhOrF)r0Qy1@diOVzg1{3-z#1IE3*cA)E@nw| z&N={@HF!}BD1OZIOy52u6M>iBOLFX z0e^mZi*5(_{@&gKV69xf{Si?Z9uin=k z#m%jGm%!fED7p74bpG@gUXPoPvoImA`k&giU>A=-KNr= zC`W#Bl(C^)w+*U9f>J7PY={A*Yv*CD?<RYnw#!XM79ZG^G^aS9 zjUiMtkfnnUVI{~xQaZo$UU{Iz(BL=6M#=2LZbv6$nQ1~J3jAWpds_D#WFB5p2mWl> zfdMB{6pyImfWU1;o6zA3d_bc(W+?#E10jSKQfxqLXsD8_I7jk9yW#`={2PQ&yM-)>irw(zVXSTDW zW>op{jj_}0SakLOsv-1^PTed!+F^ow!}YQaSp|1U4q7Tp(?(?au#^xk!#JWpN4mIu;T zv7ca(4>|ylnYfDp)A&YMC#AZO;~?Q=kdv_Lv&UMf8k~8QJw*_}RKkOY&RPk|!8wQV zYn@=JI#L6zAR>=dC3P?vsdc#3V8zxzEn~vlrB}1RPU0URL#M%GH&ZzUP~Vk{dvtkh zxcs5j8e1x!10QPF8@jpX7yc1nvRvb{FAKKabzD86Nd0h6rN7T!*Undf2E5phnQ9>d zl3XhJvo4h};~%=aCs!P?q8jg&Qj#t0YxNDuJh8IUK;wDX^?*g9%()=pkfLCej0Iw%mJo&;d2bENCO z0Utg<0?5uf18Zevij#P+3jSY~XEHV|nLRo}#&6Xg@kJES*2fXYxO**c_w_a=Hz!4x zBB$IlVzM@bj!BV=Yzy~;9}xR8Wri;MMN9u2>6sOzn>1)kgef1j&hr zz;3rNmnK$gZ3Rh}1A@YCL4pj}b=w2H0SY61^1-XIA07ywQnTcwRSMgniZ5q=Kle1B zec#PT{xB-TN6uVpAvL-RaQbmw=(jjRR47b+=x6VkX=jpPQ>rzd7ZaCGOm`!YvPPY# zzb=$7uGL2HdA&<|W9>@_>VJCTveU zk^l^f3_>BAy0AzYYoHw8$W918mEjENRY2WJTxkQ>`@O}>!5)`6R+qf3TXqh0bliV8 zAJ*=vBA`HyNNDG#Mwn5=C?Lb17YJolpqy zyxt-mf3YmXHVZoen`jM>Y^%2}lyVGKb)yLA&d|R%P+TsbX(=N?>%+u!>&ra#f&i2@ zXc}Z`?qJv-5+KceswfMAL?|g5vc08mR{RAxRc7h6>%BOvPgzf6`j+M?s}Y|So(8?9 zbKcLX%GyRq=4c?Ekt!yL01+UBbgGvuGw^j3v}3+*;HCdMeOJxAWhsQnPCaCV@4I~2 z)Zxs%%Ewc5+3K{GKOuVHh71-E$+Y*WO;Hu_Zecy_d7ojTqrs@z^}N_p^${{(J4`Uj z0Hj*|)*pS2`<{sRg?SqOPk(nSXulh^T)jJJEm~_5mmz&3lZicS(_eBcJx9fewiO#7 zRC+?5Aw0{NvHt5)k53P6bIzVJ;dY$uj!^U02$x4o7vc8UeT=;^b)(wL3%YNS6}&5d zknY?lZD!WGMKUKL=|H)#+A#snXC9g7UB~r4?b|+G&lQ#0I!;jBr&v!GDGUQ!lN4P= zwHCkpqGW+eozg0xrZQzdip$V$g#0s6zr9$TcfI>-td!`&4^kGUQ|0{MZstR$SDRED zje*D_g=iIkEgaxs`@)P4>oCBC{Y|v_4obq21&TJvbg1B)8h{4V;ub)!2yEe=a_9k( z$=#=?k09*N6!nalL-Qf=2C!4%{)(Ys|0g0zo8)fL5)a_hjQ(&PK&He2X9=@tSibpr zllT)#18aLpi>*<0^r-^C3b%S-@k(DEQrRI^<%%7;i(0MXdD5dRNkOKcr&7`iz^K zOnvL*p~zOiLTG^rF*bRB4QtiwcgIuZlx6jXZoF{4Tm0U2fw_v5FJ^08Ic@4PnGea( z-*0Q!ZoLXRF0h{sKH1KB*!q4y?$iFT`fwOMx0gB>4_FY2{y+ja7y!>P38s28%%XW&1fLqmr{^@z zj5lSJTgGx{h@z?!r#%4!d3TwOQf62i{nMRh&Z8T?(?Xq@Jo>4FS^v@of#>cm`b%fG z!uNjyg70QfSLzGrohxnyA~b9~cokFqb`I%i_HH>q@>oITn0g<`^K!EEdeyx~DKHx==p#(VfW0?OMGZv^{j9!tK>%X$ZOMe9=<)gc% zOTIPJe+X=QH%1fA7$%BJS*5<>8L2_+y+}_u66cu^`fZGi=N%G!EflzYvL`>@x=ymS zcw>5=MO<`uX9Qnxo1IPl#(%R$5%M_i8gz$zT5*Xw3R<{#0Dt*Yjim1N_2gkp7jC^v zpIuic7gveo*PrOA?&vJegPT^E?USlaB`v0ln8!#a&%}K*1yt?S?QB{4ZQwfJpAc@` zh`;I9N4K7AK)8YKPlGf4E62GceEly&EDmDSUp;VKs9`1}66@xO<7m*UF1kbxD2 zY54rMn7BzO2dKhCZMNi2_BX=Es)nsytzP9;$Z*pWV4biJ58ZbYg9WI|0mI6ebki5f zSJso`?qi=qZ@)z0iy$Lhu^S`t z000gs%DmQz=5wZEfeTJyWJPQ-K{B_V* z-(@r?%RQjB15J^M(Z7Aq-25;5suQgB0Y^1cKtA4H8>vZ#oUcKaK*Qpebh!r%67H(a zZXN1_)3q4imHr9{KA6=+o~(Xuv%l^l46|4`%NZ%4g?L%}X6VwcJVUHu*y zwtEE<0hA8DcWvb3;_5vvIl5pRr+j}{J{_uDkx4P2SkL^y5U!^|`4w8CbD7G?*&Uo8 z+^O}7{{AzHG-o9Hel=kAfi^7RnFK{@Rj1dVy?84RwT!r#-KB5S4@(EmE=C!0cbPU- z+Qok+B@DROcQcEQG;xQcGv7v?jXc!NH`)Jg0u<`HUqa}moE7Vao<~>hUcNV}+s6Q0 zXaFt&>`kCXp9}PMQLV55RHGUL)D`J?f!Dy*fJW+7zbRx*x~cBLI-*~%1DZ<5N5?sk z;vA=aIrX94RT816N(C?t+G5BYqmXH9VmZob3>fpESFf=4FT2C6v}X0*Vr+u@08~T3 zOTFBE0sm_PSo-QVi|JxK9X&4WR((G{>xX=PwG!hPO34h;@jN)Nzwm}CoM|xN(j>tB z;Mo|}_W5Mrmtjuf;pjGQeO7w?Z#5GRrh&VI>Rl&#omy2DI^~!_-lkV)=- z_}T`JE~k{SjZ)MbAa|fH5>J^59Wg2dSUE~3Faa{UfZJ3`B%XPImGT^~>@iy&KtH`w zA`Ai#18$oOel46JoTGfrl!N$Fu1GTU&DLBfdxEL!m*oX%hi{}Qfw|+^Q zut8LUmQG+(rQq9g5z>{u@c*%Y*HML%Fjw}vdF;GnIpNNS8%y_E4ddgV2L=nC2b0;3 zahF9!dYG5%eW;R&m-TqB>*Q0{qvI+>d@4Xm1Va*;ZuGeJKp4f2{*03(ZuWerZS`iO z3)Sr>D;yAsBNZ#rSuG-+IVa}$OMQ|s_@ad&@B?|oO zrwu6Y?3i)L&Ngc|(cE57ts~`GpQ0A$lF1|a^0hQ#TlI}1i33|L7uyvYmmQPeq+RGO zYo@XS*WF3h$~V_;nXh~ok^#5*DF(_WTF@1`mDXk0gPq%&WLs6XhA*e)_OIW7@yD`n z>>42yAG+_egML?!qsZ#8|>SY}${V5pl|088#d?5!UE=+RsH_v*AY zCuA1&?)5i{x`Wxa;4|buvAf%=WTk+RvjfS~Tj9VO4dbPdH7XZE{S*ySa=-l-(aehM zWKZy{Na6M_@}j^qD~e24=cT>65XHnCec75&*XSL;=%WW)skMjoIWVQ zMj>F=$<_G&{(jQ)yt`ARaD)KbUy-DYS`%IW3e6lyFM1s(1aGM$fF&0?$3RMSwg41& zr2z_fQe&F)wfy4&$Ol>AiZ=N*cx#b$=($bpIYmjp&1XOQ@e$eAFYm^A37FNkAe9Ck zsUHV|+dp5ufar55134R)>Fl89JOz-B&RvoLhT8{Ah=^|Lo)pAd-HvrF!N2E$&!7c4 zKSlR_q*7C9s1%8(g)Ft4_GW(#*|jpM%vfeZ?C7d!$90OTp!-|6_#rEeXg<-%Emy|a z3#ruqD#^i&EJ_ya7^ga3U;DOw&JC;NF&h{2Zb@1q?r!U*h5+fJ2#{5cknm6M2 zT-pv6j{N(SCC<{?B+t^Sn-(z;O+tsJ`hiWR#!+0Ba=-krln=rMls0V`*ny$V&G^{PyZT87*Mia@ABr9# zT0NcZ1@B4&NoEN;(T3AqJ!IR54=8GQqD(8WefYRjp~W85@6&!gCYEy&&~>FpLt}*2 z-t_J$?^a5-lpejveVJTV*ck(sEVk$0SLHymk(d{$Dhi_1VfYLPIN$?u)T!3!h#R#h zYE0Pl?^H3(wGDG$0P1BnfG5S9WkFzb_s`=gque$l+RZO+G?FwX!*jagaFJ?8lH!`V zSq~Lr@mtDIYE=E{NW4!YM6}ARtpwr%veVUSO#g7l=NvLmioCfhK!J+@pq*h>0U!)$ zfaoGwXPh-CJ0zDx+oo33mj`bL&k|=Exs(NwGRygw%Q+4XOvP@B#tc^Liv zFcESW77!GGO^9xl#cs8Z+@!2BbDmX?vM%H3x)qcZmDGDuv3ddO#RII`1E87thA+Bq zXCzNot{y`c7vGI#EzCYB2bg_r-zbJGI>c0lID@4`L}@@i{zR~E8fY3?8sFIg#gx`l z2@C`n(|l2dI~laEtqSZ-I7filPFVmOzYn5KzL^e2>b81U?%_vN5o~Pyq%Uo;86_LO zklv4UBSPfC)(u90%5a&Y+p3TeV21~|Hn#o5_J*Uqot#GA&#O}%;C}i05LW3~iljt! zK>12dmqb5BIk(!P23~9@F4Az&Gc8Dopf=#^Bd{ zBK!bssw{mrgDyATfa3@xA`-Ib`Z#42a?#e|9vog;u)it>$t~$a1QWCo#PSa}vk|oK z&}5rbCMilXDKenN@{WCIlj_Y?<>wGDm5G>+WKV!til0wGf7Orj;mj_ zLcOK~(F`F|$~oKFq{3e(kXS3+azh<NjHLkbcfW8Qqm?e1IHhOP=uc94Z9*6;n(KzDkGw~BJk`CI!PA_a+ z=3>JseD@|(&04n}+LaaA$>&I{x&3Hw)KmeP)87zXchwg&RtDqvNmqM}@L2{F?4sJa zNV^|c&44Z-vrpAv?k7iqL@?`UW;tHfs1c@J{ zs2@?$2GA@!GYdMrTXSnM@tkd=v4PlT4>kzGswQC8Gq9*Af8piGH>xy@=j|0YUAm|A zJ3@w03GJYWZun-h_eBH=o#e7e$z5DzGxcv=SA*`O;b( zh$gAz(Vwq@Pzd(r%9mbZA@Bs1F*H6C0->yq_c0M}3K*ZNoKwX!=U0l&{7W`J>ox)yeidI^e-=nOXDA7Z~}X81;1bx5L2dE0>}2mXa?8w~9Vz z49+KMO&5Bwf%KIL=a$^VJ)^*$`t-?09S5AIh}idVAF=Xa^T!Tj&PSjhP3g8AABt? z_a7IL+%%J!ubx$9T{1Tdm{rL}_NjkZFIh&ZnhuPBFa*|>6c0MtCg98rSU5d#KFukG*==H>~(|sC{p?H(syi-7mv10&J5As+94s(e2#wP&E4`F}w!M>k{*z`=U*=&1lROy*m7F2t zU9@;+MFGcvpYf79?q-Gy_f>@po~UJp`;;dnWtwrO-g*0Xo+==DBqg22{Bw*undk+8 zy9tVsG_--Zu_Qp5iTClkF-A^Nf_{?{8?CHn5@aU%*(-e6OcEe@W6+)esH6bSIt=wN zFgH7%!mDA(M@u;STd}S85UJ?H*v#5fK)#phvZmVi0i1=yV_y<306SNbJ-kbjK^X!z zsFT=?Nq0c(^b6xr-NB4HYGJ$m>N%i<8ZUvI$WI$>Wgv^tZZ{)K#48cmK3woeOV=E2 z!|{}qaCa1^Y!)g}hDMaG)V^uofCt=EX(L0lk~$Kw4ZZYlbN}cw?hl1GOW#)7NFSyH zk}e~g{)$epsmNzX8zcS{i0Uk~>LqM4o2jRl=1KD3GT1R1!p>H8*H8NZk1K$((bZ^ zg`>MK5pcQ=A_jc^PzTmoUQghG)aLsIYQ9caL{UoWAKVQz!Cx4n$Dw9to^~P^dYf@G zc>2uzz%bsYdvI6o1zVj};8hr8l#QF+&4&G+#$PB}isQ@Ffm7YEM$aq9T@b9Ft4*zP zBmaYR?NG|Ar}gy(LzTmw9kxyH*A&N40brXQQuSCUh(xhj{xlJzSXZ~!Ew70HQP(*r zyC@id|0&gRa{q@wj;)pI;TJqYXx`LKS7OXKl^HlXd22vu7qFrPm`{-51HikcezA4q zwT#FVxeRh;mbI`5KGLU34G{A|@YHM;iZmIO-#Vqy(p|-d9gvIIalP;T=|mlZBYxq< zvbi82$9N^Y5@h7-lW>|NC$7dodlJ`!mq5eY@-ysF)EiM*dOrK-k0dY*xbx53l!KNy zp7HJPjd=@@PZ=_$N^f71tZ+4HjF zFj+b*xABh}zD?`_7D?UEq*IMH6E%BvU(@D}U~s6*wJL_OA{71HPgX}I!tkk{9-yx~ zz`vVsP|s!dPV;aO&@L+u8wkcUEIZ#HDlm@ys-aJkNX%R-yCStj>#{SUwI8)F5FbY> z*F>So2^L!{5X&e}a)IIZty8W5TO*C3{=};CuR5;E!0E+u6;6{Cpb$%ToDwiMP699t z*n!-|q|s1YNRBRD0v0?D15ilXCBhj^09IDoDD^PdU71BugBE5V8oQo zlvVzLHgVllY2S@RUyK~|`5mI_CCpAtm|Tc&zCSZ--qiw3ufQnG{DZ#t5n46n5`ca<18VRofg95AAP^i-|wi&xC1anyw${p&*y(t z-BlIyYJ`bjERWodFZ>=~K{lJ6l9!iiZf!(qgpNKXypXLE0JMzoNSw(Kdb(=J+wLy^ z4SPf7adH0K7y7B~Zc7 za-L0g*e+Rweo>Zbx`&S9c@2m1=BxhXS9n^VJIyPzk->eN{dzk<>(`hLt_wDXH8ztq zc1IKm%g7Mp6TJ%}UFNNMbFZ(3r!lQqEB0o7S4XSJwFgYWFUWEycu3y_^rK_wjye-< z6udch=`ThVpn9^rQb|rG`~q96 z<;>6NaYz>b|v!=8d3(W4x?pJXtkrST%%C zQm8U}66bnLl zX-ceg|4(QCkwY&%8YrSq1es87?CVMhFD?W`H?%+9Dp2)nD=t1Tk175np*xwk%>_xB zscRdfSwZ$WsKs8IwYi~5ri3hxvQrh!Bi9E3Hmi97alBE&TN-YGY(X#JoKgO05WY{$ zwG&dDG_cVcvi6|w)+qi?d@BUm0ck*^$d`{#s7oR+c;&wTkj-dbwZSfI*C$3C!dBv) zk}xbt6bLNT*Q$aMZ#-ZMRP6^sLC}iMZUjY?#uqQZyoMYT4--qrX>u2!qW%k^=f3~( zc$W+ZVV=9rCl;Ku^ax@IIqEz_S({sZ4&Y#NsVAgr<>EP{mXO4akNrgIaZa~{nsILL zO4ob$;a|Z>Sl3e3&T+!L#A&KIN{(K?Y&H0S1ifR=)7uv+U%Bk;2QUY9m|};#PQCc@ z`ms*Sy{})d=E{d2f95d#shF#M{6t)yyGNOt)4J;*OJ5EkF4Oo~8$%`y2ce%z3>r|r*}7$5h*~kG=QQ@q6j_Is-}G(z@3;rqWp~t0 z6YXsH+LSlenZ4`5pEli0dMb5xS`IlHjP)nW(CT@DsRI8&MVudaKACZP+lFrbdaJ%v zPcXc>IRMzzwUoL8UiSDvVqqycpqdYw54bHR0D|@Pl#XtC4bu!DLi5}Q{ihI(+tRK# zGN5(9SmXJ8;WdBjW$#Mv-pw1Io56>&b45n>PHlz8J@H*8eFtQ<59cdRcO$Nt2o zT=iO8$7He}wfHfS;LcC;F@w{TI!5WNgVYhC&t}0Dx+$M9x`>VQ46TL=VAAo z-x3e9m<}|w`j@|Fo+b=_om*{@`7vaBiW<;5U9V1cT=(8$C0PTaS}1(jA3?4V>SzjH za*)gwkYoE$m9CCU)^LZ1X&}Vn`d#xiCLE2tL>CxxnFLHyW(ceTK3_*JDOyY}M#R85 z>7HINgYr|mxwQ)M!G6}V-ks!ckQ}OAR0cUfwyB^BVHzNVW4vMFhs^Sd0rUCArkJuD zEO3E3c1$^oNsn|l4QyFmN&ttaw8p6BU!t)4ozX1lcy16;xAV31!qiFA8&}5UEe)A^f^!gIf=DA z-~#qko|fCw>?7gpgOLCXgh19U89?m6f68LLWs-dxtjF##Z6k9Wwb@322)gutfG8XB zjmLdD_vm!y*eHe2qtSv=3|_UY zExlVNjs0=y_ZS(GK|zGPj0~RvEUX)khqAH0!(KzQKQOl{m3|`oTc3@pP$MMB!^0vR zYOOKiUOdCyr4MVi|LCaon(Y%)7n>-9-#cf=K)OVS=7q*!dUq8M(|c zQYnVs#Rll4m-Rm7IXJ-Wrn#LcIB2?Ti$N7gdI!7}vJSbypV6H^Xe*0o`b(s4S6r$> zB-{TKsIoov9l&5(``S^NO9`2&j zCm+OX9r{=n41*dW!&5+yMs{VCRpn7+!>$A1+9*vfo}#J z3>^Ki*fb8le7_=t4*qnsp4D`l$K!FjGi|b|cWdm;AbP2E-q_6p@xAClPd5L)jDevV zBm&X3hyJO7L$z}I>jE8O1u>^byWpZy0aUJExf{{S(xIP1!Am_8!eM9R=yGF${Ig5+ z=;eDq)um=)`e-kxY<1T|3?Os^7!}gl1DIq#y}c(#f@ zxGjZ+b_VB_BK?oqD`6}-ZLQ=%3a9XQl>SHNIe&Yq30=_1h_>NlhSQd}UcVNzd{$(z zZ;5NgJeSaaEE!K`8vQp=%<*2YhXG7lbtPkH8VTxG`p3YY9r~&YADt~RSoo^%Tu!%q z=fau(0pG;IOh8;<>ZO3ep^X}LFPHbj=7jIfN@l3V!>QVpA^Um7EsUvnKJv6|P=WZ3 z;O@4KZhvy_?{rH~_Hh)QQ_FGMH@yd(Tbb{2GrmpogA3374%==azqTB1IQo*hTD@N; z+%Ne2EJOTwOi4YE0-mqq(aIk~WFmzYS|(K}+e*X8$$4V2V8WRv%tWAscu3LRytf*iS*a)uI^&!Amr(Q0N;l5%KMjza-PWYtsJNG#p2I8vgw0LF1NvldGB8$R#>Z~FF+s$gE${V@CM^s1V(n@g;OiF9 zkCg|^6TbiTvoivmuAY`bjxOd3b%+_zy;PQb59Z$$|F((fS0`Jiz<#yw`6c|A;GVKM z3vW@2HzT0)*TN@Jg-mqrGi1gg)#BS_9tPr>hcQ1MaFFseeYSpUW)?=(f>u&&xp>n8 zBj^Qv2e5W{5M<(jfL~&FisQxNM`_#Csh@ByYU8IJq3BPFhx^aWzd zX_oe!vDQ*dwAF(&Bl4Y0TK>}nOxY(-0f&D2o>hcyI>uw_3595&&;X0SD-%KH6(^%< zxRXtKNG+^oT8nprn!SHD^0p$wikH4+Jq+!=PXK&0CVtuT=*&AZqwVjeTMkP4-A;mV zJ0_ejQe3tB$p^MR@px_JdkcBiHX7(HCl-)~zOf0t2&G(86=l%Wcv1XT=U%m>avHVy>=x7cG@{MP~j)a3GB9p-^(&DIVI|_<_K%17dHXmZCN$F zzE54$Trw6M;&^4B0;LTu6<2^uFhhugBB`>hze>l{ERptfCGk;hKDnU9NY1Z3k`I%f?m_+FU`~ROx_&6mMU8f81bR{HJzaA zl8n&=%ws5)*kBNNJAU_s2o3eW90mx^mS)&w$EWW0!0!C_t3(TQb9|^!Q?^g4l8C+yk740-6%Tz1k4wr(+hvB|x}Owe+MK7=JLbDl zS(eP&ERf>k_nTT_Vv_*#7@=kxRQ(XD7Z#VJqA1718h-A!O(m7=ExV`smOLx+beBR( zy^FOpy89MNJAIaJqBr~Y<|`lg4} zDE3bsK-yTWEGvAZg}X+-7{UB3syb*v#+1L|abdafbufl?GzG^XZINCIn>I8pi)3OHvUK;N6j3DuvU zd#ytY|3M9CQz~hXR*1pHl4yk?qdELQ_zU@o6cT`vaPlMeO69q*Y>FiVx8}$TZc-we zO_v;C)at2tobo*&RTD@UOx<#<FD0fa-uxoepwyc0Pqy6teUYR3?@Ne#mhn(ZyNs=h|(f0YO$ToEcrqHrCj+5pp)!K%9ApeNf;a6{6Thv_=`B{$J_`Zg^ZFA7E$V2i>Qt_ z0EH_)AGabtn?m-*eK?<8;BV$nmyfE&q`>w`Z{#G%3WtXOL$|6YPfXwpz)M0fpe>Y?cASPC!zVl=c7*zTcYWj zK*&qi_8M#iSxHaV&^T*pX5^snu~G2FH?4d&Wq@AxX@s;U@M40rEe=G%(~|S;fE+LG zDvcbmQVc}=+c18>NS<20uCy1Z+o9{pWC7%A)Qp1hamWE2^2szf z4$3+JekTQ;NsYwF2Zf)I2{tT5NLg8s9~JPf7z0kq&od9$u|skO=wFc-5Wz`5g@se$ z!U+eYwFDr>CL2IX2OAv9ZWSs?Y~E@LH5c$QcYOf2^8V%G3i7nEWE!J`ZZ=gC(6-^eH z#I0g6Pom5M5U|u52?2(nxRq&O@e&teE)nzVF;EHR6(E@BgHS{$XgU>mn#40wI(olK zvZ^h3{d$o8T*hlC7L$^C`mgqTwAC{t)?=aD%?lQ#LM2Xzx>a$Y+47;RHurGKdddeP zfH~ahpwBjNr}ngVe|*c*E0EW^c7AvNqG={ffk)djKyC7AvEi%94|v1spKl&nqf<@4 zGwdrJ{lq@{^gX^$lsfTVXTU;&RPjR6>d}$h!n!NL=~m*8-j7$EM8BpOP7B!r>Rx8 zywKBSy)08kbh~{LJI1FSqk>DtI6wUV#M@|j=+T6lcJt)(r(;cpPPD|&toa_IRe)ha z60havxMEfCGToFwmD|>cUTCaYg zHETeFT22xGR->&y&%I%dVrv6+H(%F~&RXA?co~v*0u$ob2{y(YGr}9wE>cJ-4 zDaVonRmGx>VSc(f2;uKfzdSl00d)$X72)3FsHB@)AK1Me8R+Zm2FGQqv}{yW z!#WL;xxIS{ks2}rNm+Uc58iHNdj8@~561e!8>-DrPisC7$!n7AD6!i8>NWQ~H}k+% zm%FDF_uNA|GH~b2=efoUK^bD5;R37jkr=eeROcT(?`bBXm6R0q4VSc6;(`qFM>J^c z2byvE(~#dX{y{2(9!Z_IJl;Epc#sK8`5;U;-m|mEUF^2!@?eSkn?ROiAof8W<3dLm zM#X-9z7Dbgg9@U~)dXwxQtK@IU9)3Mc5xWO*4G4zNYzwStDQjYpu>!H&}C;~&V*g< z)R-YSD|Q>91?`>=8Z%Ivy`Z(*t2h2o^syo`$T4R4w!GygOp%!xq=2_~;>4m|X8Vv* zU7il&UITi*O&wlJ9y!l&{&5zVZTOoh9p;}>yfOGm1TXKjb=(~ga~9zVKiuelshb}n zpn)Z7lRQ~d7HT`eS;G9W2hStp!(Va0U<}ma*-@%8rqF&`OfKag!Z^>Sxh>Zwdi6a6 zd=r0qX;|Lwck=d;Wb+Bhq#mK-Qsi7$U!$)a|7n$f<{HnF62ZM0;opO{SUtz~2+$)S zjT905>LxBH>%6PZ5jZOQ%&?bfs|kZtn|vE2g-Au_X<|@eeUZZ*JCFFG_jD2oysEZM z6`Hq8vCi~M?$YRS5NA*O0z8iRVW(qK4hrfX91!dY?#M`*ugc?~I3|P0Il2CwO|J*m z(Kj|fZ(%<2*kqlzVIr_;`#^t}y?mO3Gm1T85(e%1D~itqe8MWY9)Pu@I(%Yffqb>E zZXPsp)rZKvuu(~VlEd@5`i&RWAytIytc+z08=>c)kMsGvRwg`=r{H%r7-~DkRWhuv z&b%p_zn-$6JWX4;COU>@K|R2<3HJu?Nk{vcyDao(@ZDvbqsWs z_TWB3!yBmo&Mqraw*pdN1No!~X1ByR+r*sOtuggyMh57y4X=*rfq>pb&6QT6wJ&Cf z$DgG@d0Uf!*^c%3RBIa})&V;Y24Kl?AXv`>Qv(L+*8z{XO+cj4|Hd?$K^E06N#{G-^_mO^{qMz3H$@BMeZ`HL#H}Be8 zE`&uk4qGo#(p_o^B-s&$+7lLyRh(0rGrEg{JIOki#arMIy@Z4O_gV3e5nJEhK_K=$ zr`rB+Z`ewB#?5~>Fs6(1H5#&Xc-I&2G3|mA)0R{-2{MqS`_{$Na1u4-ju3TB`}bY?VVpf9;m~JKHZkL?NVREl06~+u?o613ojN|?5Q;8n!v=9?S$Avus2f? zAxlr^kH^s(L$TDR$59llS%yV5^|Sg|N7XX!NIw}DIiA5rtkzX(v#Yx0yMjEa>Fpay zsvmn5Fd{(ac84zToQu(L7Iyo|&!fmrQztU-M4D8n7lf>I>Ia+FQ=DG+@G)$H76h*8 zy&L^*o(CE}?VZvBa?3bb14aLf{cm5fblSWMK1DT5$Z&wH>zY+${Er2L@jIV4214FE zyx{)@7o0|pKyUZ;agCf{7=wz-hBlDLe^fJa{;|Cnctz_Y(419_54EF`h6?!y83TwNJE$BC2W+TNhyJ?Wu(+hu)H#o2@&qrjE zELEk+{eHm$2>Ry5rFRq4kE=VI{-{vL@^G1y>}=$eik*-AJ0J9XeBC73$E1C?VFO5k zO(ZB;<^4iuF?PWTvcTW&MU7^EV^zDmk_U-zEa{Voj9iXO=lZ$|58O;bSGpyx4-e%) z0Zn?!4L*g6pS&@{w_4>*Vx{0|2O%C{8mAkdKS~v7zih*an##8l0*%WqCUa@3`Vh`7 zr->}hV@osDm*5&ASu>*qZ#Y{Cn2Z{$e%qOrI%ZogSPvSQ$u!z5M;9C7_Qu&Rhh_f% zXCV8AcpWK~Zuu1z1j-HukXqF+=9|sMl(^)@dBQ2W4SE_)V(IPCY*r$)jR2JQn_SMp zj>Yif??ll~wNs0yp_TidK$Q^=y~+V2Ht0xRQ)MM2w)OX>D6e%oj1zk$YTUc!)@u{tdlRTOWbhjz*RHQL7X01JvMISKu&fI>JSW6{PRR9+)CeS1N(nRT@Xwed*!;sz9Y@}0QT-^y>JdbpX~~sRHw=0D5y*+Yez1(y}dslT6rE* zkM8})g364^OP2WqdmG27!^OpET3dUaG80xl*`i%^K-W6%m&dD^Nwul1$$>QOrxBES z8%1vRU)uQ<8*kS5O#?mCBJ=qs_W2I^Y>oc;tFYPWyDJBM-65pKeYyji!zsn^UG>nn z85eWQs|uYNkQXiYQ%Ly#I*zQ}0897|H0e>AeEGvEmT8{ezf8hO# zDsNVwoU*Or=6q*i)xs764ZURaX9vbhJq?*7{@zgs9uo&%wxcrAOU;HDTYwe9a=#BE zUj|OPbfF;~a=8^6bzfQyhO))W|j1|rtMhWr3hUK;4D0Bvj%9` z(L_H|-DUB-e~_-H`?8q7+)l;rO*zXK&~fcwP1N8JZ+My*e0k~M8%f7;I8p?o*VoOg z9DX5VIYEcfDnU&Kuuas3b#8+`H&3(4ANGp$(eZPBx8|M^1c1B9OB);cdl{CSaAwZ zQyzNm&oQt3+c*EK1o>2>ST5CjQMZNv0PV2QDS?j_lE}`mt}u?6>$|W4xzqI!TZcwn zh7k<-2bS&@TK&O9a(RCo^EBpXV#GC+&E#Uwry+%t+)YHFip70tp7d1$u4Op)tBZ+t zBl7nnZMz~OSq}B$X%7eN40j7a3k`+ol7Ce?$RMV{J#Iv=R5#hb7vZWt5C=jluR}$U z+s^H~k2>|12W5*|loGVR+~BvI>zN%NEMLDWYT=}MOs(SAVk1;`v)g{LVCJ_QcFDE8 zHsh$kMbyk|tSP9&F|Vt2wNbTF71%nRtGOQD zTbT$sS+O*T5xGbmu)BigTLawYB%MiSND5L`e_=#GVzvQ91#Kl20Tj{f;?APuO`33)aqI`0i z4VxE%4V)719U3bQH@?w-q$YWM9`*p)zeqe+M+g_M-H+zcq0TDW_8X^6`>z@F!eoDi ztX@a{+NJGAcYsw}0q73TU%zlA1I+EKw65LZ1MEQtv?%o*AnH9C93C9|?a^L!o6fI^ zN{YPdB3;K_L7Yd65wvAlP^KF);dsY42pi$lM6>XGXUIupx8*aRTBpX z^d{QXDbtudAANSq>Gl~GKq=9+V!M-yR5?@*mAlmswuPo#_MTrJM ze8l9^=OmL_)WN>G&a=E>{4c2O3D|F!@OYYlxF1@s?&c=Fuu+85xYeZy4_RERaL^loq zH1289`!5wri$d0G7z)ZBGwk>CQjRtV%H-w&7PCL}8|{r8$4gTc7wdKo_L5$3Wk4pL z$!fQj?t(;mok=oUeE}xjGv8~@%~DMnGjb`I8FV`G{CN-Hqdm^U-eM?uw3E9mOzzlnbU zF11FLnmUsm-CVSu_T!s`@%CZ$ZU0x54L)X9L}vT%J!drpSx@1hKhP2CY-TKt(OHV2 zK-E_L-HIp|@%}13s}o9mE@AA?w!mS*cOt}h5%C9&F26Isum7<5{m$5p0RV>n=3QU2 zuDLfrc^Upc=|~YM9Vtx)o2_am1459|iBmrqY9+h_FeiXLPo;IvCc%}8fpL?y>-PQe z8K-)Yo%3qY8NMcBtHv97(cZdE$V1r&PASbUxt^iA%zQ4uAovSp`YKOQ)z?*hWo2*b zHaypgs_lx{&qi(QadQum%CUtvNW%+-EeV*SNR=@*-v+kbXvv)SU$$MAJEXfE2-=%v zoB28=e8riUv$m*?{4eYq^UA|{eK_aWdVbv|YN9JoP>b?NECO{3azN2psA6Utw@3~_ zOy1F1I5QJh`e|<(h)f#<;omc535gUg6?NsN5D=4S_kC z8+ui@5uIL~q+J%ga}SsY;i*Nz-8!Ai1BV_l@}6A)GIkatkb~71eAdxont~J4s03D8 zfj504ZL6xW)c7hn2bk@0prjPAo5!p#i2efPCfd{dX6lamL(i7TsV zEK4P5W#KU(>u0ndc6GUYY6C-E0uJ_zb#>Z~%!*^z|7&XpxvjHr!j4dw6R?GrWj*8H zzWGn>TPDm1gbb(ZEzA_U9z7=}(s=awJ!DrRD4mdbUKTBn8 z{_QoKUKTmo58;E3w$ma0{6X=J;ILB;%jPw8h}G{WTM1)iKzuSFa)M=Fw{=w0$IlYz zSUpMf=mRQk9ny6JoDN*fqp_25i4WNZ_JX%Gn>B+hE+Q?I@dDLx zjqx-tp_k%cgX7Yj+WwB_CRN*j#J>G%O(Iz}r29WKr;g8QXk^9*M9m`dqd`zb^diVL zVgK#}8VFMKTQnlEteKUZznx3OR<-7beP(}ToOQqX<+e5Ox)ghufO#I{h{XB3dz|av zMM86Xd&iOA%p#i2o<*-sNo^j6YxLX5p9+W`F%ruC(w24Q36; zw*f?zK7F?ZL-@P74AVjas;Nxcqv@Y@Y2~nAj0|EoYcsV_U|#qM<^2Z+_(tXQ7Jd8@ z&or|y7v?vMlMWKh)Z0Fh5)4C7gEmW#>49nO7u%rj@?UJ4?fSlU$JWieB-4_mE+x&p z)Xq@|bCD9WS=wl4*=xS!q$3LozCv|Bc8!Ye_u4PV&r5fo!W^nBk;fH%#HTNA8B-!Q zyWrEm3%DrG?-P>pm?h)oE#_!ef4^3SsB+jE;(^eh_Yhp2U0Y!2zDo=V%U8T2L!I$E z0ce}}i>fA@WmRSSX@}AX;%9TY?<^Op#816N7F-5TU$_j5S<;(l2>}a1S>d3)`tdhL zB&TImrLYarF_!56Rm6jm*jJ;$@2mtBzoS5vSu@yS(Cn)&sUNo!stn-)WG$4Sl-Fba@dLg77C#a~tHigxm!>f6wEMN!miJMY z;=rsrM({gNcDoFcwF?5Px;aO7ORv^CX_4tj9QC4EVd2OSg$`zw&Rol{LErk3N<1MX zmG>J6&Lp7L;}TE0#RP^N`Rv*MNkBn;@#M?zc2EELB75? zC2}9^QnaO%zN4P_pbs8gc}Ma`a}D1LUxNMuf+nfyeKnZZ3!iyt7SBrdy!s4DSw<%aQ?RUR zcOI`4cE(GFxIsq>^BNJF>RWO!7bhO*h22MDD_S6aEfiBkiR2$^{uKKh$esyKyetF;E36yYB^69!KvfCn-}-a)s0X~+Y0C6d z|Dacm#n>1D4S}I1E8$?Gg2bv#f(%N{0PWhZ-mSPjl{7*|W#@H|uAC}s>czczY0q(h z7q2yq4}4p}4a*!!ES461Jn?LDxR*_S79j|Poq6EjS2lQ6oFmZ%>iBAXI)9HD6v`VXAkqadKYQtv`Z{N-Om1_+a{Za+fl`ir_*t+f0cwno2E zpxyVBCRwi5lvxkVhaSUlnfZ+2A5LDsRh~OJ#;2gD|6Y>_coW(J3Pj3`DLSTk1*aC0jXv1lKqh)#xSvK{5PH(0Mv`ZIF+Gjs>&v31}2O0Lsw5bbNpLx+3w&cEsKKe^Xc zqR!ejECXgm9R;U~Cxfau#ngT$0Rpd(&riC(qdtt{Q?`z}Vs~Er0-?XMI(<0LfP%u4 zLc>b!Io1JRk0-8a->Hdc%f^A>ECau-TqWnG?fc3Y%u#3gzP_vB#utb8J{&ad1`d!> za&YYUztj02=5`RA4YjfmuwTmlyZMghaQS!wwxwfV&rL`q-xS}2wQ;%+$jVx9VyHbv zZC74b{-7Do+4KWd0_>2FQ3c#z6CW1E%6|Go_zntxJ+Sc&w5PbG2Y|AvE(=@bWKWwb zYtpv+-BL1IZs=Dpx4D_5N#z6C-$d6_ z-|Xnli?`Nn8p^$BY4!(4bv^1xErXYhr@D>4<5QqM?m+a62erl+xFgf(xerZRYqt~^Fz$|!*`pFjPWk2Q^vCoS4N zh>E`1G1cfm&#vAd4>$f}6zTGET!eL(`J?)a=`d*=^BSz?*-~uw=M7JkeG+g=q1fwD zWJRn!gqoZq1lfUW?Ozx(iuLA_X?pAQ5&ssK@ zFFGW$b-1Y&m~vuVNBH5jwdeny)5*yR3Vh$WE9(@PO!{B@$;1B*wGImAU@-|ts7muG zY!ucjchEsJsHb%RM*EF^P>Fne5RgC7Ap4>loJO-dXO``1b#(R${{t@0qn}Q4g?VuD zm~q|Oh^rPa)X!gG&U)G~y@}n;i-Syexi1(ayyxnJC~p9?*5PEd9JCW*1V!~1n=;-b zCT4Ai??>9#=j>x1NVl*IL>TpUMW&lwbx09Qx+H|}e|+|r>Of=iI^*ULBSZAK>u1ok z8O)~X^9NfD!qB`|Z^bx=M%OL5r1FOC+z zK2gCM6u%Y0*bZaQ-32|DWU4339^V=b)&5+M8Y?SPS}gxu7vzRB4B$?QzR77=^fZ;)Q z|Ld1S>P-EUyH_k@?(=^_1Jmd`^V5n4!cWXM&__;URF_5d!wI;!wT5%-W zMMytul{vwqjhkI0be%Gjch&-cR4iV1=B4T}>&4{8-F*TMcUBL{^ju zK3$*vW7uq8RI&io7j5n=%)o z-%a)wp$Ff@m4uAdQal0e*4JRiD(cf0q$b`HJffD|p{LVF?ZJV7zPF;G;hfb!0< z$$BJf=c33dM;2AzNW^`56(Aswxso%HY^6#s>iu^q;5LSEiMVc@KMFsh%KJs+^Y({3 z^_-{sAn%~!tAvr%V>=zqlCIyBa-$ofgyD}9j9C^Jh1_=9?RjHX(WO^gDLbhj?T06h zV87FdeK`fX>b#wn54_$Eqt@plhHir#hwmRQGlM3(hGqDl??1zy?f&a}$&p9(vF4V{ zRpdi-W%=P*xU6rhhW^GsyZy@hqE4rw?B}z@;b-7Rtbvn+cbQ{L3&t|lHJ3*@mAdX_ zughM%X08h3yRWlg3q_Mp-w{wS7-KM3?acn+n7%V+Pk9m}THRas#n>zBGkNTm@k6eF z&ub^Qc8|=B=Wi5Q!NRdljOAR$ZG+g9omm>XD(YxyzHIiv%kNiZx)XEONC2*7=$rFb zG(r4n!s_n(<&h2h>Uu6|oNSr(q)E!4wXQ9!z^^{UZ>NaI{coZ`czMgDB*T&R(W@C) zG3id+E6M&Ry<1f4W8kxyqbuhUx)3m70SU404<7eZJgAJnuf%ITk?P!Yf8GsY@%*|P-phA(>Vw-2k<-F2` z5+ekJMxC>Lk|Ae0gLON;Z#3!oqW4w4WG&VEdsLLu3e1;*WD0L+5olB6W`U8GedhUU zu@~G=_uz&6J?P|qwZY9*u%NU$t0w6a-3{y4zCTuHDO1i0(PJg1scPR30{i85PJ^EB zrdDl8$e*syISA>^BI?>O_WxqdJ2h-)E^P*q*uuY^$Pz7=?avePf%2cP31M!%N|BtG-jEeI6!iHxUq+2AU1qB5J zq$P$fX%wWSQ$V@|W)K9FMx;cB?ruavKnA3{Tj_2FX6Cv1{onUl>zNNMKA!uWeRiCE z?d!6_Dp&a1%&u%}hVA;oG|>_Obi=_VY}#&T$IPegoE&>y4~oo#9U1t#n$1wYcVv`b zsd_8i1*Y3sY`^dsvTR}kNyhk7WLQ1N+u!2oxvH)Sw?p&8 z8uesAVt5B{V;8FIA0__=YPl6Wd(rz2C(cr^ZqqxDOX|xj{26^7WydJlle;9z=z_}B z#|JFBL#Mn;Z0@iMjc&#ROsljILrG$q%Tr`|c;2oUh$AzC()c;g3!=~8>ZihNn4}^6 zuh_J=<7D?iFk~xFZ=yeEl)oThdWi0eBrmts-K`3q3X_L@?g%MBYq!6?p4rUkM^ZsL z$*l7@6p#QDP>o_X;w)K~6c9x+jj8yJo{_t4#}#G>g!dzV0l`3UrekzQQu}X*f+#JS#yOz*P0a@)C zp-t-i33}uHIRQE;L*>83&)Vh%Vz|=jlQIZbo55pc0oiV-BKx@GGf49tdOhsXA%;l{ zT{C*qXfHhV!`S~$<{OEOkb-ZH%fa=2cE20c9V#I*pg-!0`?x5{_bBf77fjsLY9a2hS6}A_h|}~(&iuN5 zyD(fL?rcNdZ~ff+<c94z+)1=TJ~*a*+DrcH zzjvp6hjUF}c*{DHvUaNizLxThA-2)X5D42K)nheD)MxhZ^6W}L5;G_tqkPdx7mmH# z4^sCwhFgpxmd7Mf0X_rDuC_?Wo;xiyUetD4(wKh?E;sq0F?jxC<8NB~3qCo`pI$5U z>3S*a8aIDH9sCT&y8XuOp(M9X1kw>~M?}v6r)B`#`Y+G3sHajvUv<57UT;+m{t#}$ zplHdM8IQ6_(uq_AEw`9i%zd%=Ny4Sa;-tDEGG<0|)zxZdJhtt8@q%VRv;3dTp$_FSb&# zU*a1o)@s=-s&{fni~Nym-?OM_eUAw#m$|G}7B+mA*I>p9W>W~e2=5zO4S3oiE}-S5 zf#b;@toi7Hwpnk8)HEsH{q#^>y>-P;o~XZ{IHjQtmuR$}B2!_pC0*HG87B=*1yQm0 zlfP`zV-o^i>>k@~W6L(9AyJck6RQQDl!At@E#IN4COz!8iWu(UThxkm^!5%HsOk6< zt(6yzTtks0=BRPALyX1mf8$Wn*c9bEVBRJS9Qr{p`Ws1i{%oVO0}_h`vnY zQKy}6lwnRgk-H)kWZeEZy9m1u408IOxRO!;>Ym`g%S#jW;YiE;8@bM&M|)XWdS;<2PZ}&(FR6%s<@saXu>iBETT2d89SU(+lu$D(^+*^nYT-Bq! zG~=);W*x@7_4RePgQ-f^ES%gL5NrDT_z~JG-04YoXmY{q-!Yp-Gh13dUgy$O&qvkK4KSq{^CdY5Qe5M?uz~7%{GYMvtIv)s+ z{V61AVLJ}pn#Qu;QoiTZF4TWJo7JPVaT|PEy0}M3+7(^D!|N;dExb)N;YlA6A;l8K z5WEIUP06iF8Q`5saq*>M)VlH>WITb~C}t*A^{Fyv(J0K`@>w9&iZ&Hhs%1|c>@P5F zm%t&2+=>(wFz>IvZ{#EFniJ%*cT1sdJh2I!NGjS7kNWea zkSE8-DE)I@Z-Om*rN!I@nJ4K{y`-W-<~W+_Cs_(b2iis7?3t2=nwRLG4Lz+O3yUM| ztk=vu@)Op7=&YQq)<&e>S0v|Jp#A&+^(ryins!X5d*hs9$iGF|Ti}>F#X>`QDK6gf zAkPn&dgu`b7^m`|qxaaZE66rhV(M%@;Ipc?%W@^q;N;+Nw-Q?G&wLMIpc2191f#R! zM(-_YZQeBy_Or^SCE)a+%t z^sFtx;8hpRbxh@`s3EXE$2l$SZH0TtnU8f9{%ywV-YU8k7*5{^5tV#qjMQXrZx>pf z4@0T_K~u{o`OC8D&xBW{HIEOM-wc}cg9U6)EY5jK%Dv2l_RhOg$HZo?yzc4@2f<}mv<=X9jKcV;sQ_GzzaeQ`Yz!QyI zXyuUG5^NQXUocDVJAH>0#0e-aOMHJOmmmnhshi|7J?vV4)OzNc7&59C6+g?15_lQ_ z|1C9jDngi{cq@Q*d4Tg}AyWJB|H=+g^*} z5{OLm;db<{VJ+wM&DBf|et-X`?O}zc&YL888w7x5v4E<%zzh^r#&8wxh0Olnn-cDF ztCw&YuxDERI@pCj80EVfxU&F-~?>x08>ss}#jADH#;9E>N}2s2AvZMAd8QEVD1U&%AQT^oI@hA!{mD8$AE zV*zi*6%PHB|MhtTX?NC7K*`D74>kEW;a#5XKW6Nq_>KN$A#Cez3PM_u`UXaM?eCFYD%Zc?ak# z8f~+*Bfc5ZO{;|f_&_)@JZ!I|2?FnuSN;9VDCVVRXlg*`8HYh365K5>6 zWA?J_2b844P{^?xT+R_<;#EjrGWfr@T!r@6c6VH}8rwg7sMJV6k&oT%`ffv8odY3# zmi^_wUdaWR-G~Jd_{~^qo@*X2#TfZc&z5x7m9;>Bo;7w60KP0IDQE5Vi5}A z{~aL{Xuy#hkK3CoGoSn_A#t$_H@4aapgnw_5G$y)^lGk^;`N8(p6dB6UA404voDE8 zcN4C)^iF`PE=n6F-Ek$CRYL+F-F&6?KgcV?@KcHLv*IDJ%8S=ydRRaz3{cVVDKbq) zOUOGL*#=szS!j80Pe;?;gRm)V!c<@*GIU1_1S}ycKj?EsKgIA+n`qCME6JU+q`g#2 z=FQ=;2H=BK5>M<$KUkYJX1J!T+hQ%@SSw>CGp`?Uvam3HtBHdZ;k7m#vu#- zkDQIvzQt?oRAKkZkn+Dd2(NTBevRdfm0 zZ>jg?v*oG1&QUV%J`QmR!tl$}uZl&6b3xSzbodo#fEAkUz_-n+!M8nEmY+lQsQSv{ zPdaOuxX}xUY)|5E==VYTg)ZM}Tm^Tgy%B;p@;ombZ-{rroY!@N%zpUZ+%zfU=>4hA zEtV?yqVv`YohT6|K=m||0wH<#9GVTFDB0Gm{a|*`MyIFtx54QzLHO9cc_0_B*~=yP z^%L?JBcXiq<<0(qB^<3hQ#yrk8P{qyqpw-pc}K|FD}^2+$O)#D0G<9$nR&d*670kw z^L=!5UaKl!CrtM=^a<-bBdU4{Pgb6{bIq)mr(E!0&k`lvXUV9@9IkxR;Jtsw6gPmSEbfE zU{;-)fo;kLwyDGZom2UHT1@p}BO*g~abH<=pA_qH*=v5ex|oGRC@{$-*nQd_BB2}u zN3c+G=~NC>3BtO{rfE@hbIH__+(eOkpi(%CFc}Pe4aGUK z13gQDvvx|%e|u_)hpMh(YS8JO zR4TwxmvddvkWimSnPn^v@>)73T`jShw4(o?XYRG2|L$-Cq$G8_Vx3raz(-!a{l@0) z7BSRbEw&MjO4WbMo3Ei(HgRHhOvO7k|9J#&=&&ufk=my=CgvVUsP&Q)@9w#sYDA-9 zbt1@1P19tK?u0$p?8#rV?q7OEaMZiRMd444V&AL9Je*=v!gWtB`Qs*TchduyEAC8hPM6*)f>o z(X9ei^3Aw3#6rU1OO(HX{=`v#CsGC%#W#u@!*VOrb1hHrJ zC)j+A)2wAdI=pkvFrD>KPeQlivMD4zRJ-nqOs}c0V(UvQrn%PL0BSg6RU09{k>V$N zGajc^%24+F5bC^PS_iTJ+;483BrqD zX#ON%=Y00GueeE>tInxefQJLtfEVgXpNtnbxB?dl9Cai6pn0|tVS>+Edbk<9-ui~A zI~Zb1(%pqY{snoCJv20A(A75MAC~3L#S+E;GRa5}zKb6?m8!BJo}Y5>QR4mT?7O3> zDsJiLlj}`+d5zK_(AczXI>0#WQ-cQ#kg5tkjNC^wdJfXA)AE%e9L5cPOS~BSljE)a zpKIF8o|&g8QM}k|K1yq)$;uoRV-?$N8OtPhNpda zJh;z?-NJVtf7T?}itzm4`!k$6tP*QM1`kJ*ze+pxa-zaF-&I)wMRnq{7R8dQTU%QNa4}u<1^cK2+zV7X0?7%tgBZ#&T(9-#>OU$ZNgmvRs&cd}oR_8b0eu$V zbEuJyA=!=K`uJO|Id7c6Qo8GlYS>_^gA>VG|DuH@j!eSe3=F4r^3rrCR-O^=Larb< zfl(}RV}e(aV~O$2rN0gdr24^M8%AKCB*3EtL~uc@l%L!VvqS4aus;2lZ{AKfT zkthw*igP6UA0QuFr;|9tZ~p~MYHzHsZ=Xf)m5o_yL=-2fcKk#CcMCi5yX{Nro-U5# zlS^Ivu^K9x;wAqwSOgoL%⪼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 literal 0 HcmV?d00001 diff --git a/docs/screenshots/oracle-live-question-shell.png b/docs/screenshots/oracle-live-question-shell.png new file mode 100644 index 0000000000000000000000000000000000000000..156455696f167b40faeddb0476c1ae4156673e2e GIT binary patch literal 165534 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�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

=9OwIuOOU!FD^+??bf!eDoFBXGz| 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 Date: Fri, 25 Sep 2026 11:27:07 -0400 Subject: [PATCH 17/60] Bring the design brief and designer skill in line with the runtime The Oracle quiz brief now carries the skill's pedagogy fields and a pedagogy map, each verified against learning.rs, questions.rs, and the engine: mastery runes at 1/3/5 evidence, spaced retry after up to RETRY_GAP questions, level focus lenses, and PREDICT transfer at level 4+ as prompt-level requirements. The threshold contract gains lens mastery and pending reviews, and the storyboard, mechanics, sound ledger, polish matrix, traceability, and slices now describe the lesson card, the save's retire-or-review semantics, reduced motion, the manifest-gated level-up hold with its 180-tick auto-continue, stdin generation with the anonymized brief, and full six-question batches. Slice 6 is in progress. The game-designer skill describes sound as engine-derived from state (audio entries stay production requirements with no sound schema), points designers at the lesson card, shuffle, spaced retry, lens mastery, Codex route, and reduced motion, and adds evals for both. The TOML reference and Oracle asset README document prompt gating by after_ticks, the concept-quiz lesson card, and the lesson-card and Codex preview tests. Co-Authored-By: Claude Opus 5.5 --- .../skills/codequest-game-designer/SKILL.md | 68 ++++- .../assets/game-design-brief.md | 15 ++ .../codequest-game-designer/evals/evals.json | 22 +- .../references/polish.md | 24 +- .../references/visual-floor.md | 7 +- docs/game-design/oracle-quiz.md | 248 +++++++++++++----- docs/reference/codequest-toml.md | 24 +- src-tauri/assets/oracle/README.md | 5 +- 8 files changed, 332 insertions(+), 81 deletions(-) diff --git a/.agents/skills/codequest-game-designer/SKILL.md b/.agents/skills/codequest-game-designer/SKILL.md index 0050d06..a1c7753 100644 --- a/.agents/skills/codequest-game-designer/SKILL.md +++ b/.agents/skills/codequest-game-designer/SKILL.md @@ -222,11 +222,53 @@ 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`). Acceptance drops a question without a known lens or with a + missing, overlong, or location-citing rationale. +- 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 up to three + intervening questions (`RETRY_GAP`) inside the current batch, and the batch + cannot complete until it is retried. A correct review is a redemption. +- Lens mastery: evidence (first-try successes plus redemptions) wakes three + runes per lens at 1, 3, and 5 (`learning::MASTERY_THRESHOLDS`). The save + retires correct answers, keeps misses queued as reviews 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. +- 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. + +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 +283,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 +317,15 @@ CQA_VISUAL_PREVIEW_DIR=/tmp/codequest-previews \ oracle_templates_produce_nine_distinct_native_scene_frames --lib ``` +That test writes every reachable Oracle scene, including both Codex page +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`. 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 +341,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..1b5b09f 100644 --- a/.agents/skills/codequest-game-designer/evals/evals.json +++ b/.agents/skills/codequest-game-designer/evals/evals.json @@ -22,7 +22,13 @@ { "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.", + "Classifies any remaining gap, such as an in-game reduced-motion toggle, as proposed engine work rather than a manifest field." + ], "files": [] }, { @@ -34,6 +40,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 +70,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..eefee82 100644 --- a/.agents/skills/codequest-game-designer/references/polish.md +++ b/.agents/skills/codequest-game-designer/references/polish.md @@ -60,6 +60,13 @@ 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. ### Sound design @@ -73,6 +80,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 +104,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 +127,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/docs/game-design/oracle-quiz.md b/docs/game-design/oracle-quiz.md index 6c60ad8..dd0e09d 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,56 @@ 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 to a previously missed +question after its lesson card, presented in a different choice order from its +first showing. Within a batch, the review copy returns after up to three +intervening questions (`RETRY_GAP`), fewer when the batch end is nearer; a miss +still outstanding when a run ends or the app closes returns as a review in a +later run. Score, flow, survival, and Datafall counts are not evidence. + +**Mastery criterion:** Per lens, evidence (first-try successes plus +redemptions) wakes the first, second, and third mastery rune at exactly 1, 3, +and 5 (`learning::MASTERY_THRESHOLDS`); misses never light or remove a rune. +The design treats a lens as mastered when its third rune is lit and the Codex +shows no pending review for it. The runtime displays both signals but does not +combine them into a separate mastered state. + +**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 +102,28 @@ 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 → raise difficulty and deepen the batch's concept + lenses → mark a level-up → 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 + redemptions also wake per-lens mastery runes at 1, 3, and 5 evidence. +- **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 within the batch. + At zero wards, show the final score, earned Insight Rune, and a 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 correct answer retires the + question for later runs and launches; a miss keeps it queued as a review + until it is redeemed. Per-lens mastery (first-try, redeemed, and missed + counts) persists per cartridge, and cartridge load rebuilds the lesson + journal from the saved batches and that progress. 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 +131,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 up to three intervening questions with every choice moved. | The misconception named by the chosen distractor. | A correct review is a redemption, recorded separately from first-try success and bannered `REDEEMED`. | The card ignores input for 45 ticks and then waits for A or Start; review copies join the current batch, so the batch cannot level up until its misses are retried. | +| 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, and missed counts 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; lesson pages reread question, answer, and rationale marked `REVIEW PENDING` or `LEARNED`. The quiz menu summarizes `LESSONS NN REVIEW NN`. | All five lenses. | Runes at exactly 1, 3, and 5 evidence; pending reviews clear only when a miss is redeemed. | The journal can be reread from the menu before any run; missed questions persist across launches until redeemed. | +| 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,9 +162,9 @@ 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 @@ -112,13 +179,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. | +| 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 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. | | 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 inside the batch and moves the batch end, so 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 redemptions) 1 = rune I, 3 = rune II, 5 = rune III | The Codex mastery page lights the lens's three runes in a color that advances with the stage (cyan, amber, magenta), and the templated lesson-card footer shows the same stage beside the lens label. Misses never subtract a rune. | Caps at three runes while evidence keeps counting; 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 and quiz-menu subtitle, and `REVIEW PENDING` on the lesson page; a redemption clears its entry. | Displays to 99; persists across launches until each miss is redeemed. | ## Scene storyboard @@ -132,11 +201,11 @@ runes so the HUD reads as part of the illustrated world rather than debug text. | `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, 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) and an amber `!N` pending-review count; later pages reread one lesson each: lens, question, green answer, and rationale, with `REVIEW PENDING` in amber. Left/Up/L and Right/Down/R page with wrap; A/Start are inactive; B returns. | `quiz-menu` | `review-lessons` | `codex-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) and an amber `!N` pending-review count; later pages reread one lesson each: lens, question, green answer, and rationale, with `REVIEW PENDING` in amber or `LEARNED` in cyan. Left/Up/L and Right/Down/R page with wrap; A/Start are 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` | +| `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. | `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 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 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` | All scenes are reachable from `copyright`. The opening path is finite, and the @@ -202,7 +271,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 @@ -272,6 +343,9 @@ 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:** None by design. Datafall keeps the real generation + wait active and honest; it never produces learning evidence, and its 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, @@ -288,18 +362,25 @@ 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. - 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 three questions later, - never past the current batch end, and shifts later batch ends so a batch - cannot complete before its misses are retried. Each commitment updates the + Commitment immediately records the answer in the cartridge save: a correct + answer retires the question (by normalized text) for later runs and + launches, while a miss keeps it in the save's missed list so it returns as a + review until redeemed. 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 up to three intervening questions, never past the current batch end, + and shifts later batch ends so a batch cannot complete before its misses are + retried. 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 whose correct answer is recorded as a redemption. - **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 @@ -311,17 +392,21 @@ and the selected provider retries. 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. + Insight Rune marks, and raw score across the header. While the lesson card + shows, replace active controls with the owned ward, flow, `REDEEMED`, or + exact rune-crossing banner, or `REVIEW ANSWER` when none applies. ### `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. @@ -350,15 +435,17 @@ and the selected provider retries. | `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. | +| `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 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; Datafall and reward audio grow by tier. | ## Sound requirement ledger -Sound entries remain `art.kind = "audio"` production metadata, and the engine -now owns their runtime. `src-tauri/src/audio.rs` describes a constrained +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 @@ -369,16 +456,16 @@ 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). Unknown scenes are silent until the ledger names them. +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. | 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`, `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. | 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; presses that change nothing get a soft unavailable cue; engine tests script every edge. | +| `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, wrong, low-ward, flow-stage, 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 (rune awaken over flow-stage over correct; ward break over low-ward over wrong); a heartbeat bed runs on the last ward; the review lock is silent; a new cue cancels the previous cue's remaining notes. | +| `quiz-soundscape` | `quiz` | Clarify cursor movement, answer commitment, result, danger, and batch completion. | Cursor, commit, correct, redeemed, 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 (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 @@ -393,33 +480,42 @@ the Oracle's crest. | `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. | Page turns are instant input-edge cuts; there is no idle animation to compete with reading. | Page-turn and back cues; no ambience competes with reading. | B returns to the menu; A/Start are intentionally inactive; paging wraps; the empty journal has explicit guidance. | Runes wake at 1/3/5 evidence per lens and pending reviews clear as misses are redeemed. | Visual template, legacy renderer, and breakpoint tests implemented; page and back cues implemented. | +| `codex` | Archive frame holds five lens rows and totals; lesson pages hold counter, lens runes, question, answer, and rationale in bounded panels. | Page turns are instant input-edge cuts; there is no idle animation to compete with reading. | Entry confirm, alternating page-turn cues, unavailable cue for A/Start, and back cancel; no ambience competes with reading. | B returns to the menu; A/Start are intentionally inactive; paging wraps; the empty journal has explicit guidance. | Runes wake at 1/3/5 evidence per lens and pending reviews clear as misses are redeemed. | 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, 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. | 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; audio implemented/tested. | -| `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. | Cursor, one commit-and-result cue, last-ward heartbeat, question reveal. | 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 implemented/tested. | -| `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. | Batch telegraph, tier-grown reward cadence, tier continuation cue. | 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 implemented/tested. | +| `quiz` | 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), leave warning, last-ward heartbeat, question reveal; the lesson card itself is silent. | The lesson card shows the misconception and answer rationales under the ward/flow/rune/`REDEEMED` banner, 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 text supports rather than carries reward. | 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, and earned crest 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 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 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. + ## Runtime traceability | Element | Status | Evidence or required work | |---|---|---| | 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 art entries remain metadata (sound is engine-owned per trusted handler); 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 120-second process-tree deadline. Payload v2 requires a known lens and a fitting, location-free rationale for every choice; failing 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 lesson hold replaces active controls and then waits for A/Start; B leaves an active question only through a 90-tick confirmation; level-up enforces a 60-tick hold before A/Start can leave. | -| Lesson card, shuffled choices, and spaced retry | Implemented in this pass | Committed answers show the misconception and answer rationales in a composed lesson panel; display order follows `learning::presentation_order`; misses return within the batch; mastery and the lesson journal update on every commitment. Native layout, contrast, and flow tests cover worst-case copy. | -| 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. | +| 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 up to `RETRY_GAP` (3) questions inside the current batch; 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 correct answer moves the question to the retired list, a miss to the missed list (the latest attempt decides), and lens mastery records first-try, redeemed, and missed counts. Cartridge load drops retired questions, queues missed ones as 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. 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. | +| 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. | | 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 read-only mastery-and-lesson journal; 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. | @@ -439,11 +535,12 @@ the Oracle's crest. 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. +5. **Completed — Feedback/accessibility pass:** Correctness labels and + markers, shaped focus, staged motion, native-scale assertions, and reduced + motion that follows the system preference are implemented. +6. **In progress — Continuity pass:** Show the previous lesson and batch status + during a wait using state the engine already owns, beginning with a + Datafall recall line that is being implemented now. 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 @@ -452,24 +549,37 @@ the Oracle's crest. 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. **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. +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 -- Beyond answered-question history, should score, wards, hero identity, or - presentation tier persist per cartridge across launches? +- Beyond learner progress (retired and missed questions, lens mastery), should + score, wards, hero identity, or presentation tier persist per cartridge + across launches? +- Should the runtime show a distinct mastered state for a lens with three runes + and no pending review, or keep the two signals separate as they are now? +- 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 e8e2639..2205ad5 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]] @@ -143,6 +146,14 @@ deliberately inactive, 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 inside the current batch, so `batch-complete` is emitted only after +the batch's misses have been retried. These timings are engine behavior, not +manifest fields. + ## Mechanics A mechanic captures reusable rules and feedback. These fields are currently @@ -209,6 +220,13 @@ 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. + ## Schema v1 compatibility Schema v1 remains supported so existing cartridges continue to load. In v1, diff --git a/src-tauri/assets/oracle/README.md b/src-tauri/assets/oracle/README.md index b808b47..f78e010 100644 --- a/src-tauri/assets/oracle/README.md +++ b/src-tauri/assets/oracle/README.md @@ -12,7 +12,7 @@ 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 | @@ -61,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. From 3bb18f3c8e09e6f093289e5a336a4cacd73363da Mon Sep 17 00:00:00 2001 From: Jason Skrzypek Date: Fri, 25 Sep 2026 11:29:19 -0400 Subject: [PATCH 18/60] Give mechanically rejected questions one bounded repair pass Generated questions that fail only on display mechanics are now sent back once for a precise fix instead of being discarded. - questions.rs: question_violations reports every reason generated_question_is_acceptable rejects a question (question/choice/why length with measured characters and wrapped lines, missing why, unknown lens, non-ASCII characters, trivia, malformed structure), using the same primitives; a test pins that it is empty exactly when the policy accepts. Trivia and malformed questions are never repairable. - parse_generated_batch keeps the rejected questions beside the accepted ones. GeneratedBatch::repair_request builds a pure repair prompt that lists each violated field with its measurement and limit, repeats the question verbatim in the generation schema plus an id, and asks for the same schema back. merge_repairs accepts at most one repair per sent id, only when it keeps its answer index and passes the full policy, never beyond the count and never repeating an identity. - lib.rs ai_questions: when a reply falls short and a rejection is repairable, one repair call goes through ask_provider (stdin, process-tree timeout) with only what is left of the 120 s budget, skipped under 10 s. Before/after counts are logged. - An ignored live test measures the repair against the real Claude CLI. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/lib.rs | 99 ++++- src-tauri/src/questions.rs | 880 ++++++++++++++++++++++++++++++++++++- 2 files changed, 964 insertions(+), 15 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d92c58b..939aa17 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -696,6 +696,15 @@ fn ask_provider(provider: AiProvider, prompt: &str, timeout: Duration) -> Result provider_reply(provider, &output) } +/// Time one generation request may take, its repair pass included. +const AI_QUESTION_TIMEOUT: Duration = Duration::from_secs(120); +/// A repair pass is skipped when less than this is left of the budget. +const MIN_REPAIR_TIME: Duration = Duration::from_secs(10); + +/// Generates up to `count` questions. When the reply falls short and some +/// rejections failed only on mechanics (length, rationale, lens, ASCII), one +/// repair call through the same provider gives them a second chance within +/// what is left of [`AI_QUESTION_TIMEOUT`]. fn ai_questions( path: &std::path::Path, level: u32, @@ -716,8 +725,35 @@ fn ai_questions( let brief = repo_context::project_brief(path, &tracked_files(path)); let learner = questions::load_learner_state(path).unwrap_or_default(); let prompt = questions::bounded_ai_question_prompt(&name, level, count, &brief, &learner); - let result = ask_provider(provider, &prompt, Duration::from_secs(120))?; - questions::parse_generated_questions(&result, count) + let started = Instant::now(); + let result = ask_provider(provider, &prompt, AI_QUESTION_TIMEOUT)?; + let mut batch = questions::parse_generated_batch(&result, count)?; + if let Some(repair) = batch.repair_request(count) { + let before = batch.accepted.len(); + let remaining = AI_QUESTION_TIMEOUT.saturating_sub(started.elapsed()); + let repaired = if remaining < MIN_REPAIR_TIME { + eprintln!( + "CODE QUEST question repair skipped: {}s of the budget left", + remaining.as_secs() + ); + 0 + } else { + match ask_provider(provider, &repair.prompt, remaining) { + Ok(reply) => batch.merge_repairs(&repair, &reply, count), + Err(error) => { + eprintln!("CODE QUEST question repair failed: {error}"); + 0 + } + } + }; + eprintln!( + "CODE QUEST question repair: {before}/{count} accepted before, {}/{count} after ({repaired} of {} repairable repaired, {} rejected)", + batch.accepted.len(), + repair.originals.len(), + batch.rejected.len() + ); + } + batch.into_questions() } fn generate_and_save_questions( @@ -1360,6 +1396,65 @@ mod question_policy_tests { std::process::exit(7); } + /// Measures the repair pass against the installed Claude CLI: first on + /// fixtures modeled on real over-length output, then on a full + /// generation for this repository. Point CQA_CLAUDE (and optionally + /// CQA_CLAUDE_MODEL) at the CLI and run with `--ignored --nocapture`. + #[test] + #[ignore = "calls the real provider CLI"] + fn live_provider_repairs_rejected_questions() { + let provider = AiProvider::Claude; + let batch = questions::tests::over_length_batch(); + let request = batch.repair_request(6).unwrap(); + let started = Instant::now(); + let reply = ask_provider(provider, &request.prompt, AI_QUESTION_TIMEOUT) + .unwrap_or_else(|error| panic!("repair call failed: {error}")); + let elapsed = started.elapsed(); + let mut repaired = batch.clone(); + let added = repaired.merge_repairs(&request, &reply, 6); + println!( + "LIVE REPAIR: {added} of {} fixture rejections repaired in {:.1}s ({} prompt bytes)", + request.originals.len(), + elapsed.as_secs_f64(), + request.prompt.len() + ); + for question in &repaired.accepted[batch.accepted.len()..] { + println!(" REPAIRED: {} {:?}", question.q, question.choice_texts()); + } + let reply_batch = questions::parse_generated_batch(&reply, 6).unwrap_or_default(); + for rejected in &reply_batch.rejected { + println!( + " STILL REJECTED: {} {:?}", + rejected.q, + questions::question_violations(rejected) + ); + } + assert!(added > 0, "the provider repaired nothing:\n{reply}"); + + let runs = std::env::var("CQA_LIVE_RUNS") + .ok() + .and_then(|runs| runs.parse::().ok()) + .unwrap_or(1); + let count = 12; + let repo = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap(); + for run in 1..=runs { + let started = Instant::now(); + let generated = ai_questions(repo, 2, count, Some(provider)); + let elapsed = started.elapsed(); + println!( + "LIVE GENERATION {run}: {} of {count} accepted in {:.1}s", + generated.as_ref().map_or(0, Vec::len), + elapsed.as_secs_f64() + ); + assert!( + elapsed <= AI_QUESTION_TIMEOUT + Duration::from_secs(5), + "generation and repair stay within the budget" + ); + } + } + #[test] fn external_commands_are_stopped_at_their_deadline() { let mut command = Command::new(std::env::current_exe().unwrap()); diff --git a/src-tauri/src/questions.rs b/src-tauri/src/questions.rs index 6c961fc..9f7df2d 100644 --- a/src-tauri/src/questions.rs +++ b/src-tauri/src/questions.rs @@ -214,6 +214,15 @@ fn concept_key(concept: Concept) -> &'static str { } } +/// Every lens's save spelling, as the provider schema lists them. +fn lens_names() -> String { + Concept::ALL + .iter() + .map(|concept| concept_key(*concept)) + .collect::>() + .join("|") +} + /// Extensions that make a dotted word a file name rather than prose. const FILE_EXTENSIONS: [&str; 60] = [ "RS", "JS", "MJS", "CJS", "JSX", "TS", "TSX", "MTS", "CTS", "PY", "PYI", "GO", "RB", "JAVA", @@ -584,6 +593,242 @@ pub(crate) fn accepted_question_batch( (!valid.is_empty()).then_some(valid) } +/// A text field of a question, named by its JSON path in the provider schema +/// (choices counted from 0), so a repair prompt can point at it exactly. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Field { + Question, + Choice(usize), + Why(usize), +} + +impl std::fmt::Display for Field { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Question => write!(formatter, "\"q\""), + Self::Choice(index) => write!(formatter, "choices[{index}].text"), + Self::Why(index) => write!(formatter, "choices[{index}].why"), + } + } +} + +/// One specific way a generated question fails +/// [`generated_question_is_acceptable`], with the measurements a repair +/// needs. Character counts are of the normalized text the display draws. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum Violation { + /// The question wraps into more than the quiz's question rows. + QuestionTooLong { chars: usize, lines: usize }, + /// A choice is wider than one choice row. + ChoiceTooLong { choice: usize, chars: usize }, + /// A choice has no rationale (a plain string, or an empty `why`). + WhyMissing { choice: usize }, + /// A rationale wraps into more than the lesson panel's rows. + WhyTooLong { + choice: usize, + chars: usize, + lines: usize, + }, + /// The lens is absent or names no lens [`Concept::parse`] knows. Lens + /// spellings it can map are canonicalized before any check runs. + UnknownLens { given: Option }, + /// Characters the handheld font cannot draw, in order of appearance. + NotAscii { field: Field, characters: Vec }, + /// The field asks about or answers with a location or a state-in-time + /// fact. The question itself is wrong, so it is never repaired. + Trivia { field: Field }, + /// The question is structurally broken (choice count, answer index, empty + /// or repeated text). Fixing that means writing a new question. + Malformed(&'static str), +} + +impl Violation { + /// Whether rewording fields in place can fix this without changing what + /// the question asks. + pub(crate) fn is_mechanical(&self) -> bool { + !matches!(self, Self::Trivia { .. } | Self::Malformed(_)) + } + + /// One instruction line for a repair prompt. + pub(crate) fn describe(&self) -> String { + match self { + Self::QuestionTooLong { chars, lines } => format!( + "{} is {chars} characters and wraps into {lines} lines of {}; the limit is {} lines (about 100 characters).", + Field::Question, + engine::QUIZ_QUESTION_COLUMNS, + engine::QUIZ_QUESTION_ROWS, + ), + Self::ChoiceTooLong { choice, chars } => format!( + "{} is {chars} characters; the limit is {}.", + Field::Choice(*choice), + engine::QUIZ_CHOICE_CHARS + ), + Self::WhyMissing { choice } => format!( + "{} is missing; write one of at most 90 characters.", + Field::Why(*choice) + ), + Self::WhyTooLong { + choice, + chars, + lines, + } => format!( + "{} is {chars} characters and wraps into {lines} lines of {}; the limit is {} lines (about 90 characters).", + Field::Why(*choice), + learning::RATIONALE_COLUMNS, + learning::RATIONALE_ROWS, + ), + Self::UnknownLens { given } => { + let lenses = lens_names(); + match given + .as_deref() + .map(str::trim) + .filter(|lens| !lens.is_empty()) + { + Some(lens) => format!( + "\"concept\" is \"{lens}\", which is not a lens; use exactly one of {lenses}." + ), + None => format!("\"concept\" is missing; use exactly one of {lenses}."), + } + } + Self::NotAscii { field, characters } => format!( + "{field} contains {}, which the display cannot draw; use plain ASCII.", + characters + .iter() + .map(|character| format!("'{character}' (U+{:04X})", u32::from(*character))) + .collect::>() + .join(", ") + ), + Self::Trivia { field } => { + format!("{field} cites a location or a state-in-time fact.") + } + Self::Malformed(reason) => format!("the question is malformed: {reason}."), + } + } +} + +/// Non-ASCII characters in `text`, each once, in order of appearance. +fn unrenderable_characters(text: &str) -> Vec { + let mut characters = Vec::new(); + for character in text.chars() { + if !(' '..='~').contains(&character) && !characters.contains(&character) { + characters.push(character); + } + } + characters +} + +/// Every reason [`generated_question_is_acceptable`] rejects `question`; the +/// list is empty exactly when the question is accepted. It measures with the +/// same primitives as the acceptance checks and never relaxes them. +pub(crate) fn question_violations(question: &QQuestion) -> Vec { + let mut violations = Vec::new(); + let choices = question.choice_texts(); + + // The quiz layout (engine::quiz_question_fits). + if question.q.trim().is_empty() { + violations.push(Violation::Malformed("THE QUESTION IS EMPTY")); + } + let lines = engine::wrap_text(&question.q, engine::QUIZ_QUESTION_COLUMNS).len(); + if lines > engine::QUIZ_QUESTION_ROWS { + violations.push(Violation::QuestionTooLong { + chars: question.q.chars().count(), + lines, + }); + } + if choices.len() != 4 { + violations.push(Violation::Malformed("A QUESTION NEEDS EXACTLY 4 CHOICES")); + } + if question.answer >= choices.len() { + violations.push(Violation::Malformed("THE ANSWER IS NOT A CHOICE INDEX")); + } + let distinct = choices + .iter() + .map(|choice| choice.trim().to_ascii_uppercase()) + .collect::>(); + if distinct.len() != choices.len() { + violations.push(Violation::Malformed("TWO CHOICES REPEAT EACH OTHER")); + } + for (index, text) in choices.iter().enumerate() { + if text.trim().is_empty() { + violations.push(Violation::Malformed("A CHOICE IS EMPTY")); + } + let chars = text.chars().count(); + if chars > engine::QUIZ_CHOICE_CHARS { + violations.push(Violation::ChoiceTooLong { + choice: index, + chars, + }); + } + } + + // Concepts only (cites_trivia, cites_location). + if cites_trivia(&question.q) { + violations.push(Violation::Trivia { + field: Field::Question, + }); + } + for (index, text) in choices.iter().enumerate() { + if cites_trivia(text) { + violations.push(Violation::Trivia { + field: Field::Choice(index), + }); + } + } + + // Payload v2 (has_complete_rationales). + if question.lens().is_none() { + violations.push(Violation::UnknownLens { + given: question.concept.clone(), + }); + } + for (index, choice) in question.choices.iter().enumerate() { + let Some(why) = choice.why() else { + violations.push(Violation::WhyMissing { choice: index }); + continue; + }; + if why.trim().is_empty() { + violations.push(Violation::WhyMissing { choice: index }); + } else if !learning::rationale_fits(why) { + violations.push(Violation::WhyTooLong { + choice: index, + chars: why.chars().count(), + lines: engine::wrap_text(why, learning::RATIONALE_COLUMNS).len(), + }); + } + if cites_location(why) { + violations.push(Violation::Trivia { + field: Field::Why(index), + }); + } + } + + // The handheld font (renderable). + let texts = std::iter::once((Field::Question, question.q.as_str())).chain( + question + .choices + .iter() + .enumerate() + .flat_map(|(index, choice)| { + std::iter::once((Field::Choice(index), choice.text())) + .chain(choice.why().map(|why| (Field::Why(index), why))) + }), + ); + for (field, text) in texts { + let characters = unrenderable_characters(text); + if !characters.is_empty() { + violations.push(Violation::NotAscii { field, characters }); + } + } + violations +} + +/// Whether one repair call could turn `question` into an accepted question: +/// it fails, and only for mechanical reasons. +pub(crate) fn is_repairable(question: &QQuestion) -> bool { + let violations = question_violations(question); + !violations.is_empty() && violations.iter().all(Violation::is_mechanical) +} + /// Folds typographic punctuation into ASCII, collapses whitespace, and /// uppercases, matching what the handheld font can draw. fn display_text(text: &str) -> String { @@ -672,19 +917,194 @@ fn question_values(response: &str) -> Result, String> { .to_string()) } -/// Parses, normalizes, and filters one provider response into at most `count` -/// acceptable questions. -pub(crate) fn parse_generated_questions( +/// One provider response, parsed and normalized: the questions the policy +/// accepts, and the questions it rejected. +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct GeneratedBatch { + /// At most the requested count of acceptable questions, each once. + pub(crate) accepted: Vec, + /// Readable questions that failed [`generated_question_is_acceptable`], + /// in response order. Objects that are not questions at all are dropped. + pub(crate) rejected: Vec, +} + +impl GeneratedBatch { + /// The playable questions, or an error when none survived. + pub(crate) fn into_questions(self) -> Result, String> { + if self.accepted.is_empty() { + Err("INCOMPLETE OR INVALID QUESTIONS".to_string()) + } else { + Ok(self.accepted) + } + } + + /// The one repair call worth making for this batch: none when it is + /// already full or no rejection is repairable. At most `count` questions + /// are sent, each once, and none that repeats an accepted question. + pub(crate) fn repair_request(&self, count: usize) -> Option { + if self.accepted.len() >= count { + return None; + } + let mut seen = self + .accepted + .iter() + .map(|question| question_identity(&question.q)) + .collect::>(); + let originals = self + .rejected + .iter() + .filter(|question| is_repairable(question)) + .filter(|question| seen.insert(question_identity(&question.q))) + .take(count) + .cloned() + .collect::>(); + (!originals.is_empty()).then(|| RepairRequest { + prompt: repair_prompt(&originals), + originals, + }) + } + + /// Adds the acceptable repairs in `reply` to the accepted questions. A + /// repair counts only when it echoes the `id` of a question `request` + /// sent and keeps that question's answer index; each sent question adds at + /// most one repair; the batch never exceeds `count` or repeats a question. + /// Returns how many repairs were added. + pub(crate) fn merge_repairs( + &mut self, + request: &RepairRequest, + reply: &str, + count: usize, + ) -> usize { + let Ok(values) = question_values(reply) else { + return 0; + }; + let mut seen = self + .accepted + .iter() + .map(|question| question_identity(&question.q)) + .collect::>(); + let mut repaired = HashSet::new(); + let before = self.accepted.len(); + for value in values { + if self.accepted.len() >= count { + break; + } + let Some(id) = value + .get("id") + .and_then(serde_json::Value::as_u64) + .and_then(|id| usize::try_from(id).ok()) + else { + continue; + }; + let Some(original) = id + .checked_sub(1) + .and_then(|index| request.originals.get(index)) + else { + continue; + }; + let Ok(question) = serde_json::from_value::(value) else { + continue; + }; + let question = normalized_question(question); + if question.answer == original.answer + && generated_question_is_acceptable(&question) + && !repaired.contains(&id) + && seen.insert(question_identity(&question.q)) + { + repaired.insert(id); + self.accepted.push(question); + } + } + self.accepted.len() - before + } +} + +/// Parses and normalizes one provider response, keeping at most `count` +/// acceptable questions and every rejected one. Errors only when the response +/// holds no question array at all. +pub(crate) fn parse_generated_batch( response: &str, count: usize, -) -> Result, String> { - let questions = question_values(response)? +) -> Result { + let (accepted, rejected) = question_values(response)? .into_iter() .filter_map(|value| serde_json::from_value::(value).ok()) .map(normalized_question) - .collect(); - accepted_question_batch(questions, count) - .ok_or_else(|| "INCOMPLETE OR INVALID QUESTIONS".to_string()) + .partition(generated_question_is_acceptable); + Ok(GeneratedBatch { + accepted: accepted_question_batch(accepted, count).unwrap_or_default(), + rejected, + }) +} + +/// A second chance for questions rejected on mechanics alone. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct RepairRequest { + /// The questions sent for repair; the prompt numbers them from 1 as `id`. + pub(crate) originals: Vec, + pub(crate) prompt: String, +} + +/// The repair schema: a question in the generation schema plus the `id` the +/// reply must echo. Choices always appear as objects, so a legacy plain choice +/// shows where its missing rationale goes. +#[derive(Serialize)] +struct RepairItem<'a> { + id: usize, + q: &'a str, + concept: &'a str, + choices: Vec>, + answer: usize, +} + +#[derive(Serialize)] +struct RepairChoice<'a> { + text: &'a str, + why: &'a str, +} + +/// A precise repair prompt for `originals`: for each question, exactly which +/// fields break which limit and by how much, then the question itself to keep +/// verbatim otherwise, in the generation schema plus an `id`. +pub(crate) fn repair_prompt(originals: &[QQuestion]) -> String { + let questions = originals + .iter() + .enumerate() + .map(|(index, question)| { + let id = index + 1; + let fixes = question_violations(question) + .iter() + .map(|violation| format!("- {}", violation.describe())) + .collect::>() + .join("\n"); + let item = RepairItem { + id, + q: &question.q, + concept: question.concept.as_deref().unwrap_or_default(), + choices: question + .choices + .iter() + .map(|choice| RepairChoice { + text: choice.text(), + why: choice.why().unwrap_or_default(), + }) + .collect(), + answer: question.answer, + }; + let json = serde_json::to_string(&item).unwrap_or_default(); + format!("QUESTION id {id}\nFIX:\n{fixes}\nQUESTION JSON:\n{json}") + }) + .collect::>() + .join("\n\n"); + let lenses = lens_names(); + format!( + "You wrote quiz questions for a retro handheld game, and the display rejected the ones below. Each one fails only on mechanics. Fix ONLY the fields listed under FIX. Keep every other field exactly as written: the same question meaning, the same concept, the same choices in the same order, and the same answer index.\n\nDISPLAY LIMITS (hard: anything longer is discarded, not shortened):\n- \"q\" must wrap into at most {question_rows} lines of {question_columns} characters (about 100 characters).\n- Each choice \"text\" must be at most {choice_chars} characters; aim for 2 to 5 words and at most 28. Put nuance in its why, never in the choice text.\n- Each \"why\" must wrap into at most {why_rows} lines of {why_columns} characters (about 90 characters).\n- \"concept\" is exactly one of {lenses}.\n- Plain ASCII only: no curly quotes, long dashes, arrows, or accented letters.\n\nShorten by rewording, not by truncating words or sentences. Never add file names, paths, versions, or dates. Before answering, count the characters of every field you rewrite.\n\n{questions}\n\nRespond with ONLY a JSON array of the fixed questions, no prose and no code fences, one object per question above, each keeping its \"id\":\n[{{\"id\":N,\"q\":\"...\",\"concept\":\"...\",\"choices\":[{{\"text\":\"...\",\"why\":\"...\"}},{{\"text\":\"...\",\"why\":\"...\"}},{{\"text\":\"...\",\"why\":\"...\"}},{{\"text\":\"...\",\"why\":\"...\"}}],\"answer\":N}}]", + question_rows = engine::QUIZ_QUESTION_ROWS, + question_columns = engine::QUIZ_QUESTION_COLUMNS, + choice_chars = engine::QUIZ_CHOICE_CHARS, + why_rows = learning::RATIONALE_ROWS, + why_columns = learning::RATIONALE_COLUMNS, + ) } /// Parses one saved batch leniently: a question this build cannot read or no @@ -942,11 +1362,7 @@ pub(crate) fn ai_question_prompt( .map(|concept| format!("- {}: {}", concept_key(*concept), lens_guide(*concept))) .collect::>() .join("\n"); - let lens_names = Concept::ALL - .iter() - .map(|concept| concept_key(*concept)) - .collect::>() - .join("|"); + let lens_names = lens_names(); let focus = Concept::focus_for_level(level) .iter() .map(|concept| concept_key(*concept)) @@ -1014,6 +1430,89 @@ pub(crate) mod tests { const RATIONALE: &str = "THIS NAMES THE MODEL THE DESIGN DEPENDS ON."; + /// Parses one provider response into at most `count` playable questions, + /// as a generation request without a repair pass would. + fn parse_generated_questions(response: &str, count: usize) -> Result, String> { + parse_generated_batch(response, count)?.into_questions() + } + + /// A provider reply modeled on real over-length output: one accepted + /// question, three that fail only on mechanics, a trivia question, and a + /// malformed one. + pub(crate) const OVER_LENGTH_RESPONSE: &str = r#"[ + { + "q": "Why does the engine own every game rule?", + "concept": "Responsibilities", + "choices": [ + {"text": "So the shell only draws frames", "why": "One owner keeps rules consistent; the shell just shows pixels."}, + {"text": "So the UI can skip the engine", "why": "Misconception: a bypass would split the rules across layers."}, + {"text": "To make the shell faster", "why": "Misconception: speed is not why rules live in one place."}, + {"text": "To store rules in the styles", "why": "Misconception: presentation cannot enforce game rules."} + ], + "answer": 0 + }, + { + "q": "What happens when a provider reply mixes valid and invalid questions?", + "concept": "Flows", + "choices": [ + {"text": "Valid ones survive; batch may be short", "why": "Each question is judged alone, so good ones are kept."}, + {"text": "The whole reply is discarded", "why": "Misconception: one bad question does not sink the rest."}, + {"text": "Bad questions are cut to fit", "why": "Misconception: over-long text is rejected, never cut."}, + {"text": "A filler deck replaces them", "why": "Misconception: the game has no filler question deck."} + ], + "answer": 0 + }, + { + "q": "Why does loading a cartridge never run its code?", + "concept": "invariant", + "choices": [ + {"text": "Running code would be too slow", "why": "Misconception: speed is not why loaded code stays inert."}, + {"text": "Trusted handlers keep untrusted repos safe", "why": "Only the app's own handlers act, so a hostile project cannot."}, + {"text": "The shell cannot start programs", "why": "Misconception: the shell could, but loading never asks it to."}, + {"text": "Saves would grow too large", "why": "Misconception: save size has nothing to do with running code."} + ], + "answer": 1 + }, + { + "q": "In a design where the engine owns every rule and the shell only draws the frames it receives each tick, what must the shell never do?", + "concept": "architecture", + "choices": [ + "Decide game rules itself", + {"text": "Draw frames → pixels", "why": "Misconception: drawing frames is exactly the shell's job."}, + {"text": "Forward player input", "why": "Misconception: the shell must forward input to the engine."}, + {"text": "Play the engine's notes", "why": "Misconception: playing notes is fine for the shell because the engine emits them with tick stamps, so this is allowed and not wrong."} + ], + "answer": 0 + }, + { + "q": "Which file owns the game loop?", + "concept": "responsibility", + "choices": [ + {"text": "The one that holds the engine and its systems", "why": "It schedules every system."}, + {"text": "The shell script", "why": "Misconception: the shell only draws."}, + {"text": "The style sheet", "why": "Misconception: styles hold no logic."}, + {"text": "The manifest", "why": "Misconception: manifests only configure."} + ], + "answer": 0 + }, + { + "q": "Why keep saves versioned?", + "concept": "invariant", + "choices": [ + {"text": "Old saves stay readable by new builds forever", "why": "Versions let loaders migrate."}, + {"text": "Saves get smaller", "why": "Misconception: versions add bytes."}, + {"text": "Loading gets faster", "why": "Misconception: speed is not the goal."} + ], + "answer": 0 + } + ]"#; + + /// [`OVER_LENGTH_RESPONSE`], parsed: one accepted question and five + /// rejections in reply order. + pub(crate) fn over_length_batch() -> GeneratedBatch { + parse_generated_batch(OVER_LENGTH_RESPONSE, 6).unwrap() + } + /// A complete payload v2 question with `answer` 0. pub(crate) fn question(text: &str, choices: &[&str]) -> QQuestion { QQuestion { @@ -2022,4 +2521,359 @@ pub(crate) mod tests { assert_eq!(Concept::parse(concept_key(concept)), Some(concept)); } } + + #[test] + fn diagnostics_measure_each_violation_of_a_rejected_question() { + let batch = over_length_batch(); + assert_eq!(batch.accepted.len(), 1); + assert_eq!( + batch.accepted[0].q, + "WHY DOES THE ENGINE OWN EVERY GAME RULE?" + ); + assert_eq!(batch.rejected.len(), 5); + let violations = batch + .rejected + .iter() + .map(question_violations) + .collect::>(); + + assert_eq!( + violations[0], + [Violation::ChoiceTooLong { + choice: 0, + chars: 38 + }], + "VALID ONES SURVIVE; BATCH MAY BE SHORT" + ); + assert_eq!( + batch.rejected[0].concept.as_deref(), + Some("interaction"), + "a lens spelling the parser maps is canonicalized, not reported" + ); + assert_eq!( + violations[1], + [Violation::ChoiceTooLong { + choice: 1, + chars: 42 + }], + "TRUSTED HANDLERS KEEP UNTRUSTED REPOS SAFE" + ); + assert_eq!( + violations[2], + [ + Violation::QuestionTooLong { + chars: 133, + lines: 5 + }, + Violation::UnknownLens { + given: Some("architecture".into()) + }, + Violation::WhyMissing { choice: 0 }, + Violation::WhyTooLong { + choice: 3, + chars: 132, + lines: 5 + }, + Violation::NotAscii { + field: Field::Choice(1), + characters: vec!['\u{2192}'] + }, + ] + ); + assert!(violations[3].contains(&Violation::Trivia { + field: Field::Question + })); + assert!(violations[3].contains(&Violation::ChoiceTooLong { + choice: 0, + chars: 45 + })); + assert!(violations[4].contains(&Violation::Malformed("A QUESTION NEEDS EXACTLY 4 CHOICES"))); + + let repairable = batch.rejected.iter().map(is_repairable).collect::>(); + assert_eq!( + repairable, + [true, true, true, false, false], + "trivia and malformed questions are never repaired, whatever else they fail" + ); + assert!(!is_repairable(&batch.accepted[0]), "nothing to repair"); + } + + #[test] + fn diagnostics_agree_with_the_acceptance_policy() { + let mut candidates = over_length_batch().rejected; + candidates.extend(over_length_batch().accepted); + candidates.push(engine_state_question()); + let mut located_why = engine_state_question(); + located_why.choices[0] = QChoice::Explained { + text: "THE GAME ENGINE".into(), + why: "IT IS DEFINED IN ENGINE.RS.".into(), + }; + candidates.push(located_why); + let mut empty_why = engine_state_question(); + empty_why.choices[2] = QChoice::Explained { + text: "THE STYLES".into(), + why: " ".into(), + }; + candidates.push(empty_why); + let mut out_of_range = engine_state_question(); + out_of_range.answer = 4; + candidates.push(out_of_range); + let mut unrenderable_why = engine_state_question(); + unrenderable_why.choices[1] = QChoice::Explained { + text: "THE DEVICE SHELL".into(), + why: "MISCONCEPTION: THE SHELL \u{2260} THE ENGINE.".into(), + }; + candidates.push(unrenderable_why); + candidates.push(question( + "WHAT SHOULD OWN GAMEPLAY STATE?", + &["THE ENGINE", "the engine ", "THE VIEW", "THE SHELL"], + )); + candidates.push(question("", &["A", "B", "C", "D"])); + candidates.push(legacy_question( + "WHAT SHOULD OWN GAMEPLAY STATE?", + &["THE GAME ENGINE", "THE SHELL", "THE STYLES", "THE VIEW"], + )); + + for candidate in &candidates { + let violations = question_violations(candidate); + assert_eq!( + violations.is_empty(), + generated_question_is_acceptable(candidate), + "{candidate:?}: {violations:?}" + ); + } + let legacy = candidates.last().unwrap(); + assert_eq!( + question_violations(legacy), + [ + Violation::UnknownLens { given: None }, + Violation::WhyMissing { choice: 0 }, + Violation::WhyMissing { choice: 1 }, + Violation::WhyMissing { choice: 2 }, + Violation::WhyMissing { choice: 3 }, + ] + ); + } + + #[test] + fn repair_prompt_names_each_measured_violation_and_repeats_the_rest_verbatim() { + let batch = over_length_batch(); + let request = batch + .repair_request(6) + .expect("three rejections are mechanical"); + assert_eq!(request.originals, batch.rejected[..3]); + assert_eq!(request.prompt, repair_prompt(&request.originals), "pure"); + + let prompt = &request.prompt; + for expected in [ + "QUESTION id 1\nFIX:\n- choices[0].text is 38 characters; the limit is 31.\nQUESTION JSON:\n", + "QUESTION id 2\nFIX:\n- choices[1].text is 42 characters; the limit is 31.\nQUESTION JSON:\n", + "- \"q\" is 133 characters and wraps into 5 lines of 31; the limit is 4 lines (about 100 characters).", + "- \"concept\" is \"architecture\", which is not a lens; use exactly one of purpose|responsibility|interaction|invariant|tradeoff.", + "- choices[0].why is missing; write one of at most 90 characters.", + "- choices[3].why is 132 characters and wraps into 5 lines of 34; the limit is 3 lines (about 90 characters).", + "- choices[1].text contains '\u{2192}' (U+2192), which the display cannot draw; use plain ASCII.", + "Fix ONLY the fields listed under FIX", + "the same answer index", + "Never add file names, paths, versions, or dates", + "each keeping its \"id\"", + ] { + assert!(prompt.contains(expected), "missing {expected:?} in\n{prompt}"); + } + assert!( + !prompt.contains("WHICH FILE OWNS THE GAME LOOP"), + "trivia is not repaired" + ); + assert!( + !prompt.contains("WHY KEEP SAVES VERSIONED"), + "malformed is not repaired" + ); + assert!(!prompt.contains("WHY DOES THE ENGINE OWN EVERY GAME RULE")); + + // Each question travels in the generation schema plus its id, with + // every field it keeps unchanged. + let sent = prompt + .split("QUESTION JSON:\n") + .skip(1) + .map(|rest| { + let line = rest.lines().next().unwrap(); + serde_json::from_str::(line).unwrap() + }) + .collect::>(); + assert_eq!(sent.len(), 3); + for (index, (value, original)) in sent.iter().zip(&request.originals).enumerate() { + assert_eq!(value["id"], index + 1); + assert_eq!(value["q"], original.q.as_str()); + assert_eq!(value["answer"], original.answer); + assert_eq!(value["choices"].as_array().unwrap().len(), 4); + } + assert_eq!( + serde_json::from_value::(sent[1].clone()).unwrap(), + request.originals[1], + "an explained question round-trips exactly" + ); + assert_eq!( + sent[2]["choices"][0], + serde_json::json!({ "text": "DECIDE GAME RULES ITSELF", "why": "" }), + "a plain choice shows where its rationale goes" + ); + } + + #[test] + fn a_repair_is_requested_only_for_a_short_batch_with_mechanical_rejections() { + let batch = over_length_batch(); + assert!(batch.repair_request(1).is_none(), "already full"); + assert_eq!( + batch.repair_request(2).unwrap().originals.len(), + 2, + "capped" + ); + + let unrepairable = GeneratedBatch { + accepted: Vec::new(), + rejected: batch.rejected[3..].to_vec(), + }; + assert!(unrepairable.repair_request(6).is_none()); + + // A rejection repeating an accepted question, or another rejection, + // is not sent: its repair could only duplicate. + let mut repeated = batch.rejected[0].clone(); + repeated.q = batch.accepted[0].q.clone(); + let duplicates = GeneratedBatch { + accepted: batch.accepted.clone(), + rejected: vec![ + repeated, + batch.rejected[1].clone(), + batch.rejected[1].clone(), + ], + }; + assert_eq!( + duplicates.repair_request(6).unwrap().originals, + [batch.rejected[1].clone()] + ); + } + + #[test] + fn merged_repairs_keep_their_answer_and_never_exceed_the_count_or_repeat() { + let mut batch = over_length_batch(); + let request = batch.repair_request(6).unwrap(); + let reply = r#"Here are the fixes: + [ + {"id": 1, "q": "What happens when a provider reply mixes valid and invalid questions?", "concept": "interaction", "choices": [ + {"text": "Valid ones survive alone", "why": "Each question is judged alone, so good ones are kept."}, + {"text": "The whole reply is discarded", "why": "Misconception: one bad question does not sink the rest."}, + {"text": "Bad questions are cut to fit", "why": "Misconception: over-long text is rejected, never cut."}, + {"text": "A filler deck replaces them", "why": "Misconception: the game has no filler question deck."} + ], "answer": 0}, + {"id": 1, "q": "Why are mixed replies kept in part?", "concept": "interaction", "choices": [ + {"text": "Each is judged alone", "why": "A second repair of the same question is ignored."}, + {"text": "B", "why": "B."}, {"text": "C", "why": "C."}, {"text": "D", "why": "D."} + ], "answer": 0}, + {"id": 2, "q": "Why does the engine own every game rule?", "concept": "invariant", "choices": [ + {"text": "Running code would be too slow", "why": "Misconception: speed is not why loaded code stays inert."}, + {"text": "Only its own handlers act", "why": "Only the app's own handlers act, so a hostile project cannot."}, + {"text": "The shell cannot start programs", "why": "Misconception: the shell could, but loading never asks it to."}, + {"text": "Saves would grow too large", "why": "Misconception: save size has nothing to do with running code."} + ], "answer": 1}, + {"id": 2, "q": "Why does loading a cartridge never run its code?", "concept": "invariant", "choices": [ + {"text": "Only its own handlers act", "why": "Only the app's own handlers act, so a hostile project cannot."}, + {"text": "Running code would be too slow", "why": "Misconception: speed is not why loaded code stays inert."}, + {"text": "The shell cannot start programs", "why": "Misconception: the shell could, but loading never asks it to."}, + {"text": "Saves would grow too large", "why": "Misconception: save size has nothing to do with running code."} + ], "answer": 0}, + {"id": 2, "q": "Why does loading a cartridge never run its code?", "concept": "invariant", "choices": [ + {"text": "Running code would be too slow", "why": "Misconception: speed is not why loaded code stays inert."}, + {"text": "Only its own handlers act", "why": "Only the app's own handlers act, so a hostile project cannot."}, + {"text": "The shell cannot start programs", "why": "Misconception: the shell could, but loading never asks it to."}, + {"text": "Saves would grow too large", "why": "Misconception: save size has nothing to do with running code."} + ], "answer": 1}, + {"id": 3, "q": "What must the shell never do?", "concept": "architecture", "choices": [ + {"text": "Decide game rules itself", "why": "Rules belong to the engine alone."}, + {"text": "Draw frames", "why": "Misconception: drawing frames is the shell's job."}, + {"text": "Forward player input", "why": "Misconception: the shell must forward input."}, + {"text": "Play the engine's notes", "why": "Misconception: the shell plays what the engine emits."} + ], "answer": 0}, + {"id": 7, "q": "Why is this question new?", "concept": "purpose", "choices": [ + {"text": "It was never requested", "why": "Unrequested questions have no brief behind them."}, + {"text": "B", "why": "B."}, {"text": "C", "why": "C."}, {"text": "D", "why": "D."} + ], "answer": 0}, + {"q": "Why has this question no id?", "concept": "purpose", "choices": [ + {"text": "It forgot its id", "why": "Repairs must say which question they fix."}, + {"text": "B", "why": "B."}, {"text": "C", "why": "C."}, {"text": "D", "why": "D."} + ], "answer": 0} + ]"#; + + let mut capped = batch.clone(); + assert_eq!(capped.merge_repairs(&request, reply, 2), 1); + assert_eq!(capped.accepted.len(), 2, "never beyond the count"); + + assert_eq!(batch.merge_repairs(&request, reply, 6), 2); + let accepted = batch + .accepted + .iter() + .map(|question| { + ( + question.q.as_str(), + question.choices[question.answer].text(), + ) + }) + .collect::>(); + assert_eq!( + accepted, + [ + ( + "WHY DOES THE ENGINE OWN EVERY GAME RULE?", + "SO THE SHELL ONLY DRAWS FRAMES" + ), + ( + "WHAT HAPPENS WHEN A PROVIDER REPLY MIXES VALID AND INVALID QUESTIONS?", + "VALID ONES SURVIVE ALONE" + ), + ( + "WHY DOES LOADING A CARTRIDGE NEVER RUN ITS CODE?", + "ONLY ITS OWN HANDLERS ACT" + ), + ], + "one repair per id; repeats, moved answers, failed fixes, and unrequested questions drop" + ); + assert!(batch.accepted.iter().all(generated_question_is_acceptable)); + assert_eq!( + batch.accepted.len(), + batch + .accepted + .iter() + .map(|question| question_identity(&question.q)) + .collect::>() + .len() + ); + + let mut unchanged = over_length_batch(); + assert_eq!(unchanged.merge_repairs(&request, "no json here", 6), 0); + assert_eq!(unchanged.merge_repairs(&request, "[not json]", 6), 0); + assert_eq!(unchanged, over_length_batch()); + } + + #[test] + fn a_batch_with_no_accepted_questions_is_an_error_until_repaired() { + let mut batch = over_length_batch(); + batch.accepted.clear(); + assert_eq!( + batch.clone().into_questions().unwrap_err(), + "INCOMPLETE OR INVALID QUESTIONS" + ); + let request = batch.repair_request(6).unwrap(); + let fixed = serde_json::to_string(&serde_json::json!([{ + "id": 1, + "q": request.originals[0].q, + "concept": "interaction", + "choices": request.originals[0].choices.iter().enumerate().map(|(index, choice)| { + let text = if index == 0 { "VALID ONES SURVIVE" } else { choice.text() }; + serde_json::json!({ "text": text, "why": choice.why() }) + }).collect::>(), + "answer": 0, + }])) + .unwrap(); + assert_eq!(batch.merge_repairs(&request, &fixed, 6), 1); + assert_eq!(batch.into_questions().unwrap().len(), 1); + } } From bcc682e0423a1cbc76cf8e40c0a9d8e7970da50b Mon Sep 17 00:00:00 2001 From: Jason Skrzypek Date: Fri, 25 Sep 2026 11:30:15 -0400 Subject: [PATCH 19/60] Tell the waiting Oracle why generation failed, and recall lessons meanwhile The question loader now returns Result, String>. lib.rs logs the full error and passes the engine a short category: TIMED OUT, CLI UNAVAILABLE, RATE LIMITED, PROVIDER OVERLOADED, LOGIN NEEDED, BATTERIES UNVERIFIED, NOT A GIT REPO, CALL FAILED, REJECTED BATCH, SAVE FAILED, or AI DISABLED. The reply travels in EngineCommand::Questions under the existing request seq, so a superseded or foreign failure is ignored. The engine keeps the 300-tick retry, cleans the reason into one upper-case line of at most 24 characters, treats an empty batch as NO NEW QUESTIONS, and clears the reason on success or when a cartridge is inserted. While the player waits, both Datafall renderers grow their header band by one row (sanctum 15->22 px, legacy 12->21 px). The band still ends above the highest drop spawn. The new line shows: - while a failure waits out its delay: " - RETRY IN NS"; - otherwise, one journal lesson as retrieval practice, "REVIEW|RECALL [LENS:] ANSWER", with outstanding misses first and then cleared lessons, newest first, changing every four seconds. The lens is dropped before any of the answer would be cut; - with no journal, " - RETRYING" while the retry is in flight. A new lesson is written in over a few ticks. Under reduced motion it appears whole and does not move. Datafall play does not change. Native tests cover seq-mismatched failures, showing and clearing the reason, empty batches, sanitizing, recall order and cycling, the static reduced-motion line, and layout: containment, disjointness from row one, drops, and hero, contrast of at least 4.5:1, no pixels outside the line box, and no change below the band. They also write preview frames 06c-06f. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/engine.rs | 816 +++++++++++++++++++++++++++++++++++++++- src-tauri/src/lib.rs | 133 ++++++- 2 files changed, 912 insertions(+), 37 deletions(-) diff --git a/src-tauri/src/engine.rs b/src-tauri/src/engine.rs index 7a92464..fa5cb19 100644 --- a/src-tauri/src/engine.rs +++ b/src-tauri/src/engine.rs @@ -502,8 +502,10 @@ pub struct QuizQuestion { pub review: bool, } +/// 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>; + Arc Result, String> + Send + Sync + 'static>; pub type AnsweredQuestionRecorder = Arc; pub fn quiz_question_fits(question: &str, choices: &[String], answer: usize) -> bool { @@ -652,7 +654,8 @@ enum EngineCommand { 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, }, @@ -1203,6 +1206,9 @@ struct GameState { question_request_seq: u64, /// The level the newest question request asked for. question_request_level: u32, + /// Why the newest answered question request failed, as the Oracle line + /// shows it; cleared when a request succeeds or a cartridge is inserted. + question_failure: Option, /// Deck prefix committed in this session; the next run drops it. consumed_questions: usize, oracle_hero_x: i32, @@ -1240,6 +1246,7 @@ impl Default for GameState { question_retry_ticks: 0, question_request_seq: 0, question_request_level: 1, + question_failure: None, consumed_questions: 0, oracle_hero_x: 104, oracle_drops: Vec::new(), @@ -1568,6 +1575,123 @@ impl GameState { ) }) } + + /// Journal lessons in the order the waiting Oracle recalls them: + /// outstanding misses first, then cleared lessons, each newest first. + fn recall_order(&self) -> Vec<&Lesson> { + let lessons = self.lessons(); + let outstanding = lessons.iter().rev().filter(|lesson| lesson.outstanding); + let cleared = lessons.iter().rev().filter(|lesson| !lesson.outstanding); + outstanding.chain(cleared).collect() + } + + /// The lesson the Oracle recalls now. Each wait starts from the top of + /// the recall order and moves on every `ORACLE_RECALL_TICKS`. + fn recalled_lesson(&self) -> Option<&Lesson> { + let order = self.recall_order(); + let slot = (self.screen_ticks / ORACLE_RECALL_TICKS) as usize; + order.get(slot.checked_rem(order.len())?).copied() + } + + /// The waiting Oracle's second status line. While a failed request waits + /// out its retry delay the line says why; otherwise it recalls a journal + /// lesson, and with no journal it keeps naming the last failure while + /// the retry is in flight. A ready question has no failure to explain. + fn oracle_line(&self) -> Option { + let failure = self + .question_failure + .as_ref() + .filter(|_| !self.has_unanswered_question()); + if let Some(reason) = failure.filter(|_| self.question_retry_ticks > 0) { + return Some(OracleLine::Failure { + reason: reason.clone(), + retry_in: Some(u64::from(self.question_retry_ticks).div_ceil(60)), + }); + } + if let Some(lesson) = self.recalled_lesson() { + return Some(OracleLine::Recall { + outstanding: lesson.outstanding, + concept: lesson.concept, + answer: lesson.answer.clone(), + }); + } + failure.map(|reason| OracleLine::Failure { + reason: reason.clone(), + retry_in: None, + }) + } + + /// Characters of the Oracle line drawn this tick. A newly recalled + /// lesson is written in over a few ticks; reduced motion shows it whole, + /// and a failure line is always whole. + fn oracle_line_reveal(&self, line: &OracleLine) -> usize { + match line { + OracleLine::Recall { .. } if !self.reduced_motion => { + ((self.screen_ticks % ORACLE_RECALL_TICKS) as usize + 1) + * ORACLE_RECALL_REVEAL_PER_TICK + } + _ => usize::MAX, + } + } +} + +/// The second line of a waiting Oracle's header. +#[derive(Clone, Debug, Eq, PartialEq)] +enum OracleLine { + /// Why the last question request failed, and the whole seconds until the + /// retry, or `None` while the retry is in flight. + Failure { + reason: String, + retry_in: Option, + }, + /// One journal lesson offered as retrieval practice. + Recall { + outstanding: bool, + concept: Option, + answer: String, + }, +} + +/// Ticks each recalled lesson stays on the Oracle line: four seconds. +const ORACLE_RECALL_TICKS: u64 = 240; +/// Characters a newly recalled lesson reveals per tick. +const ORACLE_RECALL_REVEAL_PER_TICK: usize = 2; +/// Longest failure reason the Oracle line carries. +const ORACLE_FAILURE_CHARS: usize = 24; + +/// A provider failure reason as the Oracle line shows it: one upper-case line +/// of letters, digits, and simple punctuation, cut at a word boundary. +fn oracle_failure_reason(reason: &str) -> String { + let cleaned: String = reason + .chars() + .map(|ch| { + let ch = ch.to_ascii_uppercase(); + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | ':' | '.' | '/') { + ch + } else { + ' ' + } + }) + .collect(); + let mut short = String::new(); + for word in cleaned.split_whitespace() { + let separator = usize::from(!short.is_empty()); + if short.chars().count() + separator + word.chars().count() > ORACLE_FAILURE_CHARS { + if short.is_empty() { + short = truncate(word, ORACLE_FAILURE_CHARS); + } + break; + } + if separator == 1 { + short.push(' '); + } + short.push_str(word); + } + if short.is_empty() { + "GENERATION FAILED".into() + } else { + short + } } fn request_question_batch(state: &mut GameState, effects: &mut Effects, level: u32) { @@ -2187,6 +2311,7 @@ fn apply_commands( state.pending_questions = None; state.questions_loading = false; state.question_retry_ticks = 0; + state.question_failure = None; // Replies to requests for the previous insert no longer land. state.question_request_seq = state.question_request_seq.wrapping_add(1); if state.cartridge.as_ref().is_some_and(|cartridge| { @@ -2200,22 +2325,29 @@ fn apply_commands( } EngineCommand::Questions { cartridge_id, - questions, + result, seq, } => { let is_current_quiz = state.cartridge.as_ref().is_some_and(|cartridge| { cartridge.id == cartridge_id && cartridge.mode() == CartridgeMode::Quiz }); // A superseded request's reply must not end the newer - // request's loading state or add a batch of its own. + // request's loading state, add a batch of its own, or + // report a failure the newer request has not had. if !is_current_quiz || seq != state.question_request_seq { continue; } state.questions_loading = false; - if questions.is_empty() { - state.question_retry_ticks = 300; - continue; - } + let questions = match result { + Ok(questions) if !questions.is_empty() => questions, + failed => { + let reason = failed.err().unwrap_or_else(|| "NO NEW QUESTIONS".into()); + state.question_failure = Some(oracle_failure_reason(&reason)); + state.question_retry_ticks = 300; + continue; + } + }; + state.question_failure = None; state.question_retry_ticks = 0; let level = state.question_request_level; if state.screen == Screen::Quiz { @@ -3051,6 +3183,95 @@ fn render_oracle_atelier(frame: &mut Framebuffer, state: &GameState) { frame.text(174, 148, "START:BIND", MIST, 1); } +/// Header band heights of the Datafall scenes with one row, and with the +/// Oracle line beneath it. The tall bands end above the highest drop. +const SANCTUM_HEADER_HEIGHT: i32 = 15; +const SANCTUM_TALL_HEADER_HEIGHT: i32 = 22; +const LEGACY_HEADER_HEIGHT: i32 = 12; +const LEGACY_TALL_HEADER_HEIGHT: i32 = 21; +/// Where each Datafall scene writes its Oracle line. +const SANCTUM_ORACLE_LINE_BOX: UiBox = UiBox { + x: 5, + y: 13, + width: 230, + height: 7, +}; +const LEGACY_ORACLE_LINE_BOX: UiBox = UiBox { + x: 4, + y: 12, + width: 232, + height: 7, +}; + +/// Rendered width of text segments drawn one space apart. +fn segments_width(segments: &[(String, Color)]) -> i32 { + let characters = segments + .iter() + .map(|(text, _)| text.chars().count()) + .sum::() + + segments.len().saturating_sub(1); + text_width(&" ".repeat(characters), 1) +} + +/// The Oracle line as colored segments that fit `width` pixels at full +/// letter spacing. A recalled lesson drops its lens label before any of its +/// answer would be cut. +fn oracle_line_segments(line: &OracleLine, width: i32) -> Vec<(String, Color)> { + match line { + OracleLine::Failure { reason, retry_in } => vec![ + (reason.clone(), AMBER), + ( + match retry_in { + Some(seconds) => format!("- RETRY IN {seconds}S"), + None => "- RETRYING".into(), + }, + MIST, + ), + ], + OracleLine::Recall { + outstanding, + concept, + answer, + } => { + let tag = if *outstanding { + ("REVIEW", AMBER) + } else { + ("RECALL", CYAN) + }; + let mut segments = vec![(tag.0.to_string(), tag.1)]; + if let Some(concept) = concept { + segments.push((format!("{}:", concept.label()), MIST)); + } + segments.push((truncate(answer.trim(), QUIZ_CHOICE_CHARS), PARCH)); + if segments.len() == 3 && segments_width(&segments) > width { + segments.remove(1); + } + segments + } + } +} + +/// Draws the Oracle line inside `bounds`, as many characters of it as +/// [`GameState::oracle_line_reveal`] allows this tick. +fn draw_oracle_line(frame: &mut Framebuffer, bounds: UiBox, state: &GameState, line: &OracleLine) { + let segments = oracle_line_segments(line, bounds.width); + debug_assert!( + segments_width(&segments) <= bounds.width, + "Oracle line {line:?} does not fit {bounds:?}" + ); + let mut remaining = state.oracle_line_reveal(line); + let mut x = bounds.x; + for (text, color) in segments { + if remaining == 0 { + break; + } + let shown = truncate(&text, remaining); + frame.text(x, bounds.y, &shown, color, 1); + remaining = remaining.saturating_sub(text.chars().count() + 1); + x += (text.chars().count() as i32 + 1) * GLYPH_ADVANCE; + } +} + fn render_oracle(frame: &mut Framebuffer, state: &GameState) { if state.uses_visual_template(VisualTemplate::Sanctum) { render_oracle_sanctum(frame, state); @@ -3061,9 +3282,18 @@ fn render_oracle(frame: &mut Framebuffer, state: &GameState) { let x = ((index * 71 + state.motion_ticks() as usize * 2) % WIDTH) as i32; frame.pixel(x, 14 + (index * 29 % 96) as i32, MIST); } - frame.rect(0, 0, WIDTH as i32, 12, NAVY); - frame.rect(0, 11, WIDTH as i32, 1, PLUM); + let oracle_line = state.oracle_line(); + let header_height = if oracle_line.is_some() { + LEGACY_TALL_HEADER_HEIGHT + } else { + LEGACY_HEADER_HEIGHT + }; + frame.rect(0, 0, WIDTH as i32, header_height, NAVY); + frame.rect(0, header_height - 1, WIDTH as i32, 1, PLUM); frame.text(4, 2, "ORACLE DATAFALL", GOLD, 1); + if let Some(line) = &oracle_line { + draw_oracle_line(frame, LEGACY_ORACLE_LINE_BOX, state, line); + } let status = if state.has_unanswered_question() { "QUESTION READY".to_string() } else if state.questions_loading { @@ -3136,12 +3366,22 @@ fn render_oracle_sanctum(frame: &mut Framebuffer, state: &GameState) { PresentationTier::Adept => frame.blit_rgb_graded(ORACLE_SANCTUM, 236, 250, 226), PresentationTier::OracleBound => frame.blit_rgb(ORACLE_SANCTUM), }; - frame.rect(0, 0, WIDTH as i32, 15, VOID); + let oracle_line = state.oracle_line(); + let (header_height, header_y) = if oracle_line.is_some() { + (SANCTUM_TALL_HEADER_HEIGHT, 4) + } else { + (SANCTUM_HEADER_HEIGHT, 5) + }; + frame.rect(0, 0, WIDTH as i32, header_height, VOID); + if oracle_line.is_some() { + // The tall band covers the plate's own header rule; restate it. + frame.rect(0, header_height - 1, WIDTH as i32, 1, NAVY); + } frame.rect(0, 143, WIDTH as i32, 17, VOID); - frame.text(5, 5, "DATAFALL", AMBER, 1); + frame.text(5, header_y, "DATAFALL", AMBER, 1); frame.text( 60, - 5, + header_y, tier.label(), match tier { PresentationTier::Initiate => CYAN_DIM, @@ -3160,7 +3400,10 @@ fn render_oracle_sanctum(frame: &mut Framebuffer, state: &GameState) { state.ai_provider_status("CHANNEL") }; let status_width = status.chars().count() as i32 * GLYPH_ADVANCE - 1; - frame.text(235 - status_width, 5, &status, CYAN, 1); + frame.text(235 - status_width, header_y, &status, CYAN, 1); + if let Some(line) = &oracle_line { + draw_oracle_line(frame, SANCTUM_ORACLE_LINE_BOX, state, line); + } if tier == PresentationTier::OracleBound { for (x, y) in [ @@ -4388,10 +4631,10 @@ fn handle_effect( let sender = sender.clone(); let loader = Arc::clone(question_loader); thread::spawn(move || { - let questions = loader(cartridge_id.clone(), level, count); + let result = loader(cartridge_id.clone(), level, count); let _ = sender.send(EngineCommand::Questions { cartridge_id, - questions, + result, seq, }); }); @@ -4778,7 +5021,20 @@ mod tests { engine, EngineCommand::Questions { cartridge_id: cartridge_id.into(), - questions, + result: Ok(questions), + seq, + }, + ); + } + + /// Fails the engine's newest question request with `reason`. + fn fail(engine: &mut GameEngine, cartridge_id: &str, reason: &str) { + let seq = engine_state(engine).question_request_seq; + issue( + engine, + EngineCommand::Questions { + cartridge_id: cartridge_id.into(), + result: Err(reason.into()), seq, }, ); @@ -7645,7 +7901,7 @@ mod tests { &mut engine, EngineCommand::Questions { cartridge_id: "/tmp/engine-test".into(), - questions: vec![concept_question(0)], + result: Ok(vec![concept_question(0)]), seq: first[0].1, }, ); @@ -7660,7 +7916,7 @@ mod tests { &mut engine, EngineCommand::Questions { cartridge_id: "/tmp/engine-test".into(), - questions: vec![concept_question(1)], + result: Ok(vec![concept_question(1)]), seq: second[0].1, }, ); @@ -7672,6 +7928,528 @@ mod tests { ); } + fn oracle_line_texts(state: &GameState) -> Vec { + state + .oracle_line() + .map(|line| { + oracle_line_segments(&line, SANCTUM_ORACLE_LINE_BOX.width) + .into_iter() + .map(|(text, _)| text) + .collect() + }) + .unwrap_or_default() + } + + #[test] + fn question_failures_reach_the_oracle_line_and_clear_on_success() { + let mut engine = waiting_oracle_engine(); + let current = engine_state(&engine).question_request_seq; + assert!(engine_state(&engine).questions_loading); + + // A superseded request's failure, or another cartridge's, says + // nothing about the request still in flight. + issue( + &mut engine, + EngineCommand::Questions { + cartridge_id: "/tmp/engine-test".into(), + result: Err("TIMED OUT".into()), + seq: current.wrapping_sub(1), + }, + ); + fail(&mut engine, "/tmp/other-cartridge", "TIMED OUT"); + let state = engine_state(&engine); + assert!(state.questions_loading, "the current request is in flight"); + assert_eq!(state.question_failure, None); + assert_eq!(state.question_retry_ticks, 0); + assert_eq!(state.oracle_line(), None); + + fail(&mut engine, "/tmp/engine-test", "timed out"); + let state = engine_state(&engine); + assert!(!state.questions_loading); + assert_eq!(state.question_failure.as_deref(), Some("TIMED OUT")); + assert_eq!( + state.oracle_line(), + Some(OracleLine::Failure { + reason: "TIMED OUT".into(), + retry_in: Some(5), + }) + ); + assert_eq!(oracle_line_texts(state), ["TIMED OUT", "- RETRY IN 5S"]); + let frame = engine.frame(); + let line = LEGACY_ORACLE_LINE_BOX; + let line_x = line.x as usize..(line.x + line.width) as usize; + let line_y = line.y as usize..(line.y + line.height) as usize; + assert!( + color_pixels_in_region(frame, AMBER, line_x.clone(), line_y.clone()) > 0, + "the Datafall header names the failure" + ); + + let mut requests = Vec::new(); + for _ in 0..300 { + engine.update(); + requests.extend(request_seqs(&engine.take_effects())); + } + assert_eq!(requests.len(), 1, "the existing retry delay still holds"); + let state = engine_state(&engine); + assert!(state.questions_loading); + assert_eq!( + oracle_line_texts(state), + ["TIMED OUT", "- RETRYING"], + "without a journal the line keeps the last reason while retrying" + ); + + deliver(&mut engine, "/tmp/engine-test", vec![concept_question(0)]); + let state = engine_state(&engine); + assert_eq!(state.question_failure, None, "success clears the reason"); + assert_eq!(state.oracle_line(), None); + let mut cleared = Framebuffer::default(); + render_oracle(&mut cleared, state); + assert_eq!( + color_pixels_in_region(&cleared.pixels, AMBER, line_x, line_y), + 0, + "the header returns to one row" + ); + } + + #[test] + fn an_empty_batch_is_reported_as_a_failure_too() { + let mut engine = waiting_oracle_engine(); + deliver(&mut engine, "/tmp/engine-test", Vec::new()); + let state = engine_state(&engine); + assert_eq!(state.question_failure.as_deref(), Some("NO NEW QUESTIONS")); + assert!(state.question_retry_ticks > 0); + + // Inserting a cartridge forgets the previous cartridge's failure. + let mut cartridge = quiz_cartridge(); + cartridge.questions.clear(); + issue(&mut engine, EngineCommand::Cartridge(Some(cartridge))); + assert_eq!(engine_state(&engine).question_failure, None); + } + + #[test] + fn failure_reasons_are_sanitized_to_one_short_device_line() { + assert_eq!(oracle_failure_reason("timed out"), "TIMED OUT"); + assert_eq!( + oracle_failure_reason("CLI\nUNAVAILABLE \u{2014} \u{2713} not found"), + "CLI UNAVAILABLE NOT" + ); + assert_eq!( + oracle_failure_reason("ONE TWO THREE FOUR FIVE SIX SEVEN"), + "ONE TWO THREE FOUR FIVE" + ); + assert_eq!( + oracle_failure_reason("SUPERCALIFRAGILISTICEXPIALIDOCIOUS"), + "SUPERCALIFRAGILISTICEXPI" + ); + for empty in ["", " ", "\u{2603}\u{2603}"] { + assert_eq!(oracle_failure_reason(empty), "GENERATION FAILED"); + } + for reason in [ + "RATE LIMITED", + "a much longer reason than the oracle line has room to show", + ] { + let short = oracle_failure_reason(reason); + assert!(short.chars().count() <= ORACLE_FAILURE_CHARS, "{short}"); + assert!(short + .chars() + .all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == ' ')); + } + } + + fn recall_lesson(answer: &str, concept: Option, outstanding: bool) -> Lesson { + Lesson { + question: format!("WHAT IS {answer}?"), + answer: answer.into(), + rationale: String::new(), + concept, + outstanding, + } + } + + /// A waiting Oracle state whose journal holds `lessons`. + fn waiting_state_with(lessons: Vec) -> GameState { + let mut cartridge = oracle_template_cartridge(); + cartridge.questions.clear(); + cartridge.lessons = lessons; + GameState { + cartridge: Some(cartridge), + ai_provider: Some("CLAUDE".into()), + quiz: Some(QuizRun::new()), + questions_loading: true, + ..Default::default() + } + } + + #[test] + fn the_oracle_recalls_missed_lessons_first_and_cycles_the_journal() { + let mut state = waiting_state_with(vec![ + recall_lesson("OLDEST CLEARED", Some(Concept::Purpose), false), + recall_lesson("OLDER MISS", Some(Concept::Interaction), true), + recall_lesson("NEWER CLEARED", None, false), + recall_lesson("NEWEST MISS", Some(Concept::Invariant), true), + ]); + let answer_at = |state: &mut GameState, ticks: u64| { + state.screen_ticks = ticks; + state.recalled_lesson().map(|lesson| lesson.answer.clone()) + }; + let expected = [ + "NEWEST MISS", + "OLDER MISS", + "NEWER CLEARED", + "OLDEST CLEARED", + ]; + for (slot, answer) in expected.iter().enumerate() { + let start = slot as u64 * ORACLE_RECALL_TICKS; + assert_eq!(answer_at(&mut state, start).as_deref(), Some(*answer)); + assert_eq!( + answer_at(&mut state, start + ORACLE_RECALL_TICKS - 1).as_deref(), + Some(*answer), + "each lesson holds for its whole slot" + ); + } + assert_eq!( + answer_at(&mut state, 4 * ORACLE_RECALL_TICKS).as_deref(), + Some("NEWEST MISS"), + "the cycle wraps back to the top" + ); + + state.screen_ticks = 0; + assert_eq!( + oracle_line_texts(&state), + ["REVIEW", "INVARIANTS:", "NEWEST MISS"] + ); + state.screen_ticks = 2 * ORACLE_RECALL_TICKS; + assert_eq!(oracle_line_texts(&state), ["RECALL", "NEWER CLEARED"]); + + // A failure waiting out its retry delay takes the line; once the + // retry is in flight the journal returns. + state.question_failure = Some("RATE LIMITED".into()); + state.questions_loading = false; + state.question_retry_ticks = 61; + assert_eq!(oracle_line_texts(&state), ["RATE LIMITED", "- RETRY IN 2S"]); + state.question_retry_ticks = 0; + state.questions_loading = true; + assert_eq!(oracle_line_texts(&state), ["RECALL", "NEWER CLEARED"]); + + // A ready question has no failure to explain, but recall continues. + state.question_retry_ticks = 120; + state.cartridge.as_mut().unwrap().questions = vec![concept_question(0)]; + assert_eq!(oracle_line_texts(&state), ["RECALL", "NEWER CLEARED"]); + + assert_eq!(waiting_state_with(Vec::new()).oracle_line(), None); + } + + #[test] + fn recalled_lessons_are_written_in_but_stay_static_under_reduced_motion() { + let header = |frame: &Framebuffer| { + frame.pixels[..WIDTH * SANCTUM_TALL_HEADER_HEIGHT as usize * 4].to_vec() + }; + let mut state = waiting_state_with(journal_lessons()); + let slot_start = ORACLE_RECALL_TICKS; + state.screen_ticks = slot_start; + let line = state.oracle_line().unwrap(); + assert_eq!( + state.oracle_line_reveal(&line), + ORACLE_RECALL_REVEAL_PER_TICK + ); + let mut arriving = Framebuffer::default(); + render_oracle_sanctum(&mut arriving, &state); + maybe_write_preview("06f-sanctum-recall-arriving", &arriving.pixels); + state.screen_ticks = slot_start + 60; + let mut settled = Framebuffer::default(); + render_oracle_sanctum(&mut settled, &state); + assert_ne!( + header(&arriving), + header(&settled), + "a new lesson is written in with motion" + ); + + state.reduced_motion = true; + assert_eq!(state.oracle_line_reveal(&line), usize::MAX); + for ticks in [ + slot_start, + slot_start + 1, + slot_start + 30, + slot_start + 239, + ] { + state.screen_ticks = ticks; + let mut still = Framebuffer::default(); + render_oracle_sanctum(&mut still, &state); + assert_eq!( + header(&still), + header(&settled), + "reduced motion shows the whole line at once and holds it still" + ); + } + } + + #[test] + fn recall_never_changes_datafall_play() { + let mut plain = waiting_oracle_engine(); + let mut recalling = waiting_oracle_engine(); + recalling + .app + .world_mut() + .resource_mut::() + .cartridge + .as_mut() + .unwrap() + .lessons = journal_lessons(); + for engine in [&mut plain, &mut recalling] { + issue( + engine, + EngineCommand::Input { + button: Button::Right, + pressed: true, + }, + ); + for _ in 0..240 { + engine.update(); + } + } + let play = |engine: &GameEngine| { + let state = engine_state(engine); + format!( + "{:?} {} {} {}", + state.oracle_drops, state.oracle_hero_x, state.oracle_data, state.oracle_bug_hits + ) + }; + assert!(engine_state(&recalling).oracle_line().is_some()); + assert_eq!(play(&plain), play(&recalling)); + assert_eq!(recalling.screen(), Screen::Oracle); + } + + #[test] + fn the_oracle_line_stays_contained_disjoint_and_readable() { + let screen = LayoutBounds { + x: 0, + y: 0, + width: WIDTH as i32, + height: HEIGHT as i32, + }; + let longest_answer = "W".repeat(QUIZ_CHOICE_CHARS); + let worst_lines = [ + OracleLine::Failure { + reason: "W".repeat(ORACLE_FAILURE_CHARS), + retry_in: Some(5), + }, + OracleLine::Failure { + reason: "W".repeat(ORACLE_FAILURE_CHARS), + retry_in: None, + }, + OracleLine::Recall { + outstanding: true, + concept: Some(Concept::Invariant), + answer: longest_answer.clone(), + }, + OracleLine::Recall { + outstanding: false, + concept: Some(Concept::Invariant), + answer: "W".repeat(18), + }, + ]; + // The lens is kept whenever it fits beside the answer. + assert_eq!( + oracle_line_segments(&worst_lines[3], SANCTUM_ORACLE_LINE_BOX.width).len(), + 3 + ); + assert_eq!( + oracle_line_segments(&worst_lines[2], SANCTUM_ORACLE_LINE_BOX.width) + .last() + .map(|(text, _)| text.clone()), + Some(longest_answer.clone()), + "the answer is never cut" + ); + + let drop_offset = DROP_SPRITE_SIZE as i32 / 2; + for (scene, band_height, band_color, line_box, row_one, hero) in [ + ( + "sanctum", + SANCTUM_TALL_HEADER_HEIGHT, + VOID, + SANCTUM_ORACLE_LINE_BOX, + vec![ + text_bounds(5, 4, "DATAFALL", 1), + text_bounds(60, 4, "ORACLE-BOUND", 1), + text_bounds( + 235 - text_width("CLAUDE:CHANNEL", 1), + 4, + "CLAUDE:CHANNEL", + 1, + ), + ], + LayoutBounds { + x: ORACLE_HERO_MIN_X, + y: 106, + width: ORACLE_HERO_MAX_X + HERO_SPRITE_WIDTH as i32 - ORACLE_HERO_MIN_X, + height: HERO_SPRITE_HEIGHT as i32, + }, + ), + ( + "legacy", + LEGACY_TALL_HEADER_HEIGHT, + NAVY, + LEGACY_ORACLE_LINE_BOX, + vec![ + text_bounds(4, 2, "ORACLE DATAFALL", 1), + text_bounds( + 211 - text_width("CONTACTING CLAUDE", 1), + 2, + "CONTACTING CLAUDE", + 1, + ), + text_bounds(216, 2, "...", 1), + ], + LayoutBounds { + x: ORACLE_HERO_MIN_X, + y: 111, + width: ORACLE_HERO_MAX_X + 28 - ORACLE_HERO_MIN_X, + height: 17, + }, + ), + ] { + let band = LayoutBounds { + x: 0, + y: 0, + width: WIDTH as i32, + height: band_height, + }; + let line = ui_box_bounds(line_box); + assert!(bounds_contains(screen, band), "{scene} band"); + assert!(bounds_contains(band, line), "{scene} line inside its band"); + for (index, element) in row_one.iter().enumerate() { + assert!(bounds_contains(band, *element), "{scene} row one {index}"); + assert!( + bounds_are_disjoint(*element, line), + "{scene} row one {index} overlaps the line" + ); + } + assert!( + bounds_are_disjoint(band, hero), + "{scene} band meets the hero" + ); + for lane in [24, 54, 82, 112, 142, 210] { + let highest_drop = LayoutBounds { + x: lane - drop_offset, + y: 30 - drop_offset, + width: DROP_SPRITE_SIZE as i32, + height: DROP_SPRITE_SIZE as i32, + }; + assert!( + bounds_are_disjoint(band, highest_drop), + "{scene} band would hide a falling drop in lane {lane}" + ); + } + for worst in &worst_lines { + let segments = oracle_line_segments(worst, line_box.width); + let drawn = LayoutBounds { + x: line_box.x, + y: line_box.y, + width: segments_width(&segments), + height: 7, + }; + assert!( + bounds_contains(line, drawn), + "{scene} {worst:?} overflows its line" + ); + for (text, color) in &segments { + assert!( + contrast_ratio(*color, band_color) >= 4.5, + "{scene} `{text}` is not readable on its band" + ); + } + + // Every pixel the line draws stays inside its box. + let mut isolated = Framebuffer::default(); + let state = GameState { + reduced_motion: true, + ..Default::default() + }; + draw_oracle_line(&mut isolated, line_box, &state, worst); + for (index, pixel) in isolated.pixels.as_chunks::<4>().0.iter().enumerate() { + if pixel[3] == 0 { + continue; + } + let (x, y) = ((index % WIDTH) as i32, (index / WIDTH) as i32); + assert!( + bounds_contains( + line, + LayoutBounds { + x, + y, + width: 1, + height: 1, + } + ), + "{scene} drew outside its line at ({x}, {y})" + ); + } + } + } + + // With a line, nothing below the tall band changes: drops, hero, + // plate, and footer are exactly what the one-row scene shows. + let mut quiet = waiting_state_with(Vec::new()); + quiet.oracle_drops = vec![OracleDrop { + x: 112, + y: 30, + kind: OracleDropKind::Data, + }]; + let mut recalling = waiting_state_with(vec![recall_lesson( + &longest_answer, + Some(Concept::Invariant), + true, + )]); + recalling.oracle_drops = quiet.oracle_drops.clone(); + recalling.screen_ticks = 60; + let mut cloudy = waiting_state_with(journal_lessons()); + cloudy.oracle_drops = quiet.oracle_drops.clone(); + cloudy.questions_loading = false; + cloudy.question_retry_ticks = 240; + cloudy.question_failure = Some("TIMED OUT".into()); + let mut quiet_frame = Framebuffer::default(); + render_oracle_sanctum(&mut quiet_frame, &quiet); + let below = SANCTUM_TALL_HEADER_HEIGHT as usize * WIDTH * 4; + for (name, state) in [ + ("06c-sanctum-recall", &recalling), + ("06d-sanctum-cloudy", &cloudy), + ] { + let mut frame = Framebuffer::default(); + render_oracle_sanctum(&mut frame, state); + maybe_write_preview(name, &frame.pixels); + assert_eq!( + frame.pixels[below..], + quiet_frame.pixels[below..], + "{name} changed the playfield" + ); + } + + let legacy_state = |lessons: Vec| { + let mut cartridge = quiz_cartridge(); + cartridge.questions.clear(); + cartridge.lessons = lessons; + GameState { + cartridge: Some(cartridge), + oracle_drops: recalling.oracle_drops.clone(), + screen_ticks: 60, + ..waiting_state_with(Vec::new()) + } + }; + let legacy_quiet = legacy_state(Vec::new()); + let legacy_recalling = legacy_state(journal_lessons()); + let mut legacy_quiet_frame = Framebuffer::default(); + render_oracle(&mut legacy_quiet_frame, &legacy_quiet); + let mut legacy_frame = Framebuffer::default(); + render_oracle(&mut legacy_frame, &legacy_recalling); + maybe_write_preview("06e-datafall-recall", &legacy_frame.pixels); + let legacy_below = LEGACY_TALL_HEADER_HEIGHT as usize * WIDTH * 4; + assert_eq!( + legacy_frame.pixels[legacy_below..], + legacy_quiet_frame.pixels[legacy_below..], + "the legacy line changed the playfield" + ); + } + #[test] fn a_reply_during_the_quiz_appends_to_a_pending_batch() { let mut engine = GameEngine::new(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d92c58b..32ff4f5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -737,17 +737,14 @@ fn load_verified_question_batch_with( count: usize, provider_state: &AiProviderState, generate: &F, -) -> Vec +) -> Result, String> where F: Fn(&std::path::Path, u32, usize, AiProvider) -> Result, String>, { - let Some(provider) = provider_state.ready_provider() else { - return Vec::new(); - }; - generate(path, level, count, provider).unwrap_or_else(|error| { - eprintln!("CODE QUEST question generation failed: {error}"); - Vec::new() - }) + let provider = provider_state + .ready_provider() + .ok_or_else(|| "AI BATTERIES NOT VERIFIED".to_string())?; + generate(path, level, count, provider) } /// The engine's question loader: a fresh verified batch, without questions @@ -758,13 +755,58 @@ fn load_new_questions_with( count: usize, provider_state: &AiProviderState, generate: &F, -) -> Vec +) -> Result, String> where F: Fn(&std::path::Path, u32, usize, AiProvider) -> Result, String>, { - let generated = load_verified_question_batch_with(path, level, count, provider_state, generate); + let generated = + load_verified_question_batch_with(path, level, count, provider_state, generate)?; let retired = questions::load_retired_questions(path).unwrap_or_default(); - questions::playable_new_questions(generated, &retired) + Ok(questions::playable_new_questions(generated, &retired)) +} + +/// The short reason the waiting Oracle shows for a failed generation +/// request. The full error goes to the log; the device gets its category. +fn question_failure_reason(error: &str) -> String { + let error = error.to_ascii_uppercase(); + let mentions = |needles: &[&str]| needles.iter().any(|needle| error.contains(needle)); + if mentions(&["TIMED OUT"]) { + "TIMED OUT" + } else if mentions(&["CLI UNAVAILABLE"]) { + "CLI UNAVAILABLE" + } else if mentions(&[ + "RATE LIMIT", + "RATE_LIMIT", + "USAGE LIMIT", + "TOO MANY REQUESTS", + "QUOTA", + ]) { + "RATE LIMITED" + } else if mentions(&["OVERLOADED"]) { + "PROVIDER OVERLOADED" + } else if mentions(&[ + "LOG IN", + "LOGGED IN", + "LOGIN", + "AUTHENTICAT", + "UNAUTHORIZED", + "API KEY", + ]) { + "LOGIN NEEDED" + } else if mentions(&["NOT VERIFIED"]) { + "BATTERIES UNVERIFIED" + } else if mentions(&["NOT A GIT REPOSITORY"]) { + "NOT A GIT REPO" + } else if mentions(&["CALL FAILED"]) { + "CALL FAILED" + } else if mentions(&["QUESTION", "JSON", "CLI OUTPUT"]) { + "REJECTED BATCH" + } else if mentions(&["SAVE"]) { + "SAVE FAILED" + } else { + "GENERATION FAILED" + } + .to_string() } /// Builds the answer recorder the engine calls when a player commits an @@ -952,7 +994,7 @@ pub fn run() { let question_provider_state = provider_state.clone(); let question_loader: engine::QuestionLoader = Arc::new(move |path, level, count| { if environment_flag_enabled(std::env::var("CQA_NO_AI").ok().as_deref()) { - return Vec::new(); + return Err("AI DISABLED".to_string()); } load_new_questions_with( std::path::Path::new(&path), @@ -961,6 +1003,10 @@ pub fn run() { &question_provider_state, &generate_and_save_questions, ) + .map_err(|error| { + eprintln!("CODE QUEST question generation failed: {error}"); + question_failure_reason(&error) + }) }); let (answered_question_recorder, _save_writer) = answer_recorder_with(|path, evidence| { let _ = questions::persist_answer_evidence(path, evidence); @@ -1064,18 +1110,28 @@ mod question_policy_tests { Ok(generated) }; - assert!(load_verified_question_batch_with(&repo, 1, 1, &providers, &generate).is_empty()); + let unverified = Err("AI BATTERIES NOT VERIFIED".to_string()); + assert_eq!( + load_verified_question_batch_with(&repo, 1, 1, &providers, &generate), + unverified + ); providers.select(Some(AiProvider::Codex)).unwrap(); - assert!(load_verified_question_batch_with(&repo, 1, 1, &providers, &generate).is_empty()); + assert_eq!( + load_verified_question_batch_with(&repo, 1, 1, &providers, &generate), + unverified + ); providers.mark_verified(AiProvider::Codex).unwrap(); - let codex = load_verified_question_batch_with(&repo, 1, 1, &providers, &generate); + let codex = load_verified_question_batch_with(&repo, 1, 1, &providers, &generate).unwrap(); assert_eq!(codex[0].q, "WHAT DOES CODEX GENERATE?"); providers.select(Some(AiProvider::Claude)).unwrap(); - assert!(load_verified_question_batch_with(&repo, 1, 1, &providers, &generate).is_empty()); + assert_eq!( + load_verified_question_batch_with(&repo, 1, 1, &providers, &generate), + unverified + ); providers.mark_verified(AiProvider::Claude).unwrap(); - let claude = load_verified_question_batch_with(&repo, 1, 1, &providers, &generate); + let claude = load_verified_question_batch_with(&repo, 1, 1, &providers, &generate).unwrap(); assert_eq!(claude[0].q, "WHAT DOES CLAUDE GENERATE?"); assert_eq!( @@ -1931,7 +1987,7 @@ mod question_policy_tests { let batch = vec![retired, missed.clone()]; let generate = |_: &std::path::Path, _: u32, _: usize, _: AiProvider| Ok(batch.clone()); - let loaded = load_new_questions_with(&repo, 2, 6, &providers, &generate); + let loaded = load_new_questions_with(&repo, 2, 6, &providers, &generate).unwrap(); assert_eq!(loaded.len(), 1); assert_eq!(loaded[0].question, missed.q); @@ -1939,9 +1995,50 @@ mod question_policy_tests { assert_eq!(loaded[0].rationales, missed.rationales()); assert!(!loaded[0].review); + let failing = |_: &std::path::Path, _: u32, _: usize, _: AiProvider| { + Err("CLAUDE CALL TIMED OUT".to_string()) + }; + assert_eq!( + load_new_questions_with(&repo, 2, 6, &providers, &failing).map(|batch| batch.len()), + Err("CLAUDE CALL TIMED OUT".to_string()), + "the loader passes a failure on instead of an empty batch" + ); + remove_temporary_repo(repo); } + #[test] + fn question_failures_reach_the_device_as_short_categories() { + for (error, reason) in [ + ("CLAUDE CALL TIMED OUT", "TIMED OUT"), + ("CODEX CLI UNAVAILABLE - NOT FOUND", "CLI UNAVAILABLE"), + ( + "CLAUDE CALL FAILED - ERROR: RATE LIMIT REACHED FOR REQUESTS", + "RATE LIMITED", + ), + ( + "CODEX CALL FAILED - USAGE LIMIT HIT. TRY AGAIN", + "RATE LIMITED", + ), + ( + "CLAUDE CALL FAILED - API ERROR: OVERLOADED", + "PROVIDER OVERLOADED", + ), + ("CLAUDE CALL FAILED - INVALID API KEY", "LOGIN NEEDED"), + ("CODEX CALL FAILED - NOT LOGGED IN", "LOGIN NEEDED"), + ("AI BATTERIES NOT VERIFIED", "BATTERIES UNVERIFIED"), + ("NOT A GIT REPOSITORY", "NOT A GIT REPO"), + ("CODEX CALL FAILED - EXIT CODE 7", "CALL FAILED"), + ("INCOMPLETE OR INVALID QUESTIONS", "REJECTED BATCH"), + ("NO JSON IN RESPONSE", "REJECTED BATCH"), + ("BAD CLAUDE CLI OUTPUT", "REJECTED BATCH"), + ("COULD NOT SERIALIZE CARTRIDGE SAVE", "SAVE FAILED"), + ("something new went wrong", "GENERATION FAILED"), + ] { + assert_eq!(question_failure_reason(error), reason, "{error}"); + } + } + #[test] fn the_answer_recorder_persists_evidence_in_commit_order_off_the_engine_thread() { let repo = temporary_git_repo(); From 4cc9b71788a50baac75275ad2508426ae4add0a3 Mon Sep 17 00:00:00 2001 From: Jason Skrzypek Date: Fri, 25 Sep 2026 11:34:03 -0400 Subject: [PATCH 20/60] Describe every screen for screen readers through an engine transcript The game is a canvas, so a screen reader got nothing. The engine now has a third output channel beside the framebuffer and the chip notes: a plain-language transcript of what a sighted player can read and act on. It covers the chronicle credits, each opening beat, the title, the menu with its focus and journal, hero creation, the Datafall status and counts, the trial question with its choices in display order and the focused choice, the lesson card with both rationales, leave confirmation, level-up, game over, the Codex mastery and lesson pages, and the quest screens. The transcript is derived from the renderers' own state and helpers (the display order, the lesson composition, the feedback banner, the journal summary, the Oracle status order), so words and pixels cannot disagree. Blinks, motion, and staged reveals are left out, and the lesson card reads the same before and after its input hold, so nothing is announced twice. EngineRuntime publishes the transcript with a sequence number only when it changes, and engine_transcript(since) returns it only when newer. The shell polls every 150 ms from the animation-frame loop behind its own in-flight guard and writes a visually hidden polite live region only on a new sequence number. The browser demo returns no transcript. Co-Authored-By: Claude Opus 5.5 --- README.md | 9 + package.json | 2 +- src-tauri/src/engine.rs | 21 + src-tauri/src/engine/transcript.rs | 1292 ++++++++++++++++++++++++++ src-tauri/src/lib.rs | 10 +- src/index.html | 1 + src/main.js | 29 + tests/device-transcript-contract.mjs | 156 ++++ 8 files changed, 1518 insertions(+), 2 deletions(-) create mode 100644 src-tauri/src/engine/transcript.rs create mode 100644 tests/device-transcript-contract.mjs diff --git a/README.md b/README.md index 8866278..1c70e4d 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,14 @@ 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. + ## Install Download the current packages from the @@ -294,6 +302,7 @@ Gameplay and device presentation have a hard boundary: |---|---| | `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 | diff --git a/package.json b/package.json index 79b774b..a50ccd7 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/device-shell-robustness-contract.mjs && node tests/packaging-contract.mjs && node tests/device-audio-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", "tauri": "tauri" }, "devDependencies": { diff --git a/src-tauri/src/engine.rs b/src-tauri/src/engine.rs index 7a92464..4a3cccc 100644 --- a/src-tauri/src/engine.rs +++ b/src-tauri/src/engine.rs @@ -1931,11 +1931,17 @@ impl Default for GameEngine { } } +mod transcript; + +use transcript::TranscriptChannel; +pub use transcript::TranscriptUpdate; + #[derive(Clone)] pub struct EngineRuntime { sender: mpsc::Sender, frame: Arc>>, audio: Arc>, + transcript: Arc>, } impl EngineRuntime { @@ -1948,6 +1954,8 @@ impl EngineRuntime { let shared_frame = Arc::clone(&frame); let audio = Arc::new(Mutex::new(AudioQueue::default())); let shared_audio = Arc::clone(&audio); + let transcript = Arc::new(Mutex::new(TranscriptChannel::default())); + let shared_transcript = Arc::clone(&transcript); let engine_sender = sender.clone(); let child = Arc::new(Mutex::new(None)); let running_child = Arc::clone(&child); @@ -1977,6 +1985,10 @@ impl EngineRuntime { if let Ok(mut queue) = shared_audio.lock() { queue.append(engine.take_audio()); } + let text = engine.transcript(); + if let Ok(mut channel) = shared_transcript.lock() { + channel.publish(text); + } next_frame += FRAME_TIME; if let Some(remaining) = next_frame.checked_duration_since(Instant::now()) { thread::sleep(remaining); @@ -1991,6 +2003,7 @@ impl EngineRuntime { sender, frame, audio, + transcript, } } @@ -2033,6 +2046,14 @@ impl EngineRuntime { .unwrap_or_default() } + /// The latest screen transcript, when it is newer than `seq`. + pub fn transcript_since(&self, seq: u64) -> Option { + self.transcript + .lock() + .ok() + .and_then(|channel| channel.since(seq)) + } + fn send(&self, command: EngineCommand) -> Result<(), String> { self.sender .send(command) diff --git a/src-tauri/src/engine/transcript.rs b/src-tauri/src/engine/transcript.rs new file mode 100644 index 0000000..cfc638e --- /dev/null +++ b/src-tauri/src/engine/transcript.rs @@ -0,0 +1,1292 @@ +//! The screen transcript: the engine's third output channel, beside the +//! framebuffer and the chip sound. +//! +//! The whole game is a canvas, so a screen reader cannot read it. Each tick +//! the engine describes, in plain sentences, what a sighted player can read +//! and act on right now: the question and its choices in display order, which +//! choice has focus, the lesson card, and the controls that work. The shell +//! only places the latest transcript in a polite live region. +//! +//! Every sentence is derived from the same state and helpers the renderers +//! use (the display order, the lesson composition, the feedback banner, the +//! Oracle status rules), so the transcript and the pixels cannot disagree. +//! Decorative motion, blinking prompts, and staged reveals are left out: the +//! transcript changes only when something the player can act on changes, so +//! the live region is not re-announced every tick. + +use serde::Serialize; + +use super::*; + +/// The `engine_transcript` payload: the latest transcript and the sequence +/// number it was published under. +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)] +pub struct TranscriptUpdate { + pub seq: u64, + pub text: String, +} + +/// The latest published transcript. The sequence number advances only when +/// the text changes, so a reader that remembers it hears each screen once. +#[derive(Debug, Default)] +pub(super) struct TranscriptChannel { + latest: TranscriptUpdate, +} + +impl TranscriptChannel { + /// Publishes `text` if it differs from the latest transcript and reports + /// whether the sequence number advanced. + pub(super) fn publish(&mut self, text: String) -> bool { + if text == self.latest.text { + return false; + } + self.latest = TranscriptUpdate { + seq: self.latest.seq + 1, + text, + }; + true + } + + /// The latest transcript when it is newer than `seq`. + pub(super) fn since(&self, seq: u64) -> Option { + (self.latest.seq > seq).then(|| self.latest.clone()) + } +} + +impl GameEngine { + /// The transcript of the screen the engine is showing now. + pub(super) fn transcript(&self) -> String { + screen_transcript(self.app.world().resource::()) + } +} + +/// Describes the current screen in plain sentences. The device is silent +/// while it is off or showing the boot plate. +pub(super) fn screen_transcript(state: &GameState) -> String { + let mut out = Transcript::default(); + match state.screen { + Screen::Off | Screen::Boot => {} + Screen::Copyright => chronicle(&mut out, state), + Screen::OpeningFanfare => opening(&mut out, state), + Screen::Title => title(&mut out, state), + Screen::QuizMenu => quiz_menu(&mut out, state), + Screen::CharacterCreation => hero_creation(&mut out, state), + Screen::Oracle => datafall(&mut out, state), + Screen::Quiz => trial(&mut out, state), + Screen::LevelUp => level_up(&mut out, state), + Screen::GameOver => game_over(&mut out, state), + Screen::Codex => codex(&mut out, state), + Screen::QuestSelect => quest_select(&mut out, state), + Screen::Battle => battle(&mut out, state), + Screen::Victory => { + out.say("Quest cleared"); + out.say("A, B, or Start returns to the quest list"); + } + Screen::Defeat => { + out.say("Game over. The quest failed"); + out.say("A, B, or Start returns to the quest list"); + } + } + out.finish() +} + +/// Sentences in reading order. +#[derive(Default)] +struct Transcript(Vec); + +impl Transcript { + /// Adds `text` as one sentence with collapsed whitespace, closing it with + /// a period unless it already ends in terminal punctuation. + fn say(&mut self, text: impl AsRef) { + let text = text + .as_ref() + .split_whitespace() + .collect::>() + .join(" "); + match text.chars().last() { + None => {} + Some('.' | '!' | '?') => self.0.push(text), + Some(_) => self.0.push(format!("{text}.")), + } + } + + fn finish(self) -> String { + self.0.join(" ") + } +} + +/// An uppercase HUD label as a spoken word: `ORACLE-BOUND` reads +/// `Oracle-bound`. +fn spoken(label: &str) -> String { + let lower = label.to_ascii_lowercase(); + let mut chars = lower.chars(); + chars.next().map_or_else(String::new, |first| { + first.to_ascii_uppercase().to_string() + chars.as_str() + }) +} + +/// Question copy exactly as the trial and Codex panels wrap it. +fn shown_question(question: &str) -> String { + wrap_text(question, QUIZ_QUESTION_COLUMNS) + .into_iter() + .take(QUIZ_QUESTION_ROWS) + .collect::>() + .join(" ") +} + +fn shown_choice(choice: &str) -> String { + truncate(choice, QUIZ_CHOICE_CHARS) +} + +fn skip_prompt(out: &mut Transcript, state: &GameState) { + if state.can_signal(SceneSignal::Continue) { + out.say("A or Start skips"); + } +} + +/// The chronicle card's credits, all at once: the plate reveals them over a +/// second, but a screen reader should not hear the card three times. +fn chronicle(out: &mut Transcript, state: &GameState) { + out.say("Repository chronicle"); + let Some(cartridge) = state.cartridge.as_ref() else { + out.say("No cartridge"); + return; + }; + out.say(&cartridge.title); + let provenance = &cartridge.provenance; + out.say( + provenance + .copyright + .as_deref() + .filter(|notice| !notice.trim().is_empty()) + .unwrap_or("No declared copyright notice"), + ); + if provenance.authors.is_empty() { + out.say("No commit authors yet"); + } else { + let authors: Vec<_> = provenance.authors.iter().take(3).cloned().collect(); + out.say(format!("Commit authors: {}", authors.join(", "))); + } + out.say(match (provenance.first_year, provenance.latest_year) { + (Some(first), Some(latest)) if first == latest => format!("Archive year {first}"), + (Some(first), Some(latest)) => format!("Archive {first} to {latest}"), + _ => "History not yet written".into(), + }); + skip_prompt(out, state); +} + +/// One short line per opening beat. The Oracle plates carry no text, so each +/// beat is named from its authored scene; the legacy fanfare reads its own. +fn opening(out: &mut Transcript, state: &GameState) { + if state.uses_visual_template(VisualTemplate::Awakening) { + out.say(match state.opening_beat() { + OpeningBeat::SourceEmber => { + "A lone code-seer finds a cyan source ember in a dormant archive" + } + OpeningBeat::ArchiveAnswer => "The ember reaches the altar and wakes the archive", + OpeningBeat::MemoryVault => "The archive opens into a canyon of commit constellations", + OpeningBeat::Convergence => { + "Cyan source light and gold knowledge meet around the Oracle's dark seed" + } + OpeningBeat::OracleAwakening | OpeningBeat::Legacy => "The Oracle awakens", + }); + } else if state.screen_ticks < 120 { + out.say("Two paths converge"); + } else { + out.say("History becomes power. The Oracle opens"); + } + skip_prompt(out, state); +} + +fn title(out: &mut Transcript, state: &GameState) { + let Some(cartridge) = state.cartridge.as_ref() else { + out.say("No cartridge. Power off to load a game"); + return; + }; + out.say(&cartridge.title); + out.say(if state.uses_visual_template(VisualTemplate::Title) { + "Repository Oracle" + } else { + match state.cartridge_mode() { + Some(CartridgeMode::Custom) => "Every command is a boss", + _ => "Endless repository quiz", + } + }); + out.say("Press Start"); +} + +fn quiz_menu(out: &mut Transcript, state: &GameState) { + let oracle = state.uses_visual_template(VisualTemplate::Menu); + out.say(if oracle { + "Choose your path" + } else { + "Repo quiz" + }); + // The journal line appears exactly when the menu shows its summary. + if state.journal_summary().is_some() { + let lessons = state.lessons(); + let pending = lessons.iter().filter(|lesson| lesson.outstanding).count(); + let noun = if lessons.len() == 1 { + "lesson" + } else { + "lessons" + }; + out.say(if pending == 0 { + format!("Journal: {} {noun}, all clear", lessons.len()) + } else { + format!( + "Journal: {} {noun}, {pending} awaiting review", + lessons.len() + ) + }); + } + let first = if oracle { + "BEGIN THE TRIAL" + } else { + "BEGIN RUN" + }; + for (index, label) in [first, quiz_menu_second_option(state)] + .into_iter() + .enumerate() + { + let selected = if state.menu_selected == index { + ", selected" + } else { + "" + }; + out.say(format!("Option {} of 2: {label}{selected}", index + 1)); + } + out.say("Up and Down move, A chooses, B goes back"); +} + +fn hero_creation(out: &mut Transcript, state: &GameState) { + let atelier = state.uses_visual_template(VisualTemplate::Atelier); + let (heading, labels, bind) = if atelier { + ("Bind your code-seer", ["Name", "Path", "Aura"], "Bind") + } else { + ( + "Create your hero", + ["Name", "Class", "Style"], + "Begin quest", + ) + }; + out.say(heading); + let values = [ + HERO_NAMES[state.hero_name], + HERO_CLASSES[state.hero_class], + HERO_STYLES[state.hero_style], + ]; + let rows = labels + .iter() + .zip(values) + .map(|(label, value)| format!("{label}: {value}")) + .chain([bind.to_string()]); + for (index, row) in rows.enumerate() { + if state.hero_row == index { + out.say(format!("{row}, selected")); + } else { + out.say(row); + } + } + out.say(creation_oracle_status(state, atelier).trim_end_matches('.')); + out.say("Up and Down choose a row, Left and Right change it, Start begins, B goes back"); +} + +/// The Oracle's truthful question status, tested in the same order as every +/// Datafall status line and the audio snapshot. +fn oracle_question_status(state: &GameState) -> QuestionStatus { + if state.has_unanswered_question() { + QuestionStatus::Ready + } else if state.questions_loading { + QuestionStatus::Writing + } else if state.question_retry_ticks > 0 { + QuestionStatus::Retrying + } else { + QuestionStatus::Contacting + } +} + +fn datafall(out: &mut Transcript, state: &GameState) { + out.say("Oracle Datafall"); + if state.uses_visual_template(VisualTemplate::Sanctum) { + out.say(format!("{} bond", spoken(state.visual_tier().label()))); + } + let provider = state.ai_provider_name(); + out.say(match oracle_question_status(state) { + QuestionStatus::Ready => "The next question is ready".to_string(), + QuestionStatus::Writing => format!("{provider} is writing questions"), + QuestionStatus::Retrying => { + format!("{provider} could not write questions yet and will retry") + } + QuestionStatus::Contacting => format!("Contacting {provider}"), + }); + let charge = threshold_stage(state.oracle_data, &DATA_CHARGE_THRESHOLDS); + out.say(format!( + "Data {}, charge runes {charge} of 3", + state.oracle_data + )); + let breach = threshold_stage(state.oracle_bug_hits, &BUG_BREACH_THRESHOLDS); + out.say(format!( + "Bugs {}, containment seals {} of 3 intact", + state.oracle_bug_hits, + 3usize.saturating_sub(breach) + )); + out.say("Left and Right move to catch data and dodge bugs, B leaves"); +} + +/// Wards, flow, score, and any awakened insight rune, as the trial HUD shows +/// them. +fn run_status(run: &QuizRun) -> String { + let insight = InsightStage::from_score(run.score); + let rune = if insight == InsightStage::Unlit { + String::new() + } else { + format!(", insight rune {}", insight.label()) + }; + format!( + "Wards {} of 3, flow x{}, score {}{rune}", + run.hearts.min(3), + streak_multiplier(run.streak), + run.score + ) +} + +fn trial(out: &mut Transcript, state: &GameState) { + let Some(run) = state.quiz.as_ref() else { + out.say("Trial. Waiting for a question"); + return; + }; + let number = run.question + 1; + let Some(question) = state + .cartridge + .as_ref() + .and_then(|cartridge| cartridge.questions.get(run.question)) + else { + out.say(format!("Trial {number}. Waiting for the next question")); + return; + }; + let Some((correct, _)) = run.feedback else { + match question.concept { + Some(concept) => out.say(format!("Trial {number}, {} lens", spoken(concept.label()))), + None => out.say(format!("Trial {number}")), + } + out.say(shown_question(&question.question)); + let order = run.display_order(question.choices.len()); + for (slot, source) in order.into_iter().take(4).enumerate() { + let choice = shown_choice(&question.choices[source]); + let selected = if run.selected == slot { + ", selected" + } else { + "" + }; + out.say(format!("Choice {}: {choice}{selected}", slot + 1)); + } + out.say(run_status(run)); + if run.leave_armed > 0 { + out.say("Press B again to leave the run"); + } else { + out.say("Up and Down choose, A answers, B leaves"); + } + return; + }; + + out.say(format!( + "Trial {number}. {}", + if correct { "Correct" } else { "Missed" } + )); + out.say(quiz_feedback_banner(run)); + lesson(out, state); + if let (true, Some(concept)) = ( + state.uses_visual_template(VisualTemplate::Trial), + question.concept, + ) { + out.say(format!( + "{} mastery: {} of 3 runes", + spoken(concept.label()), + state.mastery_stage(concept) + )); + } + out.say(run_status(run)); + // Stable across the input hold, so the card is announced once. + out.say("A or Start continues after a short hold"); +} + +/// The lesson card read from the same composition the renderers draw: the +/// player's pick and why it misleads, then the answer and why it holds. +fn lesson(out: &mut Transcript, state: &GameState) { + let Some(lines) = current_lesson(state, quiz_lesson_copy_box()) else { + return; + }; + fn flush(out: &mut Transcript, label: &str, why: &mut Vec<&str>) { + if !why.is_empty() { + out.say(format!("{label}: {}", why.join(" "))); + why.clear(); + } + } + let mut label = ""; + let mut why: Vec<&str> = Vec::new(); + for line in &lines { + // Choice lines carry a two-character "- " or "+ " marker. + let choice = line.text.get(2..).unwrap_or_default(); + match line.tone { + LessonTone::Misconception => { + flush(out, label, &mut why); + out.say(format!("You chose: {choice}")); + label = "Why not"; + } + LessonTone::Answer => { + flush(out, label, &mut why); + out.say(format!("The answer: {choice}")); + label = "Why it holds"; + } + LessonTone::MisconceptionWhy | LessonTone::AnswerWhy => why.push(&line.text), + } + } + flush(out, label, &mut why); +} + +fn level_up(out: &mut Transcript, state: &GameState) { + if state.uses_visual_template(VisualTemplate::Ascension) { + out.say(format!( + "The Oracle bond ascends: {}", + spoken(state.visual_tier().label()) + )); + } else { + out.say("Level up!"); + } + if let Some(run) = state.quiz.as_ref() { + out.say(format!( + "Level {}. Batch {} survived", + run.level, run.completed_batches + )); + } + out.say(if state.level_up_can_continue() { + "A or Start continues" + } else { + "Please wait" + }); +} + +fn game_over(out: &mut Transcript, state: &GameState) { + let aftermath = state.uses_visual_template(VisualTemplate::Aftermath); + out.say(if aftermath { + "The vision closes" + } else { + "Game over" + }); + match state.quiz.as_ref() { + Some(run) => { + out.say(format!("Final score {}", run.score)); + if aftermath { + out.say(format!( + "Bond reached: {}", + spoken(state.visual_tier().label()) + )); + } + out.say(format!("Level {} reached", run.level)); + let insight = InsightStage::from_score(run.score); + if insight == InsightStage::Unlit { + out.say("No insight rune awakened"); + } else { + out.say(format!("Insight rune {}", insight.label())); + } + } + None => out.say("No questions found"), + } + out.say("A, B, or Start returns to the menu"); +} + +fn codex(out: &mut Transcript, state: &GameState) { + let lessons = state.lessons(); + let Some((index, lesson)) = state.codex_lesson() else { + out.say("Oracle Codex, mastery"); + for concept in Concept::ALL { + let pending = pending_reviews(lessons, concept); + let review = if pending > 0 { + format!(", {pending} awaiting review") + } else { + String::new() + }; + out.say(format!( + "{}: {} of 3 runes{review}", + spoken(concept.label()), + state.mastery_stage(concept) + )); + } + if lessons.is_empty() { + out.say("No lessons yet. Answer trials to write lessons"); + out.say("B goes back"); + } else { + let pending = lessons.iter().filter(|lesson| lesson.outstanding).count(); + let learned = lessons.len() - pending; + out.say(if pending == 0 { + format!("{learned} learned, all clear") + } else { + format!("{learned} learned, {pending} awaiting review") + }); + out.say("Left and Right read lessons, B goes back"); + } + return; + }; + out.say(format!( + "Oracle Codex, lesson {} of {}", + index + 1, + lessons.len() + )); + match lesson.concept { + Some(concept) => out.say(format!( + "{} lens, {} of 3 runes", + spoken(concept.label()), + state.mastery_stage(concept) + )), + None => out.say("General lesson"), + } + out.say(spoken(codex_lesson_status(lesson).0)); + out.say(shown_question(&lesson.question)); + out.say(format!("Answer: {}", shown_choice(&lesson.answer))); + if lesson.rationale.trim().is_empty() { + out.say("No rationale was recorded"); + } else { + let rationale = wrap_text(&lesson.rationale, RATIONALE_COLUMNS) + .into_iter() + .take(RATIONALE_ROWS) + .collect::>() + .join(" "); + out.say(format!("Why it holds: {rationale}")); + } + out.say("Left and Right turn pages, B goes back"); +} + +fn quest_select(out: &mut Transcript, state: &GameState) { + out.say("Choose thy quest"); + let Some(cartridge) = state.cartridge.as_ref() else { + return; + }; + let Some(quest) = cartridge.quests.get(state.quest_selected) else { + out.say("No quests on cartridge. B goes back"); + return; + }; + out.say(format!( + "Quest {} of {}: {}, against {}", + state.quest_selected + 1, + cartridge.quests.len(), + quest.name, + quest.boss + )); + out.say("Up and Down choose, L and R jump, A fights, B goes back"); +} + +/// The battle names its boss; the streamed command output stays on screen +/// only, so every line of it does not re-announce the whole battle. +fn battle(out: &mut Transcript, state: &GameState) { + if state.active_boss.trim().is_empty() { + out.say("Quest battle. The command is running"); + } else { + out.say(format!( + "Quest battle against {}. The command is running", + state.active_boss + )); + } + out.say("B aborts"); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scene_machine::SceneMachineTemplate; + + const ALL_SCREENS: [Screen; 16] = [ + Screen::Off, + Screen::Boot, + Screen::Copyright, + Screen::OpeningFanfare, + Screen::Title, + Screen::QuizMenu, + Screen::CharacterCreation, + Screen::Oracle, + Screen::Quiz, + Screen::LevelUp, + Screen::GameOver, + Screen::Codex, + Screen::QuestSelect, + Screen::Battle, + Screen::Victory, + Screen::Defeat, + ]; + + /// Fails to compile when a screen is added without joining `ALL_SCREENS`. + #[allow(dead_code)] + fn every_screen_is_listed(screen: Screen) { + match screen { + Screen::Off + | Screen::Boot + | Screen::Copyright + | Screen::OpeningFanfare + | Screen::Title + | Screen::QuizMenu + | Screen::CharacterCreation + | Screen::Oracle + | Screen::Quiz + | Screen::LevelUp + | Screen::GameOver + | Screen::Codex + | Screen::QuestSelect + | Screen::Battle + | Screen::Victory + | Screen::Defeat => {} + } + } + + fn question(index: usize) -> QuizQuestion { + QuizQuestion { + question: format!("WHICH LAYER DECIDES WHAT HAPPENS NEXT IN ROUND {index}?"), + choices: vec![ + format!("THE BEVY ENGINE {index}"), + format!("THE WEB SHELL {index}"), + format!("THE STYLESHEET {index}"), + format!("THE INSTALLER {index}"), + ], + answer: 0, + concept: Some(Concept::Responsibility), + rationales: vec![ + "THE ENGINE OWNS STATE, RULES, AND TIMING.".into(), + "THE SHELL ONLY FORWARDS INPUT AND PAINTS FRAMES.".into(), + "STYLES DRAW THE DEVICE CASE, NOT THE GAME.".into(), + "THE INSTALLER ONLY PACKAGES THE APP.".into(), + ], + review: false, + } + } + + fn quiz_cartridge() -> CartridgeSpec { + CartridgeSpec { + id: "/tmp/transcript-test".into(), + title: "TRANSCRIPT TEST".into(), + mode: CartridgeMode::Quiz, + provenance: RepositoryProvenance { + authors: vec!["ADA LOVELACE".into(), "GRACE HOPPER".into()], + first_year: Some(2020), + latest_year: Some(2024), + copyright: Some("Copyright (c) 2020-2024 Ada Lovelace".into()), + }, + codequest: None, + machine: Box::new(SceneMachineDefinition::template(SceneMachineTemplate::Quiz)), + quests: vec![], + questions: vec![question(0)], + question_batch_ends: Vec::new(), + question_batch_levels: Vec::new(), + lessons: Vec::new(), + mastery: Mastery::new(), + } + } + + /// The repository's own Oracle cartridge, with every visual template. + fn oracle_cartridge() -> CartridgeSpec { + let mut cartridge = quiz_cartridge(); + let config = CodeQuestConfig::parse(include_str!("../../../CODEQUEST.toml")) + .expect("the repository Oracle cartridge should parse"); + cartridge.machine = Box::new( + config + .runtime_machine() + .expect("the Oracle scene graph should compile") + .expect("schema v2 should produce a runtime machine"), + ); + cartridge.codequest = Some(Box::new(config)); + cartridge + } + + fn journal() -> (Vec, Mastery) { + let lessons = vec![ + Lesson { + question: "WHO OWNS THE GAME LOOP?".into(), + answer: "THE HEADLESS BEVY ENGINE".into(), + rationale: "THE SHELL ONLY DRAWS FRAMES AND FORWARDS BUTTON EDGES.".into(), + concept: Some(Concept::Responsibility), + outstanding: false, + }, + Lesson { + question: "WHAT MUST STAY TRUE WHEN A SCENE CHANGES?".into(), + answer: "ONLY THE ENGINE CHANGES SCENES".into(), + rationale: String::new(), + concept: Some(Concept::Invariant), + outstanding: true, + }, + ]; + let mastery = Mastery::from([ + ( + Concept::Purpose, + LensRecord { + first_try: 1, + ..LensRecord::default() + }, + ), + ( + Concept::Responsibility, + LensRecord { + first_try: 3, + ..LensRecord::default() + }, + ), + ( + Concept::Invariant, + LensRecord { + first_try: 5, + missed: 1, + ..LensRecord::default() + }, + ), + ]); + (lessons, mastery) + } + + fn issue(engine: &mut GameEngine, command: EngineCommand) { + engine.command(command); + engine.update(); + } + + fn press(engine: &mut GameEngine, button: Button) { + for pressed in [true, false] { + issue(engine, EngineCommand::Input { button, pressed }); + } + } + + fn state(engine: &GameEngine) -> &GameState { + engine.app.world().resource::() + } + + fn powered(cartridge: CartridgeSpec) -> GameEngine { + let mut engine = GameEngine::new(); + issue( + &mut engine, + EngineCommand::AiProvider(Some("claude".into())), + ); + issue(&mut engine, EngineCommand::Cartridge(Some(cartridge))); + issue(&mut engine, EngineCommand::Power(true)); + issue(&mut engine, EngineCommand::BootComplete); + engine + } + + /// Presses Start, letting each scene's skip hold elapse, until `screen`. + fn advance_to(engine: &mut GameEngine, screen: Screen) { + for _ in 0..40 { + if engine.screen() == screen { + return; + } + press(engine, Button::Start); + for _ in 0..30 { + if engine.screen() == screen { + return; + } + engine.update(); + } + } + panic!("never reached {screen:?}; stuck on {:?}", engine.screen()); + } + + fn deliver(engine: &mut GameEngine, questions: Vec) { + let seq = state(engine).question_request_seq; + issue( + engine, + EngineCommand::Questions { + cartridge_id: "/tmp/transcript-test".into(), + questions, + seq, + }, + ); + } + + /// An Oracle trial showing `question`, reached through the real scene + /// graph so the display order is the one the player sees. + fn trial_engine(question: QuizQuestion) -> GameEngine { + let mut cartridge = oracle_cartridge(); + cartridge.questions = vec![question]; + let mut engine = powered(cartridge); + advance_to(&mut engine, Screen::Quiz); + engine + } + + /// Moves the trial's focus to display `slot`. + fn focus(engine: &mut GameEngine, slot: usize) { + while state(engine).quiz.as_ref().unwrap().selected != slot { + press(engine, Button::Down); + } + } + + fn display_order(engine: &GameEngine) -> Vec { + state(engine).quiz.as_ref().unwrap().display_order(4) + } + + #[test] + fn the_device_is_silent_while_off_or_booting() { + let mut engine = GameEngine::new(); + assert_eq!(engine.transcript(), ""); + issue( + &mut engine, + EngineCommand::Cartridge(Some(oracle_cartridge())), + ); + issue(&mut engine, EngineCommand::Power(true)); + assert_eq!(engine.screen(), Screen::Boot); + assert_eq!(engine.transcript(), ""); + } + + #[test] + fn the_chronicle_reads_its_credits_at_once() { + let mut engine = powered(oracle_cartridge()); + assert_eq!(engine.screen(), Screen::Copyright); + let credits = "Repository chronicle. TRANSCRIPT TEST. \ + Copyright (c) 2020-2024 Ada Lovelace. \ + Commit authors: ADA LOVELACE, GRACE HOPPER. Archive 2020 to 2024."; + assert_eq!(engine.transcript(), credits); + for _ in 0..60 { + engine.update(); + } + assert_eq!( + engine.transcript(), + format!("{credits} A or Start skips."), + "only the skip prompt arrives later, when Start really skips" + ); + } + + #[test] + fn opening_beats_read_one_line_each_and_offer_the_skip() { + let mut engine = powered(oracle_cartridge()); + advance_to(&mut engine, Screen::OpeningFanfare); + assert_eq!(state(&engine).opening_beat(), OpeningBeat::SourceEmber); + let mut beats: Vec = Vec::new(); + let mut offered_skip = false; + while engine.screen() == Screen::OpeningFanfare { + let text = engine.transcript(); + offered_skip |= text.ends_with(" A or Start skips."); + let beat = text.trim_end_matches(" A or Start skips.").to_string(); + if beats.last() != Some(&beat) { + beats.push(beat); + } + engine.update(); + } + assert_eq!( + beats, + [ + "A lone code-seer finds a cyan source ember in a dormant archive.", + "The ember reaches the altar and wakes the archive.", + "The archive opens into a canyon of commit constellations.", + "Cyan source light and gold knowledge meet around the Oracle's dark seed.", + "The Oracle awakens.", + ] + ); + assert!(offered_skip); + } + + #[test] + fn the_title_names_the_cartridge_and_its_prompt() { + let mut engine = powered(oracle_cartridge()); + advance_to(&mut engine, Screen::Title); + assert_eq!( + engine.transcript(), + "TRANSCRIPT TEST. Repository Oracle. Press Start." + ); + + let mut legacy = powered(quiz_cartridge()); + advance_to(&mut legacy, Screen::Title); + assert_eq!( + legacy.transcript(), + "TRANSCRIPT TEST. Endless repository quiz. Press Start." + ); + } + + #[test] + fn the_menu_names_its_options_the_focus_and_the_journal() { + let mut cartridge = oracle_cartridge(); + (cartridge.lessons, cartridge.mastery) = journal(); + let mut engine = powered(cartridge); + advance_to(&mut engine, Screen::QuizMenu); + assert_eq!( + engine.transcript(), + "Choose your path. Journal: 2 lessons, 1 awaiting review. \ + Option 1 of 2: BEGIN THE TRIAL, selected. Option 2 of 2: OPEN THE CODEX. \ + Up and Down move, A chooses, B goes back." + ); + press(&mut engine, Button::Down); + let text = engine.transcript(); + assert!(text.contains("Option 1 of 2: BEGIN THE TRIAL. "), "{text}"); + assert!( + text.contains("Option 2 of 2: OPEN THE CODEX, selected."), + "{text}" + ); + + let mut legacy = powered(quiz_cartridge()); + advance_to(&mut legacy, Screen::QuizMenu); + assert_eq!( + legacy.transcript(), + "Repo quiz. Option 1 of 2: BEGIN RUN, selected. Option 2 of 2: OPEN THE CODEX. \ + Up and Down move, A chooses, B goes back.", + "an empty journal has no summary line" + ); + } + + #[test] + fn hero_creation_reads_each_row_the_focus_and_the_oracle() { + let mut engine = powered(oracle_cartridge()); + advance_to(&mut engine, Screen::QuizMenu); + press(&mut engine, Button::A); + assert_eq!(engine.screen(), Screen::CharacterCreation); + press(&mut engine, Button::Right); + press(&mut engine, Button::Down); + assert_eq!( + engine.transcript(), + "Bind your code-seer. Name: GREP. Path: CODE KNIGHT, selected. Aura: EMBER. \ + Bind. ORACLE READY. Up and Down choose a row, Left and Right change it, \ + Start begins, B goes back." + ); + } + + #[test] + fn datafall_names_the_oracle_status_its_counts_and_its_controls() { + let mut cartridge = oracle_cartridge(); + cartridge.questions.clear(); + let mut engine = powered(cartridge); + advance_to(&mut engine, Screen::Oracle); + let audio_status = |engine: &GameEngine| audio_snapshot(state(engine)).questions; + assert_eq!(audio_status(&engine), QuestionStatus::Writing); + assert_eq!( + engine.transcript(), + "Oracle Datafall. Initiate bond. CLAUDE is writing questions. \ + Data 0, charge runes 0 of 3. Bugs 0, containment seals 3 of 3 intact. \ + Left and Right move to catch data and dodge bugs, B leaves." + ); + + deliver(&mut engine, Vec::new()); + assert_eq!(audio_status(&engine), QuestionStatus::Retrying); + assert!(engine + .transcript() + .contains(" CLAUDE could not write questions yet and will retry. ")); + + deliver(&mut engine, vec![question(1)]); + assert_eq!(audio_status(&engine), QuestionStatus::Ready); + assert!(engine + .transcript() + .contains(" The next question is ready. ")); + + let counted = GameState { + screen: Screen::Oracle, + oracle_data: 4, + oracle_bug_hits: 3, + ..GameState::default() + }; + assert_eq!( + oracle_question_status(&counted), + audio_snapshot(&counted).questions + ); + assert_eq!( + screen_transcript(&counted), + "Oracle Datafall. Contacting AI. Data 4, charge runes 1 of 3. \ + Bugs 3, containment seals 1 of 3 intact. \ + Left and Right move to catch data and dodge bugs, B leaves." + ); + } + + #[test] + fn a_trial_reads_the_shuffled_display_order_and_the_focused_choice() { + let question = question(7); + let mut engine = trial_engine(question.clone()); + let order = display_order(&engine); + assert_eq!( + order, + learning::presentation_order(&question_identity(&question.question), 0) + ); + assert_ne!(order, [0, 1, 2, 3], "the fixture must actually be shuffled"); + + let choices: Vec = order + .iter() + .enumerate() + .map(|(slot, source)| { + let selected = if slot == 0 { ", selected" } else { "" }; + format!( + "Choice {}: {}{selected}.", + slot + 1, + question.choices[*source] + ) + }) + .collect(); + assert_eq!( + engine.transcript(), + format!( + "Trial 1, Roles lens. {} {} Wards 3 of 3, flow x1, score 0. \ + Up and Down choose, A answers, B leaves.", + question.question, + choices.join(" ") + ) + ); + + press(&mut engine, Button::Down); + let text = engine.transcript(); + assert!( + text.contains(&format!( + "Choice 1: {}. Choice 2: {}, selected.", + question.choices[order[0]], question.choices[order[1]] + )), + "{text}" + ); + + press(&mut engine, Button::B); + assert!( + engine + .transcript() + .ends_with(" Press B again to leave the run."), + "the first B arms the leave confirmation" + ); + } + + #[test] + fn a_missed_lesson_card_reads_the_pick_the_answer_and_both_rationales() { + let question = question(3); + let mut engine = trial_engine(question.clone()); + let order = display_order(&engine); + let answer_slot = order.iter().position(|source| *source == 0).unwrap(); + let wrong_slot = (answer_slot + 1) % 4; + let picked = order[wrong_slot]; + focus(&mut engine, wrong_slot); + press(&mut engine, Button::A); + + let card = engine.transcript(); + assert_eq!( + card, + format!( + "Trial 1. Missed. WARD STRAINED. You chose: {}. Why not: {} \ + The answer: {}. Why it holds: {} Roles mastery: 0 of 3 runes. \ + Wards 2 of 3, flow x1, score 0. A or Start continues after a short hold.", + question.choices[picked], + question.rationales[picked], + question.choices[0], + question.rationales[0], + ) + ); + for _ in 0..QUIZ_FEEDBACK_TICKS { + engine.update(); + } + assert!(lesson_is_live(state(&engine).quiz.as_ref().unwrap())); + assert_eq!( + engine.transcript(), + card, + "the end of the input hold must not re-announce the card" + ); + } + + #[test] + fn a_correct_lesson_card_reinforces_the_answer_alone() { + let question = question(4); + let mut engine = trial_engine(question.clone()); + let answer_slot = display_order(&engine) + .iter() + .position(|source| *source == 0) + .unwrap(); + focus(&mut engine, answer_slot); + press(&mut engine, Button::A); + assert_eq!( + engine.transcript(), + format!( + "Trial 1. Correct. REVIEW ANSWER. The answer: {}. Why it holds: {} \ + Roles mastery: 1 of 3 runes. Wards 3 of 3, flow x1, score 100. \ + A or Start continues after a short hold.", + question.choices[0], question.rationales[0] + ) + ); + } + + #[test] + fn the_codex_reads_mastery_rows_and_lesson_pages() { + let mut cartridge = oracle_cartridge(); + (cartridge.lessons, cartridge.mastery) = journal(); + let mut engine = powered(cartridge); + advance_to(&mut engine, Screen::QuizMenu); + press(&mut engine, Button::Down); + press(&mut engine, Button::A); + assert_eq!(engine.screen(), Screen::Codex); + assert_eq!( + engine.transcript(), + "Oracle Codex, mastery. Purpose: 1 of 3 runes. Roles: 2 of 3 runes. \ + Flows: 0 of 3 runes. Invariants: 3 of 3 runes, 1 awaiting review. \ + Tradeoffs: 0 of 3 runes. 1 learned, 1 awaiting review. \ + Left and Right read lessons, B goes back." + ); + + press(&mut engine, Button::Right); + assert_eq!( + engine.transcript(), + "Oracle Codex, lesson 1 of 2. Roles lens, 2 of 3 runes. Learned. \ + WHO OWNS THE GAME LOOP? Answer: THE HEADLESS BEVY ENGINE. \ + Why it holds: THE SHELL ONLY DRAWS FRAMES AND FORWARDS BUTTON EDGES. \ + Left and Right turn pages, B goes back." + ); + press(&mut engine, Button::Right); + let text = engine.transcript(); + assert!( + text.starts_with( + "Oracle Codex, lesson 2 of 2. Invariants lens, 3 of 3 runes. Review pending." + ), + "{text}" + ); + assert!(text.contains(" No rationale was recorded. "), "{text}"); + + let empty = GameState { + screen: Screen::Codex, + ..GameState::default() + }; + assert!(screen_transcript(&empty) + .ends_with(" No lessons yet. Answer trials to write lessons. B goes back.")); + } + + #[test] + fn level_up_and_game_over_read_the_run_totals() { + let mut state = GameState { + cartridge: Some(quiz_cartridge()), + screen: Screen::GameOver, + quiz: Some(QuizRun { + score: 900, + level: 3, + hearts: 0, + completed_batches: 2, + ..QuizRun::new() + }), + ..GameState::default() + }; + assert_eq!( + screen_transcript(&state), + "Game over. Final score 900. Level 3 reached. Insight rune II. \ + A, B, or Start returns to the menu." + ); + state.screen = Screen::LevelUp; + assert_eq!( + screen_transcript(&state), + "Level up! Level 3. Batch 2 survived. Please wait." + ); + } + + #[test] + fn publication_advances_the_sequence_only_when_the_text_changes() { + let mut channel = TranscriptChannel::default(); + assert!(!channel.publish(String::new()), "the device starts silent"); + assert_eq!(channel.since(0), None); + assert!(channel.publish("TITLE.".into())); + assert!(!channel.publish("TITLE.".into())); + assert_eq!( + channel.since(0), + Some(TranscriptUpdate { + seq: 1, + text: "TITLE.".into() + }) + ); + assert_eq!(channel.since(1), None, "a current reader gets nothing"); + assert!( + channel.publish(String::new()), + "powering off clears the region" + ); + assert_eq!(channel.since(1).map(|update| update.seq), Some(2)); + + // A live engine republishes only when its screen text changes, even + // though the title prompt blinks and Datafall drops move every tick. + let mut cartridge = oracle_cartridge(); + cartridge.questions.clear(); + let mut engine = powered(cartridge); + advance_to(&mut engine, Screen::Title); + let mut channel = TranscriptChannel::default(); + for _ in 0..240 { + engine.update(); + channel.publish(engine.transcript()); + } + assert_eq!(channel.since(0).map(|update| update.seq), Some(1)); + + advance_to(&mut engine, Screen::Oracle); + let mut published = 0; + let mut ticks = 0; + while state(&engine).oracle_data + state(&engine).oracle_bug_hits == 0 { + if channel.publish(engine.transcript()) { + published += 1; + } + engine.update(); + ticks += 1; + assert!(ticks < 2_000, "the Datafall should score a drop"); + } + assert!( + channel.publish(engine.transcript()), + "the first catch is news" + ); + assert!(ticks > 60, "many ticks passed before the first catch"); + assert_eq!( + published, 1, + "only entering the Datafall published before it" + ); + } + + #[test] + fn every_screen_has_a_transcript_that_does_not_panic() { + let mut quest_cartridge = quiz_cartridge(); + quest_cartridge.mode = CartridgeMode::Custom; + quest_cartridge.quests = vec![QuestSpec { + name: "SCRYING POOL".into(), + boss: "THE STATUS WYRM".into(), + command: "git status".into(), + }]; + let lesson_run = |correct| QuizRun { + feedback: Some((correct, 0)), + selected: 1, + leave_armed: 5, + ..QuizRun::new() + }; + let states = [ + GameState::default(), + GameState { + cartridge: Some(quiz_cartridge()), + ..GameState::default() + }, + GameState { + cartridge: Some(oracle_cartridge()), + quiz: Some(QuizRun::new()), + ..GameState::default() + }, + GameState { + cartridge: Some(oracle_cartridge()), + quiz: Some(lesson_run(false)), + ..GameState::default() + }, + GameState { + cartridge: Some(quiz_cartridge()), + quiz: Some(lesson_run(true)), + ..GameState::default() + }, + GameState { + cartridge: Some(quiz_cartridge()), + quiz: Some(QuizRun { + question: 9, + ..QuizRun::new() + }), + codex_page: 4, + ..GameState::default() + }, + GameState { + cartridge: Some(quest_cartridge), + active_boss: "THE STATUS WYRM".into(), + quest_selected: 3, + ..GameState::default() + }, + ]; + for mut state in states { + for screen in ALL_SCREENS { + state.screen = screen; + let text = screen_transcript(&state); + if matches!(screen, Screen::Off | Screen::Boot) { + assert_eq!(text, "", "{screen:?} is silent"); + continue; + } + assert!(!text.is_empty(), "{screen:?} must describe itself"); + assert!( + text.ends_with(['.', '!', '?']), + "{screen:?} ends mid-sentence: {text}" + ); + assert!( + !text.contains(" ") && !text.contains('\n'), + "{screen:?} is not plain prose: {text:?}" + ); + } + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d92c58b..a66ef22 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -932,6 +932,13 @@ fn engine_audio(state: State) -> audio::AudioBatch { state.0.drain_audio() } +/// The engine's screen transcript for the shell's live region, only when it +/// is newer than the `since` sequence number the shell last presented. +#[tauri::command] +fn engine_transcript(state: State, since: u64) -> Option { + state.0.transcript_since(since) +} + #[tauri::command] fn app_revision() -> &'static str { env!("CQA_APP_REVISION") @@ -986,7 +993,8 @@ pub fn run() { engine_input, engine_set_reduced_motion, engine_frame, - engine_audio + engine_audio, + engine_transcript ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/index.html b/src/index.html index 70446db..702d86a 100644 --- a/src/index.html +++ b/src/index.html @@ -42,6 +42,7 @@
+