//! 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 loot; 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; use crate::data::Registries; /// Wie ein Lauf endet (Ticket 10/14). Sieg ueber das **Fehlen** der naechsten /// Etage — es gibt keine Zahl „5" im Code. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RunOutcome { Died, Won, } pub struct Game { pub player: Creature, /// Der **laufende Kampf**, nicht die Etage (Ticket 07/14). Leer = kein Kampf. pub enemies: Vec, pub floor: Floor, pub player_pos: (u16, u16), pub turn: u64, /// Im Speicherstand, damit Nachladen nicht neu wuerfelt (Ticket 14). pub rng: Rng, pub over: Option, /// Warteschlange statt Rekursion (Ticket 08): Synchron zu feuern hiesse, /// ueber `self.enemies` zu iterieren, waehrend `self.apply` erneut laeuft. pub events: std::collections::VecDeque, /// Letzte Meldung fuer die Statuszeile. pub last: String, /// Kampflog, die letzten Zeilen. pub journal: Vec, /// Mit wem gerade gesprochen wird — Index in `Room.npcs`. pub talking_to: Option, /// Absicht aus `open_shop`. Die Schleife liest sie und schiebt den Screen; /// `game/` kennt keine Screens. pub open_shop: bool, // TODO(M5): `quests`, `current_exp`, `unspent_points` (Ticket 15 — an // `Game`, nicht am `Creature`). } impl Game { /// Erzeugt die erste Etage. Schlaegt fehl, wenn keine `[[floors]]` mit /// `number = 1` existiert — dann ist `core` kaputt, und das faellt beim /// `cargo test` auf (Ticket 04). pub fn new(player: Creature, reg: &Registries, mut rng: Rng) -> Option { let def = reg.floor_number(1)?; let mut floor = generate::generate(def, reg, &mut rng); let player_pos = floor.entrance(); floor.player_room = floor.room_at(player_pos.0, player_pos.1); Some(Self { player, enemies: Vec::new(), floor, player_pos, turn: 0, rng, over: None, events: std::collections::VecDeque::new(), last: String::from("—"), journal: Vec::new(), talking_to: None, open_shop: false, }) } pub fn creature(&self, who: CreatureRef) -> &Creature { match who { CreatureRef::Player => &self.player, CreatureRef::Enemy(i) => &self.enemies[i], } } 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. /// /// Vier Quellen: `base` → `options` → `equipment` samt Affixen → befristete /// Modifikatoren. **Kein Cache** (Ticket 07): Das passiert pro Tastendruck, /// nicht pro Frame. pub fn attribute( &self, who: CreatureRef, attr: Attribute, _against: Option, reg: &Registries, ) -> f32 { let c = self.creature(who); let from_options = c .options .iter() .filter_map(|id| reg.options.get(id)) .flat_map(|o| o.modifiers.iter()); // Gegenstands- **und** Affix-Modifikatoren des Exemplars. Ein Affix ist // strukturell dasselbe wie eine Charakteroption — eine benannte // Modifikatorliste (Ticket 07). let from_equipment = c.equipment.values().flat_map(|inst| { let item = reg.items.get(&inst.def).map(|i| i.modifiers.as_slice()).unwrap_or(&[]); let affixes = inst .affixes .iter() .filter_map(|a| reg.affixes.get(a)) .flat_map(|a| a.modifiers.iter()); item.iter().chain(affixes) }); 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(M5): `_against` fuer bedingte Modifikatoren („+5 gegen Untote", // Ticket 08) — der Parameter steht bereits in der Signatur. 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| { let item = reg.items.get(&inst.def).map(|i| i.modifiers.as_slice()).unwrap_or(&[]); let affixes = inst .affixes .iter() .filter_map(|a| reg.affixes.get(a)) .flat_map(|a| a.modifiers.iter()); item.iter().chain(affixes) }); let flat: f32 = from_options .chain(from_equipment) .filter(matching) .map(|m| m.flat) .sum::() + c.modifiers .iter() .filter(|m| m.attribute == Attribute::Resistance && m.damage_type == Some(dt)) .map(|m| m.flat) .sum::(); flat / 100.0 } // ======================================================================== // Bedingungen — Ticket 08 // ======================================================================== fn who_ref(&self, who: Who, zelf: CreatureRef, target: Option) -> Option { 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, 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()); } } // Getragene Ausruestung — der Dornenpanzer haengt hier. for inst in c.equipment.values() { if let Some(item) = reg.items.get(&inst.def) { out.extend(item.triggers.iter()); } for affix in &inst.affixes { if let Some(def) = reg.affixes.get(affix) { out.extend(def.triggers.iter()); } } } 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, ®.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, ®.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).with_damage(*damage_type), ); self.events.push_back( Event::new(EventKind::DamageTaken, victim).to(source).with_damage(*damage_type), ); 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); } // Der Laden ist reine UI-Folge: Die Aktion setzt eine Absicht, // die Schleife schiebt den Screen. `game/` kennt ratatui nicht. Action::OpenShop => self.open_shop = true, 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, ®.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 { if !t.when.matches(&event) { continue; } 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 { 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 { if self.over.is_some() { return Outcome::Rejected("Der Lauf ist vorbei"); } // 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) if i < self.enemies.len() => self.enemy_acts(i, reg), // Eine geglueckte Flucht leert `enemies` mitten in der // Reihenfolge — die stand vor dem Zug fest und zeigt danach ins // Leere. Kein Gegner mehr da heisst: der Zug ist vorbei. CreatureRef::Enemy(_) => break, } // 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. Ansagen fuer den naechsten Zug. **Nach** dem Entfernen der Toten, // sonst sagt eine Leiche an. for i in 0..self.enemies.len() { self.choose_intent(i, reg); } // 7. self.turn += 1; Outcome::TurnPassed } /// 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::PickUp if in_combat => Some("Nicht im Kampf"), Command::PickUp => { let (x, y) = self.player_pos; let hier = self .floor .player_room .is_some_and(|i| self.floor.rooms[i].loot.contains(&(x, y))); (!hier).then_some("Hier liegt nichts") } Command::UseItem(i) | Command::Drop(i) => { (*i >= self.player.inventory.len()).then_some("Kein solcher Gegenstand") } Command::Equip(i) => { let Some(inst) = self.player.inventory.get(*i) else { return Some("Kein solcher Gegenstand"); }; match reg.items.get(&inst.def).and_then(|d| d.slot.as_ref()) { Some(_) => None, None => Some("Das kann man nicht anlegen"), } } Command::Unequip(slot) => { (!self.player.equipment.contains_key(slot)).then_some("Dort ist nichts") } Command::Talk if in_combat => Some("Nicht im Kampf"), Command::Talk => { let (x, y) = self.player_pos; self.npc_here(x, y).is_none().then_some("Hier steht niemand") } Command::Choose(i) => { (*i >= self.dialog_options(reg).len()).then_some("Keine solche Antwort") } Command::Buy(i) => { let Some(stock) = self.shop_stock() else { return Some("Kein Laden offen"); }; let Some(inst) = stock.get(*i) else { return Some("Nicht im Angebot") }; let preis = self.buy_price(inst, reg); (self.gold() < preis).then_some("Zu wenig Gold") } Command::Sell(i) if *i >= self.player.inventory.len() => { Some("Kein solcher Gegenstand") } // Sein eigenes Gold zurueckzuverkaufen ergibt keinen Sinn. Command::Sell(i) => (self.player.inventory[*i].def == "core:gold") .then_some("Gold verkauft man nicht"), Command::Wait => None, } } pub fn npc_here(&self, x: u16, y: u16) -> Option { let room = self.floor.player_room?; self.floor.rooms[room].npcs.iter().position(|n| n.pos == (x, y)) } fn npc_instance(&self) -> Option<&crate::game::floor::NpcInstance> { self.floor.rooms[self.floor.player_room?].npcs.get(self.talking_to?) } /// Die **erfuellbaren** Optionen des laufenden Gesprraechs. Filtern gehoert /// hierhin und nicht in den Screen — sonst wertete die Anzeige Bedingungen /// aus (Ticket 11). pub fn dialog_options<'r>(&self, reg: &'r Registries) -> Vec<&'r crate::data::DialogOption> { let Some(npc) = self.talking_npc(reg) else { return Vec::new() }; npc.options .iter() .filter(|o| self.conditions_hold(&o.condition, CreatureRef::Player, None, reg)) .collect() } pub fn talking_npc<'r>(&self, reg: &'r Registries) -> Option<&'r crate::data::Npc> { reg.npcs.get(&self.npc_instance()?.def) } pub fn shop_stock(&self) -> Option<&[ItemInstance]> { Some(&self.npc_instance()?.stock) } /// `kauf = item_price × (1 − price_modifier/100)`. /// /// Die Charisma-Formel lebt **hier**, nicht in `Game::attribute` — dort /// waere `price_modifier` das einzige Attribut mit einem Sonderfall, und ein /// generischer Pfad mit einer Ausnahme ist die Stelle, an der spaeter jemand /// raet. pub fn price_modifier(&self, reg: &Registries) -> f32 { let p = CreatureRef::Player; self.attribute(p, Attribute::PriceModifier, None, reg) + self.attribute(p, Attribute::Charisma, None, reg) * reg.balance.charisma_preis } pub fn buy_price(&self, inst: &ItemInstance, reg: &Registries) -> u32 { let roh = self.item_price(inst, reg) as f32; (roh * (1.0 - self.price_modifier(reg) / 100.0)).max(1.0).round() as u32 } /// `verkauf = item_price × VERKAUF × (1 + price_modifier/100)` — die /// **Spanne** verhindert die Geldmaschine, deshalb braucht der Haendler /// keinen Goldvorrat. pub fn sell_price(&self, inst: &ItemInstance, reg: &Registries) -> u32 { let roh = self.item_price(inst, reg) as f32 * reg.balance.verkauf; (roh * (1.0 + self.price_modifier(reg) / 100.0)).max(1.0).round() as u32 } /// 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 { let mut out = vec![String::from("core:attack"), String::from("core:flee")]; out.extend(self.creature(who).abilities.iter().cloned()); for inst in self.creature(who).equipment.values() { if let Some(item) = reg.items.get(&inst.def) { out.extend(item.abilities.iter().cloned()); } } // TODO(M5): Faehigkeiten aus Charakteroptionen — die Registry braucht // dafuer erst ein `abilities`-Feld (Ticket 02). 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 { 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) } Command::PickUp => self.pick_up(reg), Command::UseItem(i) => self.use_item(*i, reg), Command::Equip(i) => self.equip(*i, reg), Command::Unequip(slot) => self.unequip(&slot.clone()), Command::Talk => self.talk(reg), Command::Choose(i) => self.choose(*i, reg), Command::Buy(i) => self.buy(*i, reg), Command::Sell(i) => self.sell(*i, reg), Command::Drop(i) => { let inst = self.player.inventory.remove(*i); let name = self.item_label(&inst, reg); // Fallen gelassenes wird nicht zu einem neuen Haufen: Ein Haufen // traegt eine Tabellen-ID, keinen Gegenstand (Ticket 16). // TODO(M5): Wenn Wegwerfen zurueckholbar sein soll, braucht ein // Raum eine zweite Liste fuer konkrete Instanzen. self.log(format!("{name} weggeworfen")); } } } /// Ticket 16: Der Inhalt eines Haufens wird **erst beim Aufheben** gezogen. /// Ein Gluecksring wirkt damit auch auf Beute, die auf dieser Etage schon /// liegt — bei Ziehung zur Erzeugungszeit waere der Wurf laengst gefallen. fn pick_up(&mut self, reg: &Registries) { let (x, y) = self.player_pos; let Some(room) = self.floor.player_room else { return }; let table = self.floor.rooms[room].loot_table.clone(); self.floor.rooms[room].loot.retain(|p| *p != (x, y)); let beute = loot::roll(&table, reg, &mut self.rng); if beute.is_empty() { // Faellt der Wurf auf eine Niete, verschwindet der Haufen mit einer // Meldung. Kein Neuwuerfeln — das haengt sich bei einer reinen // Nieten-Tabelle auf (Ticket 16). self.log(String::from("Nichts Brauchbares.")); return; } let namen: Vec = beute.iter().map(|i| self.item_label(i, reg)).collect(); for inst in beute { self.give(inst, reg); } self.log(format!("Aufgehoben: {}", namen.join(", "))); self.events.push_back(Event::new(EventKind::ItemPickedUp, CreatureRef::Player)); } fn talk(&mut self, reg: &Registries) { let (x, y) = self.player_pos; let Some(i) = self.npc_here(x, y) else { return }; let room = self.floor.rooms.get(self.floor.player_room.unwrap()).unwrap(); let id = room.npcs[i].def.clone(); self.talking_to = Some(i); if let Some(npc) = reg.npcs.get(&id) && !npc.greeting.is_empty() { self.log(format!("{}: {}", npc.name, npc.greeting)); } } fn choose(&mut self, index: usize, reg: &Registries) { let Some(option) = self.dialog_options(reg).get(index).copied() else { return }; let text = option.text.clone(); let actions = option.actions.clone(); self.log(format!("> {text}")); for action in &actions { self.apply(action, CreatureRef::Player, CreatureRef::Player, reg); } } fn buy(&mut self, index: usize, reg: &Registries) { let Some(stock) = self.shop_stock() else { return }; let inst = stock[index].clone(); let preis = self.buy_price(&inst, reg); // Wird erst genommen, dann gegeben: Ein Fehlschlag hier waere ein // Gegenstand aus dem Nichts. `rejects` hat die Deckung schon geprueft. if !self.take("core:gold", preis) { return; } let name = self.item_label(&inst, reg); self.give(inst, reg); let room = self.floor.player_room.unwrap(); self.floor.rooms[room].npcs[self.talking_to.unwrap()].stock.remove(index); self.log(format!("{name} gekauft fuer {preis} Gold")); } fn sell(&mut self, index: usize, reg: &Registries) { // Ein Stapel wird stueckweise verkauft — sonst gaebe es keinen Weg, // fuenf von fuenfzig Traenken loszuwerden. let inst = ItemInstance { count: 1, ..self.player.inventory[index].clone() }; let preis = self.sell_price(&inst, reg); let name = self.item_label(&inst, reg); // Ueber den Index, nicht ueber `take`: Zwei Traenke mit verschiedenen // Affixen teilen sich die `def`, und `take` naehme den erstbesten. let slot = &mut self.player.inventory[index]; slot.count -= 1; if slot.count == 0 { self.player.inventory.remove(index); } self.give(ItemInstance::stack("core:gold", preis), reg); // Der Haendler hat kein Gold-Konto: Die Spanne, nicht ein Vorrat, // begrenzt den Handel. self.log(format!("{name} verkauft fuer {preis} Gold")); } fn use_item(&mut self, index: usize, reg: &Registries) { let inst = self.player.inventory[index].clone(); let Some(def) = reg.items.get(&inst.def) else { return }; let name = self.item_label(&inst, reg); let target = if self.enemies.is_empty() { CreatureRef::Player } else { CreatureRef::Enemy(0) }; for action in &def.actions { self.apply(action, CreatureRef::Player, target, reg); } if def.consumed_on_use { self.player.inventory.remove(index); } self.log(format!("{name} benutzt")); self.events.push_back(Event::new(EventKind::ItemUsed, CreatureRef::Player)); } fn equip(&mut self, index: usize, reg: &Registries) { let inst = self.player.inventory[index].clone(); let Some(slot) = reg.items.get(&inst.def).and_then(|d| d.slot.clone()) else { return }; // Was im Slot lag, wandert zurueck ins Inventar — nicht ins Nichts. if let Some(alt) = self.player.equipment.remove(&slot) { self.player.inventory.push(alt); } self.player.inventory.remove(index); let name = self.item_label(&inst, reg); self.player.equipment.insert(slot, inst); self.log(format!("{name} angelegt")); } fn unequip(&mut self, slot: &str) { if let Some(inst) = self.player.equipment.remove(slot) { self.player.inventory.push(inst); self.log(String::from("Abgelegt")); } } /// Legt einen Stapel ins Inventar und **verschmilzt ihn**, wenn die /// Stapelregel aus Ticket 07 hält: `stackable` und gleiche `def` und /// gleiche `affixes`. pub fn give(&mut self, inst: ItemInstance, reg: &Registries) { let stapelbar = reg.items.get(&inst.def).is_some_and(|d| d.stackable); if stapelbar && let Some(vorhanden) = self.player.inventory.iter_mut().find(|i| i.stacks_with(&inst)) { vorhanden.count += inst.count; return; } self.player.inventory.push(inst); } /// Nimmt `menge` Stueck eines Gegenstands aus dem Inventar. Gibt zurueck, /// ob genug da war — Gold ist ein gewoehnlicher Gegenstand (Ticket 09), /// also laeuft Bezahlen hier durch. pub fn take(&mut self, def: &str, menge: u32) -> bool { if self.count_of(def) < menge { return false; } let mut rest = menge; self.player.inventory.retain_mut(|i| { if rest == 0 || i.def != def { return true; } let weg = rest.min(i.count); i.count -= weg; rest -= weg; i.count > 0 }); true } pub fn count_of(&self, def: &str) -> u32 { self.player.inventory.iter().filter(|i| i.def == def).map(|i| i.count).sum() } pub fn gold(&self) -> u32 { self.count_of("core:gold") } /// Was der Gegner angesagt hat, als lesbarer Name. `None` heisst: Er hat /// nichts gefunden, was er tun koennte — der Screen zeigt „zoegert". pub fn intent_label(&self, i: usize, reg: &Registries) -> Option { let id = self.enemies.get(i)?.intent.as_ref()?; Some(reg.abilities.get(id).map_or_else(|| id.clone(), |a| a.name.clone())) } /// Der Anzeigename eines Exemplars: Affixe werden vorangestellt — /// „Flammendes Eisenschwert" (Ticket 07). pub fn item_label(&self, inst: &ItemInstance, reg: &Registries) -> String { let base = reg.items.get(&inst.def).map(|d| d.name.as_str()).unwrap_or(&inst.def); let mut teile: Vec<&str> = inst .affixes .iter() .filter_map(|a| reg.affixes.get(a).map(|d| d.name.as_str())) .collect(); teile.push(base); let name = teile.join(" "); if inst.count > 1 { format!("{name} ×{}", inst.count) } else { name } } /// Preis eines Exemplars: `base_price × Π price_factor` der Affixe /// (Ticket 07) — eine Multiplikation, keine Rechnung mit Sonderfaellen. pub fn item_price(&self, inst: &ItemInstance, reg: &Registries) -> u32 { let base = reg.items.get(&inst.def).map_or(0, |d| d.base_price) as f32; let faktor: f32 = inst .affixes .iter() .filter_map(|a| reg.affixes.get(a)) .map(|a| a.price_factor) .product(); (base * faktor).round() as u32 } 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); } } /// Legt fest, was ein Gegner im **naechsten** Zug tut. Erster passender /// **und verfuegbarer** Eintrag der `ai`-Liste gewinnt (Ticket 14) — nur /// eine Runde frueher ausgewertet als bisher. Passt nichts, setzt der /// Gegner aus; einer, der die Runde blockiert, waere schlimmer. fn choose_intent(&mut self, i: usize, reg: &Registries) { let me = CreatureRef::Enemy(i); 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() }); self.enemies[i].intent = choice.map(|e| e.ability.clone()); } fn enemy_acts(&mut self, i: usize, reg: &Registries) { let me = CreatureRef::Enemy(i); if !self.creature(me).is_alive() { return; } // Ohne Ansage kein Zug. Wer mitten im Zug auftaucht, steht ohnehin // nicht in der Initiativreihenfolge — er sagt am Zugende erstmals an. let Some(ability) = self.enemies[i].intent.take() else { return }; // Die Ansage ist verbindlich — und genau deshalb **stoerbar**: Wer dem // Gegner zwischen Ansage und Ausfuehrung die Ausdauer nimmt oder die // Bedingung kippt, kostet ihn den Zug. Das ist der taktische Griff. if let Some(grund) = self.ability_block(me, &ability, reg) { let name = self.creature(me).name.clone(); self.log(format!("{name} kommt nicht dazu — {grund}.")); return; } self.use_ability(me, &ability, CreatureRef::Player, reg); } fn step_target(&self, dir: Direction) -> (u16, u16) { let (x, y) = self.player_pos; 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), } } /// 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; if let Some(i) = room { let prefab = self.floor.rooms[i].prefab.clone(); self.events.push_back( Event::new(EventKind::RoomEntered, CreatureRef::Player).in_prefab(prefab), ); 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(), }; } /// 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 { match self.floor.player_room { Some(i) => self.floor.rooms[i].prefab.clone(), None => String::from("Korridor"), } } /// Ticket 10: Der Abstieg von der **letzten definierten Etage gewinnt den /// 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) { let Some(def) = reg.floor_number(self.floor.number + 1) else { self.over = Some(RunOutcome::Won); 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.log(format!("Etage {}", self.floor.number)); self.events.push_back(Event::new(EventKind::FloorEntered, CreatureRef::Player)); } } #[cfg(test)] mod tests { use super::*; use crate::data::load_mods; fn zwergenkrieger() -> (Game, Registries) { let (reg, _) = load_mods().expect("core muss laden"); let mut hero = Creature::new("Testheld"); hero.options = vec![String::from("core:dwarf"), String::from("core:warrior")]; hero.equipment .insert(String::from("core:hand"), ItemInstance::new("core:iron_sword")); let game = Game::new(hero, ®, Rng::new(99)).expect("Etage 1 muss existieren"); (game, reg) } /// Ticket 10: Sieg ueber das **Fehlen** der naechsten Etage. Wer bis unter /// die letzte definierte Etage steigt, hat gewonnen — ohne dass irgendwo /// eine Fuenf im Code steht. #[test] fn abstieg_unter_die_letzte_etage_gewinnt() { let (mut game, reg) = zwergenkrieger(); let letzte = reg.floors.values().map(|f| f.number).max().expect("Etagen da"); for _ in 0..letzte { let treppe = (0..game.floor.height) .flat_map(|y| (0..game.floor.width).map(move |x| (x, y))) .find(|&(x, y)| game.floor.tile(x, y) == Tile::Stairs) .expect("jede Etage hat eine Treppe (Pflichtraum)"); game.player_pos = treppe; assert!(matches!(game.execute(Command::Descend, ®), Outcome::TurnPassed)); } 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, ®), 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, ®), 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), ®), 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, ®); 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, ®); } assert_eq!(game.over, Some(RunOutcome::Died), "irgendwann trifft ein Gegner"); } /// Ticket 16: Der Inhalt eines Haufens wird **erst beim Aufheben** gezogen. #[test] fn aufheben_zieht_aus_der_tabelle() { let (mut game, reg) = zwergenkrieger(); // Einen Raum mit Beute finden und sich draufstellen. let i = (0..game.floor.rooms.len()) .find(|i| !game.floor.rooms[*i].loot.is_empty()) .expect("mindestens ein Raum hat Beuteplaetze"); game.floor.rooms[i].enemies.clear(); // kein Kampf im Weg let pos = game.floor.rooms[i].loot[0]; game.player_pos = pos; game.floor.player_room = Some(i); let vorher = game.player.inventory.len(); assert!(matches!(game.execute(Command::PickUp, ®), Outcome::TurnPassed)); // Der Haufen ist weg — ob etwas drin war, entscheidet die Tabelle // (Nieten sind gewoehnliche Eintraege). assert!(!game.floor.rooms[i].loot.contains(&pos)); assert!(game.player.inventory.len() >= vorher); } #[test] fn aufheben_wo_nichts_liegt_kostet_keinen_zug() { let (mut game, reg) = zwergenkrieger(); game.player_pos = game.floor.entrance(); game.floor.player_room = game.floor.room_at(game.player_pos.0, game.player_pos.1); assert!(matches!(game.execute(Command::PickUp, ®), Outcome::Rejected(_))); assert_eq!(game.turn, 0); } /// Anlegen tauscht: Was im Slot lag, wandert **zurueck ins Inventar**, nicht /// ins Nichts. #[test] fn anlegen_tauscht_statt_zu_verschlucken() { let (mut game, reg) = zwergenkrieger(); game.player.inventory.push(ItemInstance::new("core:bone_blade")); let index = game.player.inventory.len() - 1; let alt = game.player.equipment.get("core:hand").map(|i| i.def.clone()); game.execute(Command::Equip(index), ®); assert_eq!(game.player.equipment["core:hand"].def, "core:bone_blade"); if let Some(alt) = alt { assert!( game.player.inventory.iter().any(|i| i.def == alt), "das alte Schwert muss zurueck ins Inventar" ); } } /// Ticket 07: Affixe sind Registry-Eintraege, und ihre Modifikatoren /// gehoeren in denselben Attributpfad wie die des Gegenstands. #[test] fn affixe_wirken_auf_attribute_und_preis() { let (mut game, reg) = zwergenkrieger(); let p = CreatureRef::Player; let ohne = game.attribute(p, Attribute::Damage, None, ®); let ohne_preis = game.item_price(&ItemInstance::new("core:iron_sword"), ®); let mut flammend = ItemInstance::new("core:iron_sword"); flammend.affixes.push(String::from("core:flaming")); let mit_preis = game.item_price(&flammend, ®); game.player.equipment.insert(String::from("core:hand"), flammend.clone()); let mit = game.attribute(p, Attribute::Damage, None, ®); assert!(mit > ohne, "der Affix muss Schaden bringen: {mit} > {ohne}"); // Preis = base_price × Π price_factor (Ticket 07). assert!(mit_preis > ohne_preis, "{mit_preis} > {ohne_preis}"); // Und der Anzeigename stellt den Affix voran. assert_eq!(game.item_label(&flammend, ®), "Flammendes Eisenschwert"); } /// 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, ®, ); 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, ®, ); 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, ®); game.player.modifiers.clear(); let sauber = game.attribute(p, Attribute::Damage, None, ®); 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, ®, ); let leben_vorher = game.player.health; for _ in 0..6 { game.execute(Command::Wait, ®); } 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, ®), 0.0); game.apply( &Action::ApplyModifier { modifier: String::from("core:warded"), target: Target::Zelf }, p, p, ®, ); assert!((game.resistance(p, DamageType::Fire, ®) - 0.4).abs() < 0.001); // Der Schutz gilt nur fuer die genannten Typen. assert_eq!(game.resistance(p, DamageType::Slash, ®), 0.0); } #[test] fn absteigen_ohne_treppe_kostet_keinen_zug() { let (mut game, reg) = zwergenkrieger(); game.player_pos = game.floor.entrance(); assert!(matches!(game.execute(Command::Descend, ®), Outcome::Rejected(_))); assert_eq!(game.turn, 0); } /// Der Zielsatz von Meilenstein 1 als Behauptung: ein Wert, der aus einem /// Modifikator stammt, der aus einer TOML-Datei kam. #[test] fn attribut_sammelt_aus_optionen_und_ausruestung() { let (game, reg) = zwergenkrieger(); let p = CreatureRef::Player; // Basis 1, Zwerg +2 (character_options.toml) assert_eq!(game.attribute(p, Attribute::Constitution, None, ®), 3.0); // Basis 2, Eisenschwert +5 flach (items.toml), Krieger +10 % ⇒ (2+5)*1.1 let damage = game.attribute(p, Attribute::Damage, None, ®); assert!((damage - 7.7).abs() < 0.001, "erwartet 7.7, war {damage}"); // Unberuehrtes Attribut bleibt bei der Basis. assert_eq!(game.attribute(p, Attribute::Charisma, None, ®), 1.0); } /// Ticket 07 hat `Registries` **neben** `Game` gelegt, weil ein Feld bei /// jedem Methodenaufruf mit `&mut self` kollidiert waere. Dass dieser Test /// kompiliert, *ist* der Beweis — Ticket 12 konnte ihn nicht fuehren, weil /// der Durchstich keine Registries lud. #[test] fn disjunkte_borrows_ueber_methodenaufrufe_hinweg() { let (mut game, reg) = zwergenkrieger(); game.execute(Command::Move(Direction::North), ®); let _ = game.attribute(CreatureRef::Player, Attribute::Damage, None, ®); game.execute(Command::Wait, ®); } /// Befund aus Ticket 12: „alle Commands kosten einen Zug" heisst **alle /// ausgefuehrten**. #[test] fn wand_kostet_keinen_zug() { let (mut game, reg) = zwergenkrieger(); // Die Ecke oben links ist auf jeder erzeugten Etage Fels. game.player_pos = (1, 1); assert!(matches!( game.execute(Command::Move(Direction::North), ®), Outcome::Rejected(_) )); assert_eq!(game.turn, 0, "eine Wand darf keine Zeit verbrauchen"); assert!(matches!(game.execute(Command::Wait, ®), Outcome::TurnPassed)); assert_eq!(game.turn, 1); } /// Ticket 07 beschrieb die Stapelregel, aber niemand hatte sie umgesetzt — /// der Beutezieher schob fuer "50-120 Gold" bis zu 120 Eintraege ins /// Inventar. Verschiedene Affixe stapeln bewusst **nicht**. #[test] fn gleiche_gegenstaende_stapeln_verschiedene_affixe_nicht() { let (mut game, reg) = zwergenkrieger(); game.give(ItemInstance::stack("core:gold", 30), ®); game.give(ItemInstance::stack("core:gold", 12), ®); assert_eq!(game.gold(), 42); assert_eq!(game.player.inventory.len(), 1, "Gold muss ein Stapel sein"); let mut scharf = ItemInstance::new("core:health_potion"); scharf.affixes = vec![String::from("core:sharp")]; game.give(ItemInstance::new("core:health_potion"), ®); game.give(scharf, ®); assert_eq!( game.player.inventory.iter().filter(|i| i.def == "core:health_potion").count(), 2, "verschiedene Affixe duerfen nicht stapeln" ); assert!(game.take("core:gold", 40)); assert_eq!(game.gold(), 2); assert!(!game.take("core:gold", 5), "zu wenig muss fehlschlagen"); assert_eq!(game.gold(), 2, "der Fehlschlag darf nichts abziehen"); } /// Die **Spanne** ist der ganze Grund, warum der Haendler kein Goldkonto /// braucht: Kaufen und sofort zurueckverkaufen muss Verlust machen. #[test] fn handel_kostet_die_spanne() { let (game, reg) = zwergenkrieger(); let schwert = ItemInstance::new("core:iron_sword"); let kauf = game.buy_price(&schwert, ®); let verkauf = game.sell_price(&schwert, ®); assert!(verkauf < kauf, "Ankauf {verkauf} muss unter Verkauf {kauf} liegen"); } /// Der ganze Weg: ansprechen, Laden oeffnen, kaufen. Prueft nebenbei, dass /// der Warenkorb aus der Erzeugung stammt und nicht beim Oeffnen entsteht. #[test] fn kaufen_zieht_gold_ab_und_leert_das_regal() { let (mut game, reg) = zwergenkrieger(); // Den Schmied auf irgendeiner Etage suchen — welches Prefab wo landet, // entscheidet der Wurf. let mut gefunden = None; for _ in 0..40 { if let Some((r, pos)) = game .floor .rooms .iter() .enumerate() .find_map(|(r, room)| room.npcs.first().map(|n| (r, n.pos))) { gefunden = Some((r, pos)); break; } let def = reg.floor_number(1).expect("Etage 1"); game.floor = generate::generate(def, ®, &mut game.rng); } let (raum, pos) = gefunden.expect("mindestens ein Marktraum in 40 Etagen"); game.player_pos = pos; game.floor.player_room = Some(raum); assert!(matches!(game.execute(Command::Talk, ®), Outcome::TurnPassed)); assert_eq!(game.talking_to, Some(0)); let laden = game.dialog_options(®); assert!(laden.len() >= 3, "die bedingte Option faellt raus, drei bleiben"); assert!(matches!(game.execute(Command::Choose(0), ®), Outcome::TurnPassed)); assert!(game.open_shop, "die erste Option oeffnet den Laden"); let ware = game.shop_stock().expect("Warenkorb").to_vec(); assert!(!ware.is_empty(), "der Korb wird bei der Erzeugung gezogen"); // Ohne Gold ist jeder Kauf eine Abweisung, mit Gold geht er durch. assert!(matches!(game.execute(Command::Buy(0), ®), Outcome::Rejected(_))); let preis = game.buy_price(&ware[0], ®); game.give(ItemInstance::stack("core:gold", preis), ®); assert!(matches!(game.execute(Command::Buy(0), ®), Outcome::TurnPassed)); assert_eq!(game.gold(), 0, "der Preis muss abgezogen sein"); assert_eq!(game.shop_stock().expect("Korb").len(), ware.len() - 1); assert!(game.player.inventory.iter().any(|i| i.def == ware[0].def)); } /// Gefunden im Spiel, nicht im Test: Wer oft genug flieht, trifft den Zug, /// in dem die Flucht **vor** dem Handeln eines Gegners glueckt — dann zeigt /// die vorher festgelegte Initiativreihenfolge auf einen geleerten `enemies`. #[test] fn geglueckte_flucht_bricht_die_initiativreihenfolge_ab() { let (mut game, reg) = zwergenkrieger(); // Zwei Gegner, damit die Reihenfolge einen Index >0 enthaelt, und ein // Held in der Mitte, damit nach seiner Flucht noch jemand drankaeme. let ratte = reg.creatures.values().next().expect("mindestens ein Gegner"); game.enemies = vec![Creature::from_def(ratte), Creature::from_def(ratte)]; for (i, e) in game.enemies.iter_mut().enumerate() { e.initiative = i as i32; } game.player.initiative = 1; // Flucht liegt bei 10-90 %, nach 30 Zuegen ist sie praktisch sicher. for _ in 0..30 { if game.enemies.is_empty() || game.over.is_some() { break; } game.execute( Command::UseAbility { ability: String::from("core:flee"), target: CreatureRef::Player, }, ®, ); } assert!(game.enemies.is_empty(), "in 30 Zuegen muss eine Flucht geglueckt sein"); } /// Die Ansage ist verbindlich — und genau deshalb stoerbar. Wer dem Gegner /// zwischen Ansage und Ausfuehrung die Ausdauer nimmt, kostet ihn den Zug. /// Ohne die Verbindlichkeit gaebe es dieses Fenster nicht: Eine Vorschau /// wuerde einfach auf die naechstbeste Faehigkeit umspringen. #[test] fn angesagte_faehigkeit_laesst_sich_stoeren() { let (mut game, reg) = zwergenkrieger(); let lord = reg.creatures.get("core:bone_lord").expect("Knochenfuerst"); let mut e = Creature::from_def(lord); e.base.set(Attribute::MaxStamina, 100.0); e.stamina = 100.0; game.enemies = vec![e]; // Erster Zug: Der Fuerst steht noch ohne Ansage da und handelt nicht, // sagt am Zugende aber seinen teuersten Schlag an. game.execute(Command::Wait, ®); assert_eq!(game.enemies[0].intent.as_deref(), Some("core:power_strike")); assert_eq!(game.intent_label(0, ®).as_deref(), Some("Wuchtschlag")); game.enemies[0].stamina = 0.0; let vorher = game.player.health; // Die Regeneration von 4 reicht fuer die angesagten 15 nicht. game.execute(Command::Wait, ®); assert!( game.journal.iter().any(|z| z.contains("kommt nicht dazu")), "die gestoerte Ansage muss im Verlauf stehen: {:?}", game.journal ); assert_eq!(game.player.health, vorher, "ein gestoerter Gegner richtet nichts an"); assert!(game.enemies[0].intent.is_some(), "und er sagt fuer den naechsten Zug neu an"); } }