From fc3cd732186499aed90d2bc6e0859b09fb052509 Mon Sep 17 00:00:00 2001 From: Jason Skrzypek Date: Fri, 25 Sep 2026 13:43:39 -0400 Subject: [PATCH 1/2] Split engine.rs into engine/ modules (pure move) src-tauri/src/engine.rs had grown to 14.7k lines. It is now the directory module src-tauri/src/engine/, one file per concern. engine/mod.rs keeps the shared imports, WIDTH/HEIGHT/FRAME_BYTES/NATIVE_RGB_BYTES, the module list and the crate-facing re-exports, so every path the rest of the crate uses (crate::engine::GameEngine, EngineRuntime, Button, CartridgeSpec, ...) is unchanged. engine/transcript.rs and engine/bench.rs are untouched. The split was generated by a script (split_engine.py), not by hand. It moves top-level items and the tests of `mod tests` by name, fails on any item it does not know, and checks that the token multiset is preserved except for the use/mod/visibility tokens it adds and the include_bytes!/include_str! paths that gain one `../` per directory level. An independent checker agreed: no token lost apart from those 25 paths, no declared name lost, 148 #[test] functions before and after. Private items and fields that now cross a file boundary are pub(super) (pub(in crate::engine) under render/), and each file starts with `use super::*`, so name resolution is what it was. The plate cache's thread_local! stays private to framebuffer.rs, and draw_screen, which outside render/ only the test-only engine/bench.rs calls, is imported into engine/mod.rs under cfg(test). The engine's only platform-gated items are two quest-process tests (cfg(unix) and cfg(target_os = "linux")). They sit in runtime.rs next to handle_effect, which they drive, and runtime.rs's test module carries cfg(any(unix, target_os = "linux")), the one added token that is not an import or a visibility. Every other emitted import is used by code that compiles on every target, so none goes unused on Windows or macOS. The node contracts that read engine.rs now read every engine file through tests/engine-source.mjs, and the README module map points at engine/. | Module | Lines | Tests | Contents | |---|---:|---:|---| | mod.rs | 101 | 0 | imports, frame constants, module list, re-exports | | text.rs | 91 | 2 | wrap_text, text_width, truncate, title_lines | | palette.rs | 28 | 0 | Color and the named colors | | assets.rs | 115 | 1 | embedded sprite and scene-plate bytes | | framebuffer.rs | 492 | 2 | UiBox, Framebuffer drawing primitives, the plate cache | | cartridge.rs | 123 | 1 | CartridgeSpec, QuizQuestion, QuestSpec, loader types | | progress.rs | 160 | 1 | thresholds, InsightStage, PresentationTier, streak scoring | | oracle.rs | 351 | 4 | datafall drops, oracle line, recall, failure reasons | | lesson.rs | 446 | 2 | lesson card layout and copy | | state.rs | 1033 | 11 | Screen, OpeningBeat, GameState and its core impl | | codex.rs | 165 | 2 | GameState's journal, mastery and Codex page methods | | quiz.rs | 1510 | 25 | QuizRun, RunLedger, retry scheduling, answer commit | | deck.rs | 828 | 16 | question batches, prefetch, rebatching | | commands.rs | 595 | 11 | EngineCommand, EngineEffect, apply_commands | | input.rs | 562 | 8 | Button, handle_press, hero setup controls | | advance.rs | 472 | 9 | advance_game, the per-tick step | | audio_snapshot.rs | 474 | 9 | audio_snapshot, direct_audio | | quest_process.rs | 125 | 0 | quest shell process and polling | | runtime.rs | 365 | 2 | GameEngine, EngineRuntime, handle_effect and its quest tests | | render/mod.rs | 784 | 2 | render system and draw_screen dispatch | | render/widgets.rs | 196 | 1 | runes, meters, wards, focus frames | | render/sprites.rs | 265 | 1 | hero, crab, boss, sigils, drops | | render/opening.rs | 435 | 4 | boot, chronicle, awakening, title, fanfare | | render/menu.rs | 213 | 2 | quiz menu, gateway menu | | render/atelier.rs | 230 | 2 | character creation | | render/datafall.rs | 788 | 7 | Oracle sanctum and datafall HUD | | render/trial.rs | 792 | 6 | quiz trial screen and feedback banners | | render/ascension.rs | 340 | 3 | level-up screen | | render/aftermath.rs | 576 | 4 | game over and debrief rows | | render/codex.rs | 1497 | 10 | Codex lens, lesson and self-test pages | | render/quest.rs | 65 | 0 | quest select, battle, result | | test_support.rs | 891 | 0 | shared test fixtures and drivers (cfg(test)) | Verified on the WSL mirror: cargo fmt --check, clippy --all-targets -D warnings, cargo test (411 lib tests listed before and after with identical leaf names; 400 passed, 11 ignored, as on the unsplit tree), npm test, and both CODEQUEST.toml validations all pass. No behavior change. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src-tauri/src/engine.rs | 14714 --------------------- src-tauri/src/engine/advance.rs | 472 + src-tauri/src/engine/assets.rs | 115 + src-tauri/src/engine/audio_snapshot.rs | 474 + src-tauri/src/engine/cartridge.rs | 123 + src-tauri/src/engine/codex.rs | 165 + src-tauri/src/engine/commands.rs | 595 + src-tauri/src/engine/deck.rs | 828 ++ src-tauri/src/engine/framebuffer.rs | 492 + src-tauri/src/engine/input.rs | 562 + src-tauri/src/engine/lesson.rs | 446 + src-tauri/src/engine/mod.rs | 101 + src-tauri/src/engine/oracle.rs | 351 + src-tauri/src/engine/palette.rs | 28 + src-tauri/src/engine/progress.rs | 160 + src-tauri/src/engine/quest_process.rs | 125 + src-tauri/src/engine/quiz.rs | 1510 +++ src-tauri/src/engine/render/aftermath.rs | 576 + src-tauri/src/engine/render/ascension.rs | 340 + src-tauri/src/engine/render/atelier.rs | 230 + src-tauri/src/engine/render/codex.rs | 1497 +++ src-tauri/src/engine/render/datafall.rs | 788 ++ src-tauri/src/engine/render/menu.rs | 213 + src-tauri/src/engine/render/mod.rs | 784 ++ src-tauri/src/engine/render/opening.rs | 435 + src-tauri/src/engine/render/quest.rs | 65 + src-tauri/src/engine/render/sprites.rs | 265 + src-tauri/src/engine/render/trial.rs | 792 ++ src-tauri/src/engine/render/widgets.rs | 196 + src-tauri/src/engine/runtime.rs | 365 + src-tauri/src/engine/state.rs | 1033 ++ src-tauri/src/engine/test_support.rs | 891 ++ src-tauri/src/engine/text.rs | 91 + tests/device-transcript-contract.mjs | 3 +- tests/display-contract.mjs | 6 +- tests/engine-source.mjs | 33 + tests/provider-battery-contract.mjs | 5 +- 38 files changed, 15149 insertions(+), 14722 deletions(-) delete mode 100644 src-tauri/src/engine.rs create mode 100644 src-tauri/src/engine/advance.rs create mode 100644 src-tauri/src/engine/assets.rs create mode 100644 src-tauri/src/engine/audio_snapshot.rs create mode 100644 src-tauri/src/engine/cartridge.rs create mode 100644 src-tauri/src/engine/codex.rs create mode 100644 src-tauri/src/engine/commands.rs create mode 100644 src-tauri/src/engine/deck.rs create mode 100644 src-tauri/src/engine/framebuffer.rs create mode 100644 src-tauri/src/engine/input.rs create mode 100644 src-tauri/src/engine/lesson.rs create mode 100644 src-tauri/src/engine/mod.rs create mode 100644 src-tauri/src/engine/oracle.rs create mode 100644 src-tauri/src/engine/palette.rs create mode 100644 src-tauri/src/engine/progress.rs create mode 100644 src-tauri/src/engine/quest_process.rs create mode 100644 src-tauri/src/engine/quiz.rs create mode 100644 src-tauri/src/engine/render/aftermath.rs create mode 100644 src-tauri/src/engine/render/ascension.rs create mode 100644 src-tauri/src/engine/render/atelier.rs create mode 100644 src-tauri/src/engine/render/codex.rs create mode 100644 src-tauri/src/engine/render/datafall.rs create mode 100644 src-tauri/src/engine/render/menu.rs create mode 100644 src-tauri/src/engine/render/mod.rs create mode 100644 src-tauri/src/engine/render/opening.rs create mode 100644 src-tauri/src/engine/render/quest.rs create mode 100644 src-tauri/src/engine/render/sprites.rs create mode 100644 src-tauri/src/engine/render/trial.rs create mode 100644 src-tauri/src/engine/render/widgets.rs create mode 100644 src-tauri/src/engine/runtime.rs create mode 100644 src-tauri/src/engine/state.rs create mode 100644 src-tauri/src/engine/test_support.rs create mode 100644 src-tauri/src/engine/text.rs create mode 100644 tests/engine-source.mjs 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/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