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

@@ -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<RunOutcome>,
/// Warteschlange statt Rekursion (Ticket 08): Synchron zu feuern hiesse,
/// ueber `self.enemies` zu iterieren, waehrend `self.apply` erneut laeuft.
pub events: std::collections::VecDeque<Event>,
/// Letzte Meldung fuer die Statuszeile.
pub last: String,
// TODO(M3): `events: Vec<Event>` — die Warteschlange aus Ticket 08.
/// Kampflog, die letzten Zeilen.
pub journal: Vec<String>,
// 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::<f32>()
+ c.modifiers
.iter()
.filter(|m| m.attribute == Attribute::Resistance && m.damage_type == Some(dt))
.map(|m| m.flat)
.sum::<f32>();
flat / 100.0
}
// ========================================================================
// Bedingungen — Ticket 08
// ========================================================================
fn who_ref(&self, who: Who, zelf: CreatureRef, target: Option<CreatureRef>) -> Option<CreatureRef> {
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<CreatureRef>,
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, &reg.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, &reg.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, &reg.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<CreatureRef> {
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<String> {
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<CreatureRef> {
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, &reg), 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, &reg), 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), &reg),
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, &reg);
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, &reg);
}
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,
&reg,
);
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,
&reg,
);
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, &reg);
game.player.modifiers.clear();
let sauber = game.attribute(p, Attribute::Damage, None, &reg);
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,
&reg,
);
let leben_vorher = game.player.health;
for _ in 0..6 {
game.execute(Command::Wait, &reg);
}
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, &reg), 0.0);
game.apply(
&Action::ApplyModifier { modifier: String::from("core:warded"), target: Target::Zelf },
p,
p,
&reg,
);
assert!((game.resistance(p, DamageType::Fire, &reg) - 0.4).abs() < 0.001);
// Der Schutz gilt nur fuer die genannten Typen.
assert_eq!(game.resistance(p, DamageType::Slash, &reg), 0.0);
}
#[test]
fn absteigen_ohne_treppe_kostet_keinen_zug() {
let (mut game, reg) = zwergenkrieger();