From 19e39d220d0de0c6eecf89627b5db185758cb3a4 Mon Sep 17 00:00:00 2001 From: Simonas Kareiva Date: Thu, 21 May 2026 17:12:23 +0300 Subject: [PATCH] First levels v0.1.0 --- src/bin/go.rs | 22 ++- src/describe.rs | 38 ++++ src/levels/l01_minimum.rs | 35 ++++ src/levels/l02_kv.rs | 81 +++++++++ src/levels/mod.rs | 67 ++++++++ src/lib.rs | 109 ++++++++++++ src/progress.rs | 38 ++++ src/similarity.rs | 29 ++++ src/tui.rs | 352 +++++++++++++++++++++++++++++++++++--- 9 files changed, 749 insertions(+), 22 deletions(-) create mode 100644 src/levels/l01_minimum.rs create mode 100644 src/levels/l02_kv.rs diff --git a/src/bin/go.rs b/src/bin/go.rs index 7f10e5b..05c6a31 100644 --- a/src/bin/go.rs +++ b/src/bin/go.rs @@ -1,3 +1,21 @@ -fn main() -> anyhow::Result<()> { - yamlabyrinth::tui::run() +use anyhow::Result; +use clap::Parser; + +#[derive(Parser)] +#[command(name = "go", about = "Traverse the YAML labyrinth")] +struct Cli { + /// Wipe progress and exit. + #[arg(long)] + reset: bool, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + if cli.reset { + let _ = std::fs::remove_file(yamlabyrinth::progress::save_path()); + println!("Progress wiped."); + return Ok(()); + } + let mut prog = yamlabyrinth::progress::load()?; + yamlabyrinth::tui::run(&mut prog) } diff --git a/src/describe.rs b/src/describe.rs index e69de29..60358c9 100644 --- a/src/describe.rs +++ b/src/describe.rs @@ -0,0 +1,38 @@ +//! Per-level natural-language descriptions, rendered with `tera`. +//! +//! Each level registers its own template string at startup; the `Describer` +//! is the registry. The template string lives next to the level's +//! generator, not in this module. + +use tera::{Context, Tera}; + +pub struct Describer { + tera: Tera, +} + +impl Describer { + pub fn new() -> Self { + Self { + tera: Tera::default(), + } + } + + pub fn register(&mut self, name: &str, template: &str) -> tera::Result<()> { + self.tera.add_raw_template(name, template) + } + + pub fn render( + &self, + name: &str, + ctx: &C, + ) -> tera::Result { + let ctx = Context::from_serialize(ctx)?; + self.tera.render(name, &ctx) + } +} + +impl Default for Describer { + fn default() -> Self { + Self::new() + } +} diff --git a/src/levels/l01_minimum.rs b/src/levels/l01_minimum.rs new file mode 100644 index 0000000..f449415 --- /dev/null +++ b/src/levels/l01_minimum.rs @@ -0,0 +1,35 @@ +//! Level 1 — the dungeon door. Write the smallest valid YAML. +//! +//! Paired design note: `l01.md`. + +use serde_yaml::Value; + +use super::{Generated, Level}; + +pub struct Minimum; + +impl Level for Minimum { + fn id(&self) -> u8 { + 1 + } + + fn name(&self) -> &'static str { + "The Dungeon Door" + } + + fn generate(&self, _seed: u64) -> Generated { + // Canonical target: the null document. `---`, `~`, and `null` all + // parse to `Value::Null`, so any of them passes via the semantic + // short-circuit. + let target_yaml = serde_yaml::to_string(&Value::Null).expect("serialise null"); + Generated { + target_yaml, + description: + "Write the smallest possible valid YAML — a single empty document is enough." + .to_string(), + flavor: + "A heavy door bars the way. A glyph above it asks only for the smallest valid offering." + .to_string(), + } + } +} diff --git a/src/levels/l02_kv.rs b/src/levels/l02_kv.rs new file mode 100644 index 0000000..a0a48c8 --- /dev/null +++ b/src/levels/l02_kv.rs @@ -0,0 +1,81 @@ +//! Level 2 — key-value pairs. Map each direction to what lies that way. +//! +//! Paired design note: `l02.md`. + +use rand::seq::SliceRandom; +use rand::{Rng, SeedableRng}; +use rand_chacha::ChaCha8Rng; +use serde::Serialize; +use serde_yaml::{Mapping, Value}; + +use crate::describe::Describer; + +use super::{Generated, Level}; + +pub struct KeyValue; + +const DIRECTIONS: &[&str] = &["left", "right", "straight", "back", "up", "down"]; +const FEATURES: &[&str] = &["door", "tunnel", "wall", "stairs", "pit", "altar"]; + +#[derive(Serialize)] +struct DescCtx { + pairs: Vec, +} + +#[derive(Serialize)] +struct DescPair { + direction: String, + feature: String, +} + +impl Level for KeyValue { + fn id(&self) -> u8 { + 2 + } + + fn name(&self) -> &'static str { + "Key-Value Pairs" + } + + fn generate(&self, seed: u64) -> Generated { + // Seed XOR'd with a per-level constant so the same `current_seed` + // produces different content per level. + let mut rng = ChaCha8Rng::seed_from_u64(seed ^ 0x0000_0000_0000_0002); + let n = rng.gen_range(2..=4); + let directions: Vec<&'static str> = + DIRECTIONS.choose_multiple(&mut rng, n).copied().collect(); + + let mut mapping = Mapping::new(); + let mut pairs = Vec::with_capacity(directions.len()); + for d in &directions { + let f = *FEATURES.choose(&mut rng).expect("non-empty pool"); + mapping.insert( + Value::String((*d).to_string()), + Value::String(f.to_string()), + ); + pairs.push(DescPair { + direction: (*d).to_string(), + feature: f.to_string(), + }); + } + + let target_yaml = + serde_yaml::to_string(&Value::Mapping(mapping)).expect("serialise mapping"); + + let mut d = Describer::new(); + d.register( + "l02", + "{% for p in pairs %}- {{ p.direction }} leads to a {{ p.feature }}\n{% endfor %}", + ) + .expect("register template"); + let description = d + .render("l02", &DescCtx { pairs }) + .expect("render template"); + + Generated { + target_yaml, + description, + flavor: "You stand at a junction. Map what you see.".to_string(), + } + } +} diff --git a/src/levels/mod.rs b/src/levels/mod.rs index e69de29..63f38a7 100644 --- a/src/levels/mod.rs +++ b/src/levels/mod.rs @@ -0,0 +1,67 @@ +//! Levels — hand-written Rust generators paired with design notes. +//! +//! Each level is implemented in `l_.rs` and is the authoritative +//! source of truth for what target YAML the player must reproduce and how +//! the description is rendered. +//! +//! The paired `l.md` file is a **design note only**: it documents the +//! intended scene and a minimal example of the target YAML. `.md` files +//! are *not* loaded at runtime — there is no `include_str!` and no +//! markdown parser. If a `.md` and its paired `.rs` ever disagree, the +//! `.rs` wins. + +pub mod l01_minimum; +pub mod l02_kv; + +use serde::{Deserialize, Serialize}; + +/// Game-wide difficulty. Chosen once on the TierSelect screen; persisted +/// in `Progress.tier`. Maps to a fixed passing threshold per level. +#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum Difficulty { + Easy, + Medium, + Hard, +} + +impl Difficulty { + pub fn threshold(self) -> f64 { + match self { + Self::Easy => 0.70, + Self::Medium => 0.80, + Self::Hard => 0.95, + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Easy => "Easy (70%)", + Self::Medium => "Medium (80%)", + Self::Hard => "Hard (95%)", + } + } +} + +/// What `Level::generate` returns: the canonical target YAML to grade +/// against, the player-facing description (already rendered), and the +/// dungeon flavor line. +pub struct Generated { + pub target_yaml: String, + pub description: String, + pub flavor: String, +} + +/// One level's generator. Implementations live in `l_.rs`. +pub trait Level { + fn id(&self) -> u8; + fn name(&self) -> &'static str; + fn generate(&self, seed: u64) -> Generated; +} + +/// Ordered registry of all levels. `registry()[0]` is level 1. +pub fn registry() -> Vec> { + vec![ + Box::new(l01_minimum::Minimum), + Box::new(l02_kv::KeyValue), + ] +} diff --git a/src/lib.rs b/src/lib.rs index c7c89d2..56e560e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,3 +3,112 @@ pub mod levels; pub mod progress; pub mod similarity; pub mod tui; + +#[cfg(test)] +mod smoke { + //! End-to-end wiring test with one hardcoded level (no `Level` trait yet). + + use super::*; + use serde::Serialize; + use serde_yaml::{Mapping, Value}; + + #[derive(Serialize)] + struct JunctionCtx { + directions: Vec, + } + + #[derive(Serialize)] + struct Direction { + name: &'static str, + feature: &'static str, + } + + #[test] + fn one_level_round_trip() { + // (1) Describer renders prose for a fake "junction" level. + let mut d = describe::Describer::new(); + d.register( + "junction", + "You stand at a junction:\n\ + {% for x in directions %}- {{ x.name }} leads to a {{ x.feature }}\n{% endfor %}", + ) + .unwrap(); + let prose = d + .render( + "junction", + &JunctionCtx { + directions: vec![ + Direction { name: "left", feature: "door" }, + Direction { name: "right", feature: "tunnel" }, + ], + }, + ) + .unwrap(); + assert!(prose.contains("left leads to a door")); + assert!(prose.contains("right leads to a tunnel")); + + // (2) Canonical target via serde_yaml — what a generator will do. + // A reordered candidate parses to the same Value, so the semantic + // short-circuit must score it 1.0. + let mut m = Mapping::new(); + m.insert(Value::String("left".into()), Value::String("door".into())); + m.insert(Value::String("right".into()), Value::String("tunnel".into())); + let target = serde_yaml::to_string(&Value::Mapping(m)).unwrap(); + let candidate = "right: tunnel\nleft: door\n"; + assert_eq!( + similarity::semantic_or_textual(&target, candidate), + 1.0, + "reordered keys should still be a perfect semantic match" + ); + + // (3) Textually-different, semantically-different → ratio in (0, 1). + let near_miss = similarity::similarity_ratio("a: 1\nb: 2\n", "a: 1\nb: 3\n"); + assert!(near_miss > 0.0 && near_miss < 1.0); + + // (4) Progress round-trips through the same YAML pipeline that disk + // save/load will use. + let p = progress::Progress { + tier: Some(levels::Difficulty::Medium), + completed: vec![1, 2], + current_level: 3, + current_seed: 0xCAFE, + attempts: 1, + }; + let s = serde_yaml::to_string(&p).unwrap(); + let loaded: progress::Progress = serde_yaml::from_str(&s).unwrap(); + assert_eq!(loaded.tier, p.tier); + assert_eq!(loaded.current_level, p.current_level); + assert_eq!(loaded.current_seed, p.current_seed); + } + + #[test] + fn levels_generate_canonical_yaml() { + let registry = levels::registry(); + assert_eq!(registry.len(), 2); + + // Level 1: any null-equivalent passes via the semantic short-circuit. + let g1 = registry[0].generate(0); + let parsed: serde_yaml::Value = serde_yaml::from_str(&g1.target_yaml).unwrap(); + assert!(parsed.is_null()); + assert_eq!( + similarity::semantic_or_textual(&g1.target_yaml, "---"), + 1.0, + "`---` should be accepted as the minimum YAML" + ); + assert_eq!( + similarity::semantic_or_textual(&g1.target_yaml, "null"), + 1.0 + ); + + // Level 2: deterministic per seed, non-empty mapping. + let g2 = registry[1].generate(42); + let v2: serde_yaml::Value = serde_yaml::from_str(&g2.target_yaml).unwrap(); + let m = v2.as_mapping().expect("level 2 produces a mapping"); + assert!(!m.is_empty()); + let g2_again = registry[1].generate(42); + assert_eq!( + g2.target_yaml, g2_again.target_yaml, + "same seed should produce the same target" + ); + } +} diff --git a/src/progress.rs b/src/progress.rs index e69de29..f2f11c8 100644 --- a/src/progress.rs +++ b/src/progress.rs @@ -0,0 +1,38 @@ +//! Player progress saved to `./.gameplay/state.yaml`. + +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +use crate::levels::Difficulty; + +#[derive(Default, Serialize, Deserialize)] +pub struct Progress { + pub tier: Option, + pub completed: Vec, + pub current_level: u8, + pub current_seed: u64, + pub attempts: u32, +} + +pub fn save_path() -> PathBuf { + PathBuf::from(".gameplay/state.yaml") +} + +pub fn load() -> Result { + let path = save_path(); + if !path.exists() { + return Ok(Progress::default()); + } + Ok(serde_yaml::from_str(&std::fs::read_to_string(&path)?)?) +} + +pub fn save(progress: &Progress) -> Result<()> { + let path = save_path(); + // First-run safeguard: `.gameplay/` won't exist yet. + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + std::fs::write(&path, serde_yaml::to_string(progress)?)?; + Ok(()) +} diff --git a/src/similarity.rs b/src/similarity.rs index e69de29..e4d689c 100644 --- a/src/similarity.rs +++ b/src/similarity.rs @@ -0,0 +1,29 @@ +//! Line-based YAML similarity with a semantic short-circuit. +//! +//! The canonical serialised form is whatever `serde_yaml::to_string` of the +//! target `Value` produces. Level generators must build the target via +//! `serde_yaml::to_string` so the player can submit any semantically-equal +//! YAML and still earn a perfect score. + +use similar::TextDiff; + +/// Line-based similarity ratio in `[0.0, 1.0]`. +pub fn similarity_ratio(target: &str, candidate: &str) -> f64 { + TextDiff::from_lines(target, candidate).ratio() as f64 +} + +/// Primary entry point. If both sides parse to equal `serde_yaml::Value`s, +/// return `1.0`; otherwise fall back to line-based textual similarity. The +/// short-circuit is what makes `yes` / `true` and other YAML aliases +/// forgivable without having to canonicalise the player's submission. +pub fn semantic_or_textual(target: &str, candidate: &str) -> f64 { + if let (Ok(t), Ok(c)) = ( + serde_yaml::from_str::(target), + serde_yaml::from_str::(candidate), + ) { + if t == c { + return 1.0; + } + } + similarity_ratio(target, candidate) +} diff --git a/src/tui.rs b/src/tui.rs index 7a1036b..929e8ca 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -5,14 +5,40 @@ use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, }; use ratatui::backend::CrosstermBackend; -use ratatui::widgets::{Block, Borders, Paragraph}; -use ratatui::Terminal; +use ratatui::layout::Rect; +use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; +use ratatui::{Frame, Terminal}; use std::io::{stdout, Stdout}; -pub fn run() -> Result<()> { +use crate::levels::{self, Difficulty, Level}; +use crate::progress::{self, Progress}; +use crate::similarity; + +enum Screen { + Welcome, + TierSelect { + cursor: u8, + }, + Level, + Submit { + buf: String, + }, + Result { + score: f64, + passed: bool, + level_name: String, + }, + InvalidYaml { + error: String, + }, + ResetConfirm, + Completed, +} + +pub fn run(prog: &mut Progress) -> Result<()> { install_panic_hook(); let mut terminal = enter()?; - let result = main_loop(&mut terminal); + let result = main_loop(&mut terminal, prog); leave()?; result } @@ -37,23 +63,309 @@ fn leave() -> Result<()> { Ok(()) } -fn main_loop(terminal: &mut Terminal>) -> Result<()> { - loop { - terminal.draw(|frame| { - let block = Block::default() - .borders(Borders::ALL) - .title(" YAMLabyrinth "); - let body = Paragraph::new( - "Welcome to the YAML labyrinth.\n\nPress [q] to flee.", - ) - .block(block); - frame.render_widget(body, frame.size()); - })?; +fn initial_screen(prog: &Progress, registry_len: usize) -> Screen { + match (prog.tier, prog.current_level) { + (None, 0) => Screen::Welcome, + (None, _) => Screen::TierSelect { cursor: 0 }, + (Some(_), 0) => Screen::TierSelect { cursor: 0 }, + (Some(_), n) if (n as usize) > registry_len => Screen::Completed, + (Some(_), _) => Screen::Level, + } +} - if let Event::Key(key) = event::read()? { - if key.code == KeyCode::Char('q') { - return Ok(()); - } +fn main_loop( + terminal: &mut Terminal>, + prog: &mut Progress, +) -> Result<()> { + let registry = levels::registry(); + let mut screen = initial_screen(prog, registry.len()); + + loop { + terminal.draw(|frame| render(frame, &screen, prog, ®istry))?; + let Event::Key(key) = event::read()? else { + continue; + }; + + // Swap `screen` out so we can match by value, then assign the new + // screen back. `Screen::Welcome` is just a placeholder during the + // swap; `step` never observes it. + let current = std::mem::replace(&mut screen, Screen::Welcome); + match step(current, key.code, prog, ®istry)? { + Transition::To(next) => screen = next, + Transition::Quit => return Ok(()), } } } + +enum Transition { + To(Screen), + Quit, +} + +fn step( + screen: Screen, + key: KeyCode, + prog: &mut Progress, + registry: &[Box], +) -> Result { + use Transition::*; + + Ok(match (screen, key) { + // Welcome ------------------------------------------------------------ + (Screen::Welcome, KeyCode::Enter) => To(Screen::TierSelect { cursor: 0 }), + (Screen::Welcome, KeyCode::Char('q')) => Quit, + + // TierSelect --------------------------------------------------------- + (Screen::TierSelect { cursor }, KeyCode::Up) => To(Screen::TierSelect { + cursor: cursor.saturating_sub(1), + }), + (Screen::TierSelect { cursor }, KeyCode::Down) => To(Screen::TierSelect { + cursor: (cursor + 1).min(2), + }), + (Screen::TierSelect { cursor }, KeyCode::Enter) => { + let tier = match cursor { + 0 => Difficulty::Easy, + 1 => Difficulty::Medium, + _ => Difficulty::Hard, + }; + prog.tier = Some(tier); + if prog.current_level == 0 { + prog.current_level = 1; + prog.current_seed = rand::random(); + } + progress::save(prog)?; + To(Screen::Level) + } + (Screen::TierSelect { .. }, KeyCode::Char('q')) => Quit, + + // Level -------------------------------------------------------------- + (Screen::Level, KeyCode::Char('s')) => To(Screen::Submit { buf: String::new() }), + (Screen::Level, KeyCode::Char('r')) => To(Screen::ResetConfirm), + (Screen::Level, KeyCode::Char('q')) => Quit, + + // Submit ------------------------------------------------------------- + (Screen::Submit { mut buf }, KeyCode::Char(c)) => { + buf.push(c); + To(Screen::Submit { buf }) + } + (Screen::Submit { mut buf }, KeyCode::Backspace) => { + buf.pop(); + To(Screen::Submit { buf }) + } + (Screen::Submit { .. }, KeyCode::Esc) => To(Screen::Level), + (Screen::Submit { buf }, KeyCode::Enter) => To(grade(buf.trim(), prog, registry)?), + + // Result ------------------------------------------------------------- + (Screen::Result { passed: true, .. }, _) => { + if (prog.current_level as usize) > registry.len() { + To(Screen::Completed) + } else { + To(Screen::Level) + } + } + (Screen::Result { passed: false, .. }, _) => To(Screen::Level), + + // InvalidYaml -------------------------------------------------------- + (Screen::InvalidYaml { .. }, _) => To(Screen::Submit { buf: String::new() }), + + // ResetConfirm ------------------------------------------------------- + (Screen::ResetConfirm, KeyCode::Char('y')) => { + let _ = std::fs::remove_file(progress::save_path()); + *prog = Progress::default(); + To(Screen::Welcome) + } + (Screen::ResetConfirm, _) => To(Screen::Level), + + // Completed ---------------------------------------------------------- + (Screen::Completed, KeyCode::Char('q')) => Quit, + (Screen::Completed, KeyCode::Char('r')) => To(Screen::ResetConfirm), + + // Anything else: stay where we are. + (other, _) => To(other), + }) +} + +/// Read the player's file, parse it, score it, advance progress on a pass, +/// and choose the next screen. Returns `Result` (`InvalidYaml` or `Result`). +fn grade(path: &str, prog: &mut Progress, registry: &[Box]) -> Result { + let candidate = match std::fs::read_to_string(path) { + Ok(s) => s, + Err(e) => { + return Ok(Screen::InvalidYaml { + error: format!("could not read file: {e}"), + }); + } + }; + + // Parse-first guard: invalid YAML is a wrong *format*, not a wrong + // *answer* — no scoring, no attempts bump. + if let Err(e) = serde_yaml::from_str::(&candidate) { + return Ok(Screen::InvalidYaml { error: e.to_string() }); + } + + let idx = (prog.current_level - 1) as usize; + let level = ®istry[idx]; + let g = level.generate(prog.current_seed); + let score = similarity::semantic_or_textual(&g.target_yaml, &candidate); + let threshold = prog + .tier + .expect("tier set before reaching the Level screen") + .threshold(); + let passed = score >= threshold; + let level_name = level.name().to_string(); + let level_id = level.id(); + + if passed { + prog.completed.push(level_id); + prog.current_level += 1; + prog.current_seed = rand::random(); + } else { + prog.attempts += 1; + } + progress::save(prog)?; + + Ok(Screen::Result { + score, + passed, + level_name, + }) +} + +// -- rendering -------------------------------------------------------------- + +fn render( + frame: &mut Frame, + screen: &Screen, + prog: &Progress, + registry: &[Box], +) { + let area = frame.size(); + match screen { + Screen::Welcome => render_welcome(frame, area), + Screen::TierSelect { cursor } => render_tier_select(frame, area, *cursor), + Screen::Level => render_level(frame, area, prog, registry), + Screen::Submit { buf } => render_submit(frame, area, buf), + Screen::Result { + score, + passed, + level_name, + } => render_result(frame, area, *score, *passed, level_name, prog), + Screen::InvalidYaml { error } => render_invalid_yaml(frame, area, error), + Screen::ResetConfirm => render_reset_confirm(frame, area), + Screen::Completed => render_completed(frame, area), + } +} + +fn screen_widget<'a>(title: String, body: String) -> Paragraph<'a> { + Paragraph::new(body) + .block(Block::default().borders(Borders::ALL).title(title)) + .wrap(Wrap { trim: false }) +} + +fn render_welcome(frame: &mut Frame, area: Rect) { + let body = "\n\ + You stand at the entrance to the YAML labyrinth.\n\ + Within, broken syntax festers and dictionaries\n\ + sprawl like vines. Reach the heart of it.\n\ + \n\ + [Enter] begin · [q] flee" + .to_string(); + frame.render_widget(screen_widget(" YAMLabyrinth ".to_string(), body), area); +} + +fn render_tier_select(frame: &mut Frame, area: Rect, cursor: u8) { + let rows = [ + ("Easy", "70 %", "forgive small slips"), + ("Medium", "80 %", "most details must match"), + ("Hard", "95 %", "only near-perfect passes"), + ]; + let mut body = String::from("\n"); + for (i, (name, pct, hint)) in rows.iter().enumerate() { + let marker = if i == cursor as usize { ">" } else { " " }; + body.push_str(&format!(" {marker} {name:<7} ({pct}) {hint}\n")); + } + body.push_str("\n↑/↓ choose · [Enter] confirm · [q] quit"); + frame.render_widget(screen_widget(" Choose your tier ".to_string(), body), area); +} + +fn render_level( + frame: &mut Frame, + area: Rect, + prog: &Progress, + registry: &[Box], +) { + let idx = (prog.current_level - 1) as usize; + let level = ®istry[idx]; + let g = level.generate(prog.current_seed); + let tier_label = prog.tier.map(|t| t.label()).unwrap_or("?"); + let title = format!( + " Level {}: {} · Tier: {} ", + level.id(), + level.name(), + tier_label + ); + let body = format!( + "\n{}\n\n{}\nWrite your YAML answer in a file, then\n[s] submit · [r] reset · [q] flee", + g.flavor, g.description + ); + frame.render_widget(screen_widget(title, body), area); +} + +fn render_submit(frame: &mut Frame, area: Rect, buf: &str) { + let body = format!( + "\nPath to your answer YAML:\n\n> {buf}_\n\n[Enter] grade · [Esc] cancel" + ); + frame.render_widget(screen_widget(" Submit your answer ".to_string(), body), area); +} + +fn render_result( + frame: &mut Frame, + area: Rect, + score: f64, + passed: bool, + level_name: &str, + prog: &Progress, +) { + let threshold = prog + .tier + .map(|t| t.threshold()) + .unwrap_or(0.0); + let (title, narration) = if passed { + ( + format!(" {level_name} cleared "), + "The door creaks open. You step deeper into the labyrinth.", + ) + } else { + ( + " Not yet ".to_string(), + "The labyrinth resists. Refine your YAML and try again.", + ) + }; + let body = format!( + "\nScore {:.0} % Threshold {:.0} %\n\n{}\n\n[Enter] continue", + score * 100.0, + threshold * 100.0, + narration, + ); + frame.render_widget(screen_widget(title, body), area); +} + +fn render_invalid_yaml(frame: &mut Frame, area: Rect, error: &str) { + let body = format!( + "\n{error}\n\nFix the syntax and try again.\n\n[Enter] back to submit" + ); + frame.render_widget( + screen_widget(" Couldn't parse your YAML ".to_string(), body), + area, + ); +} + +fn render_reset_confirm(frame: &mut Frame, area: Rect) { + let body = "\nWipe progress and start over?\n\n[y] yes, wipe · any other key cancels".to_string(); + frame.render_widget(screen_widget(" Reset? ".to_string(), body), area); +} + +fn render_completed(frame: &mut Frame, area: Rect) { + let body = "\nYou cleared the YAML labyrinth.\n\n[r] reset and replay · [q] quit".to_string(); + frame.render_widget(screen_widget(" Labyrinth complete ".to_string(), body), area); +}