Replace tiers with achievements

This commit is contained in:
2026-05-21 19:17:58 +03:00
parent 4765917be4
commit a6741da14c
4 changed files with 139 additions and 192 deletions

View File

@@ -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<Self> {
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",
}
}
}

View File

@@ -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);
}

View File

@@ -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<Difficulty>,
pub completed: Vec<u8>,
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,
}

View File

@@ -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<dyn Level>]) -> (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<dyn Level>],
tier: Difficulty,
) -> LogEntry {
fn level_prompt_entry(prog: &Progress, registry: &[Box<dyn Level>]) -> LogEntry {
let idx = (prog.current_level - 1) as usize;
let level = &registry[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<bool> {
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 = &registry[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<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) {
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<String> {
fn entry_lines(entry: &LogEntry) -> Vec<String> {
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<String> {
" [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<String> {
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<String> {
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,