11 Commits

Author SHA1 Message Date
9f8e5caaa8 Add level 11: Advanced Anchors
`shapes:` list defines 2-3 polygons (name + sides + interior angle sum);
`copies:` is a list of 3-4 selections (with possible repetition) drawn
from the same pool. serde_yaml expands aliases on parse, so the target
is the inlined form; players using `- &name` anchors inside the list
and `*name` aliases in `copies:` produce the same Value and pass via
the semantic short-circuit.

All randomised per seed (ChaCha8Rng XOR'd with 0x..0B).

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:59: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 1280 additions and 314 deletions

1
Cargo.lock generated
View File

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

View File

@@ -23,3 +23,4 @@ dirs = "5"
anyhow = "1"
ratatui = "0.26"
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

@@ -8,4 +8,7 @@ Third level is dictionaries. Each direction now leads to a feature with its own
depth: 10
straight:
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(),
}
}
}

View File

@@ -0,0 +1,141 @@
//! Level 11 — advanced anchors. Anchor shapes inside a list, alias
//! them elsewhere.
//!
//! Paired design note: `l11.md`.
//!
//! Like L6, serde_yaml expands aliases on parse, so the emitted target
//! is the fully-inlined form. Players who use `&anchor` / `*alias`
//! produce the same `Value` and pass via the semantic short-circuit.
use rand::seq::SliceRandom;
use rand::{Rng, SeedableRng};
use rand_chacha::ChaCha8Rng;
use serde::Serialize;
use serde_yaml::{Mapping, Sequence, Value};
use crate::describe::Describer;
use super::{Generated, Level};
pub struct AdvAnchors;
const SHAPES: &[(&str, i64)] = &[
("triangle", 3),
("square", 4),
("pentagon", 5),
("hexagon", 6),
("heptagon", 7),
("octagon", 8),
];
#[derive(Serialize)]
struct DescCtx {
shapes: Vec<ShapeDesc>,
copies: Vec<String>,
}
#[derive(Serialize)]
struct ShapeDesc {
name: String,
sides: i64,
interior_angle_sum: i64,
}
fn shape_value(name: &str, sides: i64) -> Value {
let mut m = Mapping::new();
m.insert(
Value::String("name".to_string()),
Value::String(name.to_string()),
);
m.insert(Value::String("sides".to_string()), Value::from(sides));
m.insert(
Value::String("interior".to_string()),
Value::from((sides - 2) * 180),
);
Value::Mapping(m)
}
impl Level for AdvAnchors {
fn id(&self) -> u8 {
11
}
fn name(&self) -> &'static str {
"Advanced Anchors"
}
fn generate(&self, seed: u64) -> Generated {
let mut rng = ChaCha8Rng::seed_from_u64(seed ^ 0x0000_0000_0000_000B);
let n = rng.gen_range(2..=3);
let picked: Vec<(&'static str, i64)> = SHAPES
.choose_multiple(&mut rng, n)
.copied()
.collect();
// The defining `shapes:` list.
let shapes_seq: Sequence = picked
.iter()
.map(|(name, sides)| shape_value(name, *sides))
.collect();
// `copies:` — random selections with possible repetition.
let m = rng.gen_range(3..=4);
let mut copies_seq = Sequence::new();
let mut copy_names = Vec::new();
for _ in 0..m {
let (name, sides) = picked.choose(&mut rng).unwrap();
copies_seq.push(shape_value(name, *sides));
copy_names.push((*name).to_string());
}
let mut top = Mapping::new();
top.insert(
Value::String("shapes".to_string()),
Value::Sequence(shapes_seq),
);
top.insert(
Value::String("copies".to_string()),
Value::Sequence(copies_seq),
);
let target_yaml =
serde_yaml::to_string(&Value::Mapping(top)).expect("serialise mapping");
let shape_descs: Vec<ShapeDesc> = picked
.iter()
.map(|(name, sides)| ShapeDesc {
name: (*name).to_string(),
sides: *sides,
interior_angle_sum: (sides - 2) * 180,
})
.collect();
let mut d = Describer::new();
d.register(
"l11",
"Shapes are defined once and reused.\n\
Definitions:\n\
{% for s in shapes %}- {{ s.name }}: sides={{ s.sides }}, interior={{ s.interior_angle_sum }}\n\
{% endfor %}\n\
Copies, in order: {% for c in copies %}{{ c }}{% if not loop.last %}, {% endif %}{% endfor %}\n\
💡 Anchor each shape in the list with `- &name`, then alias by `*name` in copies.",
)
.expect("register template");
let description = d
.render(
"l11",
&DescCtx {
shapes: shape_descs,
copies: copy_names,
},
)
.expect("render template");
Generated {
target_yaml,
description,
flavor: "🔺 The geometry chamber repeats its forms.".to_string(),
}
}
}

View File

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

View File

@@ -1,3 +1,4 @@
pub mod config;
pub mod describe;
pub mod levels;
pub mod progress;
@@ -68,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);
}
@@ -84,7 +84,7 @@ mod smoke {
#[test]
fn levels_generate_canonical_yaml() {
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.
let g1 = registry[0].generate(0);
@@ -110,5 +110,21 @@ mod smoke {
g2.target_yaml, g2_again.target_yaml,
"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 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,
}

1061
src/tui.rs

File diff suppressed because it is too large Load Diff