From a6741da14c1b064bd03f994a32b478df127dc07d Mon Sep 17 00:00:00 2001 From: Simonas Kareiva Date: Thu, 21 May 2026 19:17:58 +0300 Subject: [PATCH] Replace tiers with achievements --- src/levels/mod.rs | 48 +++++---- src/lib.rs | 5 +- src/progress.rs | 11 +- src/tui.rs | 267 ++++++++++++++++++---------------------------- 4 files changed, 139 insertions(+), 192 deletions(-) diff --git a/src/levels/mod.rs b/src/levels/mod.rs index 261e4e6..33973a3 100644 --- a/src/levels/mod.rs +++ b/src/levels/mod.rs @@ -15,37 +15,47 @@ 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. +/// Awarded per level based on the grade score. Replaces the old +/// game-wide difficulty tier. Thresholds: +/// - Gold ≥ 95 % +/// - Silver ≥ 80 % +/// - Bronze ≥ 70 % +/// - Below 70 % → no nugget (retry). #[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -pub enum Difficulty { - Easy, - Medium, - Hard, +pub enum Nugget { + Bronze, + Silver, + Gold, } -impl Difficulty { - pub fn threshold(self) -> f64 { - match self { - Self::Easy => 0.70, - Self::Medium => 0.80, - Self::Hard => 0.95, +impl Nugget { + /// Map a grade ratio (0.0..=1.0) to a nugget. `None` means the + /// player didn't clear the level — retry without advancing. + pub fn from_score(score: f64) -> Option { + if score >= 0.95 { + Some(Nugget::Gold) + } else if score >= 0.80 { + Some(Nugget::Silver) + } else if score >= 0.70 { + Some(Nugget::Bronze) + } else { + None } } - pub fn label(self) -> &'static str { + pub fn emoji(self) -> &'static str { match self { - Self::Easy => "Easy (70%)", - Self::Medium => "Medium (80%)", - Self::Hard => "Hard (95%)", + Self::Bronze => "🥉", + Self::Silver => "🥈", + Self::Gold => "🥇", } } pub fn name(self) -> &'static str { match self { - Self::Easy => "Easy", - Self::Medium => "Medium", - Self::Hard => "Hard", + Self::Bronze => "Bronze", + Self::Silver => "Silver", + Self::Gold => "Gold", } } } diff --git a/src/lib.rs b/src/lib.rs index 10adc92..b89f53b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -69,15 +69,14 @@ mod smoke { // (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], + nuggets: vec![(1, levels::Nugget::Silver), (2, levels::Nugget::Gold)], 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.nuggets, p.nuggets); assert_eq!(loaded.current_level, p.current_level); assert_eq!(loaded.current_seed, p.current_seed); } diff --git a/src/progress.rs b/src/progress.rs index f2f11c8..e73910c 100644 --- a/src/progress.rs +++ b/src/progress.rs @@ -4,13 +4,16 @@ use anyhow::Result; use serde::{Deserialize, Serialize}; use std::path::PathBuf; -use crate::levels::Difficulty; +use crate::levels::Nugget; #[derive(Default, Serialize, Deserialize)] +#[serde(default)] pub struct Progress { - pub tier: Option, - pub completed: Vec, - pub current_level: u8, + /// Nuggets awarded per level: `(level_id, nugget)` in order of + /// completion. A level appears at most once (re-runs after reset + /// rebuild the list from scratch). + pub nuggets: Vec<(u8, Nugget)>, + pub current_level: u8, // 1-indexed; 0 means a new game pub current_seed: u64, pub attempts: u32, } diff --git a/src/tui.rs b/src/tui.rs index 1b54df4..be57b95 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -13,7 +13,7 @@ use std::io::{stdout, Stdout}; use std::time::{Duration, Instant}; use crate::config; -use crate::levels::{self, Difficulty, Level}; +use crate::levels::{self, Level, Nugget}; use crate::progress::{self, Progress}; use crate::similarity; @@ -21,7 +21,6 @@ use crate::similarity; enum Screen { Welcome, - TierSelect { cursor: u8 }, /// Active play: left column = HistoryLog, right column = Editor. Game, ResetConfirm, @@ -35,34 +34,31 @@ enum Focus { } enum LogEntry { - /// Game rules + the full controls listing. Appended once on - /// Welcome → TierSelect so the player can scroll back to it. + /// Game rules + the full controls listing. Seeded at startup. Intro, - /// The full tier table with a marker on the chosen one. - TierChoice { - chosen: u8, // 0=Easy, 1=Medium, 2=Hard - }, LevelPrompt { level_id: u8, level_name: String, - tier_name: &'static str, flavor: String, description: String, }, ResultPass { level_name: String, score: f64, - threshold: f64, + nugget: Nugget, }, ResultFail { level_name: String, score: f64, - threshold: f64, }, InvalidYaml { error: String, }, - Completed, + Completed { + gold: u32, + silver: u32, + bronze: u32, + }, } /// Append-only chronological log of game events. Rendered bottom-anchored @@ -259,54 +255,57 @@ fn leave() -> Result<()> { Ok(()) } -fn tier_index(t: Difficulty) -> u8 { - match t { - Difficulty::Easy => 0, - Difficulty::Medium => 1, - Difficulty::Hard => 2, - } -} - fn initial_state(prog: &Progress, registry: &[Box]) -> (Screen, HistoryLog) { let mut log = HistoryLog::new(); - // The Intro entry is always the bottom of the log on startup so that - // Welcome and TierSelect can render it inline (the player can scroll - // back to it any time). + // The Intro entry is always at the bottom of the log on startup so + // Welcome can show it inline; the player can scroll back to it any + // time during play. log.push(LogEntry::Intro); - match (prog.tier, prog.current_level) { - (None, 0) => (Screen::Welcome, log), - (None, _) => (Screen::TierSelect { cursor: 0 }, log), - (Some(tier), 0) => (Screen::TierSelect { cursor: tier_index(tier) }, log), - (Some(_), n) if (n as usize) > registry.len() => { - log.push(LogEntry::Completed); + match prog.current_level { + 0 => (Screen::Welcome, log), + n if (n as usize) > registry.len() => { + let (gold, silver, bronze) = count_nuggets(&prog.nuggets); + log.push(LogEntry::Completed { + gold, + silver, + bronze, + }); (Screen::Completed, log) } - (Some(tier), _) => { - log.push(LogEntry::TierChoice { chosen: tier_index(tier) }); - log.push(level_prompt_entry(prog, registry, tier)); + _ => { + log.push(level_prompt_entry(prog, registry)); (Screen::Game, log) } } } -fn level_prompt_entry( - prog: &Progress, - registry: &[Box], - tier: Difficulty, -) -> LogEntry { +fn level_prompt_entry(prog: &Progress, registry: &[Box]) -> LogEntry { let idx = (prog.current_level - 1) as usize; let level = ®istry[idx]; let g = level.generate(prog.current_seed); LogEntry::LevelPrompt { level_id: level.id(), level_name: level.name().to_string(), - tier_name: tier.name(), flavor: g.flavor, description: g.description, } } +fn count_nuggets(nuggets: &[(u8, Nugget)]) -> (u32, u32, u32) { + let mut gold = 0; + let mut silver = 0; + let mut bronze = 0; + for (_, n) in nuggets { + match n { + Nugget::Gold => gold += 1, + Nugget::Silver => silver += 1, + Nugget::Bronze => bronze += 1, + } + } + (gold, silver, bronze) +} + fn default_focus(screen: &Screen) -> Focus { match screen { Screen::Game => Focus::Editor, @@ -326,7 +325,8 @@ fn main_loop( let mut focus = default_focus(&screen); let mut editor = Editor::new(); - // If resume put a LevelPrompt at the bottom, typewriter it from scratch. + // Resume case: if the bottom of the log is a LevelPrompt, animate it + // from scratch so resumed sessions feel the same as fresh ones. let mut typewriter = if cfg.typewriter_enabled { start_typewriter(&log, &cfg) } else { @@ -409,7 +409,7 @@ fn step( ) -> Result { let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); - // Global: Ctrl-Q quits. + // Global: Ctrl-Q quits from anywhere. if ctrl && key.code == KeyCode::Char('q') { return Ok(true); } @@ -417,7 +417,7 @@ fn step( // Global: Ctrl-X grades while in Game. if ctrl && key.code == KeyCode::Char('x') && matches!(screen, Screen::Game) { grade(editor.text(), prog, registry, log)?; - if matches!(log.entries.last(), Some(LogEntry::Completed)) { + if matches!(log.entries.last(), Some(LogEntry::Completed { .. })) { *screen = Screen::Completed; *focus = default_focus(screen); } @@ -468,10 +468,17 @@ fn step( return Ok(false); } - // Other screens (Welcome, TierSelect, ResetConfirm, Completed). + // Other screens (Welcome, ResetConfirm, Completed). match (&*screen, key.code) { (Screen::Welcome, KeyCode::Enter) => { - *screen = Screen::TierSelect { cursor: 0 }; + if prog.current_level == 0 { + prog.current_level = 1; + prog.current_seed = rand::random(); + } + progress::save(prog)?; + editor.clear(); + log.push(level_prompt_entry(prog, registry)); + *screen = Screen::Game; *focus = default_focus(screen); } (Screen::Welcome, KeyCode::Char('q')) => return Ok(true), @@ -480,37 +487,6 @@ fn step( (Screen::Welcome, KeyCode::PageUp) => log.scroll_up(10), (Screen::Welcome, KeyCode::PageDown) => log.scroll_down(10), - (Screen::TierSelect { cursor }, KeyCode::Up) => { - let c = cursor.saturating_sub(1); - *screen = Screen::TierSelect { cursor: c }; - } - (Screen::TierSelect { cursor }, KeyCode::Down) => { - let c = (*cursor + 1).min(2); - *screen = Screen::TierSelect { cursor: c }; - } - (Screen::TierSelect { cursor }, KeyCode::Enter) => { - let chosen = *cursor; - let tier = match chosen { - 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)?; - editor.clear(); - log.push(LogEntry::TierChoice { chosen }); - log.push(level_prompt_entry(prog, registry, tier)); - *screen = Screen::Game; - *focus = default_focus(screen); - } - (Screen::TierSelect { .. }, KeyCode::Char('q')) => return Ok(true), - (Screen::TierSelect { .. }, KeyCode::PageUp) => log.scroll_up(10), - (Screen::TierSelect { .. }, KeyCode::PageDown) => log.scroll_down(10), - (Screen::ResetConfirm, KeyCode::Char('y')) => { let _ = std::fs::remove_file(progress::save_path()); *prog = Progress::default(); @@ -529,11 +505,11 @@ fn step( (Screen::Completed, KeyCode::Down) => log.scroll_down(1), (Screen::Completed, KeyCode::PageUp) => log.scroll_up(10), (Screen::Completed, KeyCode::PageDown) => log.scroll_down(10), + (Screen::Completed, KeyCode::Char('q')) => return Ok(true), (Screen::Completed, KeyCode::Char('r')) => { *screen = Screen::ResetConfirm; *focus = default_focus(screen); } - (Screen::Completed, KeyCode::Char('q')) => return Ok(true), _ => {} } @@ -559,38 +535,39 @@ fn grade( let level = ®istry[idx]; let g = level.generate(prog.current_seed); let score = similarity::semantic_or_textual(&g.target_yaml, &candidate); - let tier = prog.tier.expect("tier set before grading"); - let threshold = tier.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(); - progress::save(prog)?; - log.push(LogEntry::ResultPass { - level_name, - score, - threshold, - }); + match Nugget::from_score(score) { + Some(nugget) => { + prog.nuggets.push((level_id, nugget)); + prog.current_level += 1; + prog.current_seed = rand::random(); + progress::save(prog)?; + log.push(LogEntry::ResultPass { + level_name, + score, + nugget, + }); - // Append the next LevelPrompt, or Completed if we ran out. - let next_idx = (prog.current_level - 1) as usize; - if next_idx >= registry.len() { - log.push(LogEntry::Completed); - } else { - log.push(level_prompt_entry(prog, registry, tier)); + // Append the next LevelPrompt, or Completed if we ran out. + let next_idx = (prog.current_level - 1) as usize; + if next_idx >= registry.len() { + let (gold, silver, bronze) = count_nuggets(&prog.nuggets); + log.push(LogEntry::Completed { + gold, + silver, + bronze, + }); + } else { + log.push(level_prompt_entry(prog, registry)); + } + } + None => { + prog.attempts += 1; + progress::save(prog)?; + log.push(LogEntry::ResultFail { level_name, score }); } - } else { - prog.attempts += 1; - progress::save(prog)?; - log.push(LogEntry::ResultFail { - level_name, - score, - threshold, - }); } Ok(()) @@ -621,16 +598,7 @@ fn render( let (left, right) = (chunks[0], chunks[1]); match screen { - Screen::Welcome => { - render_log(frame, left, log, Some(&welcome_prompt_lines()), typewriter) - } - Screen::TierSelect { cursor } => render_log( - frame, - left, - log, - Some(&tier_picker_lines(*cursor)), - typewriter, - ), + Screen::Welcome => render_log(frame, left, log, None, typewriter), Screen::Game | Screen::Completed => render_log(frame, left, log, None, typewriter), Screen::ResetConfirm => render_reset_confirm(frame, left), } @@ -655,9 +623,6 @@ fn status_text(screen: &Screen, focus: Focus) -> String { (Screen::Welcome, _) => { " [Enter] begin · [↑/↓] scroll · [PgUp/PgDn] page · [q] quit".into() } - (Screen::TierSelect { .. }, _) => { - " [↑/↓] choose · [Enter] confirm · [PgUp/PgDn] scroll · [q] quit".into() - } (Screen::Game, Focus::Editor) => { " [Ctrl-X] grade · [Tab] focus log · [Esc] focus log · [Ctrl-Q] quit".into() } @@ -677,28 +642,6 @@ fn screen_widget<'a>(title: String, body: String) -> Paragraph<'a> { .wrap(Wrap { trim: false }) } -fn welcome_prompt_lines() -> Vec { - vec![ - "🏰 You stand at the entrance, brave explorer. Press [Enter] to enter the dungeon.".to_string(), - ] -} - -fn tier_picker_lines(cursor: u8) -> Vec { - let rows = [ - ("🥉", "Easy", "70 %", "forgive small slips"), - ("🥈", "Medium", "80 %", "most details must match"), - ("🥇", "Hard", "95 %", "only near-perfect passes"), - ]; - let mut v = vec!["🎚 Choose your tier:".to_string()]; - for (i, (emoji, name, pct, hint)) in rows.iter().enumerate() { - let marker = if i == cursor as usize { "›" } else { " " }; - v.push(format!(" {marker} {emoji} {name:<7} ({pct}) {hint}")); - } - v.push(String::new()); - v.push(" ↑/↓ choose · [Enter] confirm · [PgUp/PgDn] scroll · [q] quit".to_string()); - v -} - fn render_reset_confirm(frame: &mut Frame, area: Rect) { let body = "\n💀 Wipe progress and start over?\n\n[y] yes, wipe · any other key cancels" .to_string(); @@ -726,8 +669,8 @@ fn render_log( }; all_lines.extend(entry_view); } - // Append the transient prompt (Welcome / TierSelect) as the bottom block. - // It's part of the bottom-anchored view but not part of the log itself. + // Append the transient prompt (rare — none currently) as the bottom + // block. Kept for future use. if let Some(prompt) = trailing_prompt { if !all_lines.is_empty() { all_lines.push(String::new()); @@ -770,11 +713,16 @@ fn truncate_entry_to_chars(entry: &LogEntry, max_chars: usize) -> Vec { fn entry_lines(entry: &LogEntry) -> Vec { match entry { LogEntry::Intro => vec![ + "🏰 You stand at the entrance, brave explorer.".to_string(), + String::new(), "🌀 YAMLabyrinth — learn YAML by writing your way through.".to_string(), String::new(), " Each chamber reveals a target. Match it with YAML in the editor".to_string(), " on the right, then press Ctrl-X to grade your attempt.".to_string(), String::new(), + " Score ≥ 95 % → 🥇 Gold · ≥ 80 % → 🥈 Silver · ≥ 70 % → 🥉 Bronze.".to_string(), + " Below 70 % means retry — refine your YAML and press Ctrl-X again.".to_string(), + String::new(), " ⌨ Controls".to_string(), " [Tab] swap focus between log and editor".to_string(), " [Ctrl-X] grade your YAML".to_string(), @@ -783,28 +731,14 @@ fn entry_lines(entry: &LogEntry) -> Vec { " [r] reset progress".to_string(), " [q] / [Ctrl-Q] quit".to_string(), ], - LogEntry::TierChoice { chosen } => { - let rows = [ - ("🥉", "Easy", "70 %", "forgive small slips"), - ("🥈", "Medium", "80 %", "most details must match"), - ("🥇", "Hard", "95 %", "only near-perfect passes"), - ]; - let mut v = vec!["🎚 Difficulty".to_string(), String::new()]; - for (i, (emoji, name, pct, hint)) in rows.iter().enumerate() { - let mark = if i == *chosen as usize { " ← chosen" } else { "" }; - v.push(format!(" {emoji} {name:<7} ({pct}) {hint}{mark}")); - } - v - } LogEntry::LevelPrompt { level_id, level_name, - tier_name, flavor, description, } => { let mut v = vec![ - format!("🗺 Level {} — {} ({})", level_id, level_name, tier_name), + format!("🗺 Level {} — {}", level_id, level_name), flavor.clone(), String::new(), ]; @@ -816,23 +750,19 @@ fn entry_lines(entry: &LogEntry) -> Vec { LogEntry::ResultPass { level_name, score, - threshold, + nugget, } => vec![format!( - " 🗝 {} cleared at {:.0} % (threshold {:.0} %) ✨", + " {} {} cleared with a {} nugget at {:.0} % ✨", + nugget.emoji(), level_name, + nugget.name(), score * 100.0, - threshold * 100.0 )], - LogEntry::ResultFail { - level_name, - score, - threshold, - } => vec![ + LogEntry::ResultFail { level_name, score } => vec![ format!( - " 🕸 {} not yet — {:.0} % (threshold {:.0} %)", + " 🕸 {} not yet — {:.0} % (need 70 % for a 🥉 Bronze)", level_name, score * 100.0, - threshold * 100.0 ), " Refine your YAML and press Ctrl-X to retry.".to_string(), ], @@ -841,8 +771,13 @@ fn entry_lines(entry: &LogEntry) -> Vec { format!(" {error}"), " Fix it in the editor and press Ctrl-X to retry.".to_string(), ], - LogEntry::Completed => vec![ + LogEntry::Completed { + gold, + silver, + bronze, + } => vec![ " 🏆 Labyrinth complete!".to_string(), + format!(" 🥇 ×{} 🥈 ×{} 🥉 ×{}", gold, silver, bronze), " [r] reset and replay · [q] quit".to_string(), ], } @@ -877,7 +812,7 @@ fn render_editor(frame: &mut Frame, area: Rect, editor: &Editor, focused: bool) } fn render_editor_inactive(frame: &mut Frame, area: Rect) { - let body = "\n 🪶 The quill rests. Pick a tier first…".to_string(); + let body = "\n 🪶 The quill rests. Press [Enter] on the welcome screen to begin.".to_string(); frame.render_widget( screen_widget(" 📝 Editor (inactive) ".to_string(), body), area,