From e145d5d2df754ac051f8e6d412edb60606bb5400 Mon Sep 17 00:00:00 2001 From: MDietermann Date: Thu, 13 Aug 2026 08:33:32 +0200 Subject: [PATCH] fix(kampf): Gegner bekommen endlich ihre Werte MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gemeldet als "Ratten vergiften zu 100 %". Dahinter lagen drei Fehler: 1. Creature::from_def parste Attributnamen mit toml::from_str, das eine ganze TOML-Datei erwartet. Der Aufruf schlug fuer jeden Namen fehl, der Zweig hiess Err(_) => continue — jeder Gegner hatte seit M3 still die Standardwerte. Die Riesenratte: 100 Leben statt 14, der Knochenfuerst war eine grosse Ratte. Kampf gegen den ersten Gegner: 22 Zuege statt 4. 2. attack_hit feuerte bei allen Traegern statt nur beim Angreifer, also vergiftete die Ratte auch bei eigenen Treffern. EventKind:: bound_to_source benennt die zwei seitengebundenen Ereignisse. 3. Action::Damage wuerfelte die Trefferformel auch bei Selbstschaden — ein flinker Held wich seinem eigenen Gift aus. Dazu ein Datenfehler: Das Gift stand in der Aktionsliste von rat_bite, und Aktionen haben kein chance. Es haengt jetzt am Trigger mit 25 %. Drei Regressionstests, darunter der, den es seit M3 haette geben muessen: ein Gegner bekommt die Werte aus seiner Definition. --- .../14-kampfformeln-und-rundenablauf.md | 49 ++++++++++ .scratch/rustkeep-1.0/map.md | 16 ++++ mods/core/abilities.toml | 3 +- mods/core/creatures.toml | 9 ++ src/game/creature.rs | 7 +- src/game/event.rs | 18 ++++ src/game/mod.rs | 92 ++++++++++++++++++- 7 files changed, 190 insertions(+), 4 deletions(-) diff --git a/.scratch/rustkeep-1.0/issues/14-kampfformeln-und-rundenablauf.md b/.scratch/rustkeep-1.0/issues/14-kampfformeln-und-rundenablauf.md index 5443ede..7340b71 100644 --- a/.scratch/rustkeep-1.0/issues/14-kampfformeln-und-rundenablauf.md +++ b/.scratch/rustkeep-1.0/issues/14-kampfformeln-und-rundenablauf.md @@ -284,3 +284,52 @@ Initiativreihenfolge dieses Zuges. **Ein** Ansagepunkt, kein zweiter beim Kampfb Ausdrücklich **nicht** dazugekommen ist ein Ziel an der Ansage: Die `ai` greift ausschließlich den Helden an, `intent` trägt deshalb nur die Fähigkeits-ID. Ein Zielfeld käme mit dem ersten Gegner, der seine Verbündeten heilt. + +### Befunde aus der zweiten Balancing-Runde — drei Fehler, ein Symptom + +Gemeldet wurde „Ratten vergiften zu 100 %, kaum zu überleben". Dahinter lagen drei unabhängige Fehler, +und der größte hatte mit Gift nichts zu tun. + +**1. Jeder Gegner hatte seit M3 die Standardwerte.** `Creature::from_def` las die Attributnamen mit +`toml::from_str::("\"max_health\"")` — und `toml::from_str` erwartet eine ganze TOML-*Datei*, +kein nacktes Skalar. Der Aufruf schlug für **jeden** Namen fehl, der Zweig hieß `Err(_) => continue`, +und damit behielt jedes Wesen still die Voreinstellung: Die Riesenratte hatte **100 Leben statt 14** und +2 Schaden statt 3. Der Knochenfürst war eine große Ratte. Ein Kampf gegen den ersten Gegner dauerte 22 +Züge. + +Kein Test hat das gesehen, weil alle Kampftests ihre Zahlen selbst setzen und nie eine +Gegnerdefinition nachschlugen. Der Fehler ist ein stiller `continue` — die Sorte, gegen die Ticket 04 +„jeder Verlust bekommt eine Zeile" beschlossen hat, nur eben in `game/` statt im Lader. + +**2. Seitengebundene Ereignisse feuerten bei allen.** Dieses Ticket und 08 trennen `attack_hit` +(feuert beim Angreifer) von `damage_taken` (beim Getroffenen), ausdrücklich damit *„kein Trigger erst +prüfen muss, auf welcher Seite er steht"*. Die Trägerschleife in `drain_events` legte aber jedes +Ereignis jedem Beteiligten vor — der Gift-Trigger der Ratte feuerte deshalb auch, wenn der **Held** +zuschlug. Dieselbe Lücke hätte den Dornenpanzer des Angreifers reflektieren lassen. + +`EventKind::bound_to_source()` benennt die beiden. Alle übrigen bleiben offen: `turn_started` muss bei +jedem ticken, sonst vergiftet sich nur der Spieler. + +**3. Man verfehlte sich selbst.** Der M3-Befund „die Trefferformel war toter Code" wurde behoben, indem +`Action::Damage` sie *immer* würfelt. Ein Gift-Tick ist aber kein Angriff, und die Formel liest die +Geschicklichkeit des Ziels — ein flinker Held wich also seinem eigenen Gift aus. Im Log stand +„M verfehlt M". Neue Regel, eine Zeile: **Man verfehlt sich nicht selbst.** + +**Dazu ein Datenfehler, kein Codefehler:** `rat_bite` legte `core:poisoned` in seiner *Aktionsliste* ab, +und Aktionen haben kein `chance` — das gibt es nur am Trigger (Ticket 08). Jeder Biss vergiftete also, +und weil erneutes Auflegen die Dauer auffrischt, war der Held ab dem ersten Treffer dauerhaft vergiftet. +Das Gift hängt jetzt am Trigger der Ratte mit `chance = 0.25` — genau der Fall, für den Ticket 08 das +Feld erfunden hat („20 % Chance, Brennen auszulösen, braucht sonst ein Script"). + +**Danach gemessen**, Elf-Dieb auf Stufe 1, je 40 Kämpfe: + +| Gegner | Züge | Lebensverlust | Tode | +|---|---|---|---| +| Riesenratte | 4,2 | 6 | 0/40 | +| Skelett | 6,6 | 18 | 0/40 | +| Ghul | 15,8 | 94 | 24/40 | +| Knochenfürst | 9,9 | 110 | 40/40 | + +Die Kurve *existiert* damit zum ersten Mal — vorher war jeder Gegner derselbe. Ob Etage 3 bis 5 fair +sind, sagt diese Tabelle **nicht**: Sie vergleicht einen Helden auf Stufe 1 mit Startdolch gegen Gegner, +denen er erst mit mehreren Aufstiegen und besserer Ausrüstung begegnet. Das entscheidet Spielen. diff --git a/.scratch/rustkeep-1.0/map.md b/.scratch/rustkeep-1.0/map.md index 77a0875..5a6ef8e 100644 --- a/.scratch/rustkeep-1.0/map.md +++ b/.scratch/rustkeep-1.0/map.md @@ -235,6 +235,22 @@ direkt aus der Stapelregel aus Ticket 01 und wird wieder zuschnappen, wenn sie n Startkampf danach: Krieger 2,5 Züge je Ratte, Dieb 3,9 bei 20 % Krit und 108 Leben. +**Zweite Balancing-Runde** (76 Tests) — gemeldet als „Ratten vergiften zu 100 %". Dahinter lagen +**drei unabhängige Fehler**, und der größte hatte mit Gift nichts zu tun: + +| Fehler | Wirkung | +|---|---| +| `Creature::from_def` parste Attributnamen mit `toml::from_str`, das eine ganze Datei erwartet | **Jeder Gegner hatte seit M3 die Standardwerte.** Die Riesenratte: 100 Leben statt 14. Der Knochenfürst war eine große Ratte. Ein stilles `Err(_) => continue` | +| `attack_hit` feuerte bei *allen* Trägern statt nur beim Angreifer | Der Gift-Trigger der Ratte feuerte auch, wenn der **Held** zuschlug. Dieselbe Lücke hätte den Dornenpanzer des Angreifers reflektieren lassen | +| `Action::Damage` würfelte die Trefferformel auch bei Selbstschaden | „M verfehlt M" — ein flinker Held wich seinem eigenen Gift aus | + +Dazu ein reiner Datenfehler: Das Gift stand in der *Aktionsliste* von `rat_bite`, und Aktionen haben +kein `chance`. Es hängt jetzt am Trigger — genau der Fall, für den Ticket 08 das Feld erfunden hat. + +Der erste ist der teuerste Befund des Projekts und der einzige, den **kein** Werkzeug gemeldet hat: +kein Compiler, kein clippy, kein Test. Die Kampftests setzen ihre Zahlen selbst und schlugen nie eine +Gegnerdefinition nach. Gefunden hat es das Spielen, und zwar erst, als alles andere schon stimmte. + **Drei Befunde aus M1, die nur der Compiler liefern konnte** — alle in die Tickets zurückgetragen: - **11**: Der Grenztest in seiner wörtlichen Fassung schlägt beim eigenen Doc-Kommentar an. Muss Kommentarzeilen ausnehmen. diff --git a/mods/core/abilities.toml b/mods/core/abilities.toml index 0a12f27..0ba73cf 100644 --- a/mods/core/abilities.toml +++ b/mods/core/abilities.toml @@ -39,8 +39,7 @@ actions = [ { action = "damage", amount = 6, damage_type = "blunt", target = 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" } ] +actions = [ { action = "damage", scale = 1.0, damage_type = "slash", target = "target" } ] [[abilities]] id = "ghoul_claw" diff --git a/mods/core/creatures.toml b/mods/core/creatures.toml index 76b9df2..abbf04b 100644 --- a/mods/core/creatures.toml +++ b/mods/core/creatures.toml @@ -14,6 +14,15 @@ 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" } ] +# Das Gift haengt am TRIGGER, nicht in der Aktionsliste des Bisses: Aktionen +# haben kein `chance`, Trigger schon (Ticket 08). In der Faehigkeit stand es +# ohne Wahrscheinlichkeit — jeder Biss vergiftete, und weil ein erneutes +# Auflegen die Dauer auffrischt, war der Held ab dem ersten Treffer dauerhaft +# vergiftet (Befund M9). +triggers = [ + { on = "attack_hit", chance = 0.25, + actions = [ { action = "apply_modifier", modifier = "core:poisoned", target = "target" } ] }, +] exp_reward = 4 [[creatures]] diff --git a/src/game/creature.rs b/src/game/creature.rs index a3c0d3d..9545941 100644 --- a/src/game/creature.rs +++ b/src/game/creature.rs @@ -124,7 +124,12 @@ impl Creature { c.abilities = def.abilities.clone(); for (name, value) in &def.base { - match toml::from_str::(&format!("\"{name}\"")) { + // **Ueber einen Wert, nicht ueber ein Dokument** (Befund M9): + // `toml::from_str` erwartet eine ganze TOML-Datei, und `"max_health"` + // allein ist keine — der Aufruf schlug also fuer *jeden* + // Attributnamen fehl, und jeder Gegner behielt seit M3 stumm die + // Standardwerte. Die Riesenratte hatte 100 Leben statt 14. + match toml::Value::String(name.clone()).try_into::() { Ok(attr) => c.base.set(attr, *value), // Unbekannter Attributname ist ein Datenfehler und wurde beim // Laden gemeldet — hier schlicht ignorieren. diff --git a/src/game/event.rs b/src/game/event.rs index ded7790..93936e5 100644 --- a/src/game/event.rs +++ b/src/game/event.rs @@ -33,6 +33,24 @@ pub enum EventKind { CombatEnded, } +impl EventKind { + /// Ob das Ereignis **eine Seite hat**. Ticket 08: „`attack_hit` feuert beim + /// Angreifer, `damage_taken` beim Getroffenen — ein gemeinsames Event + /// zwaenge jeden Trigger, erst zu pruefen, auf welcher Seite er steht." + /// + /// Genau das passierte trotzdem, weil die Traegerschleife *jedes* Ereignis + /// jedem Beteiligten vorlegte: Die Riesenratte vergiftete auch dann, wenn + /// der **Held** zuschlug (Befund M9). Bei diesen beiden feuert deshalb nur + /// der Traeger, der in `source` steht. + /// + /// Alle uebrigen bleiben offen: `turn_started` muss bei jedem ticken, sonst + /// vergiftet sich nur der Spieler; `creature_died` will ein „wenn etwas + /// stirbt"-Trigger auch von der Seitenlinie sehen. + pub fn bound_to_source(self) -> bool { + matches!(self, EventKind::AttackHit | EventKind::DamageTaken) + } +} + #[derive(Debug, Clone)] pub struct Event { pub kind: EventKind, diff --git a/src/game/mod.rs b/src/game/mod.rs index d99fd53..ddeb211 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -338,7 +338,11 @@ impl Game { let dodge = self.attribute(victim, Attribute::Dexterity, None, reg); let p = combat::hit_chance(hit, dodge, ®.balance); - if !self.rng.chance(p) { + // **Man verfehlt sich nicht selbst** (Befund M9): Ein + // Gift-Tick ging bis dahin durch die Trefferformel, und die + // liest die Geschicklichkeit des *Ziels* — ein flinker Held + // wich seinem eigenen Gift aus. „M verfehlt M" stand im Log. + if source != victim && !self.rng.chance(p) { self.log(format!( "{} verfehlt {}", self.creature(source).name, @@ -519,6 +523,10 @@ impl Game { // Im Trigger ist `self` immer sein Traeger, `target` die andere // Seite des Events (Ticket 08). for carrier in self.participants() { + // Ein seitengebundenes Ereignis gehoert genau einer Seite. + if event.kind.bound_to_source() && carrier != event.source { + continue; + } // 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); @@ -2264,4 +2272,86 @@ mod tests { game.attribute(p, Attribute::CriticalChance, None, ®) ); } + + /// **Der teuerste Befund des Projekts.** `Creature::from_def` las die + /// Attributnamen mit `toml::from_str`, das eine ganze TOML-*Datei* erwartet + /// — `"max_health"` allein ist keine. Der Aufruf schlug fuer jeden Namen + /// fehl, der Zweig hiess `Err(_) => continue`, und damit behielt **jeder + /// Gegner seit M3 stumm die Standardwerte**: Die Riesenratte hatte 100 + /// Leben statt 14 und 2 Schaden statt 3, der Knochenfuerst genau dasselbe. + /// + /// Kein Test hat je einen Gegnerwert nachgesehen — sie prueften Formeln mit + /// selbst gesetzten Zahlen. Gefunden hat es das Spielen. + #[test] + fn ein_gegner_bekommt_die_werte_aus_seiner_definition() { + let (reg, _) = load_mods().expect("core"); + + let ratte = Creature::from_def(reg.creatures.get("core:rat").expect("Ratte")); + assert_eq!(ratte.base.get(Attribute::MaxHealth), 14.0, "aus der TOML, nicht der Standard"); + assert_eq!(ratte.health, 14.0, "und der Lebensstand faengt dort an"); + assert_eq!(ratte.base.get(Attribute::Damage), 3.0); + assert_eq!(ratte.base.get(Attribute::HitChance), 0.65); + + // Gegenprobe am anderen Ende: Sonst faellt ein Fehler nicht auf, der + // zufaellig genau die Standardwerte liefert. + let fuerst = Creature::from_def(reg.creatures.get("core:bone_lord").expect("Fuerst")); + assert_eq!(fuerst.base.get(Attribute::MaxHealth), 90.0); + assert_eq!(fuerst.base.get(Attribute::CriticalFactor), 2.0); + } + + /// Ticket 08 trennt `attack_hit` (feuert beim Angreifer) von `damage_taken` + /// (beim Getroffenen), damit kein Trigger pruefen muss, auf welcher Seite er + /// steht. Die Traegerschleife legte aber **jedes** Ereignis **jedem** + /// Beteiligten vor — die Ratte vergiftete also auch dann, wenn der Held + /// zuschlug (Befund M9). + #[test] + fn ein_seitengebundenes_ereignis_feuert_nur_bei_seiner_seite() { + let (reg, _) = load_mods().expect("core"); + let mut game = Game::new(Creature::new("M"), ®, Rng::new(5)).expect("E1"); + game.enemies = vec![Creature::from_def(reg.creatures.get("core:rat").expect("Ratte"))]; + + // Fuenfzig eigene Treffer. Bei 25 % Giftchance je Treffer waere der Held + // laengst vergiftet, wenn der Trigger der Ratte hier mitfeuerte. + for _ in 0..50 { + game.events.push_back( + Event::new(EventKind::AttackHit, CreatureRef::Player) + .to(CreatureRef::Enemy(0)), + ); + game.drain_events(®); + } + assert!( + game.player.modifiers.is_empty(), + "der eigene Treffer darf den Trigger des Gegners nicht ausloesen: {:?}", + game.player.modifiers + ); + } + + /// **Man verfehlt sich nicht selbst.** Der Gift-Tick lief durch die + /// Trefferformel, und die liest die Geschicklichkeit des Ziels — ein flinker + /// Held wich seinem eigenen Gift aus. Im Log stand „M verfehlt M". + #[test] + fn eigener_schaden_verfehlt_nie() { + let (reg, _) = load_mods().expect("core"); + let mut game = Game::new(Creature::new("M"), ®, Rng::new(9)).expect("E1"); + // So flink, dass die Trefferchance auf den Boden von 5 % klemmt. + game.player.base.set(Attribute::Dexterity, 10_000.0); + + let vorher = game.player.health; + let tick = Action::Damage { + amount: 1.0, + scale: 0.0, + damage_type: DamageType::Poison, + target: Target::Zelf, + }; + for _ in 0..20 { + game.apply(&tick, CreatureRef::Player, CreatureRef::Player, ®); + } + // Mindestens 1 je Tick: Keiner darf danebengehen. Nach oben offen, + // weil ein Tick kritisch treffen darf — das ist Geschmack, kein Fehler. + assert!( + vorher - game.player.health >= 20.0, + "zwanzig Ticks, zwanzig Treffer — keiner davon ist ein Angriff: {}", + vorher - game.player.health + ); + } }