//! 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, FloorDef, Item, Prefab, 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, } #[derive(Debug)] struct ModInfo { id: String, depends: Vec, 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 { 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 { directories::ProjectDirs::from("", "", "rustkeep").map(|d| d.data_dir().to_path_buf()) } fn discover(warnings: &mut Vec) -> Vec { let mut found: HashMap = 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::(&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, } fn resolve_order(found: &[ModInfo], warnings: &mut Vec) -> Vec { let file = profile_dir().map(|d| d.join("load_order.toml")); let mut order: Vec = file .as_ref() .and_then(|p| fs::read_to_string(p).ok()) .and_then(|raw| toml::from_str::(&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>, #[serde(default)] slots: Vec>, #[serde(default)] categories: Vec>, #[serde(default)] options: Vec>, #[serde(default)] prefabs: Vec>, #[serde(default)] floors: Vec>, } /// 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); impl_entry!(Prefab); impl_entry!(FloorDef); 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( raw: Vec>, ctx: &FileCtx<'_>, into: &mut HashMap, warnings: &mut Vec, ) { 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) { let known_slots: Vec = 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 = 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 } }); // Ticket 10: `layout`-Zeilen muessen gleich lang sein, sonst ist der Block // kein Rechteck und die Platzierung rechnet mit Muell. Ein Zweizeiler, der // eine ganze Klasse von Modder-Frust abfaengt. reg.prefabs.retain(|id, prefab| { let rows = prefab.rows(); if rows.is_empty() { warnings.push(format!("{id}: layout ist leer — verworfen")); return false; } let width = rows[0].chars().count(); match rows.iter().position(|r| r.chars().count() != width) { Some(i) => { warnings.push(format!( "{id}: layout-Zeile {} ist {} Zeichen breit, erwartet {width} — verworfen", i + 1, rows[i].chars().count() )); false } None => true, } }); // Ticket 10: Passen die Pflicht-Tags grundsaetzlich nicht in `size`, ist das // ein **Datenfehler und beim Laden erkennbar**. Zur Laufzeit gibt es dafuer // dann keinen Wiederholungszaehler und keinen Rueckfallpfad — die Pruefung // sitzt dort, wo der Fehler gemacht wurde. let prefabs = ®.prefabs; reg.floors.retain(|id, floor| { let mut needed = 0u32; for tag in &floor.required { let smallest = prefabs .values() .filter(|p| p.tags.iter().any(|t| t == tag)) .map(|p| { let (w, h) = p.size(); (w as u32 + 1) * (h as u32 + 1) }) .min(); match smallest { None => { warnings.push(format!("{id}: kein Prefab mit dem Pflicht-Tag '{tag}'")); return false; } Some(area) => needed += area, } } let available = floor.size.0 as u32 * floor.size.1 as u32; if needed > available { warnings.push(format!( "{id}: Pflichträume brauchen mindestens {needed} Felder, size bietet {available}" )); return false; } true }); } // ============================================================================ // Einstiegspunkt // ============================================================================ pub fn load_mods() -> Result<(Registries, Vec), 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); parse_entries(parsed.prefabs, &ctx, &mut reg.prefabs, &mut warnings); parse_entries(parsed.floors, &ctx, &mut reg.floors, &mut warnings); } } // Phase 3 — Referenzen aufloesen resolve(&mut reg, &mut warnings); Ok((reg, warnings)) } fn toml_files(dir: &Path) -> Vec { let mut files: Vec = 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 { 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 = 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 = 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")); } }