diff --git a/README.md b/README.md index 272ae49..1034321 100644 --- a/README.md +++ b/README.md @@ -353,7 +353,7 @@ 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, 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/mod.rs` and `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 | diff --git a/docs/architecture.md b/docs/architecture.md index 0d60215..b75d947 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3,8 +3,8 @@ This guide is for people and agents who change the code. It describes how the pieces fit, which module owns each decision, and the rules a change must keep. Function and type names are the ones in the source, so every claim can be -checked with a search. Line numbers are left out on purpose: `engine.rs` moves -often. +checked with a search. Line numbers are left out on purpose: the engine +modules move often. The README covers installation, controls, and the player-facing rules. The [Oracle quiz design](game-design/oracle-quiz.md) covers the pedagogy and the @@ -47,7 +47,7 @@ Rules that follow from the boundary: | Module | Owns | |---|---| | `src-tauri/src/lib.rs` | Tauri commands, `AiProviderState` (selected vs verified provider), cartridge building (`build_cartridge`, `engine_cartridge`), provider calls (`ask_provider`, `command_output_with_timeout`), the generation pipeline (`ai_questions`), the question loader and answer recorder closures handed to the engine (`run`) | -| `src-tauri/src/engine.rs` | `GameState`, the Bevy schedule, input handling, the quiz run (`QuizRun`, `RunLedger`), the question deck and batches, retry scheduling, the lesson journal in memory, effects, CPU rendering, `EngineRuntime` | +| `src-tauri/src/engine/` | `GameState` (`state.rs`, Codex methods in `codex.rs`), the Bevy schedule and `EngineRuntime` (`runtime.rs`), input handling (`input.rs`), the quiz run (`QuizRun`, `RunLedger`) and retry scheduling (`quiz.rs`), the question deck and batches (`deck.rs`), effects (`commands.rs`), the per-tick step (`advance.rs`), CPU rendering (`render/`, one file per scene). `mod.rs` holds the shared imports and the crate-facing re-exports | | `src-tauri/src/engine/transcript.rs` | Screen sentences and `TranscriptChannel` | | `src-tauri/src/audio.rs` | `AudioSnapshot`, `AudioDirector`, cues, loops, `AudioQueue` | | `src-tauri/src/learning.rs` | `Concept` lenses, `Review`, `AnswerEvidence`, `LensRecord` and mastery gates, `Lesson`, `presentation_order`, rationale limits | @@ -846,8 +846,9 @@ shutdown, and shell-path code. Where the Rust tests live: -- `engine.rs` `mod tests`: scenario tests that drive a `GameEngine` with - commands and ticks and assert on screens, deck, frames, and audio. +- the `mod tests` of each `engine/` module: scenario tests that drive a + `GameEngine` with commands and ticks and assert on screens, deck, frames, + and audio. Their shared fixtures and drivers are in `engine/test_support.rs`. `engine/transcript.rs` has the same kind of tests for the transcript. - `learning.rs`, `questions.rs`, `scene_machine.rs`, `codequest.rs`, `audio.rs`, `save.rs`, and `lib.rs` (`question_policy_tests`): unit tests of diff --git a/src-tauri/src/engine.rs b/src-tauri/src/engine.rs deleted file mode 100644 index a374e34..0000000 --- a/src-tauri/src/engine.rs +++ /dev/null @@ -1,14714 +0,0 @@ -use std::cell::RefCell; -use std::collections::{HashMap, HashSet, VecDeque}; -use std::io::{BufRead, BufReader}; -use std::process::{Child, ExitStatus, Stdio}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{mpsc, Arc, Mutex, OnceLock, RwLock}; -use std::thread; -use std::time::{Duration, Instant}; - -use bevy::prelude::*; - -use crate::audio::{ - self, pad, AnswerPhase, AudioBatch, AudioOut, AudioQueue, AudioScene, AudioSnapshot, - QuestionStatus, RunAudio, Tier, -}; -use crate::codequest::{CodeQuestConfig, GameType, VisualTemplate}; -use crate::external_tools; -use crate::font5x7::{glyph, GLYPH_ADVANCE, GLYPH_WIDTH, LINE_HEIGHT}; -use crate::learning::{ - self, AnswerEvidence, Concept, LensRecord, Lesson, Mastery, ProgressEvent, Review, - RATIONALE_COLUMNS, RATIONALE_ROWS, -}; -use crate::scene_machine::{ - SceneEvent, SceneHandler, SceneMachine, SceneMachineDefinition, SceneSignal, -}; - -pub const WIDTH: usize = 240; -pub const HEIGHT: usize = 160; -pub const FRAME_BYTES: usize = WIDTH * HEIGHT * 4; -const NATIVE_RGB_BYTES: usize = WIDTH * HEIGHT * 3; -const HERO_SPRITE_WIDTH: usize = 24; -const HERO_SPRITE_HEIGHT: usize = 36; -const HERO_SPRITE_BYTES: usize = HERO_SPRITE_WIDTH * HERO_SPRITE_HEIGHT * 4; -const HERO_PORTRAIT_SIZE: usize = 24; -const HERO_PORTRAIT_BYTES: usize = HERO_PORTRAIT_SIZE * HERO_PORTRAIT_SIZE * 4; -const DROP_SPRITE_SIZE: usize = 16; -const DROP_SPRITE_BYTES: usize = DROP_SPRITE_SIZE * DROP_SPRITE_SIZE * 4; -pub const QUIZ_QUESTION_COLUMNS: usize = 31; -pub const QUIZ_QUESTION_ROWS: usize = 4; -pub const QUIZ_CHOICE_CHARS: usize = 31; -const QUESTION_BATCH_SIZE: usize = 6; -const FRAME_TIME: Duration = Duration::from_nanos(16_666_667); -const ORACLE_HERO_MIN_X: i32 = 8; -const ORACLE_HERO_MAX_X: i32 = 210; -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; -/// Other questions answered between a miss and its review copy. The copy may -/// carry into the next batch to keep this gap. -const RETRY_GAP: usize = 3; -const SCORE_RUNE_THRESHOLDS: [u32; 3] = [300, 900, 1_800]; -const DATA_CHARGE_THRESHOLDS: [u32; 3] = [3, 6, 9]; -const BUG_BREACH_THRESHOLDS: [u32; 3] = [1, 3, 5]; -/// How often the quest waiter checks whether the quest shell has exited. -const QUEST_POLL_INTERVAL: Duration = Duration::from_millis(25); -/// How long a finished quest's remaining output may keep arriving. -const QUEST_OUTPUT_GRACE: Duration = Duration::from_secs(1); - -const INK: Color = Color::rgb(26, 28, 44); -const NAVY: Color = Color::rgb(41, 54, 111); -const ROYAL: Color = Color::rgb(59, 93, 201); -const SKY: Color = Color::rgb(65, 166, 246); -const PARCH: Color = Color::rgb(244, 244, 244); -const MIST: Color = Color::rgb(148, 176, 194); -const GOLD: Color = Color::rgb(255, 205, 117); -const GREEN: Color = Color::rgb(56, 183, 100); -const RED: Color = Color::rgb(225, 75, 95); -const PLUM: Color = Color::rgb(93, 39, 93); -const CRAB: Color = Color::rgb(206, 142, 107); -const VOID: Color = Color::rgb(7, 10, 24); -const INDIGO: Color = Color::rgb(22, 29, 66); -const CYAN: Color = Color::rgb(67, 224, 244); -const CYAN_DIM: Color = Color::rgb(47, 146, 174); -const AMBER: Color = Color::rgb(247, 183, 72); -const VIOLET: Color = Color::rgb(105, 58, 151); -const MAGENTA: Color = Color::rgb(213, 91, 151); -const ASH: Color = Color::rgb(68, 78, 105); -const HERO_NAMES: [&str; 6] = ["SUDO", "GREP", "VIM", "FORK", "ASYNC", "PATCH"]; -const HERO_CLASSES: [&str; 6] = [ - "CODE KNIGHT", - "BUG MAGE", - "PIPE MONK", - "MERGE PALADIN", - "LINT RANGER", - "SHELL DRUID", -]; -const HERO_STYLES: [&str; 5] = ["EMBER", "OCEAN", "FOREST", "GOLD", "VOID"]; -const HERO_STYLE_COLORS: [Color; 5] = [RED, SKY, GREEN, GOLD, PLUM]; - -const ORACLE_CHRONICLE: &[u8; NATIVE_RGB_BYTES] = include_bytes!("../assets/oracle/chronicle.rgb"); -const ORACLE_AWAKENING_SOURCE: &[u8; NATIVE_RGB_BYTES] = - include_bytes!("../assets/oracle/awakening-source.rgb"); -const ORACLE_AWAKENING_SIGNAL: &[u8; NATIVE_RGB_BYTES] = - include_bytes!("../assets/oracle/awakening-signal.rgb"); -const ORACLE_AWAKENING_ARCHIVE: &[u8; NATIVE_RGB_BYTES] = - include_bytes!("../assets/oracle/awakening-archive.rgb"); -const ORACLE_AWAKENING_CONVERGENCE: &[u8; NATIVE_RGB_BYTES] = - include_bytes!("../assets/oracle/awakening-convergence.rgb"); -const ORACLE_AWAKENING: &[u8; NATIVE_RGB_BYTES] = include_bytes!("../assets/oracle/awakening.rgb"); -const ORACLE_GATEWAY: &[u8; NATIVE_RGB_BYTES] = include_bytes!("../assets/oracle/gateway.rgb"); -const ORACLE_ATELIER: &[u8; NATIVE_RGB_BYTES] = include_bytes!("../assets/oracle/atelier.rgb"); -const ORACLE_SANCTUM: &[u8; NATIVE_RGB_BYTES] = include_bytes!("../assets/oracle/sanctum.rgb"); -const ORACLE_TRIAL: &[u8; NATIVE_RGB_BYTES] = include_bytes!("../assets/oracle/trial.rgb"); -const ORACLE_ASCENSION: &[u8; NATIVE_RGB_BYTES] = include_bytes!("../assets/oracle/ascension.rgb"); -const ORACLE_AFTERMATH: &[u8; NATIVE_RGB_BYTES] = include_bytes!("../assets/oracle/aftermath.rgb"); - -const ORACLE_HEROES: [&[u8; HERO_SPRITE_BYTES]; 5] = [ - include_bytes!("../assets/oracle/hero-magenta.rgba"), - include_bytes!("../assets/oracle/hero-cyan.rgba"), - include_bytes!("../assets/oracle/hero-emerald.rgba"), - include_bytes!("../assets/oracle/hero-amber.rgba"), - include_bytes!("../assets/oracle/hero-violet.rgba"), -]; - -const ORACLE_PORTRAITS: [&[u8; HERO_PORTRAIT_BYTES]; 5] = [ - include_bytes!("../assets/oracle/portrait-magenta.rgba"), - include_bytes!("../assets/oracle/portrait-cyan.rgba"), - include_bytes!("../assets/oracle/portrait-emerald.rgba"), - include_bytes!("../assets/oracle/portrait-amber.rgba"), - include_bytes!("../assets/oracle/portrait-violet.rgba"), -]; - -const ORACLE_DATA_DROP: &[u8; DROP_SPRITE_BYTES] = - include_bytes!("../assets/oracle/drop-data.rgba"); -const ORACLE_BUG_DROP: &[u8; DROP_SPRITE_BYTES] = include_bytes!("../assets/oracle/drop-bug.rgba"); - -#[derive(Clone, Copy)] -struct Color(u8, u8, u8); - -impl Color { - const fn rgb(r: u8, g: u8, b: u8) -> Self { - Self(r, g, b) - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct UiBox { - x: i32, - y: i32, - width: i32, - height: i32, -} - -const CHRONICLE_HEADER_BOX: UiBox = UiBox { - x: 68, - y: 39, - width: 104, - height: 7, -}; -const CHRONICLE_TITLE_BOX: UiBox = UiBox { - x: 62, - y: 49, - width: 116, - height: 16, -}; -const CHRONICLE_COPYRIGHT_BOX: UiBox = UiBox { - x: 62, - y: 67, - width: 116, - height: 16, -}; -const CHRONICLE_AUTHORS_LABEL_BOX: UiBox = UiBox { - x: 62, - y: 85, - width: 116, - height: 7, -}; -const CHRONICLE_AUTHORS_BOX: UiBox = UiBox { - x: 62, - y: 94, - width: 116, - height: 23, -}; -const GATEWAY_TITLE_TOP_BOX: UiBox = UiBox { - x: 56, - y: 49, - width: 128, - height: 14, -}; -const GATEWAY_TITLE_BOTTOM_BOX: UiBox = UiBox { - x: 63, - y: 67, - width: 114, - height: 7, -}; -const GATEWAY_PROMPT_BOX: UiBox = UiBox { - x: 68, - y: 98, - width: 104, - height: 7, -}; -const GATEWAY_SIGNATURE_BOX: UiBox = UiBox { - x: 68, - y: 123, - width: 104, - height: 7, -}; -const GATEWAY_MENU_HEADING_BOX: UiBox = UiBox { - x: 54, - y: 49, - width: 132, - height: 7, -}; -const GATEWAY_MENU_SUBTITLE_BOX: UiBox = UiBox { - x: 54, - y: 62, - width: 132, - height: 7, -}; -const GATEWAY_MENU_OPTION_BOXES: [UiBox; 2] = [ - UiBox { - x: 59, - y: 92, - width: 121, - height: 20, - }, - UiBox { - x: 59, - y: 119, - width: 121, - height: 20, - }, -]; -const GATEWAY_MENU_OPTION_TEXT_BOXES: [UiBox; 2] = [ - UiBox { - x: 66, - y: 94, - width: 108, - height: 14, - }, - UiBox { - x: 66, - y: 121, - width: 108, - height: 14, - }, -]; -const MENU_FOOTER_BOX: UiBox = UiBox { - x: 39, - y: 144, - width: 162, - height: 16, -}; -const ATELIER_HEADER_BOX: UiBox = UiBox { - x: 6, - y: 4, - width: 106, - height: 16, -}; -const ATELIER_ROW_BOXES: [UiBox; 3] = [ - UiBox { - x: 116, - y: 17, - width: 98, - height: 29, - }, - UiBox { - x: 116, - y: 49, - width: 98, - height: 29, - }, - UiBox { - x: 116, - y: 81, - width: 98, - height: 29, - }, -]; - -const fn atelier_label_box(row_box: UiBox) -> UiBox { - UiBox { - x: row_box.x + 4, - y: row_box.y + 5, - width: row_box.width - 8, - height: 7, - } -} - -const fn atelier_value_box(row_box: UiBox) -> UiBox { - UiBox { - x: row_box.x + 4, - y: row_box.y + 15, - width: row_box.width - 8, - height: 9, - } -} - -const ATELIER_BIND_BOX: UiBox = UiBox { - x: 137, - y: 112, - width: 55, - height: 21, -}; -const ATELIER_BIND_TEXT_BOX: UiBox = UiBox { - x: 137, - y: 113, - width: 55, - height: 12, -}; -const ATELIER_HERO_X: i32 = 32; -const ATELIER_HERO_Y: i32 = 38; -const ATELIER_HERO_SCALE: i32 = 2; -const TRIAL_CHOICE_TEXT_X: i32 = 39; -const TRIAL_WARD_X: i32 = 126; -const TRIAL_STREAK_X: i32 = 180; -const TRIAL_SCORE_RUNES_X: i32 = 194; -const TRIAL_SCORE_X: i32 = 215; -/// The composed lesson panel that replaces the trial plate's four choice -/// frames after a commitment. It spans the frames plus the free margin beside -/// them so a full rationale row keeps visible padding inside the border. -const TRIAL_LESSON_BOX: UiBox = UiBox { - x: 20, - y: 69, - width: 210, - height: 89, -}; -/// The legacy quiz's lesson panel over its choice rows. -const QUIZ_LESSON_BOX: UiBox = UiBox { - x: 5, - y: 68, - width: 230, - height: 80, -}; -/// Top of the legacy lesson card's INK footer strip, just below the panel. -const QUIZ_LESSON_FOOTER_STRIP_Y: i32 = QUIZ_LESSON_BOX.y + QUIZ_LESSON_BOX.height; -/// Extra pixels between the misconception block and the answer block. -const LESSON_BLOCK_GAP: i32 = 4; -const ASCENSION_TITLE_BOX: UiBox = UiBox { - x: 47, - y: 68, - width: 146, - height: 34, -}; -const ASCENSION_LEVEL_BOX: UiBox = UiBox { - x: 22, - y: 117, - width: 70, - height: 16, -}; -/// The batch recap beside the risen hero, wide enough for `1ST TRY 99/99`. -const ASCENSION_BATCH_BOX: UiBox = UiBox { - x: 148, - y: 117, - width: 85, - height: 16, -}; -const AFTERMATH_CONTENT_BOX: UiBox = UiBox { - x: 141, - y: 18, - width: 85, - height: 116, -}; -const CODEX_HEADING_BOX: UiBox = UiBox { - x: 62, - y: 40, - width: 116, - height: 7, -}; -const CODEX_LENS_ROW_Y: i32 = 54; -const CODEX_LENS_ROW_PITCH: i32 = 11; -const CODEX_LENS_LABEL_X: i32 = 69; -const CODEX_LENS_RUNES_X: i32 = 133; -const CODEX_LENS_PENDING_X: i32 = 159; -const CODEX_TOTALS_BOX: UiBox = UiBox { - x: 42, - y: 122, - width: 156, - height: 17, -}; -const CODEX_PROMPT_BOX: UiBox = UiBox { - x: 42, - y: 141, - width: 156, - height: 16, -}; -const CODEX_LESSON_HEADER_BOX: UiBox = UiBox { - x: 37, - y: 2, - width: 203, - height: 28, -}; -const CODEX_QUESTION_BOX: UiBox = UiBox { - x: 17, - y: 31, - width: 197, - height: 38, -}; -/// Lesson panels start clear of the trial plate's portrait frame and end -/// before its brazier (question) or pillar (answer and rationale). -const CODEX_ANSWER_BOX: UiBox = UiBox { - x: 17, - y: 71, - width: 212, - height: 15, -}; -const CODEX_RATIONALE_BOX: UiBox = UiBox { - x: 17, - y: 88, - width: 212, - height: 66, -}; -const CODEX_TEXT_X: i32 = 23; -const CODEX_ANSWER_TEXT_X: i32 = 31; -const CODEX_QUESTION_Y: i32 = 35; -const CODEX_ANSWER_Y: i32 = 75; -const CODEX_WHY_Y: i32 = 103; -const CODEX_RATIONALE_Y: i32 = 116; -/// A sealed lesson's self-test: the `YOU CHOSE` heading, then the pick and -/// the first misconception row, each `CODEX_CHOSE_GAP` pixels apart. -const CODEX_CHOSE_Y: i32 = 99; -const CODEX_CHOSE_GAP: i32 = 11; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum PresentationTier { - Initiate, - Adept, - OracleBound, -} - -#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] -enum InsightStage { - Unlit, - RuneOne, - RuneTwo, - RuneThree, -} - -impl InsightStage { - fn from_score(score: u32) -> Self { - match threshold_stage(score, &SCORE_RUNE_THRESHOLDS) { - 0 => Self::Unlit, - 1 => Self::RuneOne, - 2 => Self::RuneTwo, - _ => Self::RuneThree, - } - } - - fn index(self) -> usize { - self as usize - } - - fn label(self) -> &'static str { - match self { - Self::Unlit => "UNLIT", - Self::RuneOne => "I", - Self::RuneTwo => "II", - Self::RuneThree => "III", - } - } - - fn color(self) -> Color { - match self { - Self::Unlit => MIST, - Self::RuneOne => CYAN, - Self::RuneTwo => AMBER, - Self::RuneThree => MAGENTA, - } - } -} - -fn threshold_stage(value: u32, thresholds: &[u32]) -> usize { - thresholds.partition_point(|threshold| value >= *threshold) -} - -fn streak_multiplier(streak: u32) -> u32 { - match streak { - 0..=2 => 1, - 3..=5 => 2, - _ => 3, - } -} - -fn score_award_for_streak(streak: u32) -> u32 { - 100 * streak_multiplier(streak) -} - -impl PresentationTier { - fn from_level(level: u32) -> Self { - match level { - 0 | 1 => Self::Initiate, - 2 | 3 => Self::Adept, - _ => Self::OracleBound, - } - } - - fn label(self) -> &'static str { - match self { - Self::Initiate => "INITIATE", - Self::Adept => "ADEPT", - Self::OracleBound => "ORACLE-BOUND", - } - } -} - -#[derive(Clone, Debug)] -pub struct QuestSpec { - pub name: String, - pub boss: String, - pub command: String, -} - -#[derive(Clone, Debug, Default)] -pub struct QuizQuestion { - pub question: String, - pub choices: Vec, - pub answer: usize, - /// The conceptual lens this question assesses, when the provider named one. - pub concept: Option, - /// One rationale per choice (same order as `choices`), or empty for legacy - /// questions generated before rationales existed. - pub rationales: Vec, - /// Whether this question returns because the player previously missed - /// it, and whether that miss was in this launch or an earlier one. - pub review: Review, -} - -/// Generates a question batch for a cartridge, level, and count. A failure -/// carries a short upper-case reason the waiting Oracle shows the player. -pub type QuestionLoader = - Arc Result, String> + Send + Sync + 'static>; -/// Persists a learner-progress event (a committed answer or a Codex reveal) -/// for the cartridge id it is given. -pub type AnsweredQuestionRecorder = Arc; - -pub fn quiz_question_fits(question: &str, choices: &[String], answer: usize) -> bool { - !question.trim().is_empty() - && wrap_text(question, QUIZ_QUESTION_COLUMNS).len() <= QUIZ_QUESTION_ROWS - && choices.len() == 4 - && answer < choices.len() - && choices - .iter() - .map(|choice| choice.trim().to_ascii_uppercase()) - .collect::>() - .len() - == choices.len() - && choices - .iter() - .all(|choice| !choice.trim().is_empty() && choice.chars().count() <= QUIZ_CHOICE_CHARS) -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum CartridgeMode { - Quiz, - Custom, -} - -#[derive(Clone, Debug, Default, Eq, PartialEq)] -pub struct RepositoryProvenance { - pub authors: Vec, - pub first_year: Option, - pub latest_year: Option, - pub copyright: Option, -} - -#[derive(Clone, Debug)] -pub struct CartridgeSpec { - pub id: String, - pub title: String, - pub mode: CartridgeMode, - pub provenance: RepositoryProvenance, - pub codequest: Option>, - pub machine: Box, - pub quests: Vec, - pub questions: Vec, - pub question_batch_ends: Vec, - /// The generation level of each batch, parallel to `question_batch_ends`. - /// Empty when the save did not record levels; the engine then assumes the - /// batches climb one level each from 1. - pub question_batch_levels: Vec, - /// The player's lesson journal for this cartridge, oldest first. - pub lessons: Vec, - /// Per-lens mastery evidence accumulated across launches. - pub mastery: Mastery, - /// Committed attempts per normalized question identity, kept across runs - /// and (for questions still in play) launches, so a question that returns - /// always rotates its choices past the layout the player last saw. - pub question_attempts: HashMap, -} - -impl CartridgeSpec { - fn mode(&self) -> CartridgeMode { - match self.codequest.as_ref().map(|config| config.game.game_type) { - Some(GameType::Quiz) => CartridgeMode::Quiz, - Some(GameType::Quest) => CartridgeMode::Custom, - None => self.mode, - } - } -} - -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] -pub enum Button { - Up, - Down, - Left, - Right, - A, - B, - Start, - Select, - L, - R, -} - -impl Button { - pub fn parse(value: &str) -> Option { - match value { - "up" => Some(Self::Up), - "down" => Some(Self::Down), - "left" => Some(Self::Left), - "right" => Some(Self::Right), - "a" => Some(Self::A), - "b" => Some(Self::B), - "start" => Some(Self::Start), - "select" => Some(Self::Select), - "l" => Some(Self::L), - "r" => Some(Self::R), - _ => None, - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum Screen { - Off, - Boot, - Copyright, - OpeningFanfare, - Title, - QuizMenu, - CharacterCreation, - Oracle, - Quiz, - LevelUp, - GameOver, - Codex, - QuestSelect, - Battle, - Victory, - Defeat, -} - -impl From for Screen { - fn from(handler: SceneHandler) -> Self { - match handler { - SceneHandler::RepositoryCredits => Self::Copyright, - SceneHandler::OpeningFanfare => Self::OpeningFanfare, - SceneHandler::Title => Self::Title, - SceneHandler::QuizMenu => Self::QuizMenu, - SceneHandler::CharacterCreation => Self::CharacterCreation, - SceneHandler::Oracle => Self::Oracle, - 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, - SceneHandler::Defeat => Self::Defeat, - } - } -} - -// Commands are short-lived queue entries and cartridge inserts are rare, so -// the large cartridge variant is not worth boxing. -#[allow(clippy::large_enum_variant)] -#[derive(Clone, Debug)] -enum EngineCommand { - Power(bool), - ReducedMotion(bool), - AiProvider(Option), - BootComplete, - Cartridge(Option), - Questions { - cartridge_id: String, - /// The generated batch, or why generation failed. - result: Result, String>, - /// The `RequestQuestions` sequence number this reply answers. - seq: u64, - }, - Input { - button: Button, - pressed: bool, - }, - QuestOutput { - line: String, - stderr: bool, - /// The `RunQuest` generation this output belongs to; output from an - /// earlier quest never lands in a later quest's log. - quest: u64, - }, - QuestDone { - success: bool, - quest: u64, - }, -} - -#[derive(Clone, Debug)] -enum EngineEffect { - RunQuest { - command: String, - /// Echoed by the quest's output and completion. - quest: u64, - }, - AbortQuest, - RequestQuestions { - cartridge_id: String, - level: u32, - count: usize, - /// Echoed by the reply so a superseded request cannot land. - seq: u64, - }, - RecordAnsweredQuestion { - cartridge_id: String, - evidence: AnswerEvidence, - }, - /// The player revealed a pending lesson's answer in the Codex. - MarkPeeked { - cartridge_id: String, - question: String, - }, -} - -#[derive(Resource, Default)] -struct Inbox(VecDeque); - -#[derive(Resource, Default)] -struct Effects(VecDeque); - -#[derive(Resource)] -struct Framebuffer { - pixels: Vec, -} - -/// A plate's address and grade (base, cyan, and gold scales), or no grade. -type PlateKey = (usize, Option<[u16; 3]>); - -thread_local! { - /// RGBA expansions of the RGB plates drawn on this thread. - static PLATES: RefCell)>> = const { RefCell::new(Vec::new()) }; -} - -/// Expands an RGB plate to opaque RGBA. A grade scales cyan-leaning pixels, -/// gold-leaning pixels, and all others by its three `/255` factors. -fn expand_plate(rgb: &[u8; NATIVE_RGB_BYTES], grade: Option<[u16; 3]>, rgba: &mut [u8]) { - let pixels = rgb - .as_chunks::<3>() - .0 - .iter() - .zip(rgba.as_chunks_mut::<4>().0.iter_mut()); - let Some([base, cyan, gold]) = grade else { - for (source, destination) in pixels { - destination.copy_from_slice(&[source[0], source[1], source[2], 255]); - } - return; - }; - for (source, destination) in pixels { - let [red, green, blue] = [source[0] as u16, source[1] as u16, source[2] as u16]; - let scale = if is_cyan_pixel(red, green, blue) { - cyan - } else if is_gold_pixel(red, green, blue) { - gold - } else { - base - }; - destination.copy_from_slice(&[ - (red * scale / 255) as u8, - (green * scale / 255) as u8, - (blue * scale / 255) as u8, - 255, - ]); - } -} - -fn is_cyan_pixel(red: u16, green: u16, blue: u16) -> bool { - blue > red.saturating_add(12) && green > red -} - -fn is_gold_pixel(red: u16, green: u16, blue: u16) -> bool { - red > blue.saturating_add(14) && green > blue -} - -/// Which awakening strength lights each pixel of the awakening plate: 0 the -/// Oracle's center diamond, 1 cyan, 2 gold, 3 ambient. The plate is constant, -/// so the classes are computed once. -fn awakening_classes() -> &'static [u8] { - static CLASSES: OnceLock> = OnceLock::new(); - CLASSES.get_or_init(|| { - ORACLE_AWAKENING - .as_chunks::<3>() - .0 - .iter() - .enumerate() - .map(|(index, source)| { - let x = (index % WIDTH) as i32; - let y = (index / WIDTH) as i32; - let [red, green, blue] = [source[0] as u16, source[1] as u16, source[2] as u16]; - if (x - 120).abs() + (y - 80).abs() < 47 { - 0 - } else if is_cyan_pixel(red, green, blue) { - 1 - } else if is_gold_pixel(red, green, blue) { - 2 - } else { - 3 - } - }) - .collect() - }) -} - -impl Default for Framebuffer { - fn default() -> Self { - Self { - pixels: vec![0; FRAME_BYTES], - } - } -} - -impl Framebuffer { - fn clear(&mut self, color: Color) { - for pixel in self.pixels.as_chunks_mut::<4>().0 { - pixel.copy_from_slice(&[color.0, color.1, color.2, 255]); - } - } - - fn blit_rgb(&mut self, rgb: &'static [u8; NATIVE_RGB_BYTES]) { - self.blit_plate(rgb, None); - } - - fn blit_rgb_graded( - &mut self, - rgb: &'static [u8; NATIVE_RGB_BYTES], - base: u16, - cyan: u16, - gold: u16, - ) { - self.blit_plate(rgb, Some([base, cyan, gold])); - } - - /// Copies a plate's RGBA expansion, built once per plate and grade on - /// this thread: every plate and grade is a constant, so expanding it - /// again each tick would only redo identical work. - fn blit_plate(&mut self, rgb: &'static [u8; NATIVE_RGB_BYTES], grade: Option<[u16; 3]>) { - // A `'static` plate's address identifies it for the life of the - // process; a duplicated constant only costs one more cache entry. - let key = (rgb.as_ptr() as usize, grade); - PLATES.with_borrow_mut(|plates| { - let index = plates - .iter() - .position(|(cached, _)| *cached == key) - .unwrap_or_else(|| { - let mut expanded = vec![0; FRAME_BYTES].into_boxed_slice(); - expand_plate(rgb, grade, &mut expanded); - plates.push((key, expanded)); - plates.len() - 1 - }); - self.pixels.copy_from_slice(&plates[index].1); - }); - } - - fn blit_awakening(&mut self, ticks: u64) { - let cyan_strength = 38 + ticks.saturating_sub(36).min(108) as u16 * 190 / 108; - let gold_strength = 38 + ticks.saturating_sub(112).min(108) as u16 * 197 / 108; - let center_strength = 38 + ticks.saturating_sub(188).min(48) as u16 * 217 / 48; - let ambient_strength = 34 + ticks.min(236) as u16 * 38 / 236; - // One scaled channel table per pixel class, indexed like - // `AwakeningClass`; each entry is the per-pixel `value * strength / 255`. - let tables = [ - center_strength, - cyan_strength, - gold_strength, - ambient_strength, - ] - .map(|strength| { - std::array::from_fn::(|value| (value as u16 * strength / 255) as u8) - }); - - for ((source, class), destination) in ORACLE_AWAKENING - .as_chunks::<3>() - .0 - .iter() - .zip(awakening_classes()) - .zip(self.pixels.as_chunks_mut::<4>().0.iter_mut()) - { - let table = &tables[*class as usize]; - *destination = [ - table[source[0] as usize], - table[source[1] as usize], - table[source[2] as usize], - 255, - ]; - } - } - - fn blit_rgba( - &mut self, - rgba: &[u8], - source_width: usize, - source_height: usize, - x: i32, - y: i32, - scale: i32, - ) { - debug_assert_eq!(rgba.len(), source_width * source_height * 4); - for source_y in 0..source_height { - for source_x in 0..source_width { - let source_index = (source_y * source_width + source_x) * 4; - let alpha = rgba[source_index + 3] as u16; - if alpha == 0 { - continue; - } - for offset_y in 0..scale { - for offset_x in 0..scale { - let target_x = x + source_x as i32 * scale + offset_x; - let target_y = y + source_y as i32 * scale + offset_y; - if target_x < 0 - || target_y < 0 - || target_x >= WIDTH as i32 - || target_y >= HEIGHT as i32 - { - continue; - } - let destination_index = (target_y as usize * WIDTH + target_x as usize) * 4; - let inverse = 255 - alpha; - for channel in 0..3 { - let source_channel = rgba[source_index + channel] as u16; - let destination_channel = - self.pixels[destination_index + channel] as u16; - self.pixels[destination_index + channel] = - ((source_channel * alpha + destination_channel * inverse) / 255) - as u8; - } - self.pixels[destination_index + 3] = 255; - } - } - } - } - } - - fn pixel(&mut self, x: i32, y: i32, color: Color) { - if x < 0 || y < 0 || x >= WIDTH as i32 || y >= HEIGHT as i32 { - return; - } - let index = (y as usize * WIDTH + x as usize) * 4; - self.pixels[index..index + 4].copy_from_slice(&[color.0, color.1, color.2, 255]); - } - - fn rect(&mut self, x: i32, y: i32, width: i32, height: i32, color: Color) { - for py in y.max(0)..(y + height).min(HEIGHT as i32) { - for px in x.max(0)..(x + width).min(WIDTH as i32) { - self.pixel(px, py, color); - } - } - } - - fn outline(&mut self, x: i32, y: i32, width: i32, height: i32, color: Color) { - self.rect(x, y, width, 1, color); - self.rect(x, y + height - 1, width, 1, color); - self.rect(x, y, 1, height, color); - self.rect(x + width - 1, y, 1, height, color); - } - - fn line(&mut self, mut x0: i32, mut y0: i32, x1: i32, y1: i32, color: Color) { - let dx = (x1 - x0).abs(); - let step_x = if x0 < x1 { 1 } else { -1 }; - let dy = -(y1 - y0).abs(); - let step_y = if y0 < y1 { 1 } else { -1 }; - let mut error = dx + dy; - loop { - self.pixel(x0, y0, color); - if x0 == x1 && y0 == y1 { - break; - } - let doubled = error * 2; - if doubled >= dy { - error += dy; - x0 += step_x; - } - if doubled <= dx { - error += dx; - y0 += step_y; - } - } - } - - fn text(&mut self, x: i32, y: i32, text: &str, color: Color, scale: i32) { - let mut cursor = x; - for ch in text.chars().map(|ch| ch.to_ascii_uppercase()) { - for (gy, row) in glyph(ch).iter().enumerate() { - for gx in 0..GLYPH_WIDTH { - let mask = 1u8 << (GLYPH_WIDTH - 1 - gx) as u32; - if row & mask != 0 { - self.rect( - cursor + gx * scale, - y + gy as i32 * scale, - scale, - scale, - color, - ); - } - } - } - cursor += GLYPH_ADVANCE * scale; - } - } - - fn compact_text(&mut self, x: i32, y: i32, text: &str, color: Color) { - let mut cursor = x; - for ch in text.chars().map(|ch| ch.to_ascii_uppercase()) { - for (glyph_y, row) in glyph(ch).iter().enumerate() { - for glyph_x in 0..GLYPH_WIDTH { - let mask = 1u8 << (GLYPH_WIDTH - 1 - glyph_x) as u32; - if row & mask != 0 { - self.pixel(cursor + glyph_x, y + glyph_y as i32, color); - } - } - } - cursor += GLYPH_WIDTH; - } - } - - fn centered_text(&mut self, y: i32, text: &str, color: Color, scale: i32) { - let width = text_width(text, scale); - self.text((WIDTH as i32 - width) / 2, y, text, color, scale); - } - - fn centered_text_in( - &mut self, - x: i32, - y: i32, - width: i32, - text: &str, - color: Color, - scale: i32, - ) { - let rendered_width = text_width(text, scale); - debug_assert!( - rendered_width <= width, - "text `{text}` is wider than its {width}px container" - ); - self.text(x + (width - rendered_width) / 2, y, text, color, scale); - } - - fn centered_text_box(&mut self, bounds: UiBox, text: &str, color: Color, scale: i32) { - let rendered_width = text_width(text, scale); - let rendered_height = 7 * scale; - debug_assert!( - rendered_width <= bounds.width && rendered_height <= bounds.height, - "text `{text}` does not fit {bounds:?}" - ); - self.text( - bounds.x + (bounds.width - rendered_width) / 2, - bounds.y + (bounds.height - rendered_height) / 2, - text, - color, - scale, - ); - } - - fn centered_compact_text_box(&mut self, bounds: UiBox, text: &str, color: Color) { - let rendered_width = text.chars().count() as i32 * GLYPH_WIDTH; - debug_assert!( - rendered_width <= bounds.width && 7 <= bounds.height, - "compact text `{text}` does not fit {bounds:?}" - ); - self.compact_text( - bounds.x + (bounds.width - rendered_width) / 2, - bounds.y + (bounds.height - 7) / 2, - text, - color, - ); - } - - fn centered_compact_lines_box(&mut self, bounds: UiBox, lines: &[String], color: Color) { - let capacity = ((bounds.height + 1) / LINE_HEIGHT) as usize; - let visible = &lines[..lines.len().min(capacity)]; - let rendered_height = visible.len() as i32 * LINE_HEIGHT - 1; - let start_y = bounds.y + (bounds.height - rendered_height) / 2; - for (index, line) in visible.iter().enumerate() { - self.centered_compact_text_box( - UiBox { - y: start_y + index as i32 * LINE_HEIGHT, - height: 7, - ..bounds - }, - line, - color, - ); - } - } - - fn wrapped_text( - &mut self, - x: i32, - y: i32, - text: &str, - color: Color, - max_chars: usize, - max_lines: usize, - ) { - for (line_no, line) in wrap_text(text, max_chars) - .into_iter() - .take(max_lines) - .enumerate() - { - self.text(x, y + line_no as i32 * LINE_HEIGHT, &line, color, 1); - } - } -} - -#[derive(Clone, Debug)] -struct QuizRun { - question: usize, - completed_batches: usize, - /// The focused display slot; `order` maps it to a source choice index. - selected: usize, - hearts: u8, - score: u32, - level: u32, - streak: u32, - leveled_up: bool, - /// The committed result and its remaining input hold. The lesson card - /// stays up after the hold reaches zero until A or Start continues. - feedback: Option<(bool, u16)>, - /// Display order of the current question: `order[slot]` is the source - /// choice index drawn in that slot. - order: Vec, - /// The question index `order` and `attempt` were derived for. - presented: Option, - /// Earlier committed attempts at the current question's identity. - attempt: u32, - /// True when the committed answer redeemed a previously missed question. - redeemed: bool, - /// When the committed miss returns, told on its lesson card. - retry_note: Option, - /// The lens and mastery stage the committed answer woke, if it crossed - /// a lens threshold. - lens_woke: Option<(Concept, usize)>, - /// Ticks left in which a second B leaves the run. - leave_armed: u16, - /// What this run taught, reported by the Ascension and Aftermath debriefs. - ledger: RunLedger, -} - -/// A per-run learning tally for the end-of-batch and end-of-run debriefs. It -/// only reports; mastery evidence lives in the cartridge's `Mastery`. -#[derive(Clone, Debug, Default, Eq, PartialEq)] -struct RunLedger { - /// First attempts (non-review questions) committed this run. - first_try: u32, - /// First attempts answered correctly this run. - first_try_right: u32, - /// Review questions answered correctly this run. - redeemed: u32, - /// First attempts committed in the batch still in progress. - batch_first_try: u32, - /// First attempts answered correctly in the batch still in progress. - batch_first_try_right: u32, - /// `(right, attempted)` first tries of the batch that completed last. - last_batch: (u32, u32), - /// Lit mastery runes per lens (`Concept::ALL` order) when the run began. - stages_at_start: [usize; 5], -} - -impl RunLedger { - /// Records one committed answer. Only a review can redeem; only a - /// question's first appearance counts as a first try. - fn record(&mut self, review: bool, correct: bool) { - if review { - self.redeemed += u32::from(correct); - } else { - self.first_try += 1; - self.batch_first_try += 1; - self.first_try_right += u32::from(correct); - self.batch_first_try_right += u32::from(correct); - } - } - - /// Snapshots the batch that just completed for the level-up screen and - /// starts counting the next one. - fn close_batch(&mut self) { - self.last_batch = (self.batch_first_try_right, self.batch_first_try); - self.batch_first_try = 0; - self.batch_first_try_right = 0; - } -} - -/// `1ST TRY a/b`, capped so the worst case is `1ST TRY 99/99`. -fn first_try_label((right, attempted): (u32, u32)) -> String { - format!("1ST TRY {}/{}", right.min(99), attempted.min(99)) -} - -/// The roman numeral of a lit mastery-rune stage. -fn rune_numeral(stage: usize) -> &'static str { - match stage { - 0 => "-", - 1 => "I", - 2 => "II", - _ => "III", - } -} - -/// When a missed question comes back, as its lesson card tells it. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum RetryNote { - /// After this many intervening questions; the copy may carry into the - /// next batch to keep its gap. - In(usize), - /// The deck does not reach the retry gap yet: the copy waits for the - /// Oracle's next questions (see `RetrySlot::Deferred`). - Later, - /// The ward broke: the miss waits in the deck for the next run. - NextRun, -} - -impl RetryNote { - fn label(self) -> String { - match self { - Self::In(0) => "UP NEXT".into(), - Self::In(gap @ 1..=9) => format!("BACK IN {gap}"), - Self::In(_) | Self::Later => "LATER".into(), - Self::NextRun => "NEXT RUN".into(), - } - } -} - -impl QuizRun { - fn new() -> Self { - Self { - question: 0, - completed_batches: 0, - selected: 0, - hearts: 3, - score: 0, - level: 1, - streak: 0, - leveled_up: false, - feedback: None, - order: Vec::new(), - presented: None, - attempt: 0, - redeemed: false, - retry_note: None, - lens_woke: None, - leave_armed: 0, - ledger: RunLedger::default(), - } - } - - /// The source choice indices in display order for a question with - /// `choice_count` choices. Until the current question has been presented, - /// choices keep their source order. - fn display_order(&self, choice_count: usize) -> Vec { - if self.presented == Some(self.question) && self.order.len() == choice_count { - self.order.clone() - } else { - (0..choice_count).collect() - } - } - - /// The source choice index shown in display `slot`. - fn source_choice(&self, slot: usize, choice_count: usize) -> usize { - self.display_order(choice_count) - .get(slot) - .copied() - .unwrap_or(slot) - } -} - -/// Normalized question identity: uppercase with collapsed whitespace, the same -/// rule the cartridge save uses to recognize answered questions. -fn question_identity(question: &str) -> String { - question - .split_whitespace() - .collect::>() - .join(" ") - .to_ascii_uppercase() -} - -/// Where a missed question's review copy is inserted: exactly `RETRY_GAP` -/// other questions after the miss. A retry always waits `RETRY_GAP` questions -/// and may carry into the next batch, so the current batch still closes on -/// time. `None` when the deck does not yet reach that far. -fn retry_insertion_index(current: usize, question_count: usize) -> Option { - let index = current + 1 + RETRY_GAP; - (index <= question_count).then_some(index) -} - -/// Where a miss's review copy went. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum RetrySlot { - /// Inserted into the deck at this index. - At(usize), - /// Held in `GameState::deferred_retries` until the deck reaches its gap. - Deferred, -} - -/// Inserts a same-launch review copy of the missed question at `current` and -/// shifts every batch end at or beyond the insertion point, so a copy past -/// the current batch end joins the next batch. When the deck is too short for -/// the gap, the copy is pushed onto `deferred` with the index it is due at. -fn schedule_retry( - questions: &mut Vec, - batch_ends: &mut [usize], - deferred: &mut Vec<(usize, QuizQuestion)>, - current: usize, -) -> Option { - let mut review = questions.get(current)?.clone(); - review.review = Review::InSession; - let Some(index) = retry_insertion_index(current, questions.len()) else { - deferred.push((current + 1 + RETRY_GAP, review)); - return Some(RetrySlot::Deferred); - }; - insert_retry(questions, batch_ends, index, review); - Some(RetrySlot::At(index)) -} - -fn insert_retry( - questions: &mut Vec, - batch_ends: &mut [usize], - index: usize, - review: QuizQuestion, -) { - questions.insert(index, review); - for end in batch_ends.iter_mut().filter(|end| **end >= index) { - *end += 1; - } -} - -/// Places deferred review copies once a delivery has grown the deck, in due -/// order, each at its due index, so the full retry gap holds and at least one -/// fresh question precedes it after an Oracle wait. A copy the deck does not -/// reach yet waits for a later delivery; if the run ends first, its lesson is -/// still outstanding and requeues for the next run. -fn place_deferred_retries(state: &mut GameState) { - if !state.run_is_live() { - return; - } - let (Some(run), Some(cartridge)) = (state.quiz.as_ref(), state.cartridge.as_mut()) else { - return; - }; - let mut deferred = std::mem::take(&mut state.deferred_retries); - deferred.sort_by_key(|(due, _)| *due); - let mut waiting = Vec::new(); - for (due, review) in deferred { - // `due` already exceeds every index the player had reached when the - // copy was deferred; the guard keeps that true however the deck moved. - let index = due.max(run.question + 1); - if index > cartridge.questions.len() { - waiting.push((due, review)); - continue; - } - insert_retry( - &mut cartridge.questions, - &mut state.batch_ends, - index, - review, - ); - } - state.deferred_retries = waiting; -} - -/// Lit mastery runes (0-3) for `concept`, gated by recent accuracy and the -/// lens's open misses in `lessons`: the one stage rule the footer, the Codex, -/// and the lens-wake check share. -fn lens_stage(mastery: &Mastery, lessons: &[Lesson], concept: Concept) -> usize { - mastery - .get(&concept) - .copied() - .unwrap_or_default() - .stage_with(pending_reviews(lessons, concept)) -} - -/// Upserts the journal entry for a committed question, keyed by identity. A -/// miss remembers the source choice `picked` and the misconception it reveals; -/// a later correct attempt clears the outstanding flag but keeps that -/// misconception. Every attempt clears the peeked flag. -fn record_lesson(lessons: &mut Vec, question: &QuizQuestion, correct: bool, picked: usize) { - let identity = question_identity(&question.question); - let existing = lessons - .iter() - .position(|entry| question_identity(&entry.question) == identity); - let misconception = if correct { - existing.and_then(|index| lessons[index].misconception.clone()) - } else { - question.choices.get(picked).map(|choice| { - ( - choice.clone(), - choice_rationale(question, picked) - .unwrap_or_default() - .to_string(), - ) - }) - }; - let lesson = Lesson { - question: question.question.clone(), - answer: question - .choices - .get(question.answer) - .cloned() - .unwrap_or_default(), - rationale: choice_rationale(question, question.answer) - .unwrap_or_default() - .to_string(), - concept: question.concept, - outstanding: !correct, - misconception, - peeked: false, - spaced_check: false, - }; - match existing { - Some(index) => lessons[index] = lesson, - None => lessons.push(lesson), - } -} - -/// The rationale for source choice `index`, when the question carries a full -/// set of rationales and that one is not blank. -fn choice_rationale(question: &QuizQuestion, index: usize) -> Option<&str> { - if question.rationales.len() != question.choices.len() { - return None; - } - question - .rationales - .get(index) - .map(|rationale| rationale.trim()) - .filter(|rationale| !rationale.is_empty()) -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum OracleDropKind { - Data, - Bug, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum OpeningBeat { - Legacy, - SourceEmber, - ArchiveAnswer, - MemoryVault, - Convergence, - OracleAwakening, -} - -#[derive(Clone, Copy, Debug)] -struct OracleDrop { - x: i32, - y: i32, - kind: OracleDropKind, -} - -#[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, - screen: Screen, - screen_ticks: u64, - held: HashSet