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,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<Spanned<toml::Value>>,
#[serde(default)]
floors: Vec<Spanned<toml::Value>>,
#[serde(default)]
abilities: Vec<Spanned<toml::Value>>,
#[serde(default)]
creatures: Vec<Spanned<toml::Value>>,
#[serde(default)]
modifiers: Vec<Spanned<toml::Value>>,
/// **Keine** Tabelle mit `[[...]]`, sondern ein einzelner Block, der
/// feldweise verschmilzt — die ausdrueckliche Ausnahme zu Ticket 03.
#[serde(default)]
balance: Option<toml::Value>,
}
/// 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<String>), 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<String>), 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<PathBuf> {
let mut files: Vec<PathBuf> = fs::read_dir(dir)
.into_iter()

View File

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

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 {