11 Commits

Author SHA1 Message Date
f0a7992626 Add level 9: Merge Keys (special operators)
`door_defaults` plus `north_door` and `south_door`, each containing a
`<<:` key whose value is the defaults dict. north overrides locked,
south overrides material. serde_yaml treats `<<` as a literal mapping
key, so the target and a player's `<<: *defaults` form parse to the
same Value.

Default material/locked and which side overrides which are randomised
per seed (ChaCha8Rng XOR'd with 0x..09).

Not wired into levels::registry() yet — integration belongs to a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:56:44 +03:00
bac059a789 Add docs 2026-05-21 21:27:37 +03:00
aa6094fdb1 Cursor fix 2026-05-21 21:24:00 +03:00
f817c7b93e Various pre-release cosmetic fixes 2026-05-21 21:21:44 +03:00
cb0abb3e3b Move help to a separate dialog box 2026-05-21 19:26:30 +03:00
a6741da14c Replace tiers with achievements 2026-05-21 19:17:58 +03:00
4765917be4 Switch to ^X as default submission key 2026-05-21 19:00:46 +03:00
42153b1733 Add statusbar w/shortcuts 2026-05-21 18:58:54 +03:00
4b3b1ce5a0 Add typewriter effect 2026-05-21 18:52:19 +03:00
740685afc5 Add level names 2026-05-21 18:31:43 +03:00
845cad7f74 Update TUI to scroll from the bottom 2026-05-21 18:31:29 +03:00
13 changed files with 1274 additions and 314 deletions

1
Cargo.lock generated
View File

@@ -1496,6 +1496,7 @@ dependencies = [
"serde_yaml", "serde_yaml",
"similar", "similar",
"tera", "tera",
"unicode-width",
] ]
[[package]] [[package]]

View File

@@ -23,3 +23,4 @@ dirs = "5"
anyhow = "1" anyhow = "1"
ratatui = "0.26" ratatui = "0.26"
crossterm = "0.27" crossterm = "0.27"
unicode-width = "0.1"

9
README.md Normal file
View File

@@ -0,0 +1,9 @@
# YAMLabyrinth
A text dungeon game that teaches you YAML.
![screenshot](screenshot.png)
## How to start:
cargo run

10
game.ini Normal file
View File

@@ -0,0 +1,10 @@
# YAMLabyrinth — game configuration.
# Delete this file to use defaults. Keys are case-insensitive; spaces
# and underscores in keys are equivalent.
# Animate level prompts character-by-character in the log.
typewriter_effect = true
# Typing speed in characters per minute. Higher = faster.
# Defaults: 2000 cpm (~30 ms per character).
typewriter_speed_cpm = 2000

BIN
screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

130
src/config.rs Normal file
View File

