Various pre-release cosmetic fixes

This commit is contained in:
2026-05-21 21:21:32 +03:00
parent cb0abb3e3b
commit f817c7b93e
8 changed files with 373 additions and 38 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"

View File

@@ -6,5 +6,5 @@
typewriter_effect = true typewriter_effect = true
# Typing speed in characters per minute. Higher = faster. # Typing speed in characters per minute. Higher = faster.
# Defaults: 1000 cpm (~60 ms per character). # Defaults: 2000 cpm (~30 ms per character).
typewriter_speed_cpm = 1000 typewriter_speed_cpm = 2000

View File

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

View File

@@ -12,6 +12,7 @@
pub mod l01_minimum; pub mod l01_minimum;
pub mod l02_kv; pub mod l02_kv;
pub mod l03_dict;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -81,5 +82,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

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

@@ -7,8 +7,10 @@ use crossterm::terminal::{
use ratatui::backend::CrosstermBackend; use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Style}; use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
use ratatui::{Frame, Terminal}; use ratatui::{Frame, Terminal};
use unicode_width::UnicodeWidthChar;
use std::io::{stdout, Stdout}; use std::io::{stdout, Stdout};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -34,8 +36,11 @@ enum Focus {
} }
enum LogEntry { enum LogEntry {
/// Game rules + the full controls listing. Seeded at startup. /// Game rules + the controls hint. Seeded at startup; appears instantly.
Intro, Intro,
/// "🏰 You stand at the entrance…" Lives as a separate entry so only
/// the greeting (not the whole intro) gets the typewriter effect.
Greeting,
LevelPrompt { LevelPrompt {
level_id: u8, level_id: u8,
level_name: String, level_name: String,
@@ -257,10 +262,18 @@ fn leave() -> Result<()> {
fn initial_state(prog: &Progress, registry: &[Box<dyn Level>]) -> (Screen, HistoryLog) { fn initial_state(prog: &Progress, registry: &[Box<dyn Level>]) -> (Screen, HistoryLog) {
let mut log = HistoryLog::new(); let mut log = HistoryLog::new();
// The Intro entry is always at the bottom of the log on startup so
// Welcome can show it inline; the player can scroll back to it any // The welcome content (Intro rules + Greeting) is seeded only for a
// time during play. // fresh game — `current_level == 0` means never started or just reset.
log.push(LogEntry::Intro); // A resumed run drops straight into its current level prompt; the
// Greeting is re-seeded on reset (see the ResetConfirm handler).
if prog.current_level == 0 {
// Intro (rules + Ctrl-H hint) appears instantly. Greeting follows as
// the dramatic last line — it's the entry that gets the typewriter
// on a fresh start.
log.push(LogEntry::Intro);
log.push(LogEntry::Greeting);
}
match prog.current_level { match prog.current_level {
0 => (Screen::Welcome, log), 0 => (Screen::Welcome, log),
@@ -373,9 +386,12 @@ fn main_loop(
)? { )? {
return Ok(()); return Ok(());
} }
if cfg.typewriter_enabled && log.entries.len() > prev_log_len { if cfg.typewriter_enabled && log.entries.len() != prev_log_len {
if let Some(tw) = start_typewriter(&log, &cfg) { if let Some(tw) = start_typewriter(&log, &cfg) {
if tw.entry_idx >= prev_log_len { // Growth: only animate genuinely new entries.
// Shrink (reset wiped the log): always animate the new
// bottom entry (Greeting).
if log.entries.len() < prev_log_len || tw.entry_idx >= prev_log_len {
typewriter = Some(tw); typewriter = Some(tw);
} }
} }
@@ -390,11 +406,11 @@ fn main_loop(
} }
} }
/// Build a `Typewriter` for the most recent `LevelPrompt` entry in the log, /// Build a `Typewriter` for the most recent animated entry in the log
/// if any. /// (either a `LevelPrompt` or the startup/post-reset `Greeting`).
fn start_typewriter(log: &HistoryLog, cfg: &config::GameConfig) -> Option<Typewriter> { fn start_typewriter(log: &HistoryLog, cfg: &config::GameConfig) -> Option<Typewriter> {
let idx = log.entries.iter().enumerate().rev().find_map(|(i, e)| { let idx = log.entries.iter().enumerate().rev().find_map(|(i, e)| {
if matches!(e, LogEntry::LevelPrompt { .. }) { if matches!(e, LogEntry::LevelPrompt { .. } | LogEntry::Greeting) {
Some(i) Some(i)
} else { } else {
None None
@@ -518,6 +534,7 @@ fn step(
editor.clear(); editor.clear();
*log = HistoryLog::new(); *log = HistoryLog::new();
log.push(LogEntry::Intro); log.push(LogEntry::Intro);
log.push(LogEntry::Greeting);
*screen = Screen::Welcome; *screen = Screen::Welcome;
*focus = default_focus(screen); *focus = default_focus(screen);
} }
@@ -773,14 +790,25 @@ fn render_log(
all_lines.extend_from_slice(prompt); all_lines.extend_from_slice(prompt);
} }
// Pick a visible slice anchored to the bottom, offset by `log.scroll`. // Wrap every logical line to the column's inner width *before* the
// bottom-anchor math. Otherwise ratatui's `Wrap` reflows long lines into
// extra visual rows the slice/pad math never accounts for, pushing the
// newest entries off the bottom edge. Wrapping here keeps the math in the
// visual-row units the terminal actually draws.
let inner_w = (area.width as usize).saturating_sub(2); // borders
let visible_h = (area.height as usize).saturating_sub(2); // borders let visible_h = (area.height as usize).saturating_sub(2); // borders
let total = all_lines.len(); let mut rows: Vec<String> = Vec::new();
for line in &all_lines {
rows.extend(wrap_line(line, inner_w));
}
// Pick a visible slice anchored to the bottom, offset by `log.scroll`.
let total = rows.len();
let max_scroll = total.saturating_sub(visible_h); let max_scroll = total.saturating_sub(visible_h);
let scroll = log.scroll.min(max_scroll); let scroll = log.scroll.min(max_scroll);
let end = total.saturating_sub(scroll); let end = total.saturating_sub(scroll);
let start = end.saturating_sub(visible_h); let start = end.saturating_sub(visible_h);
let slice = &all_lines[start..end]; let slice = &rows[start..end];
// Pad the top so the slice sits flush with the bottom of the column. // Pad the top so the slice sits flush with the bottom of the column.
let pad = visible_h.saturating_sub(slice.len()); let pad = visible_h.saturating_sub(slice.len());
@@ -793,7 +821,66 @@ fn render_log(
} else { } else {
" 📜 Labyrinth log ".to_string() " 📜 Labyrinth log ".to_string()
}; };
frame.render_widget(screen_widget(title, body), area); // Rows are pre-wrapped to `inner_w`, so render *without* `Wrap`: our
// wrapping is the single source of truth and can't drift from ratatui's.
let block = Block::default().borders(Borders::ALL).title(title);
frame.render_widget(Paragraph::new(body).block(block), area);
}
/// Word-wrap one logical line to `width` display columns, returning the visual
/// rows it occupies — always at least one, even for an empty line. Display
/// width is measured with `unicode-width` so wide glyphs (emoji) count as the
/// two cells they draw as. Leading indentation is kept; a word longer than the
/// whole column is hard-split.
fn wrap_line(line: &str, width: usize) -> Vec<String> {
if width == 0 || line.is_empty() {
return vec![String::new()];
}
let char_w = |c: char| UnicodeWidthChar::width(c).unwrap_or(0);
// Tokenize into alternating runs of spaces and non-spaces so breaks land
// between words while (most) whitespace is preserved.
let mut tokens: Vec<String> = Vec::new();
for c in line.chars() {
let is_space = c == ' ';
match tokens.last_mut() {
Some(tok) if tok.starts_with(' ') == is_space => tok.push(c),
_ => tokens.push(c.to_string()),
}
}
let mut rows: Vec<String> = Vec::new();
let mut row = String::new();
let mut row_w = 0usize;
for token in tokens {
let token_w: usize = token.chars().map(char_w).sum();
if row_w + token_w <= width {
row.push_str(&token);
row_w += token_w;
} else if token.starts_with(' ') {
// Whitespace that would overflow: swallow it at the break.
rows.push(std::mem::take(&mut row));
row_w = 0;
} else if token_w <= width {
// Word fits on a row of its own — start a fresh row with it.
rows.push(std::mem::take(&mut row));
row = token;
row_w = token_w;
} else {
// Word wider than the whole column: hard-split it across rows.
for c in token.chars() {
let w = char_w(c);
if row_w + w > width {
rows.push(std::mem::take(&mut row));
row_w = 0;
}
row.push(c);
row_w += w;
}
}
}
rows.push(row);
rows
} }
fn truncate_entry_to_chars(entry: &LogEntry, max_chars: usize) -> Vec<String> { fn truncate_entry_to_chars(entry: &LogEntry, max_chars: usize) -> Vec<String> {
@@ -808,8 +895,6 @@ fn truncate_entry_to_chars(entry: &LogEntry, max_chars: usize) -> Vec<String> {
fn entry_lines(entry: &LogEntry) -> Vec<String> { fn entry_lines(entry: &LogEntry) -> Vec<String> {
match entry { match entry {
LogEntry::Intro => vec![ LogEntry::Intro => vec![
"🏰 You stand at the entrance, brave explorer.".to_string(),
String::new(),
"🌀 YAMLabyrinth — learn YAML by writing your way through.".to_string(), "🌀 YAMLabyrinth — learn YAML by writing your way through.".to_string(),
String::new(), String::new(),
" Each chamber reveals a target. Match it with YAML in the editor".to_string(), " Each chamber reveals a target. Match it with YAML in the editor".to_string(),
@@ -820,6 +905,9 @@ fn entry_lines(entry: &LogEntry) -> Vec<String> {
String::new(), String::new(),
" Press [Ctrl-H] any time for the full controls.".to_string(), " Press [Ctrl-H] any time for the full controls.".to_string(),
], ],
LogEntry::Greeting => {
vec!["🏰 You stand at the entrance, brave explorer. Press [Enter] to ...well, enter the dungeon.".to_string()]
}
LogEntry::LevelPrompt { LogEntry::LevelPrompt {
level_id, level_id,
level_name, level_name,
@@ -878,26 +966,62 @@ fn render_editor(frame: &mut Frame, area: Rect, editor: &Editor, focused: bool)
} else { } else {
" 📝 Your YAML ".to_string() " 📝 Your YAML ".to_string()
}; };
let body = if focused { let block = Block::default().borders(Borders::ALL).title(title);
if focused {
// Highlight the character under the cursor with explicit bg + fg
// colours (not `REVERSED`) so the cursor cell is filled even when
// the underlying character is whitespace — some terminals skip
// background fill on reverse-video spaces.
let (cr, cc) = editor.cursor; let (cr, cc) = editor.cursor;
let mut lines: Vec<String> = Vec::with_capacity(editor.buffer.len()); let cursor_style = Style::default().bg(Color::White).fg(Color::Black);
for (r, line) in editor.buffer.iter().enumerate() {
if r == cr { let lines: Vec<Line> = editor
let col = cc.min(line.len()); .buffer
let mut with_cursor = String::with_capacity(line.len() + 1); .iter()
with_cursor.push_str(&line[..col]); .enumerate()
with_cursor.push('_'); .map(|(r, line)| {
with_cursor.push_str(&line[col..]); if r == cr {
lines.push(with_cursor); editor_cursor_line(line, cc, cursor_style)
} else { } else {
lines.push(line.clone()); Line::from(line.clone())
} }
} })
lines.join("\n") .collect();
let p = Paragraph::new(lines).block(block).wrap(Wrap { trim: false });
frame.render_widget(p, area);
} else { } else {
editor.text() let p = Paragraph::new(editor.text())
}; .block(block)
frame.render_widget(screen_widget(title, body), area); .wrap(Wrap { trim: false });
frame.render_widget(p, area);
}
}
/// Build the cursor-bearing line as three spans: text before, the
/// highlighted character at the cursor, text after. If the cursor sits
/// past the end of the line, the highlight falls on a trailing space.
fn editor_cursor_line(line: &str, col: usize, cursor_style: Style) -> Line<'static> {
let col = col.min(line.len());
let before = line[..col].to_string();
if col < line.len() {
let after = &line[col..];
let mut chars = after.char_indices();
let (_, c) = chars.next().expect("non-empty: col < line.len()");
let next_boundary = chars.next().map(|(i, _)| i).unwrap_or(after.len());
Line::from(vec![
Span::raw(before),
Span::styled(c.to_string(), cursor_style),
Span::raw(after[next_boundary..].to_string()),
])
} else {
Line::from(vec![
Span::raw(before),
Span::styled(" ".to_string(), cursor_style),
])
}
} }
fn render_editor_inactive(frame: &mut Frame, area: Rect) { fn render_editor_inactive(frame: &mut Frame, area: Rect) {
@@ -907,3 +1031,43 @@ fn render_editor_inactive(frame: &mut Frame, area: Rect) {
area, area,
); );
} }
#[cfg(test)]
mod tests {
use super::wrap_line;
#[test]
fn empty_line_still_occupies_one_row() {
assert_eq!(wrap_line("", 10), vec![String::new()]);
}
#[test]
fn short_line_is_left_intact() {
assert_eq!(wrap_line("hello", 10), vec!["hello".to_string()]);
}
#[test]
fn breaks_between_words() {
assert_eq!(
wrap_line("alpha beta gamma", 10),
vec!["alpha beta".to_string(), "gamma".to_string()],
);
}
#[test]
fn hard_splits_a_word_longer_than_the_column() {
assert_eq!(
wrap_line("abcdefghij", 4),
vec!["abcd".to_string(), "efgh".to_string(), "ij".to_string()],
);
}
#[test]
fn wide_emoji_counts_as_two_cells() {
// "ab " is 3 cells, "🥇" adds 2 → fills width 5 exactly; " cd" wraps.
assert_eq!(
wrap_line("ab 🥇 cd", 5),
vec!["ab 🥇".to_string(), "cd".to_string()],
);
}
}