First levels v0.1.0
This commit is contained in:
@@ -1,3 +1,21 @@
|
|||||||
fn main() -> anyhow::Result<()> {
|
use anyhow::Result;
|
||||||
yamlabyrinth::tui::run()
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<C: serde::Serialize>(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
ctx: &C,
|
||||||
|
) -> tera::Result<String> {
|
||||||
|
let ctx = Context::from_serialize(ctx)?;
|
||||||
|
self.tera.render(name, &ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Describer {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
35
src/levels/l01_minimum.rs
Normal file
35
src/levels/l01_minimum.rs
Normal file
@@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
81
src/levels/l02_kv.rs
Normal file
81
src/levels/l02_kv.rs
Normal file
@@ -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<DescPair>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
//! Levels — hand-written Rust generators paired with design notes.
|
||||||
|
//!
|
||||||
|
//! Each level is implemented in `l<XX>_<name>.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<XX>.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<XX>_<name>.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<Box<dyn Level>> {
|
||||||
|
vec![
|
||||||
|
Box::new(l01_minimum::Minimum),
|
||||||
|
Box::new(l02_kv::KeyValue),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
109
src/lib.rs
109
src/lib.rs
@@ -3,3 +3,112 @@ pub mod levels;
|
|||||||
pub mod progress;
|
pub mod progress;
|
||||||
pub mod similarity;
|
pub mod similarity;
|
||||||
pub mod tui;
|
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<Direction>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<Difficulty>,
|
||||||
|
pub completed: Vec<u8>,
|
||||||
|
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<Progress> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -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::<serde_yaml::Value>(target),
|
||||||
|
serde_yaml::from_str::<serde_yaml::Value>(candidate),
|
||||||
|
) {
|
||||||
|
if t == c {
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
similarity_ratio(target, candidate)
|
||||||
|
}
|
||||||
|
|||||||
350
src/tui.rs
350
src/tui.rs
@@ -5,14 +5,40 @@ use crossterm::terminal::{
|
|||||||
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
||||||
};
|
};
|
||||||
use ratatui::backend::CrosstermBackend;
|
use ratatui::backend::CrosstermBackend;
|
||||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
use ratatui::layout::Rect;
|
||||||
use ratatui::Terminal;
|
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
|
||||||
|
use ratatui::{Frame, Terminal};
|
||||||
use std::io::{stdout, Stdout};
|
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();
|
install_panic_hook();
|
||||||
let mut terminal = enter()?;
|
let mut terminal = enter()?;
|
||||||
let result = main_loop(&mut terminal);
|
let result = main_loop(&mut terminal, prog);
|
||||||
leave()?;
|
leave()?;
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
@@ -37,23 +63,309 @@ fn leave() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main_loop(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
|
fn initial_screen(prog: &Progress, registry_len: usize) -> Screen {
|
||||||
loop {
|
match (prog.tier, prog.current_level) {
|
||||||
terminal.draw(|frame| {
|
(None, 0) => Screen::Welcome,
|
||||||
let block = Block::default()
|
(None, _) => Screen::TierSelect { cursor: 0 },
|
||||||
.borders(Borders::ALL)
|
(Some(_), 0) => Screen::TierSelect { cursor: 0 },
|
||||||
.title(" YAMLabyrinth ");
|
(Some(_), n) if (n as usize) > registry_len => Screen::Completed,
|
||||||
let body = Paragraph::new(
|
(Some(_), _) => Screen::Level,
|
||||||
"Welcome to the YAML labyrinth.\n\nPress [q] to flee.",
|
}
|
||||||
)
|
}
|
||||||
.block(block);
|
|
||||||
frame.render_widget(body, frame.size());
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if let Event::Key(key) = event::read()? {
|
fn main_loop(
|
||||||
if key.code == KeyCode::Char('q') {
|
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
|
||||||
return Ok(());
|
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<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 = ®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<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 = ®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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user