Compare commits
11 Commits
fdcf6f3af3
...
level-5
| Author | SHA1 | Date | |
|---|---|---|---|
| b805a49aaa | |||
| bac059a789 | |||
| aa6094fdb1 | |||
| f817c7b93e | |||
| cb0abb3e3b | |||
| a6741da14c | |||
| 4765917be4 | |||
| 42153b1733 | |||
| 4b3b1ce5a0 | |||
| 740685afc5 | |||
| 845cad7f74 |
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -1496,6 +1496,7 @@ dependencies = [
|
||||
"serde_yaml",
|
||||
"similar",
|
||||
"tera",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -23,3 +23,4 @@ dirs = "5"
|
||||
anyhow = "1"
|
||||
ratatui = "0.26"
|
||||
crossterm = "0.27"
|
||||
unicode-width = "0.1"
|
||||
|
||||
9
README.md
Normal file
9
README.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# YAMLabyrinth
|
||||
|
||||
A text dungeon game that teaches you YAML.
|
||||
|
||||

|
||||
|
||||
## How to start:
|
||||
|
||||
cargo run
|
||||
10
game.ini
Normal file
10
game.ini
Normal 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
BIN
screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
130
src/config.rs
Normal file
130
src/config.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
148
src/levels/l03_dict.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
99
src/levels/l05_dict_list.rs
Normal file
99
src/levels/l05_dict_list.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
//! Level 5 — dictionaries AND lists. Each chamber keeps its own inventory.
|
||||
//!
|
||||
//! Paired design note: `l05.md`.
|
||||
|
||||
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 DictList;
|
||||
|
||||
const CHAMBERS: &[&str] = &[
|
||||
"armory", "pantry", "library", "vault", "kitchen", "cellar",
|
||||
];
|
||||
const ITEMS: &[&str] = &[
|
||||
"sword", "shield", "bread", "water", "tome", "scroll", "gem", "coin", "dagger", "potion",
|
||||
];
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct DescCtx {
|
||||
chambers: Vec<ChamberDesc>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ChamberDesc {
|
||||
name: String,
|
||||
items: Vec<String>,
|
||||
}
|
||||
|
||||
impl Level for DictList {
|
||||
fn id(&self) -> u8 {
|
||||
5
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Chambers"
|
||||
}
|
||||
|
||||
fn generate(&self, seed: u64) -> Generated {
|
||||
let mut rng = ChaCha8Rng::seed_from_u64(seed ^ 0x0000_0000_0000_0005);
|
||||
let n = rng.gen_range(2..=3);
|
||||
let chamber_names: Vec<&'static str> =
|
||||
CHAMBERS.choose_multiple(&mut rng, n).copied().collect();
|
||||
|
||||
let mut inner = Mapping::new();
|
||||
let mut desc_chambers = Vec::new();
|
||||
for name in &chamber_names {
|
||||
let item_n = rng.gen_range(2..=3);
|
||||
let items: Vec<&'static str> =
|
||||
ITEMS.choose_multiple(&mut rng, item_n).copied().collect();
|
||||
let seq: Sequence = items
|
||||
.iter()
|
||||
.map(|i| Value::String((*i).to_string()))
|
||||
.collect();
|
||||
inner.insert(Value::String((*name).to_string()), Value::Sequence(seq));
|
||||
desc_chambers.push(ChamberDesc {
|
||||
name: (*name).to_string(),
|
||||
items: items.iter().map(|s| s.to_string()).collect(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut top = Mapping::new();
|
||||
top.insert(
|
||||
Value::String("chambers".to_string()),
|
||||
Value::Mapping(inner),
|
||||
);
|
||||
|
||||
let target_yaml =
|
||||
serde_yaml::to_string(&Value::Mapping(top)).expect("serialise mapping");
|
||||
|
||||
let mut d = Describer::new();
|
||||
d.register(
|
||||
"l05",
|
||||
"Several chambers branch off, each with its own inventory:\n\
|
||||
{% for c in chambers %}\n{{ c.name }}:{% for it in c.items %}\n - {{ it }}{% endfor %}\n{% endfor %}\n\
|
||||
💡 Wrap the whole tree under a `chambers:` key — a dict of lists.",
|
||||
)
|
||||
.expect("register template");
|
||||
let description = d
|
||||
.render(
|
||||
"l05",
|
||||
&DescCtx {
|
||||
chambers: desc_chambers,
|
||||
},
|
||||
)
|
||||
.expect("render template");
|
||||
|
||||
Generated {
|
||||
target_yaml,
|
||||
description,
|
||||
flavor: "🏛 You enter a hall. Doors lead to many chambers.".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,32 +12,52 @@
|
||||
|
||||
pub mod l01_minimum;
|
||||
pub mod l02_kv;
|
||||
pub mod l03_dict;
|
||||
pub mod l05_dict_list;
|
||||
|
||||
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),
|
||||
]
|
||||
}
|
||||
|
||||
24
src/lib.rs
24
src/lib.rs
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
1061
src/tui.rs
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user