diff --git a/mods/core/abilities.toml b/mods/core/abilities.toml new file mode 100644 index 0000000..181f4e5 --- /dev/null +++ b/mods/core/abilities.toml @@ -0,0 +1,58 @@ +# Eine Faehigkeit ist ein benanntes, wiederholt ausloesbares ANGEBOT an den +# Spieler, das Aktionen ausfuehrt (Ticket 02) — kein Gegenstand im Inventar, +# sonst koennte ein Magier seine Zauber verkaufen. +# +# Angriff und Flucht sind bewusst keine Engine-Sonderfaelle, sondern gewoehnliche +# Faehigkeiten (Ticket 11/14). Ein Mod kann beide ersetzen. +# +# Menge = amount + scale * damage-Attribut(Verursacher) — eine Formel fuer +# Basisangriff, Feuerball und Wuchtschlag (Nachtrag aus Ticket 14). + +[[abilities]] +id = "attack" +name = "Angriff" +cost = 0 +actions = [ { action = "damage", scale = 1.0, damage_type = "slash", target = "target" } ] + +[[abilities]] +id = "flee" +name = "Fliehen" +cost = 10 +actions = [ { action = "flee" } ] + +[[abilities]] +id = "power_strike" +name = "Wuchtschlag" +description = "Holt weit aus. Kostet Luft." +cost = 15 +cooldown = 3 +actions = [ { action = "damage", scale = 1.5, damage_type = "blunt", target = "target" } ] + +[[abilities]] +id = "bone_throw" +name = "Knochenwurf" +cost = 8 +cooldown = 2 +actions = [ { action = "damage", amount = 6, damage_type = "blunt", target = "target" } ] + +[[abilities]] +id = "rat_bite" +name = "Biss" +cost = 0 +actions = [ { action = "damage", scale = 1.0, damage_type = "slash", target = "target" }, + { action = "apply_modifier", modifier = "core:poisoned", target = "target" } ] + +[[abilities]] +id = "ghoul_claw" +name = "Klauenhieb" +cost = 0 +actions = [ { action = "damage", scale = 1.2, damage_type = "slash", target = "target" } ] + +[[abilities]] +id = "second_wind" +name = "Zweiter Atem" +description = "Sammelt sich, wenn es eng wird." +cost = 0 +cooldown = 8 +condition = [ { check = "health_below", who = "self", value = 0.4 } ] +actions = [ { action = "change_attribute", attribute = "health", amount = 25, target = "self" } ] diff --git a/mods/core/balance.toml b/mods/core/balance.toml new file mode 100644 index 0000000..b2236c2 --- /dev/null +++ b/mods/core/balance.toml @@ -0,0 +1,20 @@ +# Die Stellknoepfe aus Ticket 14 und 16. +# +# `[balance]` ist KEINE Registry: ein einzelner Block mit festem Schema, der +# FELDWEISE verschmilzt statt als ganzer Eintrag ersetzt zu werden — die +# ausdrueckliche Ausnahme zur Ganz-oder-gar-nicht-Regel aus Ticket 03. +# +# Die Begruendung bleibt eng: Das Schema ist der Engine bekannt. Genau das +# fehlte bei Inhalt mit offenen Feldern, und das war Ticket 03s Argument gegen +# feldweises Patchen. +# +# Ein Mod, der einen Knopf dreht, schreibt EINE Zeile. Standardwerte liegen in +# der Engine — was hier fehlt, gilt trotzdem. + +[balance] +ausweich = 0.02 # wie stark dexterity des Ziels die Trefferchance senkt +flucht = 0.05 # wie stark der Geschicklichkeitsunterschied die Flucht traegt +stamina_regen = 4.0 # pro Zug — gibt `warten` seinen Zweck +resistance_cap = 0.8 # 1.0 waere Immunitaet, und das ist ein Spielabbruch +hit_floor = 0.05 +hit_ceiling = 0.95 diff --git a/mods/core/creatures.toml b/mods/core/creatures.toml new file mode 100644 index 0000000..76b9df2 --- /dev/null +++ b/mods/core/creatures.toml @@ -0,0 +1,63 @@ +# Gegner. Die Luecke lag seit Ticket 02 offen — `Creature` war der Laufzeittyp, +# woher seine Werte kommen, hat nie jemand gesagt (Ticket 14). +# +# `ai` ist eine GEORDNETE Liste: der erste passende UND verfuegbare Eintrag +# gewinnt. Ein Eintrag ohne `condition` greift immer und gehoert ans Ende — +# fehlt er, setzt der Gegner gelegentlich aus. + +[[creatures]] +id = "rat" +name = "Riesenratte" +symbol = "r" +color = "yellow" +tags = ["core:beast"] +base = { max_health = 14, damage = 3, hit_chance = 0.65, dexterity = 4, critical_chance = 5 } +abilities = ["core:rat_bite"] +ai = [ { ability = "core:rat_bite" } ] +exp_reward = 4 + +[[creatures]] +id = "skeleton" +name = "Skelett" +symbol = "s" +color = "white" +tags = ["core:undead"] +base = { max_health = 26, damage = 6, hit_chance = 0.7, dexterity = 3, critical_chance = 5 } +abilities = ["core:attack", "core:bone_throw"] +ai = [ + { condition = [ { check = "health_below", who = "self", value = 0.35 } ], + ability = "core:bone_throw" }, + { ability = "core:attack" }, +] +exp_reward = 9 + +[[creatures]] +id = "ghoul" +name = "Ghul" +symbol = "G" +color = "magenta" +tags = ["core:undead"] +base = { max_health = 40, damage = 9, hit_chance = 0.72, dexterity = 5, critical_chance = 10 } +abilities = ["core:ghoul_claw", "core:second_wind"] +ai = [ + { condition = [ { check = "health_below", who = "self", value = 0.4 } ], + ability = "core:second_wind" }, + { ability = "core:ghoul_claw" }, +] +exp_reward = 18 + +[[creatures]] +id = "bone_lord" +name = "Knochenfürst" +symbol = "K" +color = "red" +tags = ["core:undead", "core:boss"] +base = { max_health = 90, damage = 14, hit_chance = 0.78, dexterity = 6, critical_chance = 12, critical_factor = 2.0 } +abilities = ["core:attack", "core:power_strike", "core:second_wind"] +ai = [ + { condition = [ { check = "health_below", who = "self", value = 0.3 } ], + ability = "core:second_wind" }, + { ability = "core:power_strike" }, + { ability = "core:attack" }, +] +exp_reward = 60 diff --git a/mods/core/floors.toml b/mods/core/floors.toml index 026b9d0..9aaae88 100644 --- a/mods/core/floors.toml +++ b/mods/core/floors.toml @@ -15,6 +15,7 @@ size = [72, 34] rooms = 7 required = ["core:entrance", "core:stairs", "core:merchant"] prefabs = [ { tag = "core:normal", weight = 10 } ] +creatures = [ { id = "core:rat", weight = 10 } ] [[floors]] id = "floor_2" @@ -24,6 +25,7 @@ rooms = 8 required = ["core:entrance", "core:stairs", "core:merchant"] prefabs = [ { tag = "core:normal", weight = 10 }, { tag = "core:treasure", weight = 2 } ] +creatures = [ { id = "core:rat", weight = 6 }, { id = "core:skeleton", weight = 6 } ] [[floors]] id = "floor_3" @@ -33,6 +35,7 @@ rooms = 9 required = ["core:entrance", "core:stairs", "core:merchant"] prefabs = [ { tag = "core:normal", weight = 10 }, { tag = "core:treasure", weight = 3 } ] +creatures = [ { id = "core:rat", weight = 3 }, { id = "core:skeleton", weight = 8 }, { id = "core:ghoul", weight = 3 } ] [[floors]] id = "floor_4" @@ -42,6 +45,7 @@ rooms = 10 required = ["core:entrance", "core:stairs", "core:merchant"] prefabs = [ { tag = "core:normal", weight = 10 }, { tag = "core:treasure", weight = 3 } ] +creatures = [ { id = "core:skeleton", weight = 6 }, { id = "core:ghoul", weight = 8 } ] [[floors]] id = "floor_5" @@ -51,3 +55,4 @@ rooms = 11 required = ["core:entrance", "core:stairs", "core:merchant"] prefabs = [ { tag = "core:normal", weight = 10 }, { tag = "core:treasure", weight = 4 } ] +creatures = [ { id = "core:ghoul", weight = 8 }, { id = "core:bone_lord", weight = 2 } ] diff --git a/mods/core/modifiers.toml b/mods/core/modifiers.toml new file mode 100644 index 0000000..d4a1ca8 --- /dev/null +++ b/mods/core/modifiers.toml @@ -0,0 +1,43 @@ +# Benannte, befristete Modifikatoren — die dreizehnte Registry. +# +# Ticket 01 laesst `apply_modifier` einen Modifikator per ID referenzieren +# (`core:poisoned`), aber keine der zwoelf Registries definierte sie. Aufgefallen +# beim Bauen von M3. +# +# Der Schadenstyp `poison` ist NICHT der Modifikator `core:poisoned`: Der Typ +# sagt, wie gemindert wird; der Modifikator ist ein Zustand, der ueber Zuege +# tickt (Ticket 01). +# +# Eine Uhr: der Zug. Debuffs ticken auch beim Erkunden, damit "ich warte den +# Debuff draussen ab" eine Entscheidung mit Kosten bleibt. + +[[modifiers]] +id = "poisoned" +name = "Vergiftet" +symbol = "☠" +color = "green" +turns = 4 +attributes = [ { attribute = "damage", percent = -15 } ] +triggers = [ + { on = "turn_started", + actions = [ { action = "damage", amount = 2, damage_type = "poison", target = "self" } ] }, +] + +[[modifiers]] +id = "blessed" +name = "Gesegnet" +symbol = "+" +color = "yellow" +turns = 6 +attributes = [ { attribute = "damage", percent = 20 }, + { attribute = "critical_chance", flat = 10 } ] + +[[modifiers]] +id = "warded" +name = "Abgeschirmt" +symbol = "o" +color = "cyan" +turns = 5 +# Resistenz ist typ-indiziert und der EINZIGE Minderungsmechanismus (Ticket 01). +attributes = [ { attribute = "resistance", damage_type = "fire", flat = 40 }, + { attribute = "resistance", damage_type = "ice", flat = 40 } ] diff --git a/src/data/loader.rs b/src/data/loader.rs index ec7f361..cba2f0f 100644 --- a/src/data/loader.rs +++ b/src/data/loader.rs @@ -14,7 +14,10 @@ use serde::Deserialize; use serde::de::DeserializeOwned; use toml::Spanned; -use super::registries::{Category, CharacterOption, FloorDef, Item, Prefab, Registries, Slot}; +use super::registries::{ + Ability, Category, CharacterOption, CreatureDef, FloorDef, Item, ModifierDef, Prefab, + Registries, Slot, +}; /// Genau drei harte Fehler (Ticket 04). Alles Uebrige ist Warnung plus /// Verwerfen des **einzelnen Eintrags**. @@ -248,6 +251,16 @@ struct ModFile { prefabs: Vec>, #[serde(default)] floors: Vec>, + #[serde(default)] + abilities: Vec>, + #[serde(default)] + creatures: Vec>, + #[serde(default)] + modifiers: Vec>, + /// **Keine** Tabelle mit `[[...]]`, sondern ein einzelner Block, der + /// feldweise verschmilzt — die ausdrueckliche Ausnahme zu Ticket 03. + #[serde(default)] + balance: Option, } /// Damit die vier Registry-Typen durch dieselbe Einlese-Schleife passen. @@ -278,6 +291,9 @@ impl_entry!(Category); impl_entry!(CharacterOption); impl_entry!(Prefab); impl_entry!(FloorDef); +impl_entry!(Ability); +impl_entry!(CreatureDef); +impl_entry!(ModifierDef); struct FileCtx<'a> { module: &'a str, @@ -451,6 +467,7 @@ pub fn load_mods() -> Result<(Registries, Vec), LoadError> { // Phase 2 — Einlesen in Ladereihenfolge let mut reg = Registries::default(); + let mut balance_raw = toml::value::Table::new(); for id in &order { let Some(info) = found.iter().find(|m| &m.id == id) else { continue }; @@ -482,15 +499,44 @@ pub fn load_mods() -> Result<(Registries, Vec), LoadError> { 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); + parse_entries(parsed.abilities, &ctx, &mut reg.abilities, &mut warnings); + parse_entries(parsed.creatures, &ctx, &mut reg.creatures, &mut warnings); + parse_entries(parsed.modifiers, &ctx, &mut reg.modifiers, &mut warnings); + + // `[balance]` verschmilzt **feldweise** (Ticket 14): Ein Mod, der + // einen Knopf dreht, schreibt eine Zeile — und ein neuer Knopf in + // der naechsten Version bricht ihn nicht. + if let Some(table) = parsed.balance { + merge_balance(table, &mut balance_raw); + } } } + // `[balance]` erst am Ende deserialisieren: Fehlende Felder fallen dann auf + // die Engine-Standards zurueck, nicht auf die des zuletzt geladenen Mods. + match toml::Value::Table(balance_raw).try_into() { + Ok(b) => reg.balance = b, + Err(e) => warnings.push(format!("[balance]: {} — Standardwerte gelten", e.message())), + } + // Phase 3 — Referenzen aufloesen resolve(&mut reg, &mut warnings); Ok((reg, warnings)) } +/// Feldweises Verschmelzen. Die Ausnahme bleibt eng, weil ihre Begruendung eng +/// ist: `[balance]` hat ein **festes, der Engine bekanntes Schema** — genau das +/// fehlte bei Inhalt mit offenen Feldern, und das war Ticket 03s Argument gegen +/// feldweises Patchen. +fn merge_balance(from: toml::Value, into: &mut toml::value::Table) { + if let toml::Value::Table(t) = from { + for (k, v) in t { + into.insert(k, v); + } + } +} + fn toml_files(dir: &Path) -> Vec { let mut files: Vec = fs::read_dir(dir) .into_iter() diff --git a/src/data/mod.rs b/src/data/mod.rs index 4fc38d6..dea4325 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -5,4 +5,4 @@ pub mod loader; pub mod registries; pub use loader::{load_mods, write_log}; -pub use registries::{FloorDef, Prefab, Registries}; +pub use registries::{Balance, CreatureDef, FloorDef, Prefab, Registries}; diff --git a/src/data/registries.rs b/src/data/registries.rs index aae49e1..fbabfcd 100644 --- a/src/data/registries.rs +++ b/src/data/registries.rs @@ -14,6 +14,9 @@ use std::collections::HashMap; use serde::Deserialize; +use crate::game::action::Action; +use crate::game::condition::Condition; +use crate::game::event::Trigger; use crate::game::Modifier; /// Ein Ding im Spiel, **einheitlich strukturiert**. Es gibt keinen Item-Typ: @@ -92,6 +95,132 @@ pub struct CharacterOption { // Startausruestung brauchen Kampf bzw. Inventar. } +/// Ein benannter, befristeter Modifikator — „Vergiftet", „Gesegnet". +/// +/// **Befund aus M3:** Ticket 01 laesst `apply_modifier` einen Modifikator per ID +/// referenzieren (`core:poisoned`), aber keine der zwoelf Registries definiert +/// benannte Modifikatoren. Das hier ist die **dreizehnte**. +#[derive(Debug, Clone, Deserialize)] +pub struct ModifierDef { + pub id: String, + pub name: String, + #[serde(default = "default_symbol")] + pub symbol: char, + #[serde(default)] + pub color: Option, + /// Zuege bis zum Verfall. Eine Uhr: der Zug (Ticket 01) — Debuffs ticken + /// auch beim Erkunden, damit „ich warte den Debuff draussen ab" eine + /// Entscheidung mit Kosten bleibt. + #[serde(default = "three")] + pub turns: u32, + /// Die Umrechnungen, die er traegt. Bewusst `attributes` und nicht + /// `modifiers`, damit ein Modifikator nicht Modifikatoren enthaelt. + #[serde(default)] + pub attributes: Vec, + #[serde(default)] + pub triggers: Vec, + #[serde(default)] + pub overrides: Option, +} + +fn three() -> u32 { + 3 +} + +/// Ein benanntes, wiederholt ausloesbares **Angebot** an den Spieler +/// (Ticket 02) mit seiner Kampfsemantik aus Ticket 14. +#[derive(Debug, Clone, Deserialize)] +pub struct Ability { + pub id: String, + pub name: String, + #[serde(default)] + pub description: String, + /// Ausdauer — kein zweiter Ressourcenpool (Ticket 14). + #[serde(default)] + pub cost: f32, + /// Abklingzeit in Zuegen. + #[serde(default)] + pub cooldown: u32, + #[serde(default)] + pub condition: Vec, + #[serde(default)] + pub actions: Vec, + #[serde(default)] + pub overrides: Option, +} + +/// Ein Eintrag der `ai`-Liste: erster passender **und verfuegbarer** gewinnt +/// (Ticket 14). Ein Eintrag ohne `condition` greift immer. +#[derive(Debug, Clone, Deserialize)] +pub struct AiEntry { + #[serde(default)] + pub condition: Vec, + pub ability: String, +} + +/// Gegnerdefinition. Die Luecke lag seit Ticket 02 offen — `Creature` war der +/// Laufzeittyp, woher seine Werte kommen, hat nie jemand gesagt (Ticket 14). +#[derive(Debug, Clone, Deserialize)] +pub struct CreatureDef { + pub id: String, + pub name: String, + #[serde(default = "default_symbol")] + pub symbol: char, + #[serde(default)] + pub color: Option, + #[serde(default)] + pub tags: Vec, + /// Grundwerte. Was fehlt, bleibt beim Standard. + #[serde(default)] + pub base: HashMap, + #[serde(default)] + pub abilities: Vec, + #[serde(default)] + pub ai: Vec, + #[serde(default)] + pub triggers: Vec, + /// Kurzform fuer einen Todes-Trigger, den sonst jede Gegnerdefinition + /// mitschleppen muesste (Ticket 15). + #[serde(default)] + pub exp_reward: u32, + #[serde(default)] + pub overrides: Option, +} + +/// Die Stellknoepfe aus Ticket 14 und 16. **Keine Registry**: ein einzelner +/// Block mit festem Schema, der **feldweise** verschmilzt statt als ganzer +/// Eintrag ersetzt zu werden — sonst muesste ein Mod, der nur `ausweich` dreht, +/// alle Werte abschreiben, und ein neuer Knopf braeche ihn stillschweigend. +/// +/// Die Standardwerte liegen hier in der Engine, nicht in `core`. +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct Balance { + pub ausweich: f32, + pub flucht: f32, + pub stamina_regen: f32, + /// Deckel, damit ein Wesen nicht immun wird — Immunitaet ist keine Zahl, + /// sondern ein Spielabbruch (Ticket 14). + pub resistance_cap: f32, + pub hit_floor: f32, + pub hit_ceiling: f32, + // TODO(M4/M5): `exp_base`, `exp_exponent` (Ticket 15) und `luck_factor` + // (Ticket 16) — sie kommen mit ihren Lesern, nicht davor. +} + +impl Default for Balance { + fn default() -> Self { + Self { + ausweich: 0.02, + flucht: 0.05, + stamina_regen: 4.0, + resistance_cap: 0.8, + hit_floor: 0.05, + hit_ceiling: 0.95, + } + } +} + /// Ein von Hand gezeichneter Raum als ASCII-Block (Ticket 10). #[derive(Debug, Clone, Deserialize)] pub struct Prefab { @@ -142,12 +271,22 @@ pub struct FloorDef { pub required: Vec, #[serde(default)] pub prefabs: Vec, + /// Welche Gegner die `e`-Plaetze fuellen. Gewichtet, per **ID** — anders + /// als bei Prefabs, weil eine Etage ihre Gegner benennen koennen soll. + #[serde(default)] + pub creatures: Vec, #[serde(default)] pub overrides: Option, - // TODO(M3): `creatures` — welche Gegner die `e`-Plaetze fuellen. // TODO(M4): `loot` — die Beutetabelle fuer die `l`-Plaetze (Ticket 16). } +#[derive(Debug, Clone, Deserialize)] +pub struct WeightedId { + pub id: String, + #[serde(default = "one")] + pub weight: u32, +} + /// Alle geladenen Definitionen. Liegt **neben** `Game`, nicht darin — als Feld /// kollidierte `&mut game.player` mit `&game.registries` bei jedem /// Methodenaufruf (Ticket 07). @@ -159,8 +298,12 @@ pub struct Registries { pub options: HashMap, pub prefabs: HashMap, pub floors: HashMap, - // TODO(M3+): `affixes`, `abilities`, `quests`, `creatures`, `loot_tables`, - // `scripts` — zwoelf sind es am Ende. + pub abilities: HashMap, + pub creatures: HashMap, + pub modifiers: HashMap, + /// Keine Registry — ein Block mit festem Schema, feldweise verschmolzen. + pub balance: Balance, + // TODO(M4+): `affixes`, `quests`, `loot_tables`, `scripts`. } impl Registries { diff --git a/src/game/action.rs b/src/game/action.rs new file mode 100644 index 0000000..5faef3b --- /dev/null +++ b/src/game/action.rs @@ -0,0 +1,190 @@ +//! Aktionen und Schadenstypen — Ticket 01. +//! +//! Eine **Aktion** ist eine einmalige Zustandsaenderung. Sie feuert, wirkt, ist +//! vorbei. Getrennte Maschine vom Modifikator, der nie ausgefuehrt, sondern beim +//! Auslesen aufsummiert wird. + +use serde::Deserialize; + +use super::attributes::Attribute; + +/// Sieben feste Kennzeichnungen, wie Schaden gemindert wird. Engine-Vokabular +/// ohne Namensraum. Zu jedem Typ gehoert eine Resistenz, und Resistenz ist der +/// *einzige* Minderungsmechanismus (Ticket 01). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +#[repr(usize)] +pub enum DamageType { + Fire, + Ice, + Poison, + Shock, + Holy, + Slash, + Blunt, +} + +impl DamageType { + pub const ALL: [DamageType; 7] = [ + DamageType::Fire, + DamageType::Ice, + DamageType::Poison, + DamageType::Shock, + DamageType::Holy, + DamageType::Slash, + DamageType::Blunt, + ]; + + pub fn name(self) -> &'static str { + match self { + DamageType::Fire => "Feuer", + DamageType::Ice => "Eis", + DamageType::Poison => "Gift", + DamageType::Shock => "Blitz", + DamageType::Holy => "Heilig", + DamageType::Slash => "Schnitt", + DamageType::Blunt => "Stumpf", + } + } +} + +/// Wen eine Aktion trifft. Aufgeloest wird gegen die Adressen des laufenden +/// Zuges (Ticket 01). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Target { + #[default] + #[serde(rename = "self")] + Zelf, + /// Heisst in der TOML `target`; der Rust-Name weicht ab, weil + /// `Target::Target` sich liest wie ein Tippfehler. + #[serde(rename = "target")] + Other, + AllEnemies, +} + +/// Der Aktionskatalog. Zehn Eintraege sind entschieden (01, 08, 13, 14, 15); +/// M3 braucht fuenf davon. +/// +// TODO(M4+): `give_item`, `remove_item` (Inventar), `start_quest` (08), +// `give_exp` (15), `run_script` (13). +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "action", rename_all = "snake_case")] +pub enum Action { + /// Eigenstaendig statt `change_attribute health -12`, weil Schaden durch + /// eine Kette laeuft: Resistenz mindert, Krit vervielfacht, „bei erlittenem + /// Schaden" ist ein Event (Ticket 01). + /// + /// Menge = `amount + scale * damage-Attribut(Verursacher)` — eine Formel + /// fuer Basisangriff (`scale = 1.0`), Feuerball (`amount = 20`) und + /// Wuchtschlag (`scale = 1.5`), Nachtrag aus Ticket 14. + Damage { + #[serde(default)] + amount: f32, + #[serde(default)] + scale: f32, + damage_type: DamageType, + #[serde(default)] + target: Target, + }, + /// Aendert **Zustand**, nicht Attribute: `health` und `stamina`. Ein + /// Modifikator wirkt auf `max_health`, eine Aktion auf den Lebensstand — + /// die Trennung muss scharf bleiben, sonst heilt ein Ring beim Anlegen. + ChangeAttribute { + attribute: StateField, + amount: f32, + #[serde(default)] + target: Target, + }, + ApplyModifier { + modifier: String, + #[serde(default)] + target: Target, + }, + RemoveModifier { + modifier: String, + #[serde(default)] + target: Target, + }, + /// Ticket 14: achte Aktion, weil Ticket 11 Flucht zu einer Faehigkeit + /// gemacht hat und die etwas braucht, das sie ausfuehrt. + Flee, +} + +/// Die beiden Zustandsgroessen, die eine Aktion aendern darf. Bewusst **kein** +/// [`Attribute`]: Attribute werden gerechnet, Zustand wird gesetzt. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StateField { + Health, + Stamina, +} + +impl Action { + pub fn target(&self) -> Target { + match self { + Action::Damage { target, .. } + | Action::ChangeAttribute { target, .. } + | Action::ApplyModifier { target, .. } + | Action::RemoveModifier { target, .. } => *target, + Action::Flee => Target::Zelf, + } + } +} + +/// Ein befristeter Modifikator, der auf einem Wesen liegt. Er **wohnt beim +/// Traeger** (Ticket 01) — stirbt der Traeger, verschwindet die Aura von selbst. +#[derive(Debug, Clone)] +pub struct ActiveModifier { + /// ID des Registry-Eintrags, damit `remove_modifier` ihn findet. + pub id: String, + pub attribute: Attribute, + pub damage_type: Option, + pub flat: f32, + pub percent: f32, + pub turns_left: u32, + pub name: String, + pub symbol: char, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn aktionen_lesen_sich_aus_toml() { + // Genau die Schreibweise aus Ticket 01. + let toml = r#" +actions = [ + { action = "damage", amount = 12, damage_type = "fire", target = "target" }, + { action = "change_attribute", attribute = "health", amount = 20, target = "self" }, + { action = "apply_modifier", modifier = "core:poisoned", target = "target" }, + { action = "flee" }, +] +"#; + #[derive(Deserialize)] + struct Wrap { + actions: Vec, + } + let w: Wrap = toml::from_str(toml).expect("Vokabular aus Ticket 01 muss lesbar sein"); + assert_eq!(w.actions.len(), 4); + + assert!(matches!( + w.actions[0], + Action::Damage { amount: 12.0, damage_type: DamageType::Fire, target: Target::Other, .. } + )); + // `target` fehlt beim vierten Eintrag und faellt auf `self` zurueck. + assert_eq!(w.actions[3].target(), Target::Zelf); + } + + #[test] + fn skalierender_schaden_ohne_amount() { + let a: Action = toml::from_str( + r#"action = "damage" +scale = 1.5 +damage_type = "blunt""#, + ) + .expect("scale allein muss reichen"); + assert!(matches!(a, Action::Damage { amount: 0.0, scale: 1.5, .. })); + } +} diff --git a/src/game/attributes.rs b/src/game/attributes.rs index 80148f7..717707e 100644 --- a/src/game/attributes.rs +++ b/src/game/attributes.rs @@ -8,10 +8,6 @@ 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)] @@ -30,10 +26,14 @@ pub enum Attribute { 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 = 14; + pub const COUNT: usize = 15; pub const ALL: [Attribute; Self::COUNT] = [ Attribute::Strength, @@ -50,6 +50,7 @@ impl Attribute { Attribute::CriticalFactor, Attribute::PriceModifier, Attribute::CarryCapacity, + Attribute::Resistance, ]; pub fn name(self) -> &'static str { @@ -68,6 +69,7 @@ impl Attribute { Attribute::CriticalFactor => "Kritischer Faktor", Attribute::PriceModifier => "Preis-Modifikator", Attribute::CarryCapacity => "Tragfähigkeit", + Attribute::Resistance => "Resistenz", } } } @@ -114,12 +116,18 @@ impl Default for Attributes { #[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(M3): `duration`, `target` und `condition` aus Ticket 01/08. In M1 - // sind alle Modifikatoren dauerhaft und wirken auf den Traeger. + // TODO(M4): `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)`, @@ -149,11 +157,17 @@ mod tests { #[test] fn stapelregel_erst_flach_dann_prozentual() { + let m = |attribute, flat, percent| Modifier { + attribute, + damage_type: None, + flat, + percent, + }; let mods = [ - Modifier { attribute: Attribute::Strength, flat: 2.0, percent: 0.0 }, - Modifier { attribute: Attribute::Strength, flat: 3.0, percent: 10.0 }, + m(Attribute::Strength, 2.0, 0.0), + m(Attribute::Strength, 3.0, 10.0), // Fremdes Attribut darf nicht mitzaehlen: - Modifier { attribute: Attribute::Damage, flat: 100.0, percent: 0.0 }, + 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); diff --git a/src/game/combat.rs b/src/game/combat.rs new file mode 100644 index 0000000..51cce72 --- /dev/null +++ b/src/game/combat.rs @@ -0,0 +1,99 @@ +//! Kampfformeln — Ticket 14. +//! +//! Zahlen stehen hier keine: Die fuenf Stellknoepfe liegen in `[balance]` und +//! sind moddbar. Was hier steht, sind die **Formen** und die drei Klemmbereiche, +//! ohne die das Spiel Sackgassen erzeugt. + +use crate::data::Balance; + +/// `p = clamp(hit_chance − dexterity_ziel × AUSWEICH, 0.05, 0.95)` +/// +/// Der Klemmbereich ist nicht Kosmetik: Ohne ihn wird ein hochgezuechteter +/// Verteidiger unangreifbar und ein hochgezuechteter Angreifer unfehlbar — +/// beides Sackgassen, die man erst auf Etage 5 bemerkt. +pub fn hit_chance(attacker_hit: f32, defender_dex: f32, b: &Balance) -> f32 { + (attacker_hit - defender_dex * b.ausweich).clamp(b.hit_floor, b.hit_ceiling) +} + +/// `p = clamp(0.5 + (dex_held − dex_schnellster) × FLUCHT, 0.1, 0.9)` +pub fn flee_chance(own_dex: f32, fastest_enemy_dex: f32, b: &Balance) -> f32 { + (0.5 + (own_dex - fastest_enemy_dex) * b.flucht).clamp(0.1, 0.9) +} + +/// Roher Schaden vor der Minderung: `amount + scale × damage-Attribut`. +pub fn raw_damage(amount: f32, scale: f32, attacker_damage: f32) -> f32 { + amount + scale * attacker_damage +} + +/// Die Minderungskette. Ob `critical_factor` den Rohschaden oder den geminderten +/// multipliziert, ist **mathematisch belanglos** — solange beide multiplikativ +/// sind, ist `roh × kf × (1−r)` dasselbe wie `roh × (1−r) × kf`. Festzulegen war +/// nur, dass sie multiplikativ *bleiben*. +pub fn final_damage(raw: f32, critical: bool, crit_factor: f32, resistance: f32, b: &Balance) -> u32 { + let after_crit = if critical { raw * crit_factor } else { raw }; + // Deckel bei 0.8: Bei 1.0 waere ein Wesen immun, und Immunitaet ist keine + // Zahl, sondern ein Spielabbruch. Nach unten bis −1.0 fuer Verwundbarkeit. + let r = resistance.clamp(-1.0, b.resistance_cap); + // `max(1)`: Ein Treffer macht nie null, sonst gibt es Kaempfe, die + // rechnerisch nie enden. + (after_crit * (1.0 - r)).round().max(1.0) as u32 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn b() -> Balance { + Balance::default() + } + + #[test] + fn trefferchance_bleibt_entscheidbar() { + let b = b(); + // Ein absurd flinker Verteidiger bleibt treffbar. + assert_eq!(hit_chance(0.8, 10_000.0, &b), b.hit_floor); + // Ein absurd treffsicherer Angreifer verfehlt trotzdem manchmal. + assert_eq!(hit_chance(99.0, 0.0, &b), b.hit_ceiling); + } + + #[test] + fn resistenz_macht_nicht_immun() { + let b = b(); + // 100 % Resistenz waere Immunitaet — der Deckel verhindert das. + assert!(final_damage(100.0, false, 1.5, 1.0, &b) > 0); + assert_eq!(final_damage(100.0, false, 1.5, 1.0, &b), 20, "80 % gedeckelt"); + } + + #[test] + fn verwundbarkeit_ist_ausdrueckbar() { + // Negative Resistenz = mehr Schaden. „Untote nehmen doppelten heiligen + // Schaden" braucht das. + assert_eq!(final_damage(10.0, false, 1.5, -1.0, &b()), 20); + } + + #[test] + fn ein_treffer_macht_nie_null() { + assert_eq!(final_damage(0.4, false, 1.5, 0.8, &b()), 1); + } + + #[test] + fn krit_reihenfolge_ist_belanglos() { + let b = b(); + let (roh, kf, r) = (37.0f32, 1.7f32, 0.35f32); + // Beide Reihenfolgen, direkt ausgerechnet. + let erst_krit = (roh * kf) * (1.0 - r); + let erst_minderung = (roh * (1.0 - r)) * kf; + assert!((erst_krit - erst_minderung).abs() < 0.0001); + assert_eq!(final_damage(roh, true, kf, r, &b), erst_krit.round() as u32); + } + + #[test] + fn skalierender_schaden() { + // Basisangriff: scale 1.0 auf 7 Schaden. + assert_eq!(raw_damage(0.0, 1.0, 7.0), 7.0); + // Feuerball: fester Betrag, unabhaengig von der Waffe. + assert_eq!(raw_damage(20.0, 0.0, 7.0), 20.0); + // Wuchtschlag: 150 % Waffenschaden. + assert_eq!(raw_damage(0.0, 1.5, 8.0), 12.0); + } +} diff --git a/src/game/command.rs b/src/game/command.rs index e82b1f1..d8e83e2 100644 --- a/src/game/command.rs +++ b/src/game/command.rs @@ -20,6 +20,14 @@ pub enum Command { Move(Direction), Wait, Descend, + /// Der normale Angriff ist **keine eigene Variante**, sondern + /// `UseAbility("core:attack")` — Faehigkeiten sind seit Ticket 02 eine + /// Registry, also gehoert er dorthin, und ein Mod kann ihn ersetzen. + /// Dasselbe gilt fuer die Flucht. + UseAbility { + ability: String, + target: super::creature::CreatureRef, + }, } /// Ergebnis eines Kommandos — der Befund aus dem Durchstich in Ticket 12: diff --git a/src/game/condition.rs b/src/game/condition.rs new file mode 100644 index 0000000..b1c4341 --- /dev/null +++ b/src/game/condition.rs @@ -0,0 +1,112 @@ +//! Bedingungen — Ticket 08. +//! +//! **Eine** Grammatik fuer Trigger *und* Modifikatoren, damit sie sich nicht +//! subtil auseinanderentwickeln. Eine Liste ist implizit UND, jeder Eintrag ist +//! ueber `not` negierbar; es gibt **kein ODER** und keine Verschachtelung. +//! +//! Zwei Moeglichkeiten werden zu zwei Eintraegen, oder der Modder fasst sie +//! ueber ein gemeinsames Tag zusammen — ein rekursiver Ausdrucksbaum braeuchte +//! eine Klammer-Syntax, die in TOML niemand freiwillig schreibt. + +use serde::Deserialize; + +use super::attributes::Attribute; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Who { + #[default] + #[serde(rename = "self")] + Zelf, + Target, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "check", rename_all = "snake_case")] +pub enum Check { + HasTag { + #[serde(default)] + who: Who, + value: String, + }, + Attribute { + #[serde(default)] + who: Who, + attribute: Attribute, + #[serde(default)] + at_least: Option, + #[serde(default)] + below: Option, + }, + HasItem { + #[serde(default)] + who: Who, + item: String, + #[serde(default = "one")] + count: u32, + }, + /// Anteil am Maximum, 0.0–1.0. Fuer `ai`-Listen wie „unter 30 % Leben". + HealthBelow { + #[serde(default)] + who: Who, + value: f32, + }, +} + +fn one() -> u32 { + 1 +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Condition { + #[serde(flatten)] + pub check: Check, + /// Kein ODER, aber Negation je Eintrag. + #[serde(default)] + pub not: bool, +} + +impl Condition { + pub fn who(&self) -> Who { + match &self.check { + Check::HasTag { who, .. } + | Check::Attribute { who, .. } + | Check::HasItem { who, .. } + | Check::HealthBelow { who, .. } => *who, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn grammatik_aus_ticket_08_liest_sich() { + let toml = r#" +condition = [ + { check = "has_tag", who = "target", value = "core:undead" }, + { check = "attribute", who = "self", attribute = "level", at_least = 3 }, + { check = "has_item", who = "self", item = "core:key", count = 1, not = true }, +] +"#; + #[derive(Deserialize)] + struct Wrap { + condition: Vec, + } + let w: Wrap = toml::from_str(toml).expect("Grammatik aus Ticket 08 muss lesbar sein"); + assert_eq!(w.condition.len(), 3); + + assert!(matches!(w.condition[0].check, Check::HasTag { .. })); + assert_eq!(w.condition[0].who(), Who::Target); + assert!(!w.condition[0].not); + assert!(w.condition[2].not, "`not` muss neben dem Check stehen duerfen"); + } + + #[test] + fn who_faellt_auf_self_zurueck() { + let c: Condition = + toml::from_str("check = \"has_tag\"\nvalue = \"core:rat\"").expect("minimal"); + assert_eq!(c.who(), Who::Zelf); + } +} diff --git a/src/game/creature.rs b/src/game/creature.rs index a889edd..f55a7f5 100644 --- a/src/game/creature.rs +++ b/src/game/creature.rs @@ -9,7 +9,8 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; -use super::attributes::Attributes; +use super::action::ActiveModifier; +use super::attributes::{Attribute, Attributes}; /// Ein Exemplar eines Gegenstands. Traegt **nur IDs** — die Affixe sind /// Registry-Eintraege, keine eingebetteten Modifikatoren (Ticket 07). Genau @@ -51,9 +52,20 @@ pub struct Creature { pub inventory: Vec, pub equipment: HashMap, pub tags: Vec, - // TODO(M3): `modifiers: Vec` (nur befristete), - // `initiative: i32`, `cooldowns: HashMap`. - // Alle drei brauchen den Kampf, um ueberhaupt etwas zu tun. + /// **Nur befristete** Modifikatoren. Rasse und Klasse kommen aus `options`, + /// Ausruestung aus `equipment` — hineinzukopieren machte das Lesen billiger + /// und das Schreiben gefaehrlich (Ticket 07). + pub modifiers: Vec, + /// Einmal beim Betreten des Kampfes gewuerfelt. Bewusst ein Zahlenwert am + /// Wesen und keine Reihenfolgeliste: Die waere eine **zweite** Indexliste + /// mit demselben Ungueltigkeitsproblem beim Tod eines Gegners (Ticket 14). + pub initiative: i32, + pub cooldowns: HashMap, + /// Aus welcher `creatures`-Definition dieses Wesen stammt — der Held hat + /// keine. Traegt `ai`-Liste, Faehigkeiten und Trigger. + pub def: Option, + /// Faehigkeiten, die *nicht* aus Optionen oder Ausruestung kommen. + pub abilities: Vec, } impl Creature { @@ -61,13 +73,43 @@ impl Creature { let base = Attributes::default(); Self { name: name.into(), - health: base.get(super::attributes::Attribute::MaxHealth), - stamina: base.get(super::attributes::Attribute::MaxStamina), + health: base.get(Attribute::MaxHealth), + stamina: base.get(Attribute::MaxStamina), base, options: Vec::new(), inventory: Vec::new(), equipment: HashMap::new(), tags: Vec::new(), + modifiers: Vec::new(), + initiative: 0, + cooldowns: HashMap::new(), + def: None, + abilities: Vec::new(), } } + + /// Baut ein Wesen aus seiner Registry-Definition (Ticket 14). Attribute, + /// die die Definition nicht nennt, bleiben beim Standard. + pub fn from_def(def: &crate::data::CreatureDef) -> Self { + let mut c = Creature::new(def.name.clone()); + c.def = Some(def.id.clone()); + c.tags = def.tags.clone(); + c.abilities = def.abilities.clone(); + + for (name, value) in &def.base { + match toml::from_str::(&format!("\"{name}\"")) { + Ok(attr) => c.base.set(attr, *value), + // Unbekannter Attributname ist ein Datenfehler und wurde beim + // Laden gemeldet — hier schlicht ignorieren. + Err(_) => continue, + } + } + c.health = c.base.get(Attribute::MaxHealth); + c.stamina = c.base.get(Attribute::MaxStamina); + c + } + + pub fn is_alive(&self) -> bool { + self.health > 0.0 + } } diff --git a/src/game/event.rs b/src/game/event.rs new file mode 100644 index 0000000..4dcf016 --- /dev/null +++ b/src/game/event.rs @@ -0,0 +1,107 @@ +//! Events und Trigger — Ticket 08. +//! +//! Der **Trigger** ist die vierte Wirkungsform: Modifikator rechnet um, Aktion +//! wirkt einmal, Faehigkeit bietet an — der Trigger sagt *wann*. Er darf +//! ueberall stehen, wo `modifiers` und `actions` stehen. + +use serde::Deserialize; + +use super::action::Action; +use super::condition::Condition; +use super::creature::CreatureRef; + +/// Zwoelf feste Events, Engine-Vokabular ohne Namensraum. Mod-Events braechten +/// nichts, solange niemand sie *ausloesen* kann — das kommt mit Stufe (c). +/// +/// `attack_hit` und `damage_taken` bleiben getrennt: Ersteres feuert beim +/// Angreifer (dort haengt das flammende Schwert), Letzteres beim Getroffenen +/// (dort haengt der Dornenpanzer). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EventKind { + TurnStarted, + AttackHit, + DamageTaken, + CreatureDied, + ItemPickedUp, + ItemUsed, + FloorEntered, + RoomEntered, + NpcTalked, + QuestCompleted, + CombatStarted, + CombatEnded, +} + +/// **Befund aus M3:** Ticket 08 hat Events eine Nutzlast gegeben (Schadenstyp, +/// Prefab-ID, Menge), aber die Bedingungsgrammatik kann sie **gar nicht +/// pruefen** — `Check` sieht nur Wesen, nie das Ereignis. Bis das geklaert ist, +/// traegt ein Event nur, was tatsaechlich gelesen wird: wer und gegen wen. +/// Nutzlast auf Vorrat waere ein Feld, das niemand ausliest. +#[derive(Debug, Clone)] +pub struct Event { + pub kind: EventKind, + pub source: CreatureRef, + pub target: Option, +} + +impl Event { + pub fn new(kind: EventKind, source: CreatureRef) -> Self { + Self { kind, source, target: None } + } + + pub fn to(mut self, target: CreatureRef) -> Self { + self.target = Some(target); + self + } +} + +/// `{ on = , condition = [...], actions = [...] }` — die vierte +/// Wirkungsform. Im Trigger ist `self` immer sein **Traeger**, `target` die +/// andere Seite des Events. +#[derive(Debug, Clone, Deserialize)] +pub struct Trigger { + pub on: EventKind, + #[serde(default)] + pub condition: Vec, + #[serde(default)] + pub actions: Vec, + /// Wahrscheinlichkeit, dass der Trigger ueberhaupt feuert — „20 % Chance, + /// Brennen auszuloesen" braucht sonst ein Script. + #[serde(default = "always")] + pub chance: f32, +} + +fn always() -> f32 { + 1.0 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn trigger_liest_sich_aus_toml() { + let t: Trigger = toml::from_str( + r#" +on = "attack_hit" +chance = 0.2 +condition = [ { check = "has_tag", who = "target", value = "core:undead" } ] +actions = [ { action = "damage", amount = 4, damage_type = "fire", target = "target" } ] +"#, + ) + .expect("Trigger-Form aus Ticket 08"); + + assert_eq!(t.on, EventKind::AttackHit); + assert_eq!(t.condition.len(), 1); + assert_eq!(t.actions.len(), 1); + assert!((t.chance - 0.2).abs() < f32::EPSILON); + } + + #[test] + fn chance_faellt_auf_immer_zurueck() { + let t: Trigger = toml::from_str("on = \"turn_started\"").expect("minimal"); + assert_eq!(t.chance, 1.0); + assert!(t.actions.is_empty()); + } +} diff --git a/src/game/floor.rs b/src/game/floor.rs index 0d974d9..bbd4c68 100644 --- a/src/game/floor.rs +++ b/src/game/floor.rs @@ -43,10 +43,12 @@ pub struct Room { /// Fuer `room_entered` und die Trigger am Prefab (Ticket 08/10). pub prefab: String, pub rect: Rect, - /// Die `e`-Plaetze aus dem Prefab. - // TODO(M3): wird zu `enemies: Vec` — was einzieht, entscheidet - // die `creatures`-Zeile der Etage (Ticket 10/14). + /// Die `e`-Plaetze aus dem Prefab — bleiben erhalten, damit die Anzeige + /// weiss, wo etwas stand. pub enemy_spawns: Vec<(u16, u16)>, + /// Die Gegner dieses Raums. Wandern beim Betreten nach `Game.enemies` — + /// **verschoben, nicht kopiert** (Ticket 10). + pub enemies: Vec, /// Die `l`-Plaetze. Ticket 16: erst beim **Aufheben** aufgeloest, deshalb /// stehen hier Positionen und keine Gegenstaende. pub loot: Vec<(u16, u16)>, diff --git a/src/game/generate.rs b/src/game/generate.rs index e7bc24c..e232217 100644 --- a/src/game/generate.rs +++ b/src/game/generate.rs @@ -7,6 +7,7 @@ use crate::data::{FloorDef, Prefab, Registries}; +use super::creature::Creature; use super::floor::{Floor, Rect, Room, Tile}; use super::rng::Rng; @@ -128,10 +129,24 @@ pub fn generate(def: &FloorDef, reg: &Registries, rng: &mut Rng) -> Floor { } } + // Ein `e` sagt „hier steht ein Gegner", **nicht welcher** — das + // entscheidet die Etage (Ticket 10). Nur so laesst sich derselbe Raum + // auf Etage 1 und Etage 5 verwenden. + let gewichte: Vec = def.creatures.iter().map(|c| c.weight).collect(); + let enemies = enemy_spawns + .iter() + .filter_map(|_| { + let i = rng.weighted(&gewichte)?; + let cdef = reg.creatures.get(&def.creatures[i].id)?; + Some(Creature::from_def(cdef)) + }) + .collect(); + rooms.push(Room { prefab: p.prefab.clone(), rect: p.rect, enemy_spawns, + enemies, loot, cleared: false, }); diff --git a/src/game/mod.rs b/src/game/mod.rs index 30d7278..12f92d9 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -1,15 +1,22 @@ //! Der Spielkern. **Kennt ratatui nicht** — `tests/boundaries.rs` prueft das. +pub mod action; pub mod attributes; +pub mod combat; pub mod command; +pub mod condition; pub mod creature; +pub mod event; pub mod floor; pub mod generate; pub mod rng; +pub use action::{Action, ActiveModifier, DamageType, StateField, Target}; pub use attributes::{Attribute, Modifier}; pub use command::{Command, Direction, Outcome}; +pub use condition::{Check, Condition, Who}; pub use creature::{Creature, CreatureRef, ItemInstance}; +pub use event::{Event, EventKind, Trigger}; pub use floor::{Floor, Tile}; pub use rng::Rng; @@ -19,9 +26,6 @@ use crate::data::Registries; /// Etage — es gibt keine Zahl „5" im Code. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RunOutcome { - /// TODO(M3): wird gesetzt, sobald es Kampf gibt und der Held sterben kann. - /// Der `GameOver`-Screen zeigt beide Enden bereits. - #[allow(dead_code)] Died, Won, } @@ -36,9 +40,13 @@ pub struct Game { /// Im Speicherstand, damit Nachladen nicht neu wuerfelt (Ticket 14). pub rng: Rng, pub over: Option, + /// Warteschlange statt Rekursion (Ticket 08): Synchron zu feuern hiesse, + /// ueber `self.enemies` zu iterieren, waehrend `self.apply` erneut laeuft. + pub events: std::collections::VecDeque, /// Letzte Meldung fuer die Statuszeile. pub last: String, - // TODO(M3): `events: Vec` — die Warteschlange aus Ticket 08. + /// Kampflog, die letzten Zeilen. + pub journal: Vec, // TODO(M4): `quests`, `current_exp`, `unspent_points` (Ticket 15 — an // `Game`, nicht am `Creature`). } @@ -61,7 +69,9 @@ impl Game { turn: 0, rng, over: None, + events: std::collections::VecDeque::new(), last: String::from("—"), + journal: Vec::new(), }) } @@ -72,6 +82,13 @@ impl Game { } } + fn creature_mut(&mut self, who: CreatureRef) -> &mut Creature { + match who { + CreatureRef::Player => &mut self.player, + CreatureRef::Enemy(i) => &mut 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. @@ -102,61 +119,580 @@ impl Game { .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) + let mut value = attributes::stack(c.base.get(attr), from_options.chain(from_equipment), attr); + + // Vierte Quelle: befristete Modifikatoren am Traeger. Sie werden + // **nicht** beim Anlegen hineinkopiert — das machte das Lesen billiger + // und das Schreiben gefaehrlich (Ticket 07). + let (flat, percent) = c + .modifiers + .iter() + .filter(|m| m.attribute == attr && m.damage_type.is_none()) + .fold((0.0, 0.0), |(f, p), m| (f + m.flat, p + m.percent)); + value = (value + flat) * (1.0 + percent / 100.0); + + // TODO(M4): Affix-Modifikatoren des Exemplars und `_against` fuer + // bedingte Modifikatoren („+5 gegen Untote", Ticket 08). + value + } + + /// Resistenz ist **typ-indiziert** und der einzige Minderungsmechanismus + /// (Ticket 01). Basis ist null — ein Wesen ohne Resistenz-Modifikatoren + /// nimmt vollen Schaden. + pub fn resistance(&self, who: CreatureRef, dt: DamageType, reg: &Registries) -> f32 { + let c = self.creature(who); + let matching = |m: &&Modifier| { + m.attribute == Attribute::Resistance && m.damage_type == Some(dt) + }; + + 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() + }); + + let flat: f32 = from_options + .chain(from_equipment) + .filter(matching) + .map(|m| m.flat) + .sum::() + + c.modifiers + .iter() + .filter(|m| m.attribute == Attribute::Resistance && m.damage_type == Some(dt)) + .map(|m| m.flat) + .sum::(); + flat / 100.0 + } + + // ======================================================================== + // Bedingungen — Ticket 08 + // ======================================================================== + + fn who_ref(&self, who: Who, zelf: CreatureRef, target: Option) -> Option { + match who { + Who::Zelf => Some(zelf), + Who::Target => target, + } + } + + /// Eine Liste ist implizit **UND**, jeder Eintrag ueber `not` negierbar. + pub fn conditions_hold( + &self, + conds: &[Condition], + zelf: CreatureRef, + target: Option, + reg: &Registries, + ) -> bool { + conds.iter().all(|c| { + let Some(subject) = self.who_ref(c.who(), zelf, target) else { + // Kein Ziel vorhanden: Die Bedingung kann nicht halten. + return c.not; + }; + self.check_holds(&c.check, subject, reg) != c.not + }) + } + + fn check_holds(&self, check: &Check, subject: CreatureRef, reg: &Registries) -> bool { + let c = self.creature(subject); + match check { + Check::HasTag { value, .. } => c.tags.iter().any(|t| t == value), + Check::Attribute { attribute, at_least, below, .. } => { + let v = self.attribute(subject, *attribute, None, reg); + at_least.is_none_or(|min| v >= min) && below.is_none_or(|max| v < max) + } + Check::HasItem { item, count, .. } => { + let n = c.inventory.iter().filter(|i| &i.def == item).count() as u32; + n >= *count + } + Check::HealthBelow { value, .. } => { + let max = self.attribute(subject, Attribute::MaxHealth, None, reg); + max > 0.0 && c.health / max < *value + } + } + } + + // ======================================================================== + // Trigger — gesucht, nicht indiziert (Ticket 08) + // ======================================================================== + + /// Sammelt alle Trigger eines Wesens fuer ein Event. Ein Index muesste bei + /// jedem An- und Ablegen, jedem Loot und jedem Affix-Wechsel gepflegt + /// werden — genau das Invalidierungsproblem, das Ticket 07 vermieden hat. + fn triggers_for<'r>( + &self, + who: CreatureRef, + kind: EventKind, + reg: &'r Registries, + ) -> Vec<&'r Trigger> { + let c = self.creature(who); + let mut out: Vec<&Trigger> = Vec::new(); + + if let Some(def) = c.def.as_ref().and_then(|id| reg.creatures.get(id)) { + out.extend(def.triggers.iter()); + } + for m in &c.modifiers { + if let Some(def) = reg.modifiers.get(&m.id) { + out.extend(def.triggers.iter()); + } + } + // TODO(M4): Trigger an Gegenstaenden, Affixen und Charakteroptionen — + // sie brauchen dieselben drei Zeilen, sobald die Registries + // das Feld tragen. + out.retain(|t| t.on == kind); + out + } + + // ======================================================================== + // Aktionen — Ticket 01 + // ======================================================================== + + /// Der **eine** Eingang fuer Zustandsaenderungen (Ticket 07). + pub fn apply( + &mut self, + action: &Action, + source: CreatureRef, + target: CreatureRef, + reg: &Registries, + ) { + let victim = match action.target() { + Target::Zelf => source, + Target::Other | Target::AllEnemies => target, + }; + + match action { + Action::Damage { amount, scale, damage_type, .. } => { + // Erst die Trefferfrage. Der Klemmbereich haelt jeden Kampf + // entscheidbar: fuenf Prozent Rest in beide Richtungen. + let hit = self.attribute(source, Attribute::HitChance, Some(victim), reg); + let dodge = self.attribute(victim, Attribute::Dexterity, None, reg); + let p = combat::hit_chance(hit, dodge, ®.balance); + + if !self.rng.chance(p) { + self.log(format!( + "{} verfehlt {}", + self.creature(source).name, + self.creature(victim).name + )); + return; + } + + let atk = self.attribute(source, Attribute::Damage, Some(victim), reg); + let raw = combat::raw_damage(*amount, *scale, atk); + let crit_chance = self.attribute(source, Attribute::CriticalChance, None, reg); + let critical = self.rng.chance(crit_chance / 100.0); + let crit_factor = self.attribute(source, Attribute::CriticalFactor, None, reg); + let resist = self.resistance(victim, *damage_type, reg); + + let dealt = combat::final_damage(raw, critical, crit_factor, resist, ®.balance); + self.creature_mut(victim).health -= dealt as f32; + + self.log(format!( + "{} trifft {} für {dealt} {}{}", + self.creature(source).name, + self.creature(victim).name, + damage_type.name(), + if critical { " (kritisch!)" } else { "" } + )); + + // `attack_hit` beim Angreifer, `damage_taken` beim Getroffenen — + // getrennt, weil dort das flammende Schwert und hier der + // Dornenpanzer haengt (Ticket 08/14). Beide feuern **nach** dem + // Schaden: Ein Trigger, der Brennen auflegt, ist ein eigener + // Schadensfall mit eigener Minderungskette. + self.events.push_back(Event::new(EventKind::AttackHit, source).to(victim)); + self.events.push_back(Event::new(EventKind::DamageTaken, victim).to(source)); + self.check_death(victim, source); + } + + Action::ChangeAttribute { attribute, amount, .. } => { + let (max_attr, field) = match attribute { + StateField::Health => (Attribute::MaxHealth, 0), + StateField::Stamina => (Attribute::MaxStamina, 1), + }; + let max = self.attribute(victim, max_attr, None, reg); + let c = self.creature_mut(victim); + if field == 0 { + c.health = (c.health + amount).clamp(0.0, max); + } else { + c.stamina = (c.stamina + amount).clamp(0.0, max); + } + self.check_death(victim, source); + } + + Action::ApplyModifier { modifier, .. } => { + let Some(def) = reg.modifiers.get(modifier) else { + return; // beim Laden bereits gemeldet + }; + let c = self.creature_mut(victim); + // Erneutes Auflegen frischt auf, statt zu stapeln. + c.modifiers.retain(|m| m.id != *modifier); + for change in &def.attributes { + c.modifiers.push(ActiveModifier { + id: def.id.clone(), + attribute: change.attribute, + damage_type: change.damage_type, + flat: change.flat, + percent: change.percent, + turns_left: def.turns, + name: def.name.clone(), + symbol: def.symbol, + }); + } + self.log(format!("{}: {}", self.creature(victim).name, def.name)); + } + + Action::RemoveModifier { modifier, .. } => { + self.creature_mut(victim).modifiers.retain(|m| m.id != *modifier); + } + + Action::Flee => { + let own = self.attribute(source, Attribute::Dexterity, None, reg); + // Gerechnete Werte, nicht Basiswerte — sonst ignoriert die + // Flucht jeden Modifikator auf die Geschicklichkeit des Gegners. + let fastest = (0..self.enemies.len()) + .map(|i| { + self.attribute(CreatureRef::Enemy(i), Attribute::Dexterity, None, reg) + }) + .fold(0.0f32, f32::max); + let p = combat::flee_chance(own, fastest, ®.balance); + + if self.rng.chance(p) { + self.enemies.clear(); + self.log(String::from("Du entkommst.")); + self.events.push_back(Event::new(EventKind::CombatEnded, CreatureRef::Player)); + } else { + self.log(String::from("Die Flucht misslingt.")); + } + } + } + } + + fn check_death(&mut self, who: CreatureRef, killer: CreatureRef) { + if self.creature(who).is_alive() { + return; + } + let name = self.creature(who).name.clone(); + self.log(format!("{name} fällt.")); + self.events.push_back(Event::new(EventKind::CreatureDied, who).to(killer)); + + if who == CreatureRef::Player { + self.over = Some(RunOutcome::Died); + } + } + + // ======================================================================== + // Events — Warteschlange statt Rekursion (Ticket 08) + // ======================================================================== + + /// Arbeitet die Warteschlange leer. **Nach jeder einzelnen Handlung**, nicht + /// einmal am Zugende: Sonst schlaegt ein Gegner zu, den ein Dornenpanzer + /// eine Handlung zuvor getoetet hat. + fn drain_events(&mut self, reg: &Registries) { + let mut budget = 100; // Zaehler statt Zyklengraph (Ticket 08) + + while let Some(event) = self.events.pop_front() { + budget -= 1; + if budget == 0 { + self.log(String::from("Zu viele Ereignisse in einem Zug — abgebrochen.")); + self.events.clear(); + return; + } + + // Im Trigger ist `self` immer sein Traeger, `target` die andere + // Seite des Events (Ticket 08). + for carrier in self.participants() { + // Die Referenzen binden nur an `reg`, nicht an `self` — deshalb + // darf `apply` gleich danach mutieren, ohne zu klonen. + let triggers = self.triggers_for(carrier, event.kind, reg); + + for t in triggers { + let other = if carrier == event.source { event.target } else { Some(event.source) }; + if !self.conditions_hold(&t.condition, carrier, other, reg) { + continue; + } + if t.chance < 1.0 && !self.rng.chance(t.chance) { + continue; + } + for action in &t.actions { + let target = other.unwrap_or(carrier); + self.apply(action, carrier, target, reg); + } + } + } + } + } + + fn participants(&self) -> Vec { + std::iter::once(CreatureRef::Player) + .chain((0..self.enemies.len()).map(CreatureRef::Enemy)) + .collect() + } + + pub fn log(&mut self, line: String) { + self.last = line.clone(); + self.journal.push(line); + if self.journal.len() > 60 { + self.journal.remove(0); + } } /// 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 - } - Command::Descend => self.descend(reg), - }; - - // 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). + if self.over.is_some() { + return Outcome::Rejected("Der Lauf ist vorbei"); } - outcome + + // Erst pruefen, ob das Kommando ueberhaupt ausfuehrbar ist — ein + // abgewiesenes darf keinen Zug kosten und keinen Gegner handeln lassen. + if let Some(reason) = self.rejects(&cmd, reg) { + return Outcome::Rejected(reason); + } + + // 1. turn_started + self.events.push_back(Event::new(EventKind::TurnStarted, CreatureRef::Player)); + + // 2. Modifikatoren ticken, Ausdauer regeneriert, Abklingzeiten sinken + self.tick(reg); + + // 3. Teilnehmer handeln in Initiativreihenfolge, der Spieler-Command an + // seinem Platz — Abbruch, sobald der Held tot ist. + for who in self.initiative_order() { + if self.over.is_some() { + break; + } + match who { + CreatureRef::Player => self.perform(&cmd, reg), + CreatureRef::Enemy(i) => self.enemy_acts(i, reg), + } + // 4. nach *jeder* Handlung leerarbeiten + self.drain_events(reg); + } + + // 5. Tote entfernen — erst **nach** dem Leeren, sonst zeigt ein + // creature_died-Trigger auf einen bereits entfernten Gegner. + let vorher = self.enemies.len(); + self.enemies.retain(|e| e.is_alive()); + if vorher > 0 && self.enemies.is_empty() && self.over.is_none() { + self.log(String::from("Der Raum ist geräumt.")); + self.events.push_back(Event::new(EventKind::CombatEnded, CreatureRef::Player)); + self.drain_events(reg); + } + + // 6. + self.turn += 1; + Outcome::TurnPassed } - fn step(&mut self, dir: Direction) -> Outcome { + /// Grund, warum das Kommando gar nicht erst laeuft — oder `None`. + fn rejects(&self, cmd: &Command, reg: &Registries) -> Option<&'static str> { + let in_combat = !self.enemies.is_empty(); + match cmd { + Command::Move(dir) if in_combat => { + let _ = dir; + Some("Nicht im Kampf — kämpfe oder fliehe") + } + Command::Move(dir) => { + let (nx, ny) = self.step_target(*dir); + (self.floor.tile(nx, ny) == Tile::Wall).then_some("Da ist eine Wand") + } + Command::Descend if in_combat => Some("Nicht im Kampf"), + Command::Descend => (self.floor.tile(self.player_pos.0, self.player_pos.1) + != Tile::Stairs) + .then_some("Hier ist keine Treppe"), + Command::UseAbility { ability, .. } => { + self.ability_block(CreatureRef::Player, ability, reg) + } + Command::Wait => None, + } + } + + /// Alle Faehigkeiten eines Wesens, in stabiler Reihenfolge. + /// + /// `core:attack` und `core:flee` sind **jedem Wesen implizit gewaehrt** + /// (Nachtrag aus Ticket 09): Faehigkeiten kommen sonst nur von + /// Charakteroptionen und Gegenstaenden, und ein Held ohne Klasse und ohne + /// Waffe koennte nicht einmal angreifen. + pub fn abilities_of(&self, who: CreatureRef, reg: &Registries) -> Vec { + let mut out = vec![String::from("core:attack"), String::from("core:flee")]; + out.extend(self.creature(who).abilities.iter().cloned()); + // TODO(M4): Faehigkeiten aus Charakteroptionen und Ausruestung — beide + // Registries brauchen dafuer erst ein `abilities`-Feld. + out.retain(|id| reg.abilities.contains_key(id)); + out.dedup(); + out + } + + /// Warum eine Faehigkeit gesperrt ist (Ticket 14) — oder `None`. + pub fn ability_block( + &self, + who: CreatureRef, + ability: &str, + reg: &Registries, + ) -> Option<&'static str> { + let Some(def) = reg.abilities.get(ability) else { + return Some("Unbekannte Fähigkeit"); + }; + let c = self.creature(who); + + if c.stamina < def.cost { + return Some("Nicht genug Ausdauer"); + } + if c.cooldowns.get(ability).is_some_and(|t| *t > 0) { + return Some("Noch nicht bereit"); + } + let target = (!self.enemies.is_empty()).then_some(CreatureRef::Enemy(0)); + if !self.conditions_hold(&def.condition, who, target, reg) { + return Some("Bedingung nicht erfüllt"); + } + None + } + + fn tick(&mut self, reg: &Registries) { + let regen = reg.balance.stamina_regen; + for who in self.participants() { + let max_stamina = self.attribute(who, Attribute::MaxStamina, None, reg); + let c = self.creature_mut(who); + c.stamina = (c.stamina + regen).min(max_stamina); + for t in c.cooldowns.values_mut() { + *t = t.saturating_sub(1); + } + // Eine Uhr: der Zug. Debuffs ticken auch beim Erkunden (Ticket 01). + for m in &mut c.modifiers { + m.turns_left = m.turns_left.saturating_sub(1); + } + c.modifiers.retain(|m| m.turns_left > 0); + } + } + + /// Reihenfolge innerhalb eines Zuges. Gleichstand geht an den Spieler — + /// deterministisch und zu seinen Gunsten (Ticket 14). + fn initiative_order(&self) -> Vec { + let mut order = self.participants(); + order.sort_by_key(|w| { + let init = self.creature(*w).initiative; + (-init, matches!(w, CreatureRef::Enemy(_))) + }); + order + } + + fn perform(&mut self, cmd: &Command, reg: &Registries) { + match cmd { + Command::Move(dir) => self.step(*dir), + Command::Wait => self.log(String::from("gewartet")), + Command::Descend => self.descend(reg), + Command::UseAbility { ability, target } => { + self.use_ability(CreatureRef::Player, ability, *target, reg) + } + } + } + + fn use_ability( + &mut self, + who: CreatureRef, + ability: &str, + target: CreatureRef, + reg: &Registries, + ) { + let Some(def) = reg.abilities.get(ability) else { return }; + + let c = self.creature_mut(who); + c.stamina -= def.cost; + if def.cooldown > 0 { + c.cooldowns.insert(ability.to_string(), def.cooldown); + } + + for action in &def.actions { + self.apply(action, who, target, reg); + } + } + + /// Erster passender **und verfuegbarer** Eintrag der `ai`-Liste gewinnt. + /// Passt nichts, setzt der Gegner aus — einer, der die Runde blockiert, + /// waere schlimmer als einer, der nichts tut (Ticket 14). + fn enemy_acts(&mut self, i: usize, reg: &Registries) { + let me = CreatureRef::Enemy(i); + if !self.creature(me).is_alive() { + return; + } + + let Some(def) = self.creature(me).def.clone().and_then(|id| reg.creatures.get(&id)) else { + return; + }; + + let choice = def.ai.iter().find(|entry| { + self.conditions_hold(&entry.condition, me, Some(CreatureRef::Player), reg) + && self.ability_block(me, &entry.ability, reg).is_none() + }); + + if let Some(entry) = choice { + let ability = entry.ability.clone(); + self.use_ability(me, &ability, CreatureRef::Player, reg); + } + } + + fn step_target(&self, dir: Direction) -> (u16, u16) { let (x, y) = self.player_pos; - let (nx, ny) = match dir { + 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"); } + } + /// Die Pruefung sitzt in `rejects` — hier wird nur noch bewegt. + fn step(&mut self, dir: Direction) { + let (nx, ny) = self.step_target(dir); self.player_pos = (nx, ny); // Korridore gehoeren zu keinem Raum — `None` ist ein echter Zustand. let room = self.floor.room_at(nx, ny); if room != self.floor.player_room { self.floor.player_room = room; - // TODO(M3): hier feuert `room_entered` (Ticket 08/10) und schiebt - // die Gegner des Raums nach `Game.enemies`. + if let Some(i) = room { + self.events + .push_back(Event::new(EventKind::RoomEntered, CreatureRef::Player)); + self.enter_room(i); + } } self.last = match self.floor.tile(nx, ny) { Tile::Stairs => String::from("Eine Treppe führt nach unten — '>' zum Absteigen"), _ => self.room_label(), }; - Outcome::TurnPassed + } + + /// Ticket 10: **Gegner gehoeren zum Raum, nicht zur Etage.** Ein + /// `room_entered` in einen Raum mit lebenden Gegnern verschiebt sie nach + /// `Game.enemies` — verschoben, nicht kopiert, sonst gaebe es zwei + /// Wahrheiten ueber denselben Gegner. + /// + /// Der Gewinn ist, was dadurch *nicht* gebaut wird: Frei umherlaufende + /// Gegner braeuchten Pfadsuche, Sichtlinien und ein Bewegungsbudget. + fn enter_room(&mut self, i: usize) { + if self.floor.rooms[i].cleared || !self.enemies.is_empty() { + return; + } + let waiting = std::mem::take(&mut self.floor.rooms[i].enemies); + if waiting.is_empty() { + self.floor.rooms[i].cleared = true; + return; + } + + self.enemies = waiting; + for e in &mut self.enemies { + e.initiative = e.base.get(Attribute::Dexterity) as i32 + self.rng.below(6) as i32; + } + self.player.initiative = + self.player.base.get(Attribute::Dexterity) as i32 + self.rng.below(6) as i32; + + let namen: Vec<&str> = self.enemies.iter().map(|e| e.name.as_str()).collect(); + let zeile = format!("Kampf: {}", namen.join(", ")); + self.log(zeile); + self.events.push_back(Event::new(EventKind::CombatStarted, CreatureRef::Player)); } fn room_label(&self) -> String { @@ -170,23 +706,19 @@ impl Game { /// Lauf**. Es gibt keine Zahl „5" im Code — die Engine sucht `[[floors]]` /// mit `number = aktuell + 1`, und findet sie keine, ist Schluss. Ein Mod, /// der Etage 6 ergaenzt, verlaengert das Spiel ohne eine Zeile Engine-Code. - fn descend(&mut self, reg: &Registries) -> Outcome { - if self.floor.tile(self.player_pos.0, self.player_pos.1) != Tile::Stairs { - return Outcome::Rejected("Hier ist keine Treppe"); - } - + fn descend(&mut self, reg: &Registries) { let Some(def) = reg.floor_number(self.floor.number + 1) else { self.over = Some(RunOutcome::Won); - self.last = String::from("Du steigst ans Licht."); - return Outcome::TurnPassed; + self.log(String::from("Du steigst ans Licht.")); + return; }; self.floor = generate::generate(def, reg, &mut self.rng); self.player_pos = self.floor.entrance(); self.floor.player_room = self.floor.room_at(self.player_pos.0, self.player_pos.1); self.enemies.clear(); - self.last = format!("Etage {}", self.floor.number); - Outcome::TurnPassed + self.log(format!("Etage {}", self.floor.number)); + self.events.push_back(Event::new(EventKind::FloorEntered, CreatureRef::Player)); } } @@ -226,6 +758,224 @@ mod tests { assert_eq!(game.over, Some(RunOutcome::Won)); } + /// Setzt den Helden in einen Raum mit Gegnern und startet den Kampf. + fn im_kampf() -> (Game, Registries) { + let (mut game, reg) = zwergenkrieger(); + game.player.abilities = vec![String::from("core:attack"), String::from("core:flee")]; + + // Ersten Raum mit Gegnern finden und betreten. + let i = (0..game.floor.rooms.len()) + .find(|i| !game.floor.rooms[*i].enemies.is_empty()) + .expect("mindestens ein Raum hat Gegner"); + game.player_pos = (game.floor.rooms[i].rect.x + 1, game.floor.rooms[i].rect.y + 1); + game.floor.player_room = None; + game.enter_room(i); + + assert!(!game.enemies.is_empty(), "Kampf muss laufen"); + (game, reg) + } + + #[test] + fn betreten_eines_raums_verschiebt_die_gegner() { + let (mut game, _) = zwergenkrieger(); + let i = (0..game.floor.rooms.len()) + .find(|i| !game.floor.rooms[*i].enemies.is_empty()) + .expect("mindestens ein Raum hat Gegner"); + let erwartet = game.floor.rooms[i].enemies.len(); + + game.floor.player_room = None; + game.enter_room(i); + + // **Verschoben, nicht kopiert** — sonst gaebe es zwei Wahrheiten ueber + // denselben Gegner (Ticket 10). + assert_eq!(game.enemies.len(), erwartet); + assert!(game.floor.rooms[i].enemies.is_empty(), "der Raum muss leer sein"); + + // Initiative wird beim Betreten gewuerfelt, fuer Held wie Gegner. + assert!(game.player.initiative > 0); + assert!(game.enemies.iter().all(|e| e.initiative > 0)); + } + + /// Ticket 14: Gleichstand geht an den Spieler — deterministisch und zu + /// seinen Gunsten. + #[test] + fn initiative_bevorzugt_bei_gleichstand_den_spieler() { + let (mut game, _) = im_kampf(); + game.player.initiative = 5; + for e in &mut game.enemies { + e.initiative = 5; + } + assert_eq!(game.initiative_order().first(), Some(&CreatureRef::Player)); + + // Ein schnellerer Gegner kommt aber wirklich zuerst. + game.enemies[0].initiative = 6; + assert_eq!(game.initiative_order().first(), Some(&CreatureRef::Enemy(0))); + } + + #[test] + fn angriff_macht_schaden_und_kostet_einen_zug() { + let (mut game, reg) = im_kampf(); + let vorher = game.enemies[0].health; + + let cmd = Command::UseAbility { + ability: String::from("core:attack"), + target: CreatureRef::Enemy(0), + }; + assert!(matches!(game.execute(cmd, ®), Outcome::TurnPassed)); + assert_eq!(game.turn, 1); + + // Entweder ist Schaden angekommen oder der Gegner ist ganz weg. + let getroffen = game.enemies.is_empty() || game.enemies[0].health < vorher; + assert!(getroffen, "der Angriff muss wirken"); + } + + /// Ticket 14: Gesperrte Faehigkeit ergibt `Rejected` und **kostet keinen + /// Zug** — die Regel steht im Spielkern, nicht in der UI. + #[test] + fn gesperrte_faehigkeit_kostet_keinen_zug() { + let (mut game, reg) = im_kampf(); + game.player.stamina = 0.0; + + let cmd = Command::UseAbility { + ability: String::from("core:flee"), + target: CreatureRef::Player, + }; + assert!(matches!(game.execute(cmd, ®), Outcome::Rejected("Nicht genug Ausdauer"))); + assert_eq!(game.turn, 0); + } + + #[test] + fn bewegung_im_kampf_wird_abgewiesen() { + let (mut game, reg) = im_kampf(); + assert!(matches!( + game.execute(Command::Move(Direction::North), ®), + Outcome::Rejected(_) + )); + assert_eq!(game.turn, 0); + } + + /// Ticket 14: Tote werden **am Ende** von `execute` entfernt, nach dem + /// Leerarbeiten der Warteschlange. Innerhalb eines Zuges bleiben Indizes + /// stabil. + #[test] + fn tote_gegner_verschwinden_am_zugende() { + let (mut game, reg) = im_kampf(); + for e in &mut game.enemies { + e.health = 1.0; + } + let cmd = Command::UseAbility { + ability: String::from("core:attack"), + target: CreatureRef::Enemy(0), + }; + game.execute(cmd, ®); + assert!(game.enemies.iter().all(|e| e.is_alive()), "Leichen bleiben nicht liegen"); + } + + /// Ticket 14: Stirbt der Held, endet der Lauf — und der Zug bricht ab. + #[test] + fn heldentod_beendet_den_lauf() { + let (mut game, reg) = im_kampf(); + game.player.health = 1.0; + game.player.initiative = -99; // Gegner handeln zuerst + + for _ in 0..40 { + if game.over.is_some() { + break; + } + game.execute(Command::Wait, ®); + } + assert_eq!(game.over, Some(RunOutcome::Died), "irgendwann trifft ein Gegner"); + } + + /// Die Trefferformel aus Ticket 14 war beim ersten Anlauf **toter Code** — + /// jeder Angriff traf. Nur der Compiler hat es gemeldet („`hit_chance` is + /// never used"). Dieser Test haelt sie am Leben. + #[test] + fn angriffe_koennen_verfehlen() { + let (mut game, reg) = im_kampf(); + let ziel = CreatureRef::Enemy(0); + game.enemies[0].health = 10_000.0; + + // Ein unfassbar flinker Gegner: Die Trefferchance faellt auf den Boden + // (5 %), es muss also ueber 200 Angriffe hinweg auch Fehlschlaege geben. + game.enemies[0].base.set(Attribute::Dexterity, 10_000.0); + + let mut treffer = 0; + for _ in 0..200 { + let vorher = game.enemies[0].health; + game.apply( + &Action::Damage { + amount: 5.0, + scale: 0.0, + damage_type: DamageType::Slash, + target: Target::Other, + }, + CreatureRef::Player, + ziel, + ®, + ); + if game.enemies[0].health < vorher { + treffer += 1; + } + } + assert!(treffer > 0, "der Klemmbereich garantiert 5 % Trefferchance"); + assert!(treffer < 200, "ein Angriff muss verfehlen koennen — sonst ist die Formel tot"); + } + + /// Ticket 08: Der Trigger ist die vierte Wirkungsform. `core:poisoned` + /// traegt einen `turn_started`-Trigger, der Schaden macht — ohne Script. + #[test] + fn befristeter_modifikator_tickt_und_verfaellt() { + let (mut game, reg) = zwergenkrieger(); + let p = CreatureRef::Player; + + game.apply( + &Action::ApplyModifier { modifier: String::from("core:poisoned"), target: Target::Zelf }, + p, + p, + ®, + ); + assert!(!game.player.modifiers.is_empty(), "Gift muss aufliegen"); + + // Der Modifikator senkt `damage` um 15 % — vierte Quelle in `attribute`. + let vergiftet = game.attribute(p, Attribute::Damage, None, ®); + game.player.modifiers.clear(); + let sauber = game.attribute(p, Attribute::Damage, None, ®); + assert!(vergiftet < sauber, "Gift muss den Schaden senken: {vergiftet} < {sauber}"); + + // Und er verfaellt: vier Zuege, dann ist er weg. + game.apply( + &Action::ApplyModifier { modifier: String::from("core:poisoned"), target: Target::Zelf }, + p, + p, + ®, + ); + let leben_vorher = game.player.health; + for _ in 0..6 { + game.execute(Command::Wait, ®); + } + assert!(game.player.modifiers.is_empty(), "nach sechs Zuegen ist das Gift weg"); + assert!(game.player.health < leben_vorher, "der Trigger muss geschadet haben"); + } + + /// Resistenz ist typ-indiziert und kommt nur aus Modifikatoren (Ticket 01). + #[test] + fn resistenz_wirkt_typweise() { + let (mut game, reg) = zwergenkrieger(); + let p = CreatureRef::Player; + + assert_eq!(game.resistance(p, DamageType::Fire, ®), 0.0); + game.apply( + &Action::ApplyModifier { modifier: String::from("core:warded"), target: Target::Zelf }, + p, + p, + ®, + ); + assert!((game.resistance(p, DamageType::Fire, ®) - 0.4).abs() < 0.001); + // Der Schutz gilt nur fuer die genannten Typen. + assert_eq!(game.resistance(p, DamageType::Slash, ®), 0.0); + } + #[test] fn absteigen_ohne_treppe_kostet_keinen_zug() { let (mut game, reg) = zwergenkrieger(); diff --git a/src/ui/app.rs b/src/ui/app.rs index e37065a..1ee0357 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -11,7 +11,7 @@ use super::grid::GridWidget; use super::screen::{Response, Screen}; use super::theme; use crate::data::Registries; -use crate::game::{Attribute, CreatureRef, Game, Outcome, RunOutcome}; +use crate::game::{Attribute, CreatureRef, DamageType, Game, Outcome, RunOutcome}; pub struct App { /// `game` und `registries` sind **getrennte Felder**, damit die disjunkten @@ -24,6 +24,28 @@ pub struct App { rejected: Option<&'static str>, } +impl App { + /// Der Spielzustand fuehrt, der Stack folgt: `Game.enemies` und `Game.over` + /// sind die Wahrheit. Ohne das muesste jede Stelle, die einen Kampf startet + /// oder beendet, daran denken, den Screen mitzuschieben. + fn sync_screens(&mut self) { + let in_combat = !self.game.enemies.is_empty(); + let shows_combat = self.screens.iter().any(|s| matches!(s, Screen::Combat { .. })); + + if in_combat && !shows_combat { + self.screens.push(Screen::Combat { pick: 0 }); + } else if !in_combat { + self.screens.retain(|s| !matches!(s, Screen::Combat { .. })); + } + + if self.game.over.is_some() + && !self.screens.iter().any(|s| matches!(s, Screen::GameOver)) + { + self.screens.push(Screen::GameOver); + } + } +} + pub fn run(game: Game, registries: Registries, warnings: usize, log_name: Option) -> std::io::Result<()> { let mut app = App { game, @@ -45,9 +67,12 @@ pub fn run(game: Game, registries: Registries, warnings: usize, log_name: Option continue; } - let Some(screen) = app.screens.last_mut() else { break Ok(()) }; + // Die Screens liegen in `app.screens`, gelesen wird aus `app.game` + // und `app.registries` — drei getrennte Felder, disjunkte Borrows. + let App { game, registries, screens, .. } = &mut app; + let Some(screen) = screens.last_mut() else { break Ok(()) }; - match screen.handle_key(key.code) { + match screen.handle_key(key.code, game, registries) { Response::None => {} Response::Pop => { app.screens.pop(); @@ -67,10 +92,9 @@ pub fn run(game: Game, registries: Registries, warnings: usize, log_name: Option app.screens.pop(); } // Ticket 10/14: Der Lauf endet ueber `Game`, nicht ueber - // die UI — die reagiert nur darauf. - if app.game.over.is_some() { - app.screens.push(Screen::GameOver); - } + // die UI — die reagiert nur darauf. Dasselbe gilt fuer den + // Kampf: `Game.enemies` ist die Wahrheit, der Screen folgt. + app.sync_screens(); } Response::Quit => break Ok(()), } @@ -86,6 +110,7 @@ fn draw(frame: &mut Frame, app: &App) { match screen { Screen::MainMenu => draw_main_menu(frame, area, app), Screen::Explore => draw_explore(frame, area, app), + Screen::Combat { pick } => draw_combat(frame, area, app, *pick), Screen::CharacterSheet => draw_sheet(frame, area, app), Screen::GameOver => draw_game_over(frame, area, app), } @@ -143,6 +168,104 @@ fn draw_explore(frame: &mut Frame, area: Rect, app: &App) { ); } +/// Menue-Kampf (Charter: Dungeon Master / Etrian Odyssey). Links die Gegner mit +/// Ziffern, rechts die Faehigkeiten mit Kosten und **Sperrgrund** — der kommt +/// aus dem Spielkern, nicht aus der UI (Ticket 14). +fn draw_combat(frame: &mut Frame, area: Rect, app: &App, pick: usize) { + let block = Block::bordered().title(" Kampf "); + let inner = block.inner(area); + frame.render_widget(block, area); + + let [links, rechts] = Layout::horizontal([Constraint::Percentage(45), Constraint::Min(0)]) + .areas(inner); + let [gegner_area, log_area] = + Layout::vertical([Constraint::Percentage(45), Constraint::Min(0)]).areas(links); + + // Gegner mit Zielziffer und Lebensbalken. + let mut zeilen = Vec::new(); + for (i, e) in app.game.enemies.iter().enumerate() { + let max = app + .game + .attribute(CreatureRef::Enemy(i), Attribute::MaxHealth, None, &app.registries); + let anteil = if max > 0.0 { (e.health / max).clamp(0.0, 1.0) } else { 0.0 }; + let balken = "█".repeat((anteil * 10.0).round() as usize); + + let marken: String = e.modifiers.iter().map(|m| m.symbol).collect(); + zeilen.push(Line::from(format!( + "{} {:<14} {:<10} {:>4.0}/{:<4.0} {marken}", + i + 1, + e.name, + balken, + e.health.max(0.0), + max + ))); + } + if zeilen.is_empty() { + zeilen.push(Line::from("—").style(Style::new().fg(Color::DarkGray))); + } + frame.render_widget( + Paragraph::new(zeilen).block(Block::bordered().title(" Gegner ")), + gegner_area, + ); + + // Kampflog, jüngste Zeile unten. + let log: Vec = app + .game + .journal + .iter() + .rev() + .take(log_area.height.saturating_sub(2) as usize) + .rev() + .map(|l| Line::from(l.as_str())) + .collect(); + frame.render_widget(Paragraph::new(log).block(Block::bordered().title(" Verlauf ")), log_area); + + // Faehigkeiten. + let mut zeilen = Vec::new(); + for (i, id) in app.game.abilities_of(CreatureRef::Player, &app.registries).iter().enumerate() { + let Some(def) = app.registries.abilities.get(id) else { continue }; + let block = app.game.ability_block(CreatureRef::Player, id, &app.registries); + + let zeiger = if i == pick { "›" } else { " " }; + let text = format!( + "{zeiger} {:<16} {:>3} Ausd. {}", + def.name, + def.cost as u32, + block.unwrap_or("") + ); + let farbe = match (block.is_some(), i == pick) { + (true, _) => Color::DarkGray, + (false, true) => Color::Yellow, + (false, false) => Color::Reset, + }; + zeilen.push(Line::from(text).style(Style::new().fg(farbe))); + } + + let p = &app.game.player; + let max_health = + app.game.attribute(CreatureRef::Player, Attribute::MaxHealth, None, &app.registries); + let max_stamina = + app.game.attribute(CreatureRef::Player, Attribute::MaxStamina, None, &app.registries); + zeilen.push(Line::from("")); + zeilen.push(Line::from(format!( + "{} {:.0}/{:.0} Leben {:.0}/{:.0} Ausdauer", + p.name, p.health.max(0.0), max_health, p.stamina, max_stamina + ))); + if !p.modifiers.is_empty() { + let namen: Vec<&str> = p.modifiers.iter().map(|m| m.name.as_str()).collect(); + zeilen.push(Line::from(namen.join(", ")).style(Style::new().fg(Color::Green))); + } + zeilen.push( + Line::from("↑↓ wählen · 1-9 Ziel · Enter erstes Ziel · . warten · c Bogen") + .style(Style::new().fg(Color::DarkGray)), + ); + + frame.render_widget( + Paragraph::new(zeilen).block(Block::bordered().title(" Fähigkeiten ")), + rechts, + ); +} + /// Der Lauf wird am Ende **erzaehlt** — das ist der Moment, fuer den man die /// Etagen gespielt hat, und der Grund, warum Ticket 14 hier keinen Einzeiler im /// Hauptmenue wollte. @@ -186,8 +309,11 @@ fn draw_sheet(frame: &mut Frame, area: Rect, app: &App) { // Attribute: jeder Wert kommt aus Game::attribute, laeuft also durch alle // Quellen — das ist der Punkt des ganzen Durchstichs. + // `resistance` faellt heraus: Ein einzelner Wert dafuer existiert nicht, es + // sind sieben (Ticket 01). Sie stehen rechts als eigene Zeile. let rows: Vec = Attribute::ALL .iter() + .filter(|a| **a != Attribute::Resistance) .map(|a| { let base = app.game.player.base.get(*a); let total = @@ -233,6 +359,28 @@ fn draw_sheet(frame: &mut Frame, area: Rect, app: &App) { } } + // Resistenz ist typ-indiziert — nur was von null abweicht, ist erwaehnenswert. + let widerstaende: Vec = DamageType::ALL + .iter() + .filter_map(|dt| { + let r = app.game.resistance(CreatureRef::Player, *dt, &app.registries); + (r.abs() > f32::EPSILON).then(|| format!("{} {:+.0}%", dt.name(), r * 100.0)) + }) + .collect(); + if !widerstaende.is_empty() { + lines.push(Line::from("")); + lines.push(Line::from("Resistenz".bold())); + lines.push(Line::from(format!(" {}", widerstaende.join(", ")))); + } + + if !app.game.player.modifiers.is_empty() { + lines.push(Line::from("")); + lines.push(Line::from("Zustände".bold())); + for m in &app.game.player.modifiers { + lines.push(Line::from(format!(" {} ({} Züge)", m.name, m.turns_left))); + } + } + lines.push(Line::from("")); lines.push(Line::from("Esc zurück").style(Style::new().fg(Color::DarkGray))); diff --git a/src/ui/screen.rs b/src/ui/screen.rs index 2aff10a..a85eb6d 100644 --- a/src/ui/screen.rs +++ b/src/ui/screen.rs @@ -2,7 +2,8 @@ use ratatui::crossterm::event::KeyCode; -use crate::game::{Command, Direction}; +use crate::data::Registries; +use crate::game::{Command, CreatureRef, Direction, Game}; /// Popups sind gewoehnliche Screens, die sich beim Rendern als Overlay /// ausweisen — ein gemeinsamer `Vec` statt `current` plus @@ -10,6 +11,11 @@ use crate::game::{Command, Direction}; pub enum Screen { MainMenu, Explore, + /// Menue-Kampf (Charter): Faehigkeitsliste, Zielwahl mit den Zifferntasten. + Combat { + /// Cursor in der Faehigkeitsliste. + pick: usize, + }, CharacterSheet, /// Vollbild, kein Overlay. Bewusst nicht die lazy Variante mit einer /// Meldungszeile im Hauptmenue: Ein Abstiegs-Roguelike lebt davon, dass der @@ -37,7 +43,12 @@ impl Screen { false } - pub fn handle_key(&mut self, key: KeyCode) -> Response { + /// **Befund aus M3:** Ticket 11 hatte `handle_key(&mut self, key)` ohne + /// Kontext festgelegt. Der Kampf-Screen muss aber wissen, wie viele + /// Faehigkeiten es gibt, um den Cursor zu klemmen, und welche der Cursor + /// meint. Lesender Zugriff genuegt — die Regel „ein Screen fasst `Game` + /// nicht an" bleibt, weil nur `Response::Command` etwas aendert. + pub fn handle_key(&mut self, key: KeyCode, game: &Game, reg: &Registries) -> Response { match self { Screen::MainMenu => match key { KeyCode::Enter => Response::Push(Screen::Explore), @@ -68,6 +79,45 @@ impl Screen { _ => Response::None, }, + Screen::Combat { pick } => { + let abilities = game.abilities_of(CreatureRef::Player, reg); + if abilities.is_empty() { + return Response::None; + } + *pick = (*pick).min(abilities.len() - 1); + + match key { + KeyCode::Up | KeyCode::Char('k') => { + *pick = pick.saturating_sub(1); + Response::None + } + KeyCode::Down | KeyCode::Char('j') => { + *pick = (*pick + 1).min(abilities.len() - 1); + Response::None + } + // Ziel ueber die Zifferntasten; ohne Gegner (Flucht, + // Selbstheilung) zaehlt der Held selbst als Ziel. + KeyCode::Enter | KeyCode::Char('1'..='9') => { + let index = match key { + KeyCode::Char(c @ '1'..='9') => (c as u8 - b'1') as usize, + _ => 0, + }; + let target = if game.enemies.is_empty() { + CreatureRef::Player + } else { + CreatureRef::Enemy(index.min(game.enemies.len() - 1)) + }; + Response::Command(Command::UseAbility { + ability: abilities[*pick].clone(), + target, + }) + } + KeyCode::Char('.') => Response::Command(Command::Wait), + KeyCode::Char('c') => Response::Push(Screen::CharacterSheet), + _ => Response::None, + } + } + Screen::CharacterSheet => match key { KeyCode::Esc | KeyCode::Char('c') | KeyCode::Char('q') => Response::Pop, _ => Response::None,