'i' oeffnet denselben Screen wie draussen. `rejects` hat `UseItem` im Kampf nie verboten — es fehlte nur die Tuer in der UI. Dabei: Ein Gegenstand traf `Enemy(0)`, auch wenn der schon tot war. Jetzt der erste lebende. Dass er den Zielcursor ignoriert, steht als TODO — es faellt erst mit dem ersten Wurfgegenstand auf.
1775 lines
72 KiB
Rust
1775 lines
72 KiB
Rust
//! 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::{Cheat, 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, ®.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<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"),
|
||
// Ein Cheat wird nie abgewiesen — das ist sein Zweck.
|
||
Command::Debug(_) => None,
|
||
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(), reg),
|
||
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::Debug(cheat) => self.cheat(cheat, 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 = self.take_at(index);
|
||
let preis = self.sell_price(&inst, reg);
|
||
let name = self.item_label(&inst, reg);
|
||
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"));
|
||
}
|
||
|
||
/// Die Werkbank. Steht im Spielkern und nicht in der UI, weil sie durch
|
||
/// dieselbe Tuer muss wie alles andere: `Command` ist der einzige Weg
|
||
/// hinein (Ticket 11).
|
||
fn cheat(&mut self, cheat: &Cheat, reg: &Registries) {
|
||
match cheat {
|
||
Cheat::Give(id) => {
|
||
let stapelbar = reg.items.get(id).is_some_and(|d| d.stackable);
|
||
let inst = ItemInstance::stack(id.clone(), if stapelbar { 10 } else { 1 });
|
||
let name = self.item_label(&inst, reg);
|
||
self.give(inst, reg);
|
||
self.log(format!("[debug] {name}"));
|
||
}
|
||
Cheat::Heal => {
|
||
let p = CreatureRef::Player;
|
||
self.player.health = self.attribute(p, Attribute::MaxHealth, None, reg);
|
||
self.player.stamina = self.attribute(p, Attribute::MaxStamina, None, reg);
|
||
self.log(String::from("[debug] geheilt"));
|
||
}
|
||
Cheat::Descend => {
|
||
self.log(String::from("[debug] abgestiegen"));
|
||
self.descend(reg);
|
||
}
|
||
Cheat::ClearRoom => {
|
||
for e in &mut self.enemies {
|
||
e.health = 0.0;
|
||
}
|
||
self.log(String::from("[debug] Raum geleert"));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Nimmt **ein Stueck** aus dem Eintrag an `index` und gibt es zurueck.
|
||
/// Ueber den Index, nicht ueber die `def`: Zwei Traenke mit verschiedenen
|
||
/// Affixen teilen sich die `def`, und `take` naehme den erstbesten.
|
||
fn take_at(&mut self, index: usize) -> ItemInstance {
|
||
let slot = &mut self.player.inventory[index];
|
||
slot.count -= 1;
|
||
let leer = slot.count == 0;
|
||
let einzeln = ItemInstance { count: 1, ..slot.clone() };
|
||
if leer {
|
||
self.player.inventory.remove(index);
|
||
}
|
||
einzeln
|
||
}
|
||
|
||
fn use_item(&mut self, index: usize, reg: &Registries) {
|
||
// Ein Stapel wird stueckweise benutzt — `count` wegzuwerfen hiesse,
|
||
// mit einem Trank alle zu trinken.
|
||
let inst = ItemInstance { count: 1, ..self.player.inventory[index].clone() };
|
||
let Some(def) = reg.items.get(&inst.def) else { return };
|
||
|
||
let name = self.item_label(&inst, reg);
|
||
// TODO(M5): Ein Gegenstand trifft immer den ersten Gegner. Seit der
|
||
// Kampf-Screen einen Zielcursor hat, ist das eine Luecke —
|
||
// sie faellt erst auf, wenn `core` den ersten Wurfgegenstand
|
||
// bekommt. Dann traegt `UseItem` ein Ziel wie `UseAbility`.
|
||
let target = match self.enemies.iter().position(|e| e.is_alive()) {
|
||
Some(i) => CreatureRef::Enemy(i),
|
||
None => CreatureRef::Player,
|
||
};
|
||
for action in &def.actions {
|
||
self.apply(action, CreatureRef::Player, target, reg);
|
||
}
|
||
|
||
if def.consumed_on_use {
|
||
self.take_at(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 def = self.player.inventory[index].def.clone();
|
||
let Some(slot) = reg.items.get(&def).and_then(|d| d.slot.clone()) else { return };
|
||
|
||
let inst = self.take_at(index);
|
||
// Was im Slot lag, wandert zurueck ins Inventar — nicht ins Nichts.
|
||
if let Some(alt) = self.player.equipment.remove(&slot) {
|
||
self.give(alt, reg);
|
||
}
|
||
let name = self.item_label(&inst, reg);
|
||
self.player.equipment.insert(slot, inst);
|
||
self.log(format!("{name} angelegt"));
|
||
}
|
||
|
||
fn unequip(&mut self, slot: &str, reg: &Registries) {
|
||
if let Some(inst) = self.player.equipment.remove(slot) {
|
||
// Ueber `give`, damit ein abgelegter Stapel wieder verschmilzt.
|
||
self.give(inst, reg);
|
||
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, ®, 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");
|
||
}
|
||
|
||
/// Gefunden im Spiel: Zwei Heiltraenke lagen als **ein** Stapel im
|
||
/// Inventar, und `use_item` entfernte den Eintrag — ein Schluck, beide weg.
|
||
/// Der Stapel war in M4 dazugekommen, seine Verbraucher nicht mitgezogen.
|
||
#[test]
|
||
fn ein_stapel_wird_stueckweise_verbraucht() {
|
||
let (mut game, reg) = zwergenkrieger();
|
||
game.give(ItemInstance::stack("core:health_potion", 2), ®);
|
||
let platz = game.player.inventory.len() - 1;
|
||
game.player.health = 10.0;
|
||
|
||
assert!(matches!(game.execute(Command::UseItem(platz), ®), Outcome::TurnPassed));
|
||
assert_eq!(game.count_of("core:health_potion"), 1, "nur einer darf weg sein");
|
||
assert!(game.player.health > 10.0, "und er muss auch wirken");
|
||
|
||
// Der zweite raeumt den Eintrag ab, statt einen Stapel der Groesse 0
|
||
// stehen zu lassen.
|
||
assert!(matches!(game.execute(Command::UseItem(platz), ®), Outcome::TurnPassed));
|
||
assert_eq!(game.count_of("core:health_potion"), 0);
|
||
assert!(
|
||
!game.player.inventory.iter().any(|i| i.def == "core:health_potion"),
|
||
"der leere Eintrag muss verschwinden"
|
||
);
|
||
}
|
||
|
||
/// Die Werkbank laeuft durch dieselbe Tuer wie alles andere — sie ist ein
|
||
/// `Command`, kein Seiteneingang in `Game`. Der Test haelt genau das fest.
|
||
#[test]
|
||
fn werkbank_gibt_stapelbares_im_stapel() {
|
||
let (mut game, reg) = zwergenkrieger();
|
||
|
||
let cheat = Command::Debug;
|
||
game.execute(cheat(Cheat::Give(String::from("core:gold"))), ®);
|
||
game.execute(cheat(Cheat::Give(String::from("core:iron_sword"))), ®);
|
||
assert_eq!(game.gold(), 10, "stapelbares kommt zu zehnt");
|
||
assert_eq!(game.count_of("core:iron_sword"), 1, "der Rest einzeln");
|
||
|
||
game.player.health = 1.0;
|
||
game.player.stamina = 0.0;
|
||
game.execute(cheat(Cheat::Heal), ®);
|
||
let voll = game.attribute(CreatureRef::Player, Attribute::MaxHealth, None, ®);
|
||
assert_eq!(game.player.health, voll);
|
||
|
||
// Gegner werden getoetet, nicht entfernt — so laeuft der normale Weg
|
||
// samt „Der Raum ist geräumt".
|
||
let ratte = reg.creatures.get("core:rat").expect("Riesenratte");
|
||
game.enemies = vec![Creature::from_def(ratte)];
|
||
game.execute(cheat(Cheat::ClearRoom), ®);
|
||
assert!(game.enemies.is_empty());
|
||
assert!(game.journal.iter().any(|z| z.contains("geräumt")), "{:?}", game.journal);
|
||
}
|
||
|
||
/// Im Kampf trinken zu koennen ist eine Entscheidung — Zug gegen Leben.
|
||
/// Der Spielkern hat es immer erlaubt, nur der Kampf-Screen hatte keine
|
||
/// Taste dafuer; der Test haelt fest, dass die Erlaubnis echt ist.
|
||
#[test]
|
||
fn ein_trank_im_kampf_kostet_einen_zug() {
|
||
let (mut game, reg) = zwergenkrieger();
|
||
let ratte = reg.creatures.get("core:rat").expect("Riesenratte");
|
||
game.enemies = vec![Creature::from_def(ratte)];
|
||
game.give(ItemInstance::stack("core:health_potion", 2), ®);
|
||
let platz = game.player.inventory.len() - 1;
|
||
game.player.health = 10.0;
|
||
|
||
let vorher = game.turn;
|
||
assert!(matches!(game.execute(Command::UseItem(platz), ®), Outcome::TurnPassed));
|
||
assert!(game.player.health > 10.0, "der Trank wirkt auch im Kampf");
|
||
assert_eq!(game.turn, vorher + 1, "und er kostet den Zug");
|
||
assert_eq!(game.count_of("core:health_potion"), 1);
|
||
}
|
||
}
|