fix(kampf): Gegner bekommen endlich ihre Werte
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.
This commit is contained in:
@@ -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::<Attribute>("\"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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]]
|
||||
|
||||
@@ -124,7 +124,12 @@ impl Creature {
|
||||
c.abilities = def.abilities.clone();
|
||||
|
||||
for (name, value) in &def.base {
|
||||
match toml::from_str::<Attribute>(&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::<Attribute>() {
|
||||
Ok(attr) => c.base.set(attr, *value),
|
||||
// Unbekannter Attributname ist ein Datenfehler und wurde beim
|
||||
// Laden gemeldet — hier schlicht ignorieren.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user