//! 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). #[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, /// **Typ-indiziert**: Ein Modifikator darauf traegt zusaetzlich einen /// `damage_type`. Die Basis in `Attributes` bleibt ungenutzt — Resistenz /// kommt ausschliesslich aus Modifikatoren (Ticket 01). Resistance, } impl Attribute { pub const COUNT: usize = 15; 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, Attribute::Resistance, ]; 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", Attribute::Resistance => "Resistenz", } } } /// 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, /// Nur bei `attribute = "resistance"`: Resistenz ist typ-indiziert /// (Ticket 01), alle uebrigen Attribute sind Skalare. #[serde(default)] pub damage_type: Option, #[serde(default)] pub flat: f32, #[serde(default)] pub percent: f32, // TODO(M5): `target` fuer Auren (`all_enemies`) und `condition` fuer // bedingte Modifikatoren („+5 gegen Untote", Ticket 08). Beide // brauchen `Game::attribute`s `against`-Parameter, der bereits // in der Signatur steht. } /// 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, 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 m = |attribute, flat, percent| Modifier { attribute, damage_type: None, flat, percent, }; let mods = [ m(Attribute::Strength, 2.0, 0.0), m(Attribute::Strength, 3.0, 10.0), // Fremdes Attribut darf nicht mitzaehlen: m(Attribute::Damage, 100.0, 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); } }