@@ -0,0 +1,130 @@
//! Game configuration, loaded from `./game.ini`.
//!
//! Two settings today:
//! - `typewriter_effect` (bool, default `true`) — animate level prompts
//! character-by-character as they appear in the log.
//! - `typewriter_speed_cpm` (u32, default `1000`) — typing speed in
//! characters per minute (~60 ms per character).
//!
//! Missing keys fall back to defaults. Missing file falls back to all
//! defaults. Comments (`#` or `;`) and `[sections]` are ignored.
use std::path::PathBuf;
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GameConfig {
pub typewriter_enabled: bool,
pub typewriter_speed_cpm: u32,
}
impl Default for GameConfig {
fn default() -> Self {
Self {
typewriter_enabled: true,
typewriter_speed_cpm: 1000,
}
}
}
impl GameConfig {
/// How long each character takes to appear during the typewriter
/// animation. Falls back to the default speed if `speed_cpm` is 0.
pub fn char_duration(&self) -> Duration {
let cpm = if self.typewriter_speed_cpm == 0 {
Self::default().typewriter_speed_cpm
} else {
self.typewriter_speed_cpm
};
Duration::from_millis(60_000 / cpm as u64)
}
}
pub fn config_path() -> PathBuf {
PathBuf::from("game.ini")
}
pub fn load() -> GameConfig {
match std::fs::read_to_string(config_path()) {
Ok(content) => parse(&content),
Err(_) => GameConfig::default(),
}
}
pub fn parse(content: &str) -> GameConfig {
let mut cfg = GameConfig::default();
for line in content.lines() {
let line = line.trim();
if line.is_empty()
|| line.starts_with('#')
|| line.starts_with(';')
|| line.starts_with('[')
{
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let key = key.trim().to_lowercase().replace(' ', "_");
let value = value.trim();
match key.as_str() {
"typewriter_effect" | "typewriter_enabled" => {
if let Ok(b) = value.parse::<bool>() {
cfg.typewriter_enabled = b;
}
}
"typewriter_speed" | "typewriter_speed_cpm" => {
if let Ok(n) = value.parse::<u32>() {
cfg.typewriter_speed_cpm = n;
}
}
_ => {}
}
}
cfg
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults() {
let cfg = GameConfig::default();
assert!(cfg.typewriter_enabled);
assert_eq!(cfg.typewriter_speed_cpm, 1000);
// 1000 cpm = 60 ms per char
assert_eq!(cfg.char_duration().as_millis(), 60);
}
#[test]
fn parser_reads_both_underscored_and_spaced_keys() {
let ini = "\
# comment\n\
; another comment\n\
[section]\n\
typewriter effect = false\n\
typewriter_speed_cpm = 240\n\
";
let cfg = parse(ini);
assert!(!cfg.typewriter_enabled);
assert_eq!(cfg.typewriter_speed_cpm, 240);
// 240 cpm = 250 ms per char
assert_eq!(cfg.char_duration().as_millis(), 250);
}
#[test]
fn parser_keeps_defaults_on_garbage() {
let cfg = parse("typewriter_effect = maybe\ntypewriter_speed_cpm = fast\n");
assert_eq!(cfg, GameConfig::default());
}
#[test]
fn zero_speed_falls_back_to_default_duration() {
let cfg = GameConfig {
typewriter_enabled: true,
typewriter_speed_cpm: 0,
};
assert_eq!(cfg.char_duration().as_millis(), 60);
}
}

View File

@@ -9,3 +9,6 @@ Third level is dictionaries. Each direction now leads to a feature with its own
straight: straight:
type: wall type: wall
depth: depth:
The rendered description ends with a hint reminding the player that each
feature's name goes under a `type:` key (the property keeps its own name).

148
src/levels/l03_dict.rs Normal file
View File

@@ -0,0 +1,148 @@
//! Level 3 — dictionaries. Each direction leads to a feature with its
//! own type + one characteristic property.
//!
//! Paired design note: `l03.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 Dict;
const DIRECTIONS: &[&str] = &["left", "right", "straight", "back", "up", "down"];
enum Feature {
Door,
Tunnel,
Pit,
Stairs,
Wall,
Altar,
}
impl Feature {
fn name(&self) -> &'static str {
match self {
Feature::Door => "door",
Feature::Tunnel => "tunnel",
Feature::Pit => "pit",
Feature::Stairs => "stairs",
Feature::Wall => "wall",
Feature::Altar => "altar",
}
}
fn random(rng: &mut ChaCha8Rng) -> Self {
match rng.gen_range(0..6) {
0 => Feature::Door,
1 => Feature::Tunnel,
2 => Feature::Pit,
3 => Feature::Stairs,
4 => Feature::Wall,
_ => Feature::Altar,
}
}
/// One characteristic property: (key, value).
fn property(&self, rng: &mut ChaCha8Rng) -> (&'static str, Value) {
match self {
Feature::Door => ("locked", Value::Bool(rng.gen_bool(0.5))),
Feature::Tunnel => ("depth", Value::from(rng.gen_range(5..=30i64))),
Feature::Pit => ("depth", Value::from(rng.gen_range(5..=30i64))),
Feature::Stairs => {
let going = if rng.gen_bool(0.5) { "up" } else { "down" };
("going", Value::String(going.to_string()))
}
Feature::Wall => ("cracked", Value::Bool(rng.gen_bool(0.5))),
Feature::Altar => ("blessed", Value::Bool(rng.gen_bool(0.5))),
}
}
}
#[derive(Serialize)]
struct DescCtx {
entries: Vec<DescEntry>,
}
#[derive(Serialize)]
struct DescEntry {
direction: String,
feature: String,
prop_name: String,
prop_value: String,
}
impl Level for Dict {
fn id(&self) -> u8 {
3
}
fn name(&self) -> &'static str {
"Dictionaries"
}
fn generate(&self, seed: u64) -> Generated {
// Per-level constant so the same `current_seed` produces different
// content per level.
let mut rng = ChaCha8Rng::seed_from_u64(seed ^ 0x0000_0000_0000_0003);
let n = rng.gen_range(2..=3);
let directions: Vec<&'static str> =
DIRECTIONS.choose_multiple(&mut rng, n).copied().collect();
let mut top = Mapping::new();
let mut entries = Vec::with_capacity(directions.len());
for d in &directions {
let feature = Feature::random(&mut rng);
let (prop_name, prop_value) = feature.property(&mut rng);
let mut inner = Mapping::new();
inner.insert(
Value::String("type".to_string()),
Value::String(feature.name().to_string()),
);
inner.insert(
Value::String(prop_name.to_string()),
prop_value.clone(),
);
top.insert(Value::String((*d).to_string()), Value::Mapping(inner));
let prop_value_str = match &prop_value {
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
Value::String(s) => s.clone(),
_ => String::new(),
};
entries.push(DescEntry {
direction: (*d).to_string(),
feature: feature.name().to_string(),
prop_name: prop_name.to_string(),
prop_value: prop_value_str,
});
}
let target_yaml =
serde_yaml::to_string(&Value::Mapping(top)).expect("serialise mapping");
let mut d = Describer::new();
d.register(
"l03",
"{% for e in entries %}- {{ e.direction }} → {{ e.feature }} ({{ e.prop_name }}: {{ e.prop_value }})\n{% endfor %}\n💡 Each feature is a dictionary — give it a `type:` key plus its property.",
)
.expect("register template");
let description = d
.render("l03", &DescCtx { entries })
.expect("render template");
Generated {
target_yaml,
description,
flavor: "🧭 You stand at a junction. Each path reveals its own detail.".to_string(),
}
}
}

135
src/levels/l09_operators.rs Normal file
View File

@@ -0,0 +1,135 @@
//! Level 9 — special operators. The merge key (`<<`) lets a door
//! inherit a template and override one field.
//!
//! Paired design note: `l09.md`.
//!
//! `serde_yaml` treats `<<` as a literal mapping key (it does NOT
//! perform YAML 1.1 merge-key resolution). The target therefore carries
//! a `<<` key whose value is the defaults dict, exactly as the player's
//! `<<: *defaults` parses.
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 Operators;
const MATERIALS: &[&str] = &["oak", "iron", "stone", "silver", "bone"];
#[derive(Serialize)]
struct DescCtx {
default_material: String,
default_locked: bool,
north_locked: bool,
south_material: String,
}
impl Level for Operators {
fn id(&self) -> u8 {
9
}
fn name(&self) -> &'static str {
"Merge Keys"
}
fn generate(&self, seed: u64) -> Generated {
let mut rng = ChaCha8Rng::seed_from_u64(seed ^ 0x0000_0000_0000_0009);
let default_material = (*MATERIALS.choose(&mut rng).unwrap()).to_string();
let default_locked = rng.gen_bool(0.5);
// The north door overrides locked; choose the opposite so the
// override is meaningful.
let north_locked = !default_locked;
// The south door overrides material; pick anything but default.
let other_materials: Vec<&str> = MATERIALS
.iter()
.filter(|m| **m != default_material)
.copied()
.collect();
let south_material = (*other_materials.choose(&mut rng).unwrap()).to_string();
// Build the defaults mapping (referenced by all doors).
let mut defaults = Mapping::new();
defaults.insert(
Value::String("material".to_string()),
Value::String(default_material.clone()),
);
defaults.insert(
Value::String("locked".to_string()),
Value::Bool(default_locked),
);
let mut north_door = Mapping::new();
north_door.insert(
Value::String("<<".to_string()),
Value::Mapping(defaults.clone()),
);
north_door.insert(
Value::String("locked".to_string()),
Value::Bool(north_locked),
);
let mut south_door = Mapping::new();
south_door.insert(
Value::String("<<".to_string()),
Value::Mapping(defaults.clone()),
);
south_door.insert(
Value::String("material".to_string()),
Value::String(south_material.clone()),
);
let mut top = Mapping::new();
top.insert(
Value::String("door_defaults".to_string()),
Value::Mapping(defaults),
);
top.insert(
Value::String("north_door".to_string()),
Value::Mapping(north_door),
);
top.insert(
Value::String("south_door".to_string()),
Value::Mapping(south_door),
);
let target_yaml =
serde_yaml::to_string(&Value::Mapping(top)).expect("serialise mapping");
let mut d = Describer::new();
d.register(
"l09",
"Two doors share a template:\n\
default material: {{ default_material }}\n\
default locked: {{ default_locked }}\n\
\n\
north_door overrides locked → {{ north_locked }}\n\
south_door overrides material → {{ south_material }}\n\
💡 Anchor the defaults (`door_defaults: &name`) and merge with `<<: *name` in each door.",
)
.expect("register template");
let description = d
.render(
"l09",
&DescCtx {
default_material,
default_locked,
north_locked,
south_material,
},
)
.expect("render template");
Generated {
target_yaml,
description,
flavor: "🚪 Two doors echo a single template.".to_string(),
}
}
}

View File

@@ -12,32 +12,52 @@
pub mod l01_minimum; pub mod l01_minimum;
pub mod l02_kv; pub mod l02_kv;
pub mod l03_dict;
pub mod l09_operators;
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 {
match self {
Self::Bronze => "Bronze",
Self::Silver => "Silver",
Self::Gold => "Gold",
} }
} }
} }
@@ -63,5 +83,6 @@ pub fn registry() -> Vec<Box<dyn Level>> {
vec![ vec![
Box::new(l01_minimum::Minimum), Box::new(l01_minimum::Minimum),
Box::new(l02_kv::KeyValue), Box::new(l02_kv::KeyValue),
Box::new(l03_dict::Dict),
] ]
} }

View File

@@ -1,3 +1,4 @@
pub mod config;
pub mod describe; pub mod describe;
pub mod levels; pub mod levels;
pub mod progress; pub mod progress;
@@ -68,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);
} }
@@ -84,7 +84,7 @@ mod smoke {
#[test] #[test]
fn levels_generate_canonical_yaml() { fn levels_generate_canonical_yaml() {
let registry = levels::registry(); let registry = levels::registry();
assert_eq!(registry.len(), 2); assert_eq!(registry.len(), 3);
// Level 1: any null-equivalent passes via the semantic short-circuit. // Level 1: any null-equivalent passes via the semantic short-circuit.
let g1 = registry[0].generate(0); let g1 = registry[0].generate(0);
@@ -110,5 +110,21 @@ mod smoke {
g2.target_yaml, g2_again.target_yaml, g2.target_yaml, g2_again.target_yaml,
"same seed should produce the same target" "same seed should produce the same target"
); );
// Level 3: deterministic per seed; produces a mapping of mappings,
// each inner mapping has a `type` key.
let g3 = registry[2].generate(123);
let v3: serde_yaml::Value = serde_yaml::from_str(&g3.target_yaml).unwrap();
let m3 = v3.as_mapping().expect("level 3 produces a mapping");
assert!(!m3.is_empty());
for (_dir, feature) in m3 {
let inner = feature.as_mapping().expect("level 3 inner is a mapping");
assert!(
inner.get(serde_yaml::Value::String("type".into())).is_some(),
"each direction must carry a `type` key"
);
}
let g3_again = registry[2].generate(123);
assert_eq!(g3.target_yaml, g3_again.target_yaml);
} }
} }

View File

@@ -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,
} }

1035
src/tui.rs

File diff suppressed because it is too large Load Diff