Files
rustkeep/src/game/mod.rs
MDietermann 6014471c33 feat(kampf): verbindliche Gegner-Ansage und Zielcursor
Am Zugende legt jeder Gegner fest, was er im naechsten Zug tut; der
Kampf-Screen zeigt es unter dem Lebensbalken. Verbindlich statt Vorschau,
denn nur so entsteht ein Fenster: Wer dem Gegner zwischen Ansage und
Ausfuehrung die Ausdauer nimmt, kostet ihn den Zug.

Zielwahl bekommt einen eigenen Cursor, Tab schaltet zwischen Gegner- und
Faehigkeitsliste um — dieselbe Bedienung wie im Laden. Die Ziffern
springen nur noch aufs Ziel, ausgeloest wird mit Enter.
2026-08-12 20:49:24 +02:00

1659 lines
66 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<Creature>,
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<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,
/// Kampflog, die letzten Zeilen.
pub journal: Vec<String>,
/// Mit wem gerade gesprochen wird — Index in `Room.npcs`.
pub talking_to: Option<usize>,
/// 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<Self> {
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<CreatureRef>,
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::<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());
}
}
// 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, &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).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, &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 {
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<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 {
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<usize> {
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<String> {
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<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)
}
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<String> = 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<String> {
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, &reg, 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, &reg), 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, &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");
}
/// 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, &reg), 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, &reg), 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), &reg);
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, &reg);
let ohne_preis =
game.item_price(&ItemInstance::new("core:iron_sword"), &reg);
let mut flammend = ItemInstance::new("core:iron_sword");
flammend.affixes.push(String::from("core:flaming"));
let mit_preis = game.item_price(&flammend, &reg);
game.player.equipment.insert(String::from("core:hand"), flammend.clone());
let mit = game.attribute(p, Attribute::Damage, None, &reg);
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, &reg), "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,
&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();
game.player_pos = game.floor.entrance();
assert!(matches!(game.execute(Command::Descend, &reg), 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, &reg), 3.0);
// Basis 2, Eisenschwert +5 flach (items.toml), Krieger +10 % ⇒ (2+5)*1.1
let damage = game.attribute(p, Attribute::Damage, None, &reg);
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, &reg), 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), &reg);
let _ = game.attribute(CreatureRef::Player, Attribute::Damage, None, &reg);
game.execute(Command::Wait, &reg);
}
/// 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), &reg),
Outcome::Rejected(_)
));
assert_eq!(game.turn, 0, "eine Wand darf keine Zeit verbrauchen");
assert!(matches!(game.execute(Command::Wait, &reg), 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), &reg);
game.give(ItemInstance::stack("core:gold", 12), &reg);
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"), &reg);
game.give(scharf, &reg);
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, &reg);
let verkauf = game.sell_price(&schwert, &reg);
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, &reg, &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, &reg), Outcome::TurnPassed));
assert_eq!(game.talking_to, Some(0));
let laden = game.dialog_options(&reg);
assert!(laden.len() >= 3, "die bedingte Option faellt raus, drei bleiben");
assert!(matches!(game.execute(Command::Choose(0), &reg), 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), &reg), Outcome::Rejected(_)));
let preis = game.buy_price(&ware[0], &reg);
game.give(ItemInstance::stack("core:gold", preis), &reg);
assert!(matches!(game.execute(Command::Buy(0), &reg), 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,
},
&reg,
);
}
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, &reg);
assert_eq!(game.enemies[0].intent.as_deref(), Some("core:power_strike"));
assert_eq!(game.intent_label(0, &reg).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, &reg);
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");
}
}