feat: m3 kampf, events und trigger

Der Dungeon hat jetzt Gegner, und man kann sterben.

- game/action.rs: sieben Schadenstypen, fuenf der zehn Aktionen, Target
- game/condition.rs: Bedingungsgrammatik (Liste = UND, not je Eintrag)
- game/event.rs: zwoelf Events, Trigger als vierte Wirkungsform
- game/combat.rs: Treffer- und Schadensformel mit drei Klemmbereichen
- Game::apply, drain_events (FIFO), Initiative, Rundenablauf aus Ticket 14
- data/: abilities-, creatures- und modifiers-Registry, [balance] feldweise
- ui/: Combat-Screen mit Faehigkeitsmenue und Sperrgrund

Drei Befunde:
- Die Trefferformel war toter Code — jeder Angriff traf. Nur der Compiler
  hat es gemeldet ('hit_chance is never used'). Test haelt sie am Leben.
- Die Warteschlange war ein Stack (pop statt pop_front), obwohl Ticket 08
  ausdruecklich 'haengt es hinten an' sagt. Jetzt VecDeque.
- Benannte Modifikatoren (core:poisoned) hatten keine Registry — Ticket 01
  laesst apply_modifier per ID referenzieren, aber keine der zwoelf
  definierte sie. Das ist die dreizehnte.

43 Tests.
This commit is contained in:
2026-08-12 17:03:00 +02:00
parent 8bcd53efa2
commit 217a7acbbc
20 changed files with 1990 additions and 75 deletions

View File

@@ -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<String>,
/// 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<Modifier>,
#[serde(default)]
pub triggers: Vec<Trigger>,
#[serde(default)]
pub overrides: Option<String>,
}
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<Condition>,
#[serde(default)]
pub actions: Vec<Action>,
#[serde(default)]
pub overrides: Option<String>,
}
/// 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<Condition>,
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<String>,
#[serde(default)]
pub tags: Vec<String>,
/// Grundwerte. Was fehlt, bleibt beim Standard.
#[serde(default)]
pub base: HashMap<String, f32>,
#[serde(default)]
pub abilities: Vec<String>,
#[serde(default)]
pub ai: Vec<AiEntry>,
#[serde(default)]
pub triggers: Vec<Trigger>,
/// Kurzform fuer einen Todes-Trigger, den sonst jede Gegnerdefinition
/// mitschleppen muesste (Ticket 15).
#[serde(default)]
pub exp_reward: u32,
#[serde(default)]
pub overrides: Option<String>,
}
/// 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<String>,
#[serde(default)]
pub prefabs: Vec<WeightedTag>,
/// 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<WeightedId>,
#[serde(default)]
pub overrides: Option<String>,
// 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<String, CharacterOption>,
pub prefabs: HashMap<String, Prefab>,
pub floors: HashMap<String, FloorDef>,
// TODO(M3+): `affixes`, `abilities`, `quests`, `creatures`, `loot_tables`,
// `scripts` — zwoelf sind es am Ende.
pub abilities: HashMap<String, Ability>,
pub creatures: HashMap<String, CreatureDef>,
pub modifiers: HashMap<String, ModifierDef>,
/// Keine Registry — ein Block mit festem Schema, feldweise verschmolzen.
pub balance: Balance,
// TODO(M4+): `affixes`, `quests`, `loot_tables`, `scripts`.
}
impl Registries {