First levels v0.1.0

This commit is contained in:
2026-05-21 17:12:23 +03:00
parent aa9cb6ea53
commit 19e39d220d
9 changed files with 749 additions and 22 deletions

View File

@@ -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<CrosstermBackend<Stdout>>) -> 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<CrosstermBackend<Stdout>>,
prog: &mut Progress,
) -> Result<()> {
let registry = levels::registry();
let mut screen = initial_screen(prog, registry.len());
loop {
terminal.draw(|frame| render(frame, &screen, prog, &registry))?;
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, &registry)? {
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<dyn Level>],
) -> Result<Transition> {
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<dyn Level>]) -> Result<Screen> {
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::<serde_yaml::Value>(&candidate) {
return Ok(Screen::InvalidYaml { error: e.to_string() });
}
let idx = (prog.current_level - 1) as usize;
let level = &registry[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<dyn Level>],
) {
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<dyn Level>],
) {
let idx = (prog.current_level - 1) as usize;
let level = &registry[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);
}