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

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