feat: m1 vertikaler durchstich — mod-lader, registries, attributberechnung, screen-stack

Ersetzt src/ vollstaendig: print_box, include_str! und enum ItemType fallen,
wie Ticket 01-03 es vorgezeichnet haben.

- data/: Mod-Lader mit zwei Suchpfaden, selbstheilender load_order.toml,
  drei Phasen und zweistufiger Deserialisierung (03/04)
- game/: Attributkatalog, Creature, Game::attribute ueber mehrere Quellen,
  Command mit Outcome (01/07/11/12)
- ui/: Screen-Stack mit Response, Grid-Widget, Press-Filter (06/11/12)
- mods/core/: echte TOML-Dateien auf der Platte statt include_str!

10 Tests, darunter der Borrow-Beweis fuer Registries-neben-Game, den
Ticket 12 nicht fuehren konnte.
This commit is contained in:
2026-08-12 15:39:17 +02:00
parent 9c5efab331
commit b39491916f
28 changed files with 3414 additions and 279 deletions

535
src/data/loader.rs Normal file
View File

@@ -0,0 +1,535 @@
//! Mod-Ladepipeline (Ticket 03) und Fehlerstrategie (Ticket 04).
//!
//! Drei Phasen: Manifeste → Einlesen → Aufloesen. In Phase 2 wird nichts
//! aufgeloest, dadurch gibt es **keine Vorwaertsreferenzen** — ein Mod darf auf
//! etwas zeigen, das erst ein spaeterer liefert.
use std::collections::HashMap;
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::Deserialize;
use serde::de::DeserializeOwned;
use toml::Spanned;
use super::registries::{Category, CharacterOption, Item, Registries, Slot};
/// Genau drei harte Fehler (Ticket 04). Alles Uebrige ist Warnung plus
/// Verwerfen des **einzelnen Eintrags**.
///
/// Handgeschrieben statt `thiserror`: drei Varianten, ein Konsument — und in
/// einem Ticket, das Rust lernen soll, ist dieses `match` der Zweck.
#[derive(Debug)]
pub enum LoadError {
CoreMissing,
DependencyMissing { module: String, dep: String },
DependencyOrder { module: String, dep: String },
}
impl fmt::Display for LoadError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
LoadError::CoreMissing => write!(
f,
"Das Kernspiel wurde nicht gefunden. Erwartet wird ein Ordner \
'mods/core' mit einer manifest.toml."
),
LoadError::DependencyMissing { module, dep } => {
write!(f, "'{module}' braucht '{dep}', aber '{dep}' ist nicht installiert.")
}
LoadError::DependencyOrder { module, dep } => write!(
f,
"'{module}' muss nach '{dep}' geladen werden, steht in der \
load_order.toml aber davor."
),
}
}
}
// ============================================================================
// Manifest und Mod-Erkennung
// ============================================================================
#[derive(Debug, Deserialize)]
struct Manifest {
id: String,
#[allow(dead_code)]
name: String,
/// Einziger Leser ist der Save-Header (Ticket 03/09).
#[allow(dead_code)]
version: String,
#[serde(default)]
depends: Vec<String>,
}
#[derive(Debug)]
struct ModInfo {
id: String,
depends: Vec<String>,
path: PathBuf,
}
/// Zwei Suchpfade (Ticket 03): `mods/` neben der Binary und `mods/` im
/// Benutzerprofil. Bei gleicher Mod-ID **gewinnt das Profil** — es existiert
/// genau dafuer, dass die Sachen des Spielers eine Neuinstallation ueberleben.
fn search_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Ok(exe) = std::env::current_exe()
&& let Some(dir) = exe.parent()
{
paths.push(dir.join("mods"));
}
// Entwicklungsfall: `cargo run` legt die Binary nach target/debug/.
paths.push(PathBuf::from("mods"));
if let Some(dir) = profile_dir() {
paths.push(dir.join("mods"));
}
paths
}
/// `data_dir()` ueber die Crate `directories` — Windows, Linux *und* macOS.
/// Keine Trennung von Daten und Konfiguration: Mods, `load_order.toml`, Save
/// und Logs liegen in einem Ordner (Ticket 03).
pub fn profile_dir() -> Option<PathBuf> {
directories::ProjectDirs::from("", "", "rustkeep").map(|d| d.data_dir().to_path_buf())
}
fn discover(warnings: &mut Vec<String>) -> Vec<ModInfo> {
let mut found: HashMap<String, ModInfo> = HashMap::new();
for base in search_paths() {
let Ok(entries) = fs::read_dir(&base) else { continue };
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let manifest_path = path.join("manifest.toml");
let Ok(raw) = fs::read_to_string(&manifest_path) else {
// Ordner ohne Manifest ist kein Mod — sonst legt eine vergessene
// screenshots/-Kopie das Spiel lahm (Ticket 03).
warnings.push(format!("{}: kein manifest.toml, übersprungen", path.display()));
continue;
};
match toml::from_str::<Manifest>(&raw) {
Ok(m) => {
if let Some(old) = found.get(&m.id) {
warnings.push(format!(
"'{}' liegt zweimal: {} gewinnt gegen {}",
m.id,
path.display(),
old.path.display()
));
}
found.insert(
m.id.clone(),
ModInfo { id: m.id, depends: m.depends, path },
);
}
Err(e) => warnings.push(format!("{}: {}", manifest_path.display(), e.message())),
}
}
}
found.into_values().collect()
}
// ============================================================================
// Ladereihenfolge — selbstheilend (Ticket 03)
// ============================================================================
#[derive(Debug, Default, Deserialize, serde::Serialize)]
struct LoadOrder {
#[serde(default)]
mods: Vec<String>,
}
fn resolve_order(found: &[ModInfo], warnings: &mut Vec<String>) -> Vec<String> {
let file = profile_dir().map(|d| d.join("load_order.toml"));
let mut order: Vec<String> = file
.as_ref()
.and_then(|p| fs::read_to_string(p).ok())
.and_then(|raw| toml::from_str::<LoadOrder>(&raw).ok())
.map(|lo| lo.mods)
.unwrap_or_default();
// Eintrag ohne Ordner: Warnzeile und ueberspringen. Das ist der Normalfall
// nach dem Deinstallieren und darf den Start nicht verhindern.
order.retain(|id| {
let exists = found.iter().any(|m| &m.id == id);
if !exists {
warnings.push(format!("'{id}' steht in load_order.toml, ist aber nicht installiert"));
}
exists
});
// Ordner ohne Eintrag: hinten anhaengen. Ein entpackter Mod laeuft damit,
// ohne dass der Spieler von der Sortierdatei wissen muss.
let mut fresh: Vec<&str> = found
.iter()
.filter(|m| !order.contains(&m.id))
.map(|m| m.id.as_str())
.collect();
fresh.sort_unstable();
if order.is_empty() {
// Erststart: core zuerst, Rest alphabetisch.
if let Some(pos) = fresh.iter().position(|id| *id == "core") {
order.push(fresh.remove(pos).to_string());
}
}
order.extend(fresh.into_iter().map(str::to_string));
if let Some(path) = file {
let _ = fs::create_dir_all(path.parent().unwrap());
if let Ok(text) = toml::to_string(&LoadOrder { mods: order.clone() }) {
// Schreibfehler wird verschluckt — ein Spiel, das wegen seiner
// Sortierdatei nicht startet, waere die Karikatur guter
// Fehlerbehandlung (Ticket 04).
let _ = fs::write(&path, text);
}
}
order
}
/// `depends` sortiert nicht, es **prueft** — das ist der Preis dafuer, dass ein
/// Mensch die Reihenfolge pflegt (Ticket 03).
fn check_depends(found: &[ModInfo], order: &[String]) -> Result<(), LoadError> {
for (i, id) in order.iter().enumerate() {
let Some(info) = found.iter().find(|m| &m.id == id) else { continue };
for dep in &info.depends {
match order.iter().position(|o| o == dep) {
None => {
return Err(LoadError::DependencyMissing {
module: id.clone(),
dep: dep.clone(),
});
}
Some(j) if j > i => {
return Err(LoadError::DependencyOrder {
module: id.clone(),
dep: dep.clone(),
});
}
Some(_) => {}
}
}
}
Ok(())
}
// ============================================================================
// Phase 2 — Einlesen, zweistufig (Ticket 04)
// ============================================================================
/// Jede Datei darf jede Registry-Tabelle enthalten (Ticket 03). `toml::Value`
/// als Zwischenstufe ist der Grund, warum ein kaputter Eintrag nur *sich selbst*
/// mitreisst und nicht die ganze Datei.
#[derive(Debug, Default, Deserialize)]
struct ModFile {
#[serde(default)]
items: Vec<Spanned<toml::Value>>,
#[serde(default)]
slots: Vec<Spanned<toml::Value>>,
#[serde(default)]
categories: Vec<Spanned<toml::Value>>,
#[serde(default)]
options: Vec<Spanned<toml::Value>>,
}
/// Damit die vier Registry-Typen durch dieselbe Einlese-Schleife passen.
trait Entry: DeserializeOwned {
fn id(&self) -> &str;
fn set_id(&mut self, id: String);
fn overrides(&self) -> Option<&str>;
}
macro_rules! impl_entry {
($t:ty) => {
impl Entry for $t {
fn id(&self) -> &str {
&self.id
}
fn set_id(&mut self, id: String) {
self.id = id;
}
fn overrides(&self) -> Option<&str> {
self.overrides.as_deref()
}
}
};
}
impl_entry!(Item);
impl_entry!(Slot);
impl_entry!(Category);
impl_entry!(CharacterOption);
struct FileCtx<'a> {
module: &'a str,
/// Pfad relativ zum Mod-Ordner — die Mod-ID steht ohnehin vorn.
file: String,
source: &'a str,
}
/// Zeile aus dem Byte-Offset. `toml::de::Error` und `Spanned` liefern nur
/// Offsets, keine Zeilen (gegen die Doku geprueft).
fn line_of(source: &str, offset: usize) -> usize {
source[..offset.min(source.len())].matches('\n').count() + 1
}
fn parse_entries<T: Entry>(
raw: Vec<Spanned<toml::Value>>,
ctx: &FileCtx<'_>,
into: &mut HashMap<String, T>,
warnings: &mut Vec<String>,
) {
for (index, spanned) in raw.into_iter().enumerate() {
let line = line_of(ctx.source, spanned.span().start);
let value = spanned.into_inner();
let mut entry = match T::deserialize(value) {
Ok(e) => e,
Err(e) => {
// Ein Eintrag ohne lesbare `id` kann sich nicht benennen und
// heisst nach seiner Position (Ticket 04).
warnings.push(format!(
"{}/{}:{}: [{}] — {}",
ctx.module, ctx.file, line, index, e.message()
));
continue;
}
};
// Der Lader stellt manifest.id automatisch voran (Ticket 03).
let own_id = format!("{}:{}", ctx.module, entry.id());
// `overrides` ersetzt den genannten Eintrag und uebernimmt dessen ID —
// die eigene wird dann gar nicht registriert (Ticket 03).
let key = match entry.overrides() {
Some(target) => {
let target = target.to_string();
if !into.contains_key(&target) {
warnings.push(format!(
"{}/{}:{}: {} überschreibt '{}', das es nicht gibt — verworfen",
ctx.module, ctx.file, line, own_id, target
));
continue;
}
warnings.push(format!("{} überschreibt '{}'", ctx.module, target));
target
}
None => {
if into.contains_key(&own_id) {
warnings.push(format!("{} überschreibt '{}'", ctx.module, own_id));
}
own_id
}
};
entry.set_id(key.clone());
into.insert(key, entry);
}
}
// ============================================================================
// Phase 3 — Aufloesen
// ============================================================================
fn resolve(reg: &mut Registries, warnings: &mut Vec<String>) {
let known_slots: Vec<String> = reg.slots.keys().cloned().collect();
reg.items.retain(|id, item| match &item.slot {
Some(slot) if !known_slots.contains(slot) => {
warnings.push(format!("{id}: Slot '{slot}' existiert nicht — verworfen"));
false
}
_ => true,
});
let known_categories: Vec<String> = reg.categories.keys().cloned().collect();
reg.options.retain(|id, option| {
if known_categories.contains(&option.category) {
true
} else {
warnings.push(format!(
"{id}: Kategorie '{}' existiert nicht — verworfen",
option.category
));
false
}
});
}
// ============================================================================
// Einstiegspunkt
// ============================================================================
pub fn load_mods() -> Result<(Registries, Vec<String>), LoadError> {
let mut warnings = Vec::new();
// Phase 1 — Manifeste, Reihenfolge, depends
let found = discover(&mut warnings);
if !found.iter().any(|m| m.id == "core") {
return Err(LoadError::CoreMissing);
}
let order = resolve_order(&found, &mut warnings);
check_depends(&found, &order)?;
// Phase 2 — Einlesen in Ladereihenfolge
let mut reg = Registries::default();
for id in &order {
let Some(info) = found.iter().find(|m| &m.id == id) else { continue };
for file in toml_files(&info.path) {
let name = file
.strip_prefix(&info.path)
.unwrap_or(&file)
.to_string_lossy()
.into_owned();
let Ok(source) = fs::read_to_string(&file) else { continue };
// Kaputte Syntax reisst die ganze Datei mit — da kommt der Parser
// schlicht nicht weiter. Das ist keine Entscheidung, sondern eine
// Tatsache (Ticket 04).
let parsed: ModFile = match toml::from_str(&source) {
Ok(p) => p,
Err(e) => {
let line = e.span().map(|s| line_of(&source, s.start)).unwrap_or(0);
warnings.push(format!("{}/{}:{}: {}", info.id, name, line, e.message()));
continue;
}
};
let ctx = FileCtx { module: &info.id, file: name, source: &source };
parse_entries(parsed.items, &ctx, &mut reg.items, &mut warnings);
parse_entries(parsed.slots, &ctx, &mut reg.slots, &mut warnings);
parse_entries(parsed.categories, &ctx, &mut reg.categories, &mut warnings);
parse_entries(parsed.options, &ctx, &mut reg.options, &mut warnings);
}
}
// Phase 3 — Referenzen aufloesen
resolve(&mut reg, &mut warnings);
Ok((reg, warnings))
}
fn toml_files(dir: &Path) -> Vec<PathBuf> {
let mut files: Vec<PathBuf> = fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == "toml"))
.filter(|p| p.file_name().is_some_and(|n| n != "manifest.toml"))
.collect();
files.sort();
files
}
// ============================================================================
// Warnungen ins Log (Ticket 04)
// ============================================================================
/// Schreibt die Warnliste, behaelt die letzten zehn Laeufe und gibt den
/// Dateinamen zurueck. Ein sauberer Lauf legt keine Datei an.
pub fn write_log(warnings: &[String]) -> Option<String> {
if warnings.is_empty() {
return None;
}
let dir = profile_dir()?.join("logs");
fs::create_dir_all(&dir).ok()?;
// ponytail: Sekunden seit Epoch statt JJJJMMTT-HHMMSS. Sortierbar, und die
// Alternative waere Datumsarithmetik von Hand oder eine Crate dafuer.
let stamp = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
let name = format!("load-{stamp}.log");
fs::write(dir.join(&name), warnings.join("\n")).ok()?;
let mut old: Vec<PathBuf> = fs::read_dir(&dir)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("load-") && n.ends_with(".log"))
})
.collect();
old.sort();
for path in old.iter().rev().skip(10) {
let _ = fs::remove_file(path);
}
Some(name)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn zeilennummer_aus_offset() {
let src = "a\nb\nc";
assert_eq!(line_of(src, 0), 1);
assert_eq!(line_of(src, 2), 2);
assert_eq!(line_of(src, 4), 3);
}
/// Der Check aus Ticket 04: Ein kaputtes Kernspiel faellt beim `cargo test`
/// auf, nicht beim Spieler — und die Ladelogik bleibt frei von Sonderregeln.
#[test]
fn core_laedt_ohne_warnungen() {
let (reg, warnings) = load_mods().expect("core muss laden");
let relevant: Vec<&String> =
warnings.iter().filter(|w| !w.contains("load_order.toml")).collect();
assert!(relevant.is_empty(), "core lädt nicht sauber: {relevant:#?}");
assert!(reg.items.contains_key("core:iron_sword"));
assert!(reg.slots.contains_key("core:hand"));
assert!(reg.options.contains_key("core:dwarf"));
}
/// Ticket 04 versprach, dass ein kaputter Eintrag nur *sich selbst*
/// mitreisst. Das ist mit serde nicht gratis — hier wird es geprueft.
#[test]
fn kaputter_eintrag_reisst_die_nachbarn_nicht_mit() {
let source = r#"
[[items]]
id = "gut"
name = "Gut"
[[items]]
id = "kaputt"
name = 42
[[items]]
id = "auch_gut"
name = "Auch gut"
"#;
let parsed: ModFile = toml::from_str(source).expect("Syntax ist in Ordnung");
let ctx = FileCtx { module: "test", file: "items.toml".into(), source };
let mut items: HashMap<String, Item> = HashMap::new();
let mut warnings = Vec::new();
parse_entries(parsed.items, &ctx, &mut items, &mut warnings);
assert_eq!(items.len(), 2, "die beiden heilen Eintraege muessen ueberleben");
assert!(items.contains_key("test:gut"));
assert!(items.contains_key("test:auch_gut"));
assert_eq!(warnings.len(), 1);
// `Spanned` zeigt auf den Anfang des Eintrags (`[[items]]`, Zeile 6),
// nicht auf das schuldige Feld — genau der Zeilenbereich, den Ticket 04
// zugesagt hat. Der Feldname steht dafuer in `message()`.
assert!(warnings[0].contains("items.toml:6"), "Zeile fehlt: {}", warnings[0]);
assert!(warnings[0].contains("expected a string"));
}
}