Replace tiers with achievements
This commit is contained in:
@@ -15,37 +15,47 @@ pub mod l02_kv;
|
|||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// Game-wide difficulty. Chosen once on the TierSelect screen; persisted
|
/// Awarded per level based on the grade score. Replaces the old
|
||||||
/// in `Progress.tier`. Maps to a fixed passing threshold per level.
|
/// game-wide difficulty tier. Thresholds:
|
||||||
|
/// - Gold ≥ 95 %
|
||||||
|
/// - Silver ≥ 80 %
|
||||||
|
/// - Bronze ≥ 70 %
|
||||||
|
/// - Below 70 % → no nugget (retry).
|
||||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub enum Difficulty {
|
pub enum Nugget {
|
||||||
Easy,
|
Bronze,
|
||||||
Medium,
|
Silver,
|
||||||
Hard,
|
Gold,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Difficulty {
|
impl Nugget {
|
||||||
pub fn threshold(self) -> f64 {
|
/// Map a grade ratio (0.0..=1.0) to a nugget. `None` means the
|
||||||
match self {
|
/// player didn't clear the level — retry without advancing.
|
||||||
Self::Easy => 0.70,
|
pub fn from_score(score: f64) -> Option<Self> {
|
||||||
Self::Medium => 0.80,
|
if score >= 0.95 {
|
||||||
Self::Hard => 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 {
|
match self {
|
||||||
Self::Easy => "Easy (70%)",
|
Self::Bronze => "🥉",
|
||||||
Self::Medium => "Medium (80%)",
|
Self::Silver => "🥈",
|
||||||
Self::Hard => "Hard (95%)",
|
Self::Gold => "🥇",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn name(self) -> &'static str {
|
pub fn name(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Easy => "Easy",
|
Self::Bronze => "Bronze",
|
||||||
Self::Medium => "Medium",
|
Self::Silver => "Silver",
|
||||||
Self::Hard => "Hard",
|
Self::Gold => "Gold",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,15 +69,14 @@ mod smoke {
|
|||||||
// (4) Progress round-trips through the same YAML pipeline that disk
|
// (4) Progress round-trips through the same YAML pipeline that disk
|
||||||
// save/load will use.
|
// save/load will use.
|
||||||
let p = progress::Progress {
|
let p = progress::Progress {
|
||||||
tier: Some(levels::Difficulty::Medium),
|
nuggets: vec![(1, levels::Nugget::Silver), (2, levels::Nugget::Gold)],
|
||||||
completed: vec![1, 2],
|
|
||||||
current_level: 3,
|
current_level: 3,
|
||||||
current_seed: 0xCAFE,
|
current_seed: 0xCAFE,
|
||||||
attempts: 1,
|
attempts: 1,
|
||||||
};
|
};
|
||||||
let s = serde_yaml::to_string(&p).unwrap();
|
let s = serde_yaml::to_string(&p).unwrap();
|
||||||
let loaded: progress::Progress = serde_yaml::from_str(&s).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_level, p.current_level);
|
||||||
assert_eq!(loaded.current_seed, p.current_seed);
|
assert_eq!(loaded.current_seed, p.current_seed);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,16 @@ use anyhow::Result;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use crate::levels::Difficulty;
|
use crate::levels::Nugget;
|
||||||
|
|
||||||
#[derive(Default, Serialize, Deserialize)]
|
#[derive(Default, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
pub struct Progress {
|
pub struct Progress {
|
||||||
pub tier: Option<Difficulty>,
|
/// Nuggets awarded per level: `(level_id, nugget)` in order of
|
||||||
pub completed: Vec<u8>,
|
/// completion. A level appears at most once (re-runs after reset
|
||||||
pub current_level: u8,
|
/// 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 current_seed: u64,
|
||||||
pub attempts: u32,
|
pub attempts: u32,
|
||||||
}
|
}
|
||||||
|
|||||||
241
src/tui.rs
241
src/tui.rs
@@ -13,7 +13,7 @@ use std::io::{stdout, Stdout};
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use crate::config;
|
use crate::config;
|
||||||
use crate::levels::{self, Difficulty, Level};
|
use crate::levels::{self, Level, Nugget};
|
||||||
use crate::progress::{self, Progress};
|
use crate::progress::{self, Progress};
|
||||||
use crate::similarity;
|
use crate::similarity;
|
||||||
|
|
||||||
@@ -21,7 +21,6 @@ use crate::similarity;
|
|||||||
|
|
||||||
enum Screen {
|
enum Screen {
|
||||||
Welcome,
|
Welcome,
|
||||||
TierSelect { cursor: u8 },
|
|
||||||
/// Active play: left column = HistoryLog, right column = Editor.
|
/// Active play: left column = HistoryLog, right column = Editor.
|
||||||
Game,
|
Game,
|
||||||
ResetConfirm,
|
ResetConfirm,
|
||||||
@@ -35,34 +34,31 @@ enum Focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum LogEntry {
|
enum LogEntry {
|
||||||
/// Game rules + the full controls listing. Appended once on
|
/// Game rules + the full controls listing. Seeded at startup.
|
||||||
/// Welcome → TierSelect so the player can scroll back to it.
|
|
||||||
Intro,
|
Intro,
|
||||||
/// The full tier table with a marker on the chosen one.
|
|
||||||
TierChoice {
|
|
||||||
chosen: u8, // 0=Easy, 1=Medium, 2=Hard
|
|
||||||
},
|
|
||||||
LevelPrompt {
|
LevelPrompt {
|
||||||
level_id: u8,
|
level_id: u8,
|
||||||
level_name: String,
|
level_name: String,
|
||||||
tier_name: &'static str,
|
|
||||||
flavor: String,
|
flavor: String,
|
||||||
description: String,
|
description: String,
|
||||||
},
|
},
|
||||||
ResultPass {
|
ResultPass {
|
||||||
level_name: String,
|
level_name: String,
|
||||||
score: f64,
|
score: f64,
|
||||||
threshold: f64,
|
nugget: Nugget,
|
||||||
},
|
},
|
||||||
ResultFail {
|
ResultFail {
|
||||||
level_name: String,
|
level_name: String,
|
||||||
score: f64,
|
score: f64,
|
||||||
threshold: f64,
|
|
||||||
},
|
},
|
||||||
InvalidYaml {
|
InvalidYaml {
|
||||||
error: String,
|
error: String,
|
||||||
},
|
},
|
||||||
Completed,
|
Completed {
|
||||||
|
gold: u32,
|
||||||
|
silver: u32,
|
||||||
|
bronze: u32,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Append-only chronological log of game events. Rendered bottom-anchored
|
/// Append-only chronological log of game events. Rendered bottom-anchored
|
||||||
@@ -259,54 +255,57 @@ fn leave() -> Result<()> {
|
|||||||
Ok(())
|
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<dyn Level>]) -> (Screen, HistoryLog) {
|
fn initial_state(prog: &Progress, registry: &[Box<dyn Level>]) -> (Screen, HistoryLog) {
|
||||||
let mut log = HistoryLog::new();
|
let mut log = HistoryLog::new();
|
||||||
// The Intro entry is always the bottom of the log on startup so that
|
// The Intro entry is always at the bottom of the log on startup so
|
||||||
// Welcome and TierSelect can render it inline (the player can scroll
|
// Welcome can show it inline; the player can scroll back to it any
|
||||||
// back to it any time).
|
// time during play.
|
||||||
log.push(LogEntry::Intro);
|
log.push(LogEntry::Intro);
|
||||||
|
|
||||||
match (prog.tier, prog.current_level) {
|
match prog.current_level {
|
||||||
(None, 0) => (Screen::Welcome, log),
|
0 => (Screen::Welcome, log),
|
||||||
(None, _) => (Screen::TierSelect { cursor: 0 }, log),
|
n if (n as usize) > registry.len() => {
|
||||||
(Some(tier), 0) => (Screen::TierSelect { cursor: tier_index(tier) }, log),
|
let (gold, silver, bronze) = count_nuggets(&prog.nuggets);
|
||||||
(Some(_), n) if (n as usize) > registry.len() => {
|
log.push(LogEntry::Completed {
|
||||||
log.push(LogEntry::Completed);
|
gold,
|
||||||
|
silver,
|
||||||
|
bronze,
|
||||||
|
});
|
||||||
(Screen::Completed, log)
|
(Screen::Completed, log)
|
||||||
}
|
}
|
||||||
(Some(tier), _) => {
|
_ => {
|
||||||
log.push(LogEntry::TierChoice { chosen: tier_index(tier) });
|
log.push(level_prompt_entry(prog, registry));
|
||||||
log.push(level_prompt_entry(prog, registry, tier));
|
|
||||||
(Screen::Game, log)
|
(Screen::Game, log)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn level_prompt_entry(
|
fn level_prompt_entry(prog: &Progress, registry: &[Box<dyn Level>]) -> LogEntry {
|
||||||
prog: &Progress,
|
|
||||||
registry: &[Box<dyn Level>],
|
|
||||||
tier: Difficulty,
|
|
||||||
) -> LogEntry {
|
|
||||||
let idx = (prog.current_level - 1) as usize;
|
let idx = (prog.current_level - 1) as usize;
|
||||||
let level = ®istry[idx];
|
let level = ®istry[idx];
|
||||||
let g = level.generate(prog.current_seed);
|
let g = level.generate(prog.current_seed);
|
||||||
LogEntry::LevelPrompt {
|
LogEntry::LevelPrompt {
|
||||||
level_id: level.id(),
|
level_id: level.id(),
|
||||||
level_name: level.name().to_string(),
|
level_name: level.name().to_string(),
|
||||||
tier_name: tier.name(),
|
|
||||||
flavor: g.flavor,
|
flavor: g.flavor,
|
||||||
description: g.description,
|
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 {
|
fn default_focus(screen: &Screen) -> Focus {
|
||||||
match screen {
|
match screen {
|
||||||
Screen::Game => Focus::Editor,
|
Screen::Game => Focus::Editor,
|
||||||
@@ -326,7 +325,8 @@ fn main_loop(
|
|||||||
let mut focus = default_focus(&screen);
|
let mut focus = default_focus(&screen);
|
||||||
let mut editor = Editor::new();
|
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 {
|
let mut typewriter = if cfg.typewriter_enabled {
|
||||||
start_typewriter(&log, &cfg)
|
start_typewriter(&log, &cfg)
|
||||||
} else {
|
} else {
|
||||||
@@ -409,7 +409,7 @@ fn step(
|
|||||||
) -> Result<bool> {
|
) -> Result<bool> {
|
||||||
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||||
|
|
||||||
// Global: Ctrl-Q quits.
|
// Global: Ctrl-Q quits from anywhere.
|
||||||
if ctrl && key.code == KeyCode::Char('q') {
|
if ctrl && key.code == KeyCode::Char('q') {
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
@@ -417,7 +417,7 @@ fn step(
|
|||||||
// Global: Ctrl-X grades while in Game.
|
// Global: Ctrl-X grades while in Game.
|
||||||
if ctrl && key.code == KeyCode::Char('x') && matches!(screen, Screen::Game) {
|
if ctrl && key.code == KeyCode::Char('x') && matches!(screen, Screen::Game) {
|
||||||
grade(editor.text(), prog, registry, log)?;
|
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;
|
*screen = Screen::Completed;
|
||||||
*focus = default_focus(screen);
|
*focus = default_focus(screen);
|
||||||
}
|
}
|
||||||
@@ -468,10 +468,17 @@ fn step(
|
|||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Other screens (Welcome, TierSelect, ResetConfirm, Completed).
|
// Other screens (Welcome, ResetConfirm, Completed).
|
||||||
match (&*screen, key.code) {
|
match (&*screen, key.code) {
|
||||||
(Screen::Welcome, KeyCode::Enter) => {
|
(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);
|
*focus = default_focus(screen);
|
||||||
}
|
}
|
||||||
(Screen::Welcome, KeyCode::Char('q')) => return Ok(true),
|
(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::PageUp) => log.scroll_up(10),
|
||||||
(Screen::Welcome, KeyCode::PageDown) => log.scroll_down(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')) => {
|
(Screen::ResetConfirm, KeyCode::Char('y')) => {
|
||||||
let _ = std::fs::remove_file(progress::save_path());
|
let _ = std::fs::remove_file(progress::save_path());
|
||||||
*prog = Progress::default();
|
*prog = Progress::default();
|
||||||
@@ -529,11 +505,11 @@ fn step(
|
|||||||
(Screen::Completed, KeyCode::Down) => log.scroll_down(1),
|
(Screen::Completed, KeyCode::Down) => log.scroll_down(1),
|
||||||
(Screen::Completed, KeyCode::PageUp) => log.scroll_up(10),
|
(Screen::Completed, KeyCode::PageUp) => log.scroll_up(10),
|
||||||
(Screen::Completed, KeyCode::PageDown) => log.scroll_down(10),
|
(Screen::Completed, KeyCode::PageDown) => log.scroll_down(10),
|
||||||
|
(Screen::Completed, KeyCode::Char('q')) => return Ok(true),
|
||||||
(Screen::Completed, KeyCode::Char('r')) => {
|
(Screen::Completed, KeyCode::Char('r')) => {
|
||||||
*screen = Screen::ResetConfirm;
|
*screen = Screen::ResetConfirm;
|
||||||
*focus = default_focus(screen);
|
*focus = default_focus(screen);
|
||||||
}
|
}
|
||||||
(Screen::Completed, KeyCode::Char('q')) => return Ok(true),
|
|
||||||
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -559,38 +535,39 @@ fn grade(
|
|||||||
let level = ®istry[idx];
|
let level = ®istry[idx];
|
||||||
let g = level.generate(prog.current_seed);
|
let g = level.generate(prog.current_seed);
|
||||||
let score = similarity::semantic_or_textual(&g.target_yaml, &candidate);
|
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_name = level.name().to_string();
|
||||||
let level_id = level.id();
|
let level_id = level.id();
|
||||||
|
|
||||||
if passed {
|
match Nugget::from_score(score) {
|
||||||
prog.completed.push(level_id);
|
Some(nugget) => {
|
||||||
|
prog.nuggets.push((level_id, nugget));
|
||||||
prog.current_level += 1;
|
prog.current_level += 1;
|
||||||
prog.current_seed = rand::random();
|
prog.current_seed = rand::random();
|
||||||
progress::save(prog)?;
|
progress::save(prog)?;
|
||||||
log.push(LogEntry::ResultPass {
|
log.push(LogEntry::ResultPass {
|
||||||
level_name,
|
level_name,
|
||||||
score,
|
score,
|
||||||
threshold,
|
nugget,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Append the next LevelPrompt, or Completed if we ran out.
|
// Append the next LevelPrompt, or Completed if we ran out.
|
||||||
let next_idx = (prog.current_level - 1) as usize;
|
let next_idx = (prog.current_level - 1) as usize;
|
||||||
if next_idx >= registry.len() {
|
if next_idx >= registry.len() {
|
||||||
log.push(LogEntry::Completed);
|
let (gold, silver, bronze) = count_nuggets(&prog.nuggets);
|
||||||
|
log.push(LogEntry::Completed {
|
||||||
|
gold,
|
||||||
|
silver,
|
||||||
|
bronze,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
log.push(level_prompt_entry(prog, registry, tier));
|
log.push(level_prompt_entry(prog, registry));
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
|
None => {
|
||||||
prog.attempts += 1;
|
prog.attempts += 1;
|
||||||
progress::save(prog)?;
|
progress::save(prog)?;
|
||||||
log.push(LogEntry::ResultFail {
|
log.push(LogEntry::ResultFail { level_name, score });
|
||||||
level_name,
|
}
|
||||||
score,
|
|
||||||
threshold,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -621,16 +598,7 @@ fn render(
|
|||||||
let (left, right) = (chunks[0], chunks[1]);
|
let (left, right) = (chunks[0], chunks[1]);
|
||||||
|
|
||||||
match screen {
|
match screen {
|
||||||
Screen::Welcome => {
|
Screen::Welcome => render_log(frame, left, log, None, typewriter),
|
||||||
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::Game | Screen::Completed => 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),
|
Screen::ResetConfirm => render_reset_confirm(frame, left),
|
||||||
}
|
}
|
||||||
@@ -655,9 +623,6 @@ fn status_text(screen: &Screen, focus: Focus) -> String {
|
|||||||
(Screen::Welcome, _) => {
|
(Screen::Welcome, _) => {
|
||||||
" [Enter] begin · [↑/↓] scroll · [PgUp/PgDn] page · [q] quit".into()
|
" [Enter] begin · [↑/↓] scroll · [PgUp/PgDn] page · [q] quit".into()
|
||||||
}
|
}
|
||||||
(Screen::TierSelect { .. }, _) => {
|
|
||||||
" [↑/↓] choose · [Enter] confirm · [PgUp/PgDn] scroll · [q] quit".into()
|
|
||||||
}
|
|
||||||
(Screen::Game, Focus::Editor) => {
|
(Screen::Game, Focus::Editor) => {
|
||||||
" [Ctrl-X] grade · [Tab] focus log · [Esc] focus log · [Ctrl-Q] quit".into()
|
" [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 })
|
.wrap(Wrap { trim: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn welcome_prompt_lines() -> Vec<String> {
|
|
||||||
vec![
|
|
||||||
"🏰 You stand at the entrance, brave explorer. Press [Enter] to enter the dungeon.".to_string(),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tier_picker_lines(cursor: u8) -> Vec<String> {
|
|
||||||
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) {
|
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"
|
let body = "\n💀 Wipe progress and start over?\n\n[y] yes, wipe · any other key cancels"
|
||||||
.to_string();
|
.to_string();
|
||||||
@@ -726,8 +669,8 @@ fn render_log(
|
|||||||
};
|
};
|
||||||
all_lines.extend(entry_view);
|
all_lines.extend(entry_view);
|
||||||
}
|
}
|
||||||
// Append the transient prompt (Welcome / TierSelect) as the bottom block.
|
// Append the transient prompt (rare — none currently) as the bottom
|
||||||
// It's part of the bottom-anchored view but not part of the log itself.
|
// block. Kept for future use.
|
||||||
if let Some(prompt) = trailing_prompt {
|
if let Some(prompt) = trailing_prompt {
|
||||||
if !all_lines.is_empty() {
|
if !all_lines.is_empty() {
|
||||||
all_lines.push(String::new());
|
all_lines.push(String::new());
|
||||||
@@ -770,11 +713,16 @@ fn truncate_entry_to_chars(entry: &LogEntry, max_chars: usize) -> Vec<String> {
|
|||||||
fn entry_lines(entry: &LogEntry) -> Vec<String> {
|
fn entry_lines(entry: &LogEntry) -> Vec<String> {
|
||||||
match entry {
|
match entry {
|
||||||
LogEntry::Intro => vec![
|
LogEntry::Intro => vec![
|
||||||
|
"🏰 You stand at the entrance, brave explorer.".to_string(),
|
||||||
|
String::new(),
|
||||||
"🌀 YAMLabyrinth — learn YAML by writing your way through.".to_string(),
|
"🌀 YAMLabyrinth — learn YAML by writing your way through.".to_string(),
|
||||||
String::new(),
|
String::new(),
|
||||||
" Each chamber reveals a target. Match it with YAML in the editor".to_string(),
|
" 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(),
|
" on the right, then press Ctrl-X to grade your attempt.".to_string(),
|
||||||
String::new(),
|
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(),
|
" ⌨ Controls".to_string(),
|
||||||
" [Tab] swap focus between log and editor".to_string(),
|
" [Tab] swap focus between log and editor".to_string(),
|
||||||
" [Ctrl-X] grade your YAML".to_string(),
|
" [Ctrl-X] grade your YAML".to_string(),
|
||||||
@@ -783,28 +731,14 @@ fn entry_lines(entry: &LogEntry) -> Vec<String> {
|
|||||||
" [r] reset progress".to_string(),
|
" [r] reset progress".to_string(),
|
||||||
" [q] / [Ctrl-Q] quit".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 {
|
LogEntry::LevelPrompt {
|
||||||
level_id,
|
level_id,
|
||||||
level_name,
|
level_name,
|
||||||
tier_name,
|
|
||||||
flavor,
|
flavor,
|
||||||
description,
|
description,
|
||||||
} => {
|
} => {
|
||||||
let mut v = vec![
|
let mut v = vec![
|
||||||
format!("🗺 Level {} — {} ({})", level_id, level_name, tier_name),
|
format!("🗺 Level {} — {}", level_id, level_name),
|
||||||
flavor.clone(),
|
flavor.clone(),
|
||||||
String::new(),
|
String::new(),
|
||||||
];
|
];
|
||||||
@@ -816,23 +750,19 @@ fn entry_lines(entry: &LogEntry) -> Vec<String> {
|
|||||||
LogEntry::ResultPass {
|
LogEntry::ResultPass {
|
||||||
level_name,
|
level_name,
|
||||||
score,
|
score,
|
||||||
threshold,
|
nugget,
|
||||||
} => vec![format!(
|
} => vec![format!(
|
||||||
" 🗝 {} cleared at {:.0} % (threshold {:.0} %) ✨",
|
" {} {} cleared with a {} nugget at {:.0} % ✨",
|
||||||
|
nugget.emoji(),
|
||||||
level_name,
|
level_name,
|
||||||
|
nugget.name(),
|
||||||
score * 100.0,
|
score * 100.0,
|
||||||
threshold * 100.0
|
|
||||||
)],
|
)],
|
||||||
LogEntry::ResultFail {
|
LogEntry::ResultFail { level_name, score } => vec![
|
||||||
level_name,
|
|
||||||
score,
|
|
||||||
threshold,
|
|
||||||
} => vec![
|
|
||||||
format!(
|
format!(
|
||||||
" 🕸 {} not yet — {:.0} % (threshold {:.0} %)",
|
" 🕸 {} not yet — {:.0} % (need 70 % for a 🥉 Bronze)",
|
||||||
level_name,
|
level_name,
|
||||||
score * 100.0,
|
score * 100.0,
|
||||||
threshold * 100.0
|
|
||||||
),
|
),
|
||||||
" Refine your YAML and press Ctrl-X to retry.".to_string(),
|
" Refine your YAML and press Ctrl-X to retry.".to_string(),
|
||||||
],
|
],
|
||||||
@@ -841,8 +771,13 @@ fn entry_lines(entry: &LogEntry) -> Vec<String> {
|
|||||||
format!(" {error}"),
|
format!(" {error}"),
|
||||||
" Fix it in the editor and press Ctrl-X to retry.".to_string(),
|
" 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(),
|
" 🏆 Labyrinth complete!".to_string(),
|
||||||
|
format!(" 🥇 ×{} 🥈 ×{} 🥉 ×{}", gold, silver, bronze),
|
||||||
" [r] reset and replay · [q] quit".to_string(),
|
" [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) {
|
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(
|
frame.render_widget(
|
||||||
screen_widget(" 📝 Editor (inactive) ".to_string(), body),
|
screen_widget(" 📝 Editor (inactive) ".to_string(), body),
|
||||||
area,
|
area,
|
||||||
|
|||||||
Reference in New Issue
Block a user