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

1648
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,3 +6,6 @@ edition = "2024"
[dependencies]
serde = { version = "1.0.229", features = ["derive"] }
toml = "1.1.4"
# crossterm kommt als Re-Export mit (Ticket 06/12 — kein eigener Eintrag noetig)
ratatui = "0.30.2"
directories = "6.0.0"

View File

@@ -0,0 +1,48 @@
# Rasse und Klasse sind Charakteroptionen, keine eigenen Konzepte (Ticket 02).
# Die Kategorien sind selbst Inhalt und tragen `pick = N`, damit ein Mod eine
# dritte Wahl ergaenzen kann, ohne dass die Engine davon weiss.
[[categories]]
id = "race"
name = "Rasse"
order = 10
pick = 1
[[categories]]
id = "class"
name = "Klasse"
order = 20
pick = 1
[[options]]
id = "dwarf"
category = "core:race"
name = "Zwerg"
description = "Zäh, misstrauisch, trinkfest."
modifiers = [ { attribute = "constitution", flat = 2 },
{ attribute = "max_health", flat = 10 } ]
[[options]]
id = "elf"
category = "core:race"
name = "Elf"
description = "Flink, hochmütig, langlebig."
modifiers = [ { attribute = "dexterity", flat = 2 },
{ attribute = "luck", flat = 1 } ]
[[options]]
id = "warrior"
category = "core:class"
name = "Krieger"
description = "Schlägt zu, wo andere nachdenken."
modifiers = [ { attribute = "strength", flat = 3 },
{ attribute = "damage", percent = 10 } ]
[[options]]
id = "thief"
category = "core:class"
name = "Dieb"
description = "Nimmt, was nicht niet- und nagelfest ist."
modifiers = [ { attribute = "dexterity", flat = 2 },
{ attribute = "luck", flat = 2 },
{ attribute = "critical_chance", percent = 15 } ]

38
mods/core/items.toml Normal file
View File

@@ -0,0 +1,38 @@
# Es gibt keinen Item-Typ (Ticket 02). Ein Schwert ist ein Gegenstand mit einem
# Modifikator auf `damage`, ein Helm einer mit einem Modifikator auf Rüstung —
# der Unterschied steht in den Modifikatoren, nicht in einer Kategorie.
[[items]]
id = "iron_sword"
name = "Eisenschwert"
base_price = 45
slot = "core:hand"
symbol = "/"
color = "white"
modifiers = [ { attribute = "damage", flat = 5 } ]
[[items]]
id = "leather_cap"
name = "Lederkappe"
base_price = 18
slot = "core:head"
symbol = "^"
color = "yellow"
modifiers = [ { attribute = "max_health", flat = 8 } ]
[[items]]
id = "health_potion"
name = "Heiltrank"
base_price = 12
stackable = true
consumed_on_use = true
symbol = "!"
color = "red"
[[items]]
id = "gold"
name = "Gold"
base_price = 1
stackable = true
symbol = "$"
color = "yellow"

4
mods/core/manifest.toml Normal file
View File

@@ -0,0 +1,4 @@
id = "core"
name = "rustkeep"
version = "0.1.0"
depends = []

17
mods/core/slots.toml Normal file
View File

@@ -0,0 +1,17 @@
# Slots sind Inhalt, kein Aufzaehlungswert — der Charakterbogen rendert die
# vorhandenen, statt ein festes Layout zu mauern (Ticket 02).
[[slots]]
id = "head"
name = "Kopf"
order = 10
[[slots]]
id = "body"
name = "Rumpf"
order = 20
[[slots]]
id = "hand"
name = "Hand"
order = 30

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"));
}
}

8
src/data/mod.rs Normal file
View File

@@ -0,0 +1,8 @@
//! Definitionen und Mod-Lader. Kennt ratatui nicht — Farben sind hier Namen,
//! uebersetzt wird in `ui/theme.rs` (Ticket 11).
pub mod loader;
pub mod registries;
pub use loader::{load_mods, write_log};
pub use registries::Registries;

116
src/data/registries.rs Normal file
View File

@@ -0,0 +1,116 @@
//! Die Definitionen, die aus TOML geladen werden — Ticket 02.
//!
//! Einige Felder liest in M1 noch niemand: `base_price` wartet auf den Handel,
//! `stackable` und `consumed_on_use` aufs Inventar, `symbol` auf Gegenstaende,
//! die auf dem Boden liegen. Sie stehen trotzdem hier, weil sie **das
//! Datenmodell** sind und `mods/core` sie bereits schreibt — sie erst spaeter
//! einzufuehren hiesse, die TOML-Dateien zweimal anzufassen.
//!
//! ponytail: modulweites `allow` — es versteckt ab M2 auch echte Leichen.
//! Raus, sobald Handel und Inventar die Felder tatsaechlich lesen.
#![allow(dead_code)]
use std::collections::HashMap;
use serde::Deserialize;
use crate::game::Modifier;
/// Ein Ding im Spiel, **einheitlich strukturiert**. Es gibt keinen Item-Typ:
/// `enum ItemType` vermischte Slot, Verhalten und Kategorie (Ticket 02).
#[derive(Debug, Clone, Deserialize)]
pub struct Item {
pub id: String,
pub name: String,
#[serde(default)]
pub base_price: u32,
/// Leer = nicht ausruestbar.
pub slot: Option<String>,
#[serde(default)]
pub stackable: bool,
#[serde(default)]
pub consumed_on_use: bool,
#[serde(default = "default_symbol")]
pub symbol: char,
#[serde(default)]
pub color: Option<String>,
#[serde(default)]
pub modifiers: Vec<Modifier>,
/// Ersetzt den genannten Eintrag und uebernimmt dessen ID (Ticket 03).
#[serde(default)]
pub overrides: Option<String>,
// TODO(M3+): `actions`, `abilities`, `triggers` — brauchen erst Kampf und
// Event-System.
}
fn default_symbol() -> char {
'?'
}
/// Eine benannte Stelle am Charakter. Moddbarer Inhalt, kein fester
/// Aufzaehlungswert (Ticket 02).
#[derive(Debug, Clone, Deserialize)]
pub struct Slot {
pub id: String,
pub name: String,
#[serde(default)]
pub order: i32,
#[serde(default)]
pub overrides: Option<String>,
}
/// Kategorie von Charakteroptionen — selbst Inhalt, mit `pick = N`.
#[derive(Debug, Clone, Deserialize)]
pub struct Category {
pub id: String,
pub name: String,
#[serde(default)]
pub order: i32,
#[serde(default = "one")]
pub pick: u32,
#[serde(default)]
pub overrides: Option<String>,
}
fn one() -> u32 {
1
}
/// Eine Wahl beim Charakterbau. Rasse und Klasse sind beides davon.
#[derive(Debug, Clone, Deserialize)]
pub struct CharacterOption {
pub id: String,
pub category: String,
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub modifiers: Vec<Modifier>,
#[serde(default)]
pub overrides: Option<String>,
// TODO(M3): `abilities` und `on_create` (Ticket 02) — Faehigkeiten und
// Startausruestung brauchen Kampf bzw. Inventar.
}
/// Alle geladenen Definitionen. Liegt **neben** `Game`, nicht darin — als Feld
/// kollidierte `&mut game.player` mit `&game.registries` bei jedem
/// Methodenaufruf (Ticket 07).
#[derive(Debug, Default)]
pub struct Registries {
pub items: HashMap<String, Item>,
pub slots: HashMap<String, Slot>,
pub categories: HashMap<String, Category>,
pub options: HashMap<String, CharacterOption>,
// TODO(M2+): `affixes`, `abilities`, `quests`, `creatures`, `prefabs`,
// `floors`, `loot_tables`, `scripts` — zwoelf sind es am Ende.
}
impl Registries {
/// Slots in Anzeigereihenfolge. Der Charakterbogen schleift darueber,
/// statt ein Layout zu mauern (Ticket 02).
pub fn slots_ordered(&self) -> Vec<&Slot> {
let mut v: Vec<&Slot> = self.slots.values().collect();
v.sort_by_key(|s| (s.order, s.id.clone()));
v
}
}

161
src/game/attributes.rs Normal file
View File

@@ -0,0 +1,161 @@
//! Attributkatalog und Modifikatoren — Ticket 01.
//!
//! Der Katalog ist **fest** und Engine-Vokabular ohne Namensraum. Ein Modder
//! kann ihn nicht erweitern; das ist Absicht (siehe Glossar, „Attribut").
use serde::Deserialize;
/// Fester Katalog aus Ticket 01. `level` steht hier, weil Bedingungen und
/// Formeln es lesen — die Quelle fuer Aufstiegspunkte ist aber `base.level`
/// und nicht dieser gelesene Wert (Ticket 15).
///
// TODO(M3): `resistance` fehlt — es ist typ-indiziert (sieben Schadenstypen aus
// Ticket 01) und braucht damit eine andere Form als die uebrigen.
// Kommt mit dem Kampf.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
#[repr(usize)]
pub enum Attribute {
Strength,
Dexterity,
Constitution,
Charisma,
Luck,
Level,
MaxHealth,
MaxStamina,
Damage,
HitChance,
CriticalChance,
CriticalFactor,
PriceModifier,
CarryCapacity,
}
impl Attribute {
pub const COUNT: usize = 14;
pub const ALL: [Attribute; Self::COUNT] = [
Attribute::Strength,
Attribute::Dexterity,
Attribute::Constitution,
Attribute::Charisma,
Attribute::Luck,
Attribute::Level,
Attribute::MaxHealth,
Attribute::MaxStamina,
Attribute::Damage,
Attribute::HitChance,
Attribute::CriticalChance,
Attribute::CriticalFactor,
Attribute::PriceModifier,
Attribute::CarryCapacity,
];
pub fn name(self) -> &'static str {
match self {
Attribute::Strength => "Stärke",
Attribute::Dexterity => "Geschicklichkeit",
Attribute::Constitution => "Konstitution",
Attribute::Charisma => "Charisma",
Attribute::Luck => "Glück",
Attribute::Level => "Level",
Attribute::MaxHealth => "Leben (max)",
Attribute::MaxStamina => "Ausdauer (max)",
Attribute::Damage => "Schaden",
Attribute::HitChance => "Trefferchance",
Attribute::CriticalChance => "Kritische Chance",
Attribute::CriticalFactor => "Kritischer Faktor",
Attribute::PriceModifier => "Preis-Modifikator",
Attribute::CarryCapacity => "Tragfähigkeit",
}
}
}
/// Die Grundwerte eines Wesens. Bewusst ein Array statt vierzehn benannter
/// Felder: `get` und `set` sind damit je eine Zeile statt je eines
/// vierzehnarmigen `match`.
#[derive(Debug, Clone)]
pub struct Attributes([f32; Attribute::COUNT]);
impl Attributes {
pub fn get(&self, a: Attribute) -> f32 {
self.0[a as usize]
}
pub fn set(&mut self, a: Attribute, v: f32) {
self.0[a as usize] = v;
}
}
impl Default for Attributes {
/// Startwerte eines frischen Helden. Zahlen sind bewusst grob — Balancing
/// setzt ein spielbares Spiel voraus (siehe Map, „Not yet specified").
fn default() -> Self {
let mut a = Attributes([0.0; Attribute::COUNT]);
a.set(Attribute::Strength, 1.0);
a.set(Attribute::Dexterity, 1.0);
a.set(Attribute::Constitution, 1.0);
a.set(Attribute::Charisma, 1.0);
a.set(Attribute::Luck, 1.0);
a.set(Attribute::Level, 1.0);
a.set(Attribute::MaxHealth, 100.0);
a.set(Attribute::MaxStamina, 100.0);
a.set(Attribute::Damage, 2.0);
a.set(Attribute::HitChance, 0.8);
a.set(Attribute::CriticalFactor, 1.5);
a.set(Attribute::CarryCapacity, 50.0);
a
}
}
/// Rechnet ein Attribut um, solange er existiert — Ticket 01. Er wird nie
/// ausgefuehrt, sondern beim Auslesen aufsummiert.
#[derive(Debug, Clone, Deserialize)]
pub struct Modifier {
pub attribute: Attribute,
#[serde(default)]
pub flat: f32,
#[serde(default)]
pub percent: f32,
// TODO(M3): `duration`, `target` und `condition` aus Ticket 01/08. In M1
// sind alle Modifikatoren dauerhaft und wirken auf den Traeger.
}
/// Stapelregel aus Ticket 01: `(Basis + Σ flach) × (1 + Σ prozentual)`,
/// intern `f32`, gerundet an genau einer Stelle — hier gar nicht, weil das
/// Runden dorthin gehoert, wo ein Wert angezeigt oder verrechnet wird.
pub fn stack<'a>(base: f32, modifiers: impl Iterator<Item = &'a Modifier>, attr: Attribute) -> f32 {
let (flat, percent) = modifiers
.filter(|m| m.attribute == attr)
.fold((0.0, 0.0), |(f, p), m| (f + m.flat, p + m.percent));
(base + flat) * (1.0 + percent / 100.0)
}
#[cfg(test)]
mod tests {
use super::*;
/// `ALL` wird von Hand neben dem Enum gepflegt. Wer eine Variante ergaenzt
/// und die Liste vergisst, faellt sonst nirgends auf — der Charakterbogen
/// zeigt das Attribut dann einfach nie.
#[test]
fn all_ist_vollstaendig_und_in_indexreihenfolge() {
assert_eq!(Attribute::ALL.len(), Attribute::COUNT);
for (i, a) in Attribute::ALL.iter().enumerate() {
assert_eq!(*a as usize, i, "{a:?} steht an der falschen Stelle in ALL");
}
}
#[test]
fn stapelregel_erst_flach_dann_prozentual() {
let mods = [
Modifier { attribute: Attribute::Strength, flat: 2.0, percent: 0.0 },
Modifier { attribute: Attribute::Strength, flat: 3.0, percent: 10.0 },
// Fremdes Attribut darf nicht mitzaehlen:
Modifier { attribute: Attribute::Damage, flat: 100.0, percent: 0.0 },
];
// (10 + 5) * 1.10 == 16.5, nicht 10*1.1 + 5 == 16.0
assert_eq!(stack(10.0, mods.iter(), Attribute::Strength), 16.5);
}
}

32
src/game/command.rs Normal file
View File

@@ -0,0 +1,32 @@
//! Kommandos der Oberflaeche an das Spiel — Ticket 11.
//!
//! **Nicht** zu verwechseln mit `Action`, dem TOML-Effektvokabular aus Ticket
//! 01. „Geh nach Norden" ist keine der zehn Aktionen und wird nie eine.
#[derive(Debug, Clone, Copy)]
pub enum Direction {
North,
South,
West,
East,
}
/// Ticket 11 hat zwoelf Commands festgelegt; M1 braucht zwei davon.
///
// TODO(M2+): `UseAbility`, `UseItem`, `Equip`, `Unequip`, `Drop`, `PickUp`,
// `Descend`, `Talk`, `Buy`, `Sell` — und `AssignPoints` aus 15.
#[derive(Debug, Clone)]
pub enum Command {
Move(Direction),
Wait,
}
/// Ergebnis eines Kommandos — der Befund aus dem Durchstich in Ticket 12:
/// „alle Commands kosten einen Zug" heisst **alle ausgefuehrten**. Gegen eine
/// Wand zu laufen darf keine Zeit verbrauchen, sonst tickt ein Gift-Debuff,
/// weil jemand danebengetippt hat.
#[derive(Debug, Clone)]
pub enum Outcome {
TurnPassed,
Rejected(&'static str),
}

73
src/game/creature.rs Normal file
View File

@@ -0,0 +1,73 @@
//! Wesen — Held und Gegner sind derselbe Typ (Ticket 07).
//!
//! `health`, `stamina`, `tags` und `CreatureRef::Enemy` liest in M1 noch
//! niemand: Sie brauchen den Kampf. Sie stehen trotzdem hier, weil sie die
//! Struktur aus Ticket 07 sind und nicht deren halbe Vorstufe.
#![allow(dead_code)]
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use super::attributes::Attributes;
/// Ein Exemplar eines Gegenstands. Traegt **nur IDs** — die Affixe sind
/// Registry-Eintraege, keine eingebetteten Modifikatoren (Ticket 07). Genau
/// deshalb bleibt der Speicherstand eine ID-Liste.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ItemInstance {
pub def: String,
#[serde(default)]
pub affixes: Vec<String>,
}
impl ItemInstance {
pub fn new(def: impl Into<String>) -> Self {
Self { def: def.into(), affixes: Vec::new() }
}
}
/// Adresse eines Wesens. Faellt direkt aus der Zwei-Felder-Entscheidung in
/// Ticket 07: Held und Gegner liegen getrennt, es gibt also keinen
/// gemeinsamen Index.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CreatureRef {
Player,
Enemy(usize),
}
#[derive(Debug, Clone)]
pub struct Creature {
pub name: String,
/// Grundwerte. `base.level` ist die einzige Quelle fuer Aufstiegspunkte
/// (Ticket 15) — das *gelesene* Attribut darf hoeher ausfallen.
pub base: Attributes,
/// Zustand, scharf getrennt von den Attributen (Ticket 01).
pub health: f32,
pub stamina: f32,
/// Gewaehlte Charakteroptionen. Ohne dieses Feld haette der Held nach dem
/// Charakterbau keine Rasse mehr (Ticket 07).
pub options: Vec<String>,
pub inventory: Vec<ItemInstance>,
pub equipment: HashMap<String, ItemInstance>,
pub tags: Vec<String>,
// TODO(M3): `modifiers: Vec<ActiveModifier>` (nur befristete),
// `initiative: i32`, `cooldowns: HashMap<AbilityId, u32>`.
// Alle drei brauchen den Kampf, um ueberhaupt etwas zu tun.
}
impl Creature {
pub fn new(name: impl Into<String>) -> Self {
let base = Attributes::default();
Self {
name: name.into(),
health: base.get(super::attributes::Attribute::MaxHealth),
stamina: base.get(super::attributes::Attribute::MaxStamina),
base,
options: Vec::new(),
inventory: Vec::new(),
equipment: HashMap::new(),
tags: Vec::new(),
}
}
}

91
src/game/floor.rs Normal file
View File

@@ -0,0 +1,91 @@
//! Etage — Ticket 10 entscheidet den Innenbau, M1 baut einen Testraum von Hand.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tile {
Wall,
Floor,
Door,
Stairs,
Entrance,
}
#[derive(Debug)]
pub struct Floor {
pub number: u8,
pub width: u16,
pub height: u16,
pub tiles: Vec<Tile>,
// TODO(M2): `rooms: Vec<Room>` und `player_room: Option<usize>` aus Ticket
// 10 — mit ihnen kommen `room_entered`, Kampfbeginn und die
// Raum-Beutetabellen. M1 hat genau einen Raum und braucht die
// Buchfuehrung noch nicht.
}
impl Floor {
pub fn tile(&self, x: u16, y: u16) -> Tile {
if x >= self.width || y >= self.height {
return Tile::Wall;
}
self.tiles[y as usize * self.width as usize + x as usize]
}
/// Handgebauter Testraum. Ticket 10 legt fest, dass Prefabs als ASCII-Bloecke
/// geschrieben werden — deshalb steht der Raum hier schon in derselben Form,
/// auch wenn der Generator erst in M2 kommt.
pub fn test_room() -> Self {
const LAYOUT: &[&str] = &[
"########################",
"#......................#",
"#..####....######......#",
"#..#..........#........#",
"#..#..........#...##...#",
"#.....####....#...##...#",
"#........#........>....#",
"#..<.....#.............#",
"########################",
];
let width = LAYOUT[0].len() as u16;
let height = LAYOUT.len() as u16;
let tiles = LAYOUT
.iter()
.flat_map(|row| row.chars())
.map(|c| match c {
'#' => Tile::Wall,
'>' => Tile::Stairs,
'<' => Tile::Entrance,
'+' => Tile::Door,
_ => Tile::Floor,
})
.collect();
Self { number: 1, width, height, tiles }
}
/// Startfeld: das `<` im Layout, sonst das erste begehbare Feld.
pub fn entrance(&self) -> (u16, u16) {
for y in 0..self.height {
for x in 0..self.width {
if self.tile(x, y) == Tile::Entrance {
return (x, y);
}
}
}
(1, 1)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn testraum_ist_rechteckig_und_hat_eingang() {
let f = Floor::test_room();
assert_eq!(f.tiles.len(), f.width as usize * f.height as usize);
assert_eq!(f.tile(f.entrance().0, f.entrance().1), Tile::Entrance);
// Rand ist geschlossen, sonst laeuft der Held aus der Karte.
assert_eq!(f.tile(0, 0), Tile::Wall);
assert_eq!(f.tile(f.width - 1, f.height - 1), Tile::Wall);
}
}

191
src/game/mod.rs Normal file
View File

@@ -0,0 +1,191 @@
//! Der Spielkern. **Kennt ratatui nicht** — `tests/boundaries.rs` prueft das.
pub mod attributes;
pub mod command;
pub mod creature;
pub mod floor;
pub use attributes::{Attribute, Modifier};
pub use command::{Command, Direction, Outcome};
pub use creature::{Creature, CreatureRef, ItemInstance};
pub use floor::{Floor, Tile};
use crate::data::Registries;
pub struct Game {
pub player: Creature,
/// Der **laufende Kampf**, nicht die Etage (Ticket 07/14). Leer = kein Kampf.
pub enemies: Vec<Creature>,
pub floor: Floor,
pub player_pos: (u16, u16),
pub turn: u64,
/// Letzte Meldung fuer die Statuszeile.
pub last: String,
// TODO(M3): `events: Vec<Event>` (Warteschlange aus Ticket 08) und der
// xorshift-Seed aus Ticket 14. Beide brauchen erst einen Trigger
// bzw. einen Wurf, um etwas zu tun.
// TODO(M4): `quests`, `current_exp`, `unspent_points` (Ticket 15 — an
// `Game`, nicht am `Creature`).
}
impl Game {
pub fn new(player: Creature) -> Self {
let floor = Floor::test_room();
let player_pos = floor.entrance();
Self {
player,
enemies: Vec::new(),
floor,
player_pos,
turn: 0,
last: String::from("—"),
}
}
pub fn creature(&self, who: CreatureRef) -> &Creature {
match who {
CreatureRef::Player => &self.player,
CreatureRef::Enemy(i) => &self.enemies[i],
}
}
/// Der Lesezugang liegt auf `Game`, nicht auf `Creature` — eine Aura wohnt
/// beim Traeger und wird beim Auslesen des Ziels eingesammelt (Ticket 01),
/// ein `Creature` kann seine Attribute also gar nicht allein ausrechnen.
///
/// Vier Quellen: `base` → `options` → `equipment` samt Affixen → befristete
/// Modifikatoren. **Kein Cache** (Ticket 07): Das passiert pro Tastendruck,
/// nicht pro Frame.
pub fn attribute(
&self,
who: CreatureRef,
attr: Attribute,
_against: Option<CreatureRef>,
reg: &Registries,
) -> f32 {
let c = self.creature(who);
let from_options = c
.options
.iter()
.filter_map(|id| reg.options.get(id))
.flat_map(|o| o.modifiers.iter());
let from_equipment = c.equipment.values().flat_map(|inst| {
reg.items
.get(&inst.def)
.map(|i| i.modifiers.as_slice())
.unwrap_or(&[])
.iter()
});
// TODO(M3): Affix-Modifikatoren des Exemplars, befristete Modifikatoren
// am Traeger, und `_against` fuer bedingte Modifikatoren
// („+5 Schaden gegen Untote", Ticket 08).
attributes::stack(c.base.get(attr), from_options.chain(from_equipment), attr)
}
/// Der **eine** Eingang, an dem ein Zug endet (Ticket 11).
pub fn execute(&mut self, cmd: Command, _reg: &Registries) -> Outcome {
let outcome = match cmd {
Command::Move(dir) => self.step(dir),
Command::Wait => {
self.last = String::from("gewartet");
Outcome::TurnPassed
}
};
// Nur ausgefuehrte Kommandos kosten einen Zug (Ticket 12).
if matches!(outcome, Outcome::TurnPassed) {
self.turn += 1;
// TODO(M3): befristete Modifikatoren ticken, Ausdauer regeneriert,
// Abklingzeiten sinken, `turn_started` feuern, dann die
// Event-Warteschlange leerarbeiten (Ticket 08/14).
}
outcome
}
fn step(&mut self, dir: Direction) -> Outcome {
let (x, y) = self.player_pos;
let (nx, ny) = match dir {
Direction::North => (x, y.saturating_sub(1)),
Direction::South => (x, y + 1),
Direction::West => (x.saturating_sub(1), y),
Direction::East => (x + 1, y),
};
if self.floor.tile(nx, ny) == Tile::Wall {
return Outcome::Rejected("Da ist eine Wand");
}
self.player_pos = (nx, ny);
self.last = match self.floor.tile(nx, ny) {
Tile::Stairs => String::from("Eine Treppe führt nach unten"),
_ => format!("{},{}", nx, ny),
};
Outcome::TurnPassed
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data::load_mods;
fn zwergenkrieger() -> (Game, Registries) {
let (reg, _) = load_mods().expect("core muss laden");
let mut hero = Creature::new("Testheld");
hero.options = vec![String::from("core:dwarf"), String::from("core:warrior")];
hero.equipment
.insert(String::from("core:hand"), ItemInstance::new("core:iron_sword"));
(Game::new(hero), reg)
}
/// Der Zielsatz von Meilenstein 1 als Behauptung: ein Wert, der aus einem
/// Modifikator stammt, der aus einer TOML-Datei kam.
#[test]
fn attribut_sammelt_aus_optionen_und_ausruestung() {
let (game, reg) = zwergenkrieger();
let p = CreatureRef::Player;
// Basis 1, Zwerg +2 (character_options.toml)
assert_eq!(game.attribute(p, Attribute::Constitution, None, &reg), 3.0);
// Basis 2, Eisenschwert +5 flach (items.toml), Krieger +10 % ⇒ (2+5)*1.1
let damage = game.attribute(p, Attribute::Damage, None, &reg);
assert!((damage - 7.7).abs() < 0.001, "erwartet 7.7, war {damage}");
// Unberuehrtes Attribut bleibt bei der Basis.
assert_eq!(game.attribute(p, Attribute::Charisma, None, &reg), 1.0);
}
/// Ticket 07 hat `Registries` **neben** `Game` gelegt, weil ein Feld bei
/// jedem Methodenaufruf mit `&mut self` kollidiert waere. Dass dieser Test
/// kompiliert, *ist* der Beweis — Ticket 12 konnte ihn nicht fuehren, weil
/// der Durchstich keine Registries lud.
#[test]
fn disjunkte_borrows_ueber_methodenaufrufe_hinweg() {
let (mut game, reg) = zwergenkrieger();
game.execute(Command::Move(Direction::North), &reg);
let _ = game.attribute(CreatureRef::Player, Attribute::Damage, None, &reg);
game.execute(Command::Wait, &reg);
}
/// Befund aus Ticket 12: „alle Commands kosten einen Zug" heisst **alle
/// ausgefuehrten**.
#[test]
fn wand_kostet_keinen_zug() {
let (mut game, reg) = zwergenkrieger();
game.player_pos = (1, 1);
assert!(matches!(
game.execute(Command::Move(Direction::North), &reg),
Outcome::Rejected(_)
));
assert_eq!(game.turn, 0, "eine Wand darf keine Zeit verbrauchen");
assert!(matches!(game.execute(Command::Wait, &reg), Outcome::TurnPassed));
assert_eq!(game.turn, 1);
}
}

View File

@@ -1,82 +0,0 @@
use serde::Deserialize;
use serde::de::DeserializeOwned;
pub mod armor;
pub mod weapon;
pub use armor::ItemArmor;
pub use weapon::ItemWeapon;
// === ENUMS ===
#[derive(Debug, Deserialize)]
pub enum ItemType {
Weapon,
OffHand,
Helmet,
Chestplate,
Gloves,
Boots,
Consumable,
Currency,
}
// === STRUCTS ===
#[derive(Deserialize)]
pub struct Item {
pub id: String,
pub name: String,
pub value: u32,
pub item_type: ItemType,
}
/// Rohform einer Item-Datei. Jede TOML nutzt `[[items]]` als Wurzel,
/// dadurch bedient dieser eine Wrapper alle Dateien.
#[derive(Deserialize)]
struct ItemFile<T> {
items: Vec<T>,
}
/// Laufzeit-Registry: einmal beim Start laden, danach als `&ItemCatalog` durchreichen.
// TODO: OffHand, Consumable und Currency haben noch keine Structs/TOMLs — hier ergaenzen.
pub struct ItemCatalog {
pub weapons: Vec<ItemWeapon>,
pub armors: Vec<ItemArmor>,
}
// === FUNCTIONS ===
pub fn load_items() -> ItemCatalog {
ItemCatalog {
weapons: parse(include_str!("items/weapons.toml"), "weapons.toml"),
armors: parse(include_str!("items/armors.toml"), "armors.toml"),
}
}
fn parse<T: DeserializeOwned>(raw: &str, file: &str) -> Vec<T> {
let parsed: ItemFile<T> =
toml::from_str(raw).unwrap_or_else(|e| panic!("{} ist kaputt:\n{}", file, e));
parsed.items
}
/// Rahmen um beliebig viele Zeilen. Wird von jedem `print_values` genutzt.
pub(crate) fn print_box(lines: &[String]) {
let longest: usize = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0);
let border: String = format!("+-{}-+", "-".repeat(longest));
println!("{}", border);
for line in lines {
println!("| {}{} |", line, " ".repeat(longest - line.chars().count()));
}
println!("{}", border);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn item_files_parse() {
let catalog: ItemCatalog = load_items();
assert!(!catalog.weapons.is_empty());
assert!(!catalog.armors.is_empty());
}
}

View File

@@ -1,22 +0,0 @@
use serde::Deserialize;
use super::{Item, print_box};
#[derive(Deserialize)]
pub struct ItemArmor {
pub armor: u32,
#[serde(flatten)]
pub item: Item,
}
impl ItemArmor {
pub fn print_values(&self) {
print_box(&[
format!("Stats for {} ", self.item.name),
format!("ID: {}", self.item.id),
format!("Slot: {:?}", self.item.item_type),
format!("Armor: {}", self.armor),
format!("Value: {}", self.item.value),
]);
}
}

View File

@@ -1,13 +0,0 @@
[[items]]
id = "iron_helmet"
name = "Iron Helmet"
armor = 4
value = 6
item_type = "Helmet"
[[items]]
id = "leather_boots"
name = "Leather Boots"
armor = 2
value = 3
item_type = "Boots"

View File

@@ -1,26 +0,0 @@
use serde::Deserialize;
use super::{Item, print_box};
#[derive(Deserialize)]
pub struct ItemWeapon {
pub base_damage: u32,
pub critical_factor: f32,
#[serde(flatten)]
pub item: Item,
}
impl ItemWeapon {
pub fn print_values(&self) {
let crit_damage: u32 = (self.base_damage as f32 * self.critical_factor).ceil() as u32;
let crit_percent: i32 = ((self.critical_factor - 1.0) * 100.0).round() as i32;
print_box(&[
format!("Stats for {} ", self.item.name),
format!("ID: {}", self.item.id),
format!("Base Damage: {} ({})", self.base_damage, crit_damage),
format!("Critical Factor: {}%", crit_percent),
format!("Value: {}", self.item.value),
]);
}
}

View File

@@ -1,15 +0,0 @@
[[items]]
id = "iron_sword"
name = "Iron Sword"
base_damage = 10
critical_factor = 1.1
value = 5
item_type = "Weapon"
[[items]]
id = "iron_dagger"
name = "Iron Dagger"
base_damage = 8
critical_factor = 1.5
value = 3
item_type = "Weapon"

View File

@@ -1,20 +1,50 @@
mod items;
mod player;
mod stats;
//! rustkeep — Meilenstein 1: vertikaler Durchstich.
//!
//! Ziel: Der Held laeuft in einem handgebauten Testraum herum, oeffnet den
//! Charakterbogen und sieht dort einen Wert, der aus einem Modifikator stammt,
//! der aus einer TOML-Datei kam.
//!
//! Damit stehen die Zusagen auf dem Pruefstand, die bisher nur auf Papier
//! standen: `Registries` neben `Game` (07), zweistufige Deserialisierung (04),
//! `Game::attribute` ueber mehrere Quellen (07/08), `game/` bleibt ratatui-frei
//! (11), Press-Filter unter Windows (06/12).
use items::*;
use player::*;
mod data;
mod game;
mod ui;
use game::{Creature, ItemInstance};
fn main() {
let catalog: ItemCatalog = load_items();
for weapon in &catalog.weapons {
weapon.print_values();
}
for armor in &catalog.armors {
armor.print_values();
// Die `Result`-Kette endet **vor** `main` (Ticket 04): `main() -> Result<…>`
// druckt ueber `Debug` und setzt dem Spieler `LoadError { .. }` vor.
let (registries, warnings) = match data::load_mods() {
Ok(v) => v,
Err(e) => {
eprintln!("{e}");
std::process::exit(1);
}
};
let player: Player = Player::new("Reya".to_string());
player.show_stats();
let log_name = data::write_log(&warnings);
let game = game::Game::new(starting_hero());
if let Err(e) = ui::run(game, registries, warnings.len(), log_name) {
eprintln!("Terminal-Fehler: {e}");
std::process::exit(1);
}
}
/// Vorgefertigter Held, damit der Durchstich etwas zu zeigen hat.
///
// TODO(M2): faellt weg, sobald der `CharacterCreation`-Screen aus Ticket 11
// existiert — dort waehlt der Spieler die Optionen selbst, und
// `on_create` (Ticket 02) legt die Startausruestung an.
fn starting_hero() -> Creature {
let mut hero = Creature::new("Reya");
hero.options = vec![String::from("core:dwarf"), String::from("core:warrior")];
hero.equipment.insert(String::from("core:hand"), ItemInstance::new("core:iron_sword"));
hero.equipment.insert(String::from("core:head"), ItemInstance::new("core:leather_cap"));
hero.inventory.push(ItemInstance::new("core:health_potion"));
hero
}

View File

@@ -1,37 +0,0 @@
use crate::items::print_box;
use crate::stats::Stats;
pub struct Player {
pub name: String,
pub stats: Stats,
pub next_level_exp: usize,
pub current_exp: usize,
}
impl Player {
pub fn new(name: String) -> Self {
Self {
name,
stats: Stats::new(),
next_level_exp: 10,
current_exp: 0,
}
}
pub fn show_stats(&self) {
let stats: &Stats = &self.stats;
print_box(&[
format!("Name: {}", self.name),
format!(
"Level: {} ({}/{})",
stats.level, self.current_exp, self.next_level_exp
),
format!("Strength: {}", stats.strength),
format!("Dexterity: {}", stats.dexterity),
format!("Constitution: {}", stats.constitution),
format!("Charisma: {}", stats.charisma),
format!("Luck: {}", stats.luck),
]);
}
}

View File

@@ -1,68 +0,0 @@
pub struct Stats {
pub max_health: f32,
pub current_health: f32,
pub max_stamina: f32,
pub current_stamina: f32,
pub strength: usize, // Determines base_damage modifier
pub dexterity: usize, // Determines Critical Factor Modifier
pub constitution: usize, // Determines Max Health Bonuses
pub charisma: usize, // Determines Shop Prices
pub luck: usize, // Determines Loot Drop & Critical Chance
pub level: usize,
}
impl Stats {
pub fn new() -> Self {
let health: f32 = 100.0;
let stamina: f32 = 100.0;
Self {
max_health: health,
current_health: health,
max_stamina: stamina,
current_stamina: stamina,
strength: 1,
dexterity: 1,
constitution: 1,
charisma: 1,
luck: 1,
level: 1,
}
}
/// Delta auf die aktuelle Health: positiv heilt, negativ verletzt.
pub fn change_health(&mut self, delta: f32) {
self.current_health = (self.current_health + delta).clamp(0.0, self.max_health);
}
/// Delta auf die aktuelle Stamina: positiv regeneriert, negativ verbraucht.
// TODO: noch ungenutzt — wird vom Kampfsystem aufgerufen, sobald es existiert.
pub fn change_stamina(&mut self, delta: f32) {
self.current_stamina = (self.current_stamina + delta).clamp(0.0, self.max_stamina);
}
pub fn is_dead(&self) -> bool {
self.current_health <= 0.0
}
// TODO: noch ungenutzt — wird vom Exp-/Level-System aufgerufen, sobald es existiert.
pub fn level_up(&mut self) {
self.level = (self.level + 1).clamp(1, 50);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn health_clamps_at_both_ends() {
let mut stats: Stats = Stats::new();
stats.change_health(50.0);
assert_eq!(stats.current_health, stats.max_health);
stats.change_health(-500.0);
assert_eq!(stats.current_health, 0.0);
assert!(stats.is_dead());
}
}

205
src/ui/app.rs Normal file
View File

@@ -0,0 +1,205 @@
//! Die Schleife. Blockierendes `event::read()`, kein Tick (Ticket 06).
use ratatui::Frame;
use ratatui::crossterm::event::{self, Event, KeyEventKind};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Style, Stylize};
use ratatui::text::Line;
use ratatui::widgets::{Block, Paragraph, Row, Table};
use super::grid::GridWidget;
use super::screen::{Response, Screen};
use super::theme;
use crate::data::Registries;
use crate::game::{Attribute, CreatureRef, Game, Outcome};
pub struct App {
/// `game` und `registries` sind **getrennte Felder**, damit die disjunkten
/// Borrows aus Ticket 07 funktionieren.
pub game: Game,
pub registries: Registries,
screens: Vec<Screen>,
warnings: usize,
log_name: Option<String>,
rejected: Option<&'static str>,
}
pub fn run(game: Game, registries: Registries, warnings: usize, log_name: Option<String>) -> std::io::Result<()> {
let mut app = App {
game,
registries,
screens: vec![Screen::MainMenu],
warnings,
log_name,
rejected: None,
};
ratatui::run(|terminal| {
loop {
terminal.draw(|frame| draw(frame, &app))?;
let Event::Key(key) = event::read()? else { continue };
// Pflichtfilter unter Windows: Ticket 12 hat gemessen, dass jeder
// Tastendruck zwei Events erzeugt (Press und Release).
if key.kind != KeyEventKind::Press {
continue;
}
let Some(screen) = app.screens.last_mut() else { break Ok(()) };
match screen.handle_key(key.code) {
Response::None => {}
Response::Pop => {
app.screens.pop();
if app.screens.is_empty() {
break Ok(());
}
}
Response::Push(s) => app.screens.push(s),
Response::Command(cmd) => {
// Hier steht die Zusage aus Ticket 07 auf dem Pruefstand:
// `&mut app.game` neben `&app.registries` in einem Aufruf.
app.rejected = match app.game.execute(cmd, &app.registries) {
Outcome::TurnPassed => None,
Outcome::Rejected(reason) => Some(reason),
};
while app.screens.last().is_some_and(Screen::is_overlay) {
app.screens.pop();
}
}
Response::Quit => break Ok(()),
}
}
})
}
fn draw(frame: &mut Frame, app: &App) {
let area = frame.area();
let first = app.screens.iter().rposition(|s| !s.is_overlay()).unwrap_or(0);
for screen in &app.screens[first..] {
match screen {
Screen::MainMenu => draw_main_menu(frame, area, app),
Screen::Explore => draw_explore(frame, area, app),
Screen::CharacterSheet => draw_sheet(frame, area, app),
}
}
}
fn draw_main_menu(frame: &mut Frame, area: Rect, app: &App) {
let mut text = vec![
Line::from("rustkeep".bold()),
Line::from(""),
Line::from("Enter Dungeon betreten"),
Line::from("q beenden"),
];
// Eine Zeile, kein Screen — der Inhalt gehoert in einen Texteditor
// (Ticket 04).
if app.warnings > 0 {
let note = match &app.log_name {
Some(file) => format!("{} Ladewarnungen — logs/{file}", app.warnings),
None => format!("{} Ladewarnungen (Log nicht schreibbar)", app.warnings),
};
text.push(Line::from(""));
text.push(Line::from(note).style(Style::new().fg(Color::Yellow)));
}
frame.render_widget(
Paragraph::new(text).block(Block::bordered().title(" Hauptmenü ")),
area,
);
}
fn draw_explore(frame: &mut Frame, area: Rect, app: &App) {
let block = Block::bordered().title(format!(" Etage {} ", app.game.floor.number));
let inner = block.inner(area);
frame.render_widget(block, area);
let [grid, bar] = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(inner);
frame.render_widget(
GridWidget { floor: &app.game.floor, player: app.game.player_pos },
grid,
);
let (note, color) = match app.rejected {
Some(reason) => (reason.to_string(), Color::Red),
None => (app.game.last.clone(), Color::Cyan),
};
frame.render_widget(
Paragraph::new(format!(
"Zug {} │ {} │ c Charakterbogen │ {}",
app.game.turn, app.game.player.name, note
))
.right_aligned()
.style(Style::new().fg(color)),
bar,
);
}
/// Der Charakterbogen ist eine **Schleife ueber Registries**, kein gemauertes
/// Layout — Modder koennen Slots und Wahlkategorien ergaenzen (Ticket 02).
fn draw_sheet(frame: &mut Frame, area: Rect, app: &App) {
let block = Block::bordered().title(" Charakterbogen ");
let inner = block.inner(area);
frame.render_widget(block, area);
let [left, right] = Layout::horizontal([Constraint::Percentage(50); 2]).areas(inner);
// Attribute: jeder Wert kommt aus Game::attribute, laeuft also durch alle
// Quellen — das ist der Punkt des ganzen Durchstichs.
let rows: Vec<Row> = Attribute::ALL
.iter()
.map(|a| {
let base = app.game.player.base.get(*a);
let total =
app.game
.attribute(CreatureRef::Player, *a, None, &app.registries);
let cell = if (total - base).abs() < f32::EPSILON {
format!("{total:.0}")
} else {
format!("{total:.0} ({base:.0} Basis)")
};
Row::new(vec![a.name().to_string(), cell])
})
.collect();
frame.render_widget(
Table::new(rows, [Constraint::Length(20), Constraint::Min(0)])
.block(Block::bordered().title(" Attribute ")),
left,
);
let mut lines = Vec::new();
lines.push(Line::from("Herkunft".bold()));
for id in &app.game.player.options {
let name = app.registries.options.get(id).map(|o| o.name.as_str()).unwrap_or(id);
lines.push(Line::from(format!(" {name}")));
}
lines.push(Line::from(""));
lines.push(Line::from("Ausrüstung".bold()));
for slot in app.registries.slots_ordered() {
let worn = app.game.player.equipment.get(&slot.id).and_then(|inst| {
app.registries.items.get(&inst.def).map(|i| (i.name.as_str(), i.color.as_deref()))
});
match worn {
Some((name, color)) => lines.push(Line::from(vec![
format!(" {:<10} ", slot.name).into(),
name.to_string().fg(theme::color(color)),
])),
None => lines.push(
Line::from(format!(" {:<10} —", slot.name)).style(Style::new().fg(Color::DarkGray)),
),
}
}
lines.push(Line::from(""));
lines.push(Line::from("Esc zurück").style(Style::new().fg(Color::DarkGray)));
frame.render_widget(
Paragraph::new(lines).block(Block::bordered().title(" Herkunft & Ausrüstung ")),
right,
);
}

44
src/ui/grid.rs Normal file
View File

@@ -0,0 +1,44 @@
//! Dungeon-Grid als eigenes Widget. Ticket 06 hat festgestellt, dass es weder
//! mitgeliefert noch als Drittanbieter-Crate existiert — also Eigenbau mit
//! direktem Buffer-Zugriff. Der Durchstich in Ticket 12 hat das bestaetigt.
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::Widget;
use crate::game::{Floor, Tile};
pub struct GridWidget<'a> {
pub floor: &'a Floor,
pub player: (u16, u16),
}
impl Widget for GridWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
for y in 0..self.floor.height {
for x in 0..self.floor.width {
let (px, py) = (area.x + x, area.y + y);
if px >= area.right() || py >= area.bottom() {
continue;
}
let (symbol, color) = match self.floor.tile(x, y) {
Tile::Wall => ("█", Color::DarkGray),
Tile::Floor => ("·", Color::Rgb(60, 60, 70)),
Tile::Door => ("+", Color::Yellow),
Tile::Stairs => (">", Color::Green),
Tile::Entrance => ("<", Color::Blue),
};
buf[(px, py)].set_symbol(symbol).set_style(Style::new().fg(color));
}
}
let (sx, sy) = (area.x + self.player.0, area.y + self.player.1);
if sx < area.right() && sy < area.bottom() {
buf[(sx, sy)]
.set_symbol("@")
.set_style(Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD));
}
}
}

8
src/ui/mod.rs Normal file
View File

@@ -0,0 +1,8 @@
//! Alles, was ratatui kennen darf.
mod app;
mod grid;
mod screen;
mod theme;
pub use app::run;

73
src/ui/screen.rs Normal file
View File

@@ -0,0 +1,73 @@
//! Screen-Stack und Antwort — Ticket 11, im Durchstich (12) bestaetigt.
use ratatui::crossterm::event::KeyCode;
use crate::game::{Command, Direction};
/// Popups sind gewoehnliche Screens, die sich beim Rendern als Overlay
/// ausweisen — ein gemeinsamer `Vec<Screen>` statt `current` plus
/// `Option<Popup>`, weil Popup ueber Popup real ist.
pub enum Screen {
MainMenu,
Explore,
CharacterSheet,
// TODO(M2+): `CharacterCreation`, `Combat`, `Inventory`, `QuestLog`,
// `Shop`, `Dialog`, `Confirm`, `GameOver` — elf sind es am Ende
// (Ticket 11/14).
}
/// Der Screen **gibt zurueck, statt selbst zu schieben**: sonst haelt er ein
/// `&mut` auf genau die `Vec`, in der er liegt. Nebeneffekt — `Response::Command`
/// ist der einzige Weg nach `game/`.
pub enum Response {
None,
Pop,
Push(Screen),
Command(Command),
Quit,
}
impl Screen {
pub fn is_overlay(&self) -> bool {
// TODO(M2): `Confirm` ist das erste echte Overlay. Bis dahin haelt die
// Regel „ein Command schliesst alle Overlays" nur die Luft.
false
}
pub fn handle_key(&mut self, key: KeyCode) -> Response {
match self {
Screen::MainMenu => match key {
KeyCode::Enter => Response::Push(Screen::Explore),
KeyCode::Char('q') | KeyCode::Esc => Response::Quit,
_ => Response::None,
},
Screen::Explore => match key {
// Pfeiltasten *und* hjkl — drei zusaetzliche Arme sind die
// billige Hoeflichkeit gegenueber Vim-Fingern (Ticket 11).
KeyCode::Up | KeyCode::Char('k') => {
Response::Command(Command::Move(Direction::North))
}
KeyCode::Down | KeyCode::Char('j') => {
Response::Command(Command::Move(Direction::South))
}
KeyCode::Left | KeyCode::Char('h') => {
Response::Command(Command::Move(Direction::West))
}
KeyCode::Right | KeyCode::Char('l') => {
Response::Command(Command::Move(Direction::East))
}
KeyCode::Char('.') => Response::Command(Command::Wait),
// Screen-Navigation ist reine UI und fasst `Game` nie an.
KeyCode::Char('c') => Response::Push(Screen::CharacterSheet),
KeyCode::Esc => Response::Pop,
_ => Response::None,
},
Screen::CharacterSheet => match key {
KeyCode::Esc | KeyCode::Char('c') | KeyCode::Char('q') => Response::Pop,
_ => Response::None,
},
}
}
}

26
src/ui/theme.rs Normal file
View File

@@ -0,0 +1,26 @@
//! Die einzige Stelle, an der aus einem Farb*namen* eine ratatui-Farbe wird.
//!
//! Ticket 11 hat `symbol` als Schluessel verworfen, weil `ui/theme.rs` **Code**
//! ist: Ein Modder, der `meinmod:frostbite` anlegt, koennte dort nichts
//! eintragen. Deshalb tragen die TOML-Daten Zeichen und Farbnamen selbst, und
//! hier steht nur die Uebersetzung.
use ratatui::style::Color;
pub fn color(name: Option<&str>) -> Color {
match name.unwrap_or_default() {
"black" => Color::Black,
"red" => Color::Red,
"green" => Color::Green,
"yellow" => Color::Yellow,
"blue" => Color::Blue,
"magenta" => Color::Magenta,
"cyan" => Color::Cyan,
"white" => Color::White,
"gray" | "grey" => Color::Gray,
"dark_gray" | "dark_grey" => Color::DarkGray,
// Unbekannter Name ist kein Fehler, sondern Standardfarbe — ein Tippfehler
// im Mod soll das Spiel nicht anhalten (Ticket 04).
_ => Color::Reset,
}
}

51
tests/boundaries.rs Normal file
View File

@@ -0,0 +1,51 @@
//! Ticket 11: `game/` kennt ratatui nicht — und zwar mechanisch geprueft.
//!
//! Ein Cargo-Workspace mit `game` als eigener Crate waere echte
//! Compiler-Durchsetzung und ist die Decke, falls es je ernst wird. Bis dahin
//! faengt dieser Test denselben Fehler und kostet keine Umstrukturierung.
use std::fs;
use std::path::Path;
fn rust_files(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
for entry in fs::read_dir(dir).expect("Verzeichnis lesbar").flatten() {
let path = entry.path();
if path.is_dir() {
rust_files(&path, out);
} else if path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
}
#[test]
fn game_bleibt_ratatui_frei() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
for dir in ["src/game", "src/data"] {
let mut files = Vec::new();
rust_files(&root.join(dir), &mut files);
assert!(!files.is_empty(), "{dir} sollte Dateien enthalten");
for file in files {
let source = fs::read_to_string(&file).expect("Datei lesbar");
// Kommentare ausnehmen. Ticket 11 hat „liest die Dateien und prueft
// auf ratatui" gesagt — die woertliche Fassung schlaegt an, sobald
// ein Doc-Kommentar erklaert, dass die Grenze existiert.
let offender = source
.lines()
.enumerate()
.find(|(_, l)| !l.trim_start().starts_with("//") && l.contains("ratatui"));
if let Some((n, line)) = offender {
panic!(
"{}:{}: „{}\" — die Grenze aus Ticket 11 ist gefallen",
file.display(),
n + 1,
line.trim()
);
}
}
}
}