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

35
src/levels/l01_minimum.rs Normal file
View 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
View 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(),
}
}
}

View File

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