From 197f62ce33f25c8e3dc741e133e6d3c41f320589 Mon Sep 17 00:00:00 2001 From: MDietermann Date: Wed, 12 Aug 2026 17:56:28 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20m4=20teil=201=20=E2=80=94=20beute,=20in?= =?UTF-8?q?ventar=20und=20affixe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - data/: [[loot_tables]] und [[affixes]] als Registries; LootEntry mit guaranteed, nothing, table-in-table, count und affixes - game/loot.rs: EINE Ziehmaschinerie fuer Gegenstaende und Affixe, Tiefendeckel gegen wechselseitigen Bezug - PickUp zieht erst beim Aufheben (Ticket 16) — moeglich nur, weil alle Haufen dasselbe Symbol zeigen - UseItem/Equip/Unequip/Drop, Inventar-Screen als Registry-Schleife - Affixe im Attributpfad, in der Resistenz und in der Trigger-Suche; item_label stellt Affixnamen voran, item_price multipliziert Faktoren - Room haelt eine loot_table (nicht je Haufen eine), Prefab-loot ueberschreibt die Etagentabelle 53 Tests. Offen aus M4: Handel. --- mods/core/affixes.toml | 46 ++++++++ mods/core/floors.toml | 5 + mods/core/items.toml | 10 ++ mods/core/loot.toml | 88 +++++++++++++++ src/data/loader.rs | 12 +- src/data/mod.rs | 4 +- src/data/registries.rs | 92 ++++++++++++++- src/game/command.rs | 11 +- src/game/floor.rs | 5 + src/game/generate.rs | 11 ++ src/game/loot.rs | 207 ++++++++++++++++++++++++++++++++++ src/game/mod.rs | 250 +++++++++++++++++++++++++++++++++++++++-- src/game/rng.rs | 8 ++ src/ui/app.rs | 60 +++++++++- src/ui/grid.rs | 17 +++ src/ui/screen.rs | 42 +++++++ 16 files changed, 849 insertions(+), 19 deletions(-) create mode 100644 mods/core/affixes.toml create mode 100644 mods/core/loot.toml create mode 100644 src/game/loot.rs diff --git a/mods/core/affixes.toml b/mods/core/affixes.toml new file mode 100644 index 0000000..6420c7c --- /dev/null +++ b/mods/core/affixes.toml @@ -0,0 +1,46 @@ +# Affixe (Ticket 07/16): eine benannte Modifikatorliste, die an einem EINZELNEN +# Exemplar haengt. Strukturell dasselbe wie eine Charakteroption — der +# Unterschied ist, woran sie haengt. +# +# Abstufungen sind EIGENE EINTRAEGE, keine Zahl am Exemplar: Eine Staerke je +# Exemplar haette Save-Format (ID-Liste), Preisformel und Stapelregel auf einmal +# gekostet. Mit Stufen kann der Modder seine Abstufungen benennen. + +[[affixes]] +id = "sharp" +name = "Scharfes" +price_factor = 1.3 +modifiers = [ { attribute = "damage", flat = 2 } ] + +[[affixes]] +id = "flaming" +name = "Flammendes" +price_factor = 1.6 +modifiers = [ { attribute = "damage", flat = 3 } ] +triggers = [ + { on = "attack_hit", chance = 0.25, + actions = [ { action = "damage", amount = 4, damage_type = "fire", target = "target" } ] }, +] + +[[affixes]] +id = "greater_flaming" +name = "Stark flammendes" +price_factor = 2.4 +modifiers = [ { attribute = "damage", flat = 6 } ] +triggers = [ + { on = "attack_hit", chance = 0.4, + actions = [ { action = "damage", amount = 9, damage_type = "fire", target = "target" } ] }, +] + +[[affixes]] +id = "sturdy" +name = "Robuste" +price_factor = 1.4 +modifiers = [ { attribute = "max_health", flat = 12 } ] + +[[affixes]] +id = "warding" +name = "Schirmende" +price_factor = 1.8 +modifiers = [ { attribute = "resistance", damage_type = "fire", flat = 20 }, + { attribute = "resistance", damage_type = "ice", flat = 20 } ] diff --git a/mods/core/floors.toml b/mods/core/floors.toml index 9aaae88..cad9eac 100644 --- a/mods/core/floors.toml +++ b/mods/core/floors.toml @@ -16,6 +16,7 @@ rooms = 7 required = ["core:entrance", "core:stairs", "core:merchant"] prefabs = [ { tag = "core:normal", weight = 10 } ] creatures = [ { id = "core:rat", weight = 10 } ] +loot = "core:floor_1_table" [[floors]] id = "floor_2" @@ -26,6 +27,7 @@ required = ["core:entrance", "core:stairs", "core:merchant"] prefabs = [ { tag = "core:normal", weight = 10 }, { tag = "core:treasure", weight = 2 } ] creatures = [ { id = "core:rat", weight = 6 }, { id = "core:skeleton", weight = 6 } ] +loot = "core:floor_1_table" [[floors]] id = "floor_3" @@ -36,6 +38,7 @@ required = ["core:entrance", "core:stairs", "core:merchant"] prefabs = [ { tag = "core:normal", weight = 10 }, { tag = "core:treasure", weight = 3 } ] creatures = [ { id = "core:rat", weight = 3 }, { id = "core:skeleton", weight = 8 }, { id = "core:ghoul", weight = 3 } ] +loot = "core:floor_3_table" [[floors]] id = "floor_4" @@ -46,6 +49,7 @@ required = ["core:entrance", "core:stairs", "core:merchant"] prefabs = [ { tag = "core:normal", weight = 10 }, { tag = "core:treasure", weight = 3 } ] creatures = [ { id = "core:skeleton", weight = 6 }, { id = "core:ghoul", weight = 8 } ] +loot = "core:floor_3_table" [[floors]] id = "floor_5" @@ -56,3 +60,4 @@ required = ["core:entrance", "core:stairs", "core:merchant"] prefabs = [ { tag = "core:normal", weight = 10 }, { tag = "core:treasure", weight = 4 } ] creatures = [ { id = "core:ghoul", weight = 8 }, { id = "core:bone_lord", weight = 2 } ] +loot = "core:floor_5_table" diff --git a/mods/core/items.toml b/mods/core/items.toml index a150d11..65998ba 100644 --- a/mods/core/items.toml +++ b/mods/core/items.toml @@ -53,3 +53,13 @@ triggers = [ when = { damage_type = "fire" }, actions = [ { action = "damage", amount = 3, damage_type = "fire", target = "target" } ] }, ] + +[[items]] +id = "bone_blade" +name = "Knochenklinge" +base_price = 210 +slot = "core:hand" +symbol = "/" +color = "magenta" +modifiers = [ { attribute = "damage", flat = 11 }, + { attribute = "critical_chance", flat = 8 } ] diff --git a/mods/core/loot.toml b/mods/core/loot.toml new file mode 100644 index 0000000..34db25d --- /dev/null +++ b/mods/core/loot.toml @@ -0,0 +1,88 @@ +# Beutetabellen (Ticket 16). EINE Maschinerie fuer Gegenstaende und Affixe: +# dieselben Gewichte, dieselbe Tabelle-in-Tabelle, dieselben Nieten. +# +# Gewichte statt Prozent — Prozente muessten sich auf 100 summieren, und ein +# Eintrag, den ein Mod hinzufuegt, macht die Summe kaputt. +# +# Der MOD-EINHAENGEPUNKT ist Tabelle-in-Tabelle: Ein Mod legt seine eigene +# Tabelle an, bindet `core:floor_3_table` als Untertabelle ein und haengt die +# Etage um. Zwei gewoehnliche Ueberschreibungen, kein zweiter Mechanismus. + +# --- Affixe --------------------------------------------------------------- +# Abstufungen sind EIGENE Eintraege mit kleinerem Gewicht, keine Zahl am +# Exemplar. Deshalb erhoeht ein zusaetzlicher Zug auch die Chance auf die +# staerkere Stufe. + +[[loot_tables]] +id = "weapon_affixes" +rolls = "0-1" +entries = [ + { affix = "core:sharp", weight = 6 }, + { affix = "core:flaming", weight = 3 }, + { affix = "core:greater_flaming", weight = 1 }, + { nothing = true, weight = 14 }, +] + +[[loot_tables]] +id = "armor_affixes" +rolls = "0-1" +entries = [ + { affix = "core:sturdy", weight = 5 }, + { affix = "core:warding", weight = 2 }, + { nothing = true, weight = 16 }, +] + +# --- Etagen --------------------------------------------------------------- + +[[loot_tables]] +id = "floor_1_table" +rolls = "1-2" +entries = [ + { item = "core:health_potion", weight = 12, count = "1-2" }, + { item = "core:gold", weight = 10, count = "3-9" }, + { item = "core:iron_sword", weight = 2, affixes = "core:weapon_affixes" }, + { item = "core:leather_cap", weight = 2, affixes = "core:armor_affixes" }, + { nothing = true, weight = 8 }, +] + +[[loot_tables]] +id = "floor_3_table" +rolls = "1-3" +entries = [ + { item = "core:health_potion", weight = 10, count = "1-2" }, + { item = "core:gold", weight = 10, count = "6-18" }, + { item = "core:iron_sword", weight = 3, affixes = "core:weapon_affixes" }, + { item = "core:thorn_mail", weight = 1, affixes = "core:armor_affixes" }, + { table = "core:rare_table", weight = 2 }, + { nothing = true, weight = 6 }, +] + +[[loot_tables]] +id = "floor_5_table" +rolls = "2-3" +entries = [ + { item = "core:health_potion", weight = 8, count = "2-3" }, + { item = "core:gold", weight = 10, count = "15-40" }, + { item = "core:iron_sword", weight = 4, affixes = "core:weapon_affixes" }, + { item = "core:thorn_mail", weight = 3, affixes = "core:armor_affixes" }, + { table = "core:rare_table", weight = 4 }, +] + +[[loot_tables]] +id = "rare_table" +rolls = "1-1" +entries = [ + { item = "core:bone_blade", weight = 3, affixes = "core:weapon_affixes" }, + { item = "core:thorn_mail", weight = 4, affixes = "core:armor_affixes" }, +] + +# `guaranteed` nimmt an der Gewichtung nicht teil und kommt immer — mit +# Gewichten allein laesst sich "sicher" nicht ausdruecken. +[[loot_tables]] +id = "boss_table" +rolls = "1-2" +entries = [ + { item = "core:bone_blade", guaranteed = true, affixes = "core:weapon_affixes" }, + { item = "core:gold", weight = 10, count = "50-120" }, + { item = "core:health_potion", weight = 6, count = "2-4" }, +] diff --git a/src/data/loader.rs b/src/data/loader.rs index cba2f0f..a4bfe42 100644 --- a/src/data/loader.rs +++ b/src/data/loader.rs @@ -15,8 +15,8 @@ use serde::de::DeserializeOwned; use toml::Spanned; use super::registries::{ - Ability, Category, CharacterOption, CreatureDef, FloorDef, Item, ModifierDef, Prefab, - Registries, Slot, + Ability, Affix, Category, CharacterOption, CreatureDef, FloorDef, Item, LootTable, ModifierDef, + Prefab, Registries, Slot, }; /// Genau drei harte Fehler (Ticket 04). Alles Uebrige ist Warnung plus @@ -257,6 +257,10 @@ struct ModFile { creatures: Vec>, #[serde(default)] modifiers: Vec>, + #[serde(default)] + affixes: Vec>, + #[serde(default)] + loot_tables: Vec>, /// **Keine** Tabelle mit `[[...]]`, sondern ein einzelner Block, der /// feldweise verschmilzt — die ausdrueckliche Ausnahme zu Ticket 03. #[serde(default)] @@ -294,6 +298,8 @@ impl_entry!(FloorDef); impl_entry!(Ability); impl_entry!(CreatureDef); impl_entry!(ModifierDef); +impl_entry!(Affix); +impl_entry!(LootTable); struct FileCtx<'a> { module: &'a str, @@ -502,6 +508,8 @@ pub fn load_mods() -> Result<(Registries, Vec), LoadError> { parse_entries(parsed.abilities, &ctx, &mut reg.abilities, &mut warnings); parse_entries(parsed.creatures, &ctx, &mut reg.creatures, &mut warnings); parse_entries(parsed.modifiers, &ctx, &mut reg.modifiers, &mut warnings); + parse_entries(parsed.affixes, &ctx, &mut reg.affixes, &mut warnings); + parse_entries(parsed.loot_tables, &ctx, &mut reg.loot_tables, &mut warnings); // `[balance]` verschmilzt **feldweise** (Ticket 14): Ein Mod, der // einen Knopf dreht, schreibt eine Zeile — und ein neuer Knopf in diff --git a/src/data/mod.rs b/src/data/mod.rs index dea4325..81328a6 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -5,4 +5,6 @@ pub mod loader; pub mod registries; pub use loader::{load_mods, write_log}; -pub use registries::{Balance, CreatureDef, FloorDef, Prefab, Registries}; +pub use registries::{ + Balance, CreatureDef, FloorDef, LootEntry, Prefab, Registries, parse_range, +}; diff --git a/src/data/registries.rs b/src/data/registries.rs index 2c19780..f39023a 100644 --- a/src/data/registries.rs +++ b/src/data/registries.rs @@ -229,6 +229,90 @@ impl Default for Balance { } } +/// Eine benannte Modifikatorliste, die an einem **einzelnen Exemplar** haengt — +/// „Flammendes Eisenschwert" (Ticket 07). +/// +/// Abstufungen sind **eigene Eintraege** mit kleinerem Gewicht (`core:flaming` +/// neben `core:greater_flaming`), keine Zahl am Exemplar: Eine Staerke je +/// Exemplar haette Save-Format, Preisformel und Stapelregel auf einmal +/// gekostet (Ticket 16). +#[derive(Debug, Clone, Deserialize)] +pub struct Affix { + pub id: String, + /// Wird dem Gegenstandsnamen vorangestellt: „Flammendes Eisenschwert". + pub name: String, + #[serde(default = "one_f32")] + pub price_factor: f32, + #[serde(default)] + pub modifiers: Vec, + #[serde(default)] + pub triggers: Vec, + #[serde(default)] + pub overrides: Option, +} + +fn one_f32() -> f32 { + 1.0 +} + +/// Ein Eintrag einer Beutetabelle. Genau eine der vier Formen (Ticket 16). +#[derive(Debug, Clone, Deserialize)] +pub struct LootEntry { + #[serde(default = "one")] + pub weight: u32, + /// Wird **immer** ausgegeben und nimmt an der Gewichtung nicht teil — mit + /// Gewichten allein laesst sich „sicher" nicht ausdruecken, und Bosse + /// stehen in der Destination. + #[serde(default)] + pub guaranteed: bool, + + #[serde(default)] + pub item: Option, + /// Tabelle-in-Tabelle: liefert Seltenheitsstufen, ohne dass „Seltenheit" + /// ein Begriff werden muss. Zugleich der **Mod-Einhaengepunkt** — ein Mod + /// bindet `core:floor_3_table` als Untertabelle ein. + #[serde(default)] + pub table: Option, + /// Dieselbe Maschinerie zieht Gegenstaende *und* Affixe. + #[serde(default)] + pub affix: Option, + /// Niete. Ein gewoehnlicher Eintrag, kein eigenes `drop_chance`-Feld — die + /// Engine kennt eine Mechanik statt zweier. + #[serde(default)] + pub nothing: bool, + + /// Stapelmenge, als Bereich `"1-2"`. + #[serde(default)] + pub count: Option, + /// Affix-Tabelle fuer diesen Gegenstand. + #[serde(default)] + pub affixes: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct LootTable { + pub id: String, + /// Wie viele Zuege, als Bereich `"1-3"`. + #[serde(default)] + pub rolls: Option, + #[serde(default)] + pub entries: Vec, + #[serde(default)] + pub overrides: Option, +} + +/// Bereichsangabe `"1-3"` oder `"2"`. Ein Zweizeiler statt einer Grammatik. +pub fn parse_range(spec: Option<&String>) -> (u32, u32) { + let Some(s) = spec else { return (1, 1) }; + match s.split_once('-') { + Some((a, b)) => (a.trim().parse().unwrap_or(1), b.trim().parse().unwrap_or(1)), + None => { + let n = s.trim().parse().unwrap_or(1); + (n, n) + } + } +} + /// Ein von Hand gezeichneter Raum als ASCII-Block (Ticket 10). #[derive(Debug, Clone, Deserialize)] pub struct Prefab { @@ -283,9 +367,11 @@ pub struct FloorDef { /// als bei Prefabs, weil eine Etage ihre Gegner benennen koennen soll. #[serde(default)] pub creatures: Vec, + /// Beutetabelle fuer die `l`-Plaetze (Ticket 16). + #[serde(default)] + pub loot: Option, #[serde(default)] pub overrides: Option, - // TODO(M4): `loot` — die Beutetabelle fuer die `l`-Plaetze (Ticket 16). } #[derive(Debug, Clone, Deserialize)] @@ -309,9 +395,11 @@ pub struct Registries { pub abilities: HashMap, pub creatures: HashMap, pub modifiers: HashMap, + pub affixes: HashMap, + pub loot_tables: HashMap, /// Keine Registry — ein Block mit festem Schema, feldweise verschmolzen. pub balance: Balance, - // TODO(M4+): `affixes`, `quests`, `loot_tables`, `scripts`. + // TODO(M5+): `quests` (Ticket 08) und `scripts` (Ticket 13). } impl Registries { diff --git a/src/game/command.rs b/src/game/command.rs index d8e83e2..903e6ab 100644 --- a/src/game/command.rs +++ b/src/game/command.rs @@ -13,13 +13,20 @@ pub enum Direction { /// Ticket 11 hat zwoelf Commands festgelegt; M1 braucht zwei davon. /// -// TODO(M3+): `UseAbility`, `UseItem`, `Equip`, `Unequip`, `Drop`, `PickUp`, -// `Talk`, `Buy`, `Sell` — und `AssignPoints` aus 15. +// TODO(M5+): `Talk`, `Buy`, `Sell` — und `AssignPoints` aus Ticket 15. #[derive(Debug, Clone)] pub enum Command { Move(Direction), Wait, Descend, + /// Hebt den Haufen auf, auf dem der Held steht. Ticket 16: Der Inhalt wird + /// **erst hier** gezogen — moeglich nur, weil alle Haufen dasselbe Symbol + /// zeigen, die Anzeige also nichts verraet. + PickUp, + UseItem(usize), + Equip(usize), + Unequip(String), + Drop(usize), /// Der normale Angriff ist **keine eigene Variante**, sondern /// `UseAbility("core:attack")` — Faehigkeiten sind seit Ticket 02 eine /// Registry, also gehoert er dorthin, und ein Mod kann ihn ersetzen. diff --git a/src/game/floor.rs b/src/game/floor.rs index bbd4c68..d4856e4 100644 --- a/src/game/floor.rs +++ b/src/game/floor.rs @@ -49,6 +49,11 @@ pub struct Room { /// Die Gegner dieses Raums. Wandern beim Betreten nach `Game.enemies` — /// **verschoben, nicht kopiert** (Ticket 10). pub enemies: Vec, + /// Aus welcher Tabelle die Haufen dieses Raums ziehen — Prefab-`loot` oder, + /// wenn es fehlt, die Etagentabelle. **Eine** ID je Raum: Alle Haufen ziehen + /// ohnehin aus derselben, sie je Haufen zu wiederholen waere Redundanz + /// (Ticket 16). + pub loot_table: String, /// Die `l`-Plaetze. Ticket 16: erst beim **Aufheben** aufgeloest, deshalb /// stehen hier Positionen und keine Gegenstaende. pub loot: Vec<(u16, u16)>, diff --git a/src/game/generate.rs b/src/game/generate.rs index e232217..5cb0abc 100644 --- a/src/game/generate.rs +++ b/src/game/generate.rs @@ -142,11 +142,22 @@ pub fn generate(def: &FloorDef, reg: &Registries, rng: &mut Rng) -> Floor { }) .collect(); + // Ein Prefab darf ueber sein optionales `loot`-Feld die Etagentabelle + // ersetzen — sonst ist ein Schatzkammer-Raum nur ein Raum mit mehr + // `l`-Zeichen (Ticket 16). + let loot_table = reg + .prefabs + .get(&p.prefab) + .and_then(|pf| pf.loot.clone()) + .or_else(|| def.loot.clone()) + .unwrap_or_default(); + rooms.push(Room { prefab: p.prefab.clone(), rect: p.rect, enemy_spawns, enemies, + loot_table, loot, cleared: false, }); diff --git a/src/game/loot.rs b/src/game/loot.rs new file mode 100644 index 0000000..17e6a1c --- /dev/null +++ b/src/game/loot.rs @@ -0,0 +1,207 @@ +//! Beutetabellen ziehen — Ticket 16. +//! +//! **Eine** Maschinerie fuer Gegenstaende *und* Affixe: dieselben Gewichte, +//! dieselbe Tabelle-in-Tabelle, dieselben Nieten. +//! +//! Gewichte statt Prozente ist keine Geschmacksfrage, sondern eine +//! Modding-Frage: Prozente muessen sich auf 100 summieren, und ein hinzugefuegter +//! Eintrag macht die Summe kaputt. + +use crate::data::{Registries, parse_range}; + +use super::creature::ItemInstance; +use super::rng::Rng; + +/// Zieht aus einer Tabelle. Rekursion ueber Tabelle-in-Tabelle ist mit einer +/// Tiefe gedeckelt — eine Tabelle, die sich selbst einbindet, wuerde sonst +/// endlos laufen, und das ist ein Datenfehler, kein Absturzgrund. +pub fn roll(table: &str, reg: &Registries, rng: &mut Rng) -> Vec { + let mut out = Vec::new(); + draw(table, reg, rng, 0, &mut out); + out +} + +fn draw( + table_id: &str, + reg: &Registries, + rng: &mut Rng, + depth: u8, + out: &mut Vec, +) { + if depth > 8 { + return; + } + let Some(table) = reg.loot_tables.get(table_id) else { return }; + + // `guaranteed` nimmt an der Gewichtung nicht teil und kommt immer. + for entry in table.entries.iter().filter(|e| e.guaranteed) { + emit(entry, reg, rng, depth, out); + } + + let pool: Vec<&crate::data::LootEntry> = + table.entries.iter().filter(|e| !e.guaranteed).collect(); + if pool.is_empty() { + return; + } + let weights: Vec = pool.iter().map(|e| e.weight).collect(); + + let (lo, hi) = parse_range(table.rolls.as_ref()); + let rolls = rng.range(lo, hi); + for _ in 0..rolls { + if let Some(i) = rng.weighted(&weights) { + emit(pool[i], reg, rng, depth, out); + } + } +} + +fn emit( + entry: &crate::data::LootEntry, + reg: &Registries, + rng: &mut Rng, + depth: u8, + out: &mut Vec, +) { + // Niete: ein gewoehnlicher Eintrag, kein eigenes `drop_chance`-Feld. + if entry.nothing { + return; + } + + if let Some(sub) = &entry.table { + draw(sub, reg, rng, depth + 1, out); + return; + } + + // Ein Affix-Eintrag ausserhalb einer Affix-Tabelle ergibt keinen + // Gegenstand — er wird von `affixes_for` gelesen, nicht hier. + if entry.affix.is_some() { + return; + } + + let Some(item) = &entry.item else { return }; + if !reg.items.contains_key(item) { + return; // beim Laden gemeldet + } + + let affixes = match &entry.affixes { + Some(table) => affixes_for(table, reg, rng), + None => Vec::new(), + }; + + let (lo, hi) = parse_range(entry.count.as_ref()); + for _ in 0..rng.range(lo, hi) { + out.push(ItemInstance { def: item.clone(), affixes: affixes.clone() }); + } +} + +/// Zieht Affixe aus derselben Tabellenmaschinerie. Abstufungen sind eigene +/// Eintraege mit kleinerem Gewicht — deshalb erhoeht ein zusaetzlicher Zug +/// (Glueck) auch die Chance auf die staerkere Stufe. +fn affixes_for(table_id: &str, reg: &Registries, rng: &mut Rng) -> Vec { + let Some(table) = reg.loot_tables.get(table_id) else { return Vec::new() }; + let weights: Vec = table.entries.iter().map(|e| e.weight).collect(); + + let (lo, hi) = parse_range(table.rolls.as_ref()); + let mut out = Vec::new(); + for _ in 0..rng.range(lo, hi) { + let Some(i) = rng.weighted(&weights) else { continue }; + let entry = &table.entries[i]; + if entry.nothing { + continue; + } + if let Some(affix) = &entry.affix + && reg.affixes.contains_key(affix) + && !out.contains(affix) + { + out.push(affix.clone()); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::data::load_mods; + + #[test] + fn niete_liefert_nichts_und_stuerzt_nicht_ab() { + let (reg, _) = load_mods().expect("core"); + let mut rng = Rng::new(1); + // Eine Tabelle, die es nicht gibt, ist kein Absturzgrund. + assert!(roll("gibtsnicht", ®, &mut rng).is_empty()); + } + + #[test] + fn garantierter_eintrag_kommt_immer() { + let (reg, _) = load_mods().expect("core"); + for seed in 1..30u64 { + let mut rng = Rng::new(seed); + let beute = roll("core:boss_table", ®, &mut rng); + assert!( + beute.iter().any(|i| i.def == "core:bone_blade"), + "seed {seed}: das Einzelstueck muss fallen" + ); + } + } + + #[test] + fn affixe_kommen_aus_derselben_maschinerie() { + let (reg, _) = load_mods().expect("core"); + let mut mit_affix = 0; + for seed in 1..80u64 { + let mut rng = Rng::new(seed); + for inst in roll("core:floor_5_table", ®, &mut rng) { + if !inst.affixes.is_empty() { + mit_affix += 1; + // Jeder gezogene Affix muss existieren. + assert!(inst.affixes.iter().all(|a| reg.affixes.contains_key(a))); + } + } + } + assert!(mit_affix > 0, "auf Etage 5 muessen Affixe vorkommen"); + } + + #[test] + fn derselbe_seed_liefert_dieselbe_beute() { + let (reg, _) = load_mods().expect("core"); + let a = roll("core:floor_3_table", ®, &mut Rng::new(77)); + let b = roll("core:floor_3_table", ®, &mut Rng::new(77)); + let ids = |v: &[ItemInstance]| { + v.iter().map(|i| (i.def.clone(), i.affixes.clone())).collect::>() + }; + assert_eq!(ids(&a), ids(&b), "sonst waere der Seed im Save wertlos"); + } + + /// Zwei Tabellen, die sich gegenseitig einbinden, sind ein Datenfehler — + /// aber kein Absturzgrund. Der Tiefendeckel ist die Stelle, die das traegt, + /// also braucht sie einen Test. Gebaut von Hand, damit `core` keine kaputten + /// Daten ausliefert. + #[test] + fn wechselseitiger_bezug_haengt_nicht() { + use crate::data::registries::{LootEntry, LootTable}; + use crate::data::Registries; + + let tabelle = |id: &str, other: &str| LootTable { + id: id.to_string(), + rolls: Some(String::from("1-1")), + entries: vec![LootEntry { + weight: 1, + guaranteed: false, + item: None, + table: Some(other.to_string()), + affix: None, + nothing: false, + count: None, + affixes: None, + }], + overrides: None, + }; + + let mut reg = Registries::default(); + reg.loot_tables.insert(String::from("a"), tabelle("a", "b")); + reg.loot_tables.insert(String::from("b"), tabelle("b", "a")); + + // Terminiert, statt den Stack zu sprengen. + assert!(roll("a", ®, &mut Rng::new(5)).is_empty()); + } +} diff --git a/src/game/mod.rs b/src/game/mod.rs index 0dbad8c..5b830f5 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -9,6 +9,7 @@ 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}; @@ -111,12 +112,17 @@ impl Game { .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| { - reg.items - .get(&inst.def) - .map(|i| i.modifiers.as_slice()) - .unwrap_or(&[]) + 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); @@ -131,8 +137,8 @@ impl Game { .fold((0.0, 0.0), |(f, p), m| (f + m.flat, p + m.percent)); value = (value + flat) * (1.0 + percent / 100.0); - // TODO(M4): Affix-Modifikatoren des Exemplars und `_against` fuer - // bedingte Modifikatoren („+5 gegen Untote", Ticket 08). + // TODO(M5): `_against` fuer bedingte Modifikatoren („+5 gegen Untote", + // Ticket 08) — der Parameter steht bereits in der Signatur. value } @@ -151,7 +157,13 @@ impl Game { .filter_map(|id| reg.options.get(id)) .flat_map(|o| o.modifiers.iter()); let from_equipment = c.equipment.values().flat_map(|inst| { - reg.items.get(&inst.def).map(|i| i.modifiers.as_slice()).unwrap_or(&[]).iter() + let 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 @@ -243,8 +255,11 @@ impl Game { if let Some(item) = reg.items.get(&inst.def) { out.extend(item.triggers.iter()); } - // TODO(M4): Affix-Trigger des Exemplars, sobald die Affix-Registry - // steht — dieselben zwei Zeilen. + 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 @@ -517,6 +532,30 @@ impl Game { 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::Wait => None, } } @@ -530,8 +569,13 @@ impl Game { pub fn abilities_of(&self, who: CreatureRef, reg: &Registries) -> Vec { let mut out = vec![String::from("core:attack"), String::from("core:flee")]; out.extend(self.creature(who).abilities.iter().cloned()); - // TODO(M4): Faehigkeiten aus Charakteroptionen und Ausruestung — beide - // Registries brauchen dafuer erst ein `abilities`-Feld. + 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 @@ -598,9 +642,114 @@ impl Game { 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::Drop(i) => { + let inst = self.player.inventory.remove(*i); + let name = self.item_label(&inst, reg); + // Fallen gelassenes wird nicht zu einem neuen Haufen: Ein Haufen + // traegt eine Tabellen-ID, keinen Gegenstand (Ticket 16). + // TODO(M5): Wenn Wegwerfen zurueckholbar sein soll, braucht ein + // Raum eine zweite Liste fuer konkrete Instanzen. + self.log(format!("{name} weggeworfen")); + } } } + /// Ticket 16: Der Inhalt eines Haufens wird **erst beim Aufheben** gezogen. + /// Ein Gluecksring wirkt damit auch auf Beute, die auf dieser Etage schon + /// liegt — bei Ziehung zur Erzeugungszeit waere der Wurf laengst gefallen. + fn pick_up(&mut self, reg: &Registries) { + let (x, y) = self.player_pos; + let Some(room) = self.floor.player_room else { return }; + let table = self.floor.rooms[room].loot_table.clone(); + self.floor.rooms[room].loot.retain(|p| *p != (x, y)); + + let beute = loot::roll(&table, reg, &mut self.rng); + if beute.is_empty() { + // Faellt der Wurf auf eine Niete, verschwindet der Haufen mit einer + // Meldung. Kein Neuwuerfeln — das haengt sich bei einer reinen + // Nieten-Tabelle auf (Ticket 16). + self.log(String::from("Nichts Brauchbares.")); + return; + } + + let namen: Vec = beute.iter().map(|i| self.item_label(i, reg)).collect(); + self.player.inventory.extend(beute); + self.log(format!("Aufgehoben: {}", namen.join(", "))); + self.events.push_back(Event::new(EventKind::ItemPickedUp, CreatureRef::Player)); + } + + 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")); + } + } + + /// 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); + teile.join(" ") + } + + /// 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, @@ -901,6 +1050,85 @@ mod tests { 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. diff --git a/src/game/rng.rs b/src/game/rng.rs index 272699b..20c448f 100644 --- a/src/game/rng.rs +++ b/src/game/rng.rs @@ -41,6 +41,14 @@ impl Rng { if n == 0 { 0 } else { (self.next_u64() % n as u64) as usize } } + /// Einschliesslich beider Grenzen. + pub fn range(&mut self, lo: u32, hi: u32) -> u32 { + if hi <= lo { + return lo; + } + lo + self.below((hi - lo + 1) as usize) as u32 + } + pub fn chance(&mut self, p: f32) -> bool { (self.next_u64() % 10_000) < (p.clamp(0.0, 1.0) * 10_000.0) as u64 } diff --git a/src/ui/app.rs b/src/ui/app.rs index 1ee0357..f9dea6b 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -111,6 +111,7 @@ fn draw(frame: &mut Frame, app: &App) { Screen::MainMenu => draw_main_menu(frame, area, app), Screen::Explore => draw_explore(frame, area, app), Screen::Combat { pick } => draw_combat(frame, area, app, *pick), + Screen::Inventory { pick } => draw_inventory(frame, area, app, *pick), Screen::CharacterSheet => draw_sheet(frame, area, app), Screen::GameOver => draw_game_over(frame, area, app), } @@ -159,7 +160,7 @@ fn draw_explore(frame: &mut Frame, area: Rect, app: &App) { }; frame.render_widget( Paragraph::new(format!( - "Zug {} │ {} │ hjkl/Pfeile .warten >absteigen c Bogen │ {}", + "Zug {} │ {} │ .warten g aufheben >absteigen i Inventar c Bogen │ {}", app.game.turn, app.game.player.name, note )) .right_aligned() @@ -168,6 +169,63 @@ fn draw_explore(frame: &mut Frame, area: Rect, app: &App) { ); } +/// Links das Inventar mit Cursor, rechts die Ausrüstung als **Schleife über die +/// Slot-Registry** — Modder können Slots ergänzen, ohne dass hier etwas +/// geändert wird (Ticket 02). +fn draw_inventory(frame: &mut Frame, area: Rect, app: &App, pick: usize) { + let block = Block::bordered().title(" Inventar "); + let inner = block.inner(area); + frame.render_widget(block, area); + let [links, rechts] = + Layout::horizontal([Constraint::Percentage(60), Constraint::Min(0)]).areas(inner); + + let mut zeilen = Vec::new(); + for (i, inst) in app.game.player.inventory.iter().enumerate() { + let def = app.registries.items.get(&inst.def); + let name = app.game.item_label(inst, &app.registries); + let preis = app.game.item_price(inst, &app.registries); + let slot = def.and_then(|d| d.slot.as_deref()).unwrap_or("—"); + + let zeiger = if i == pick { "›" } else { " " }; + let farbe = if i == pick { Color::Yellow } else { Color::Reset }; + zeilen.push( + Line::from(format!("{zeiger} {name:<30} {slot:<12} {preis:>5} G")) + .style(Style::new().fg(farbe)), + ); + } + if zeilen.is_empty() { + zeilen.push(Line::from("leer").style(Style::new().fg(Color::DarkGray))); + } + zeilen.push(Line::from("")); + zeilen.push( + Line::from("↑↓ wählen · u benutzen · e anlegen · d wegwerfen · 1-9 ablegen · Esc") + .style(Style::new().fg(Color::DarkGray)), + ); + + frame.render_widget( + Paragraph::new(zeilen).block(Block::bordered().title(" Getragen ")), + links, + ); + + let mut aus = Vec::new(); + for (i, slot) in app.registries.slots_ordered().iter().enumerate() { + let getragen = app.game.player.equipment.get(&slot.id); + let text = match getragen { + Some(inst) => app.game.item_label(inst, &app.registries), + None => String::from("—"), + }; + let farbe = if getragen.is_some() { Color::Reset } else { Color::DarkGray }; + aus.push( + Line::from(format!("{} {:<10} {}", i + 1, slot.name, text)) + .style(Style::new().fg(farbe)), + ); + } + frame.render_widget( + Paragraph::new(aus).block(Block::bordered().title(" Ausrüstung ")), + rechts, + ); +} + /// Menue-Kampf (Charter: Dungeon Master / Etrian Odyssey). Links die Gegner mit /// Ziffern, rechts die Faehigkeiten mit Kosten und **Sperrgrund** — der kommt /// aus dem Spielkern, nicht aus der UI (Ticket 14). diff --git a/src/ui/grid.rs b/src/ui/grid.rs index 2c8999c..df50af9 100644 --- a/src/ui/grid.rs +++ b/src/ui/grid.rs @@ -36,6 +36,23 @@ impl Widget for GridWidget<'_> { } } + // Beutehaufen: **ein** generisches Symbol fuer alles. Nur deshalb darf + // der Inhalt erst beim Aufheben gezogen werden — die Anzeige verraet + // nichts, was noch nicht gewuerfelt ist (Ticket 16). + for room in &self.floor.rooms { + for (lx, ly) in &room.loot { + if *lx < ox || *ly < oy { + continue; + } + let (px, py) = (area.x + lx - ox, area.y + ly - oy); + if px < area.right() && py < area.bottom() { + buf[(px, py)] + .set_symbol("*") + .set_style(Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD)); + } + } + } + let (sx, sy) = (area.x + self.player.0 - ox, area.y + self.player.1 - oy); if sx < area.right() && sy < area.bottom() { buf[(sx, sy)] diff --git a/src/ui/screen.rs b/src/ui/screen.rs index a85eb6d..b5f3ef7 100644 --- a/src/ui/screen.rs +++ b/src/ui/screen.rs @@ -16,6 +16,10 @@ pub enum Screen { /// Cursor in der Faehigkeitsliste. pick: usize, }, + /// Zwei Bereiche: Inventar mit Cursor, Ausrüstung als Registry-Schleife. + Inventory { + pick: usize, + }, CharacterSheet, /// Vollbild, kein Overlay. Bewusst nicht die lazy Variante mit einer /// Meldungszeile im Hauptmenue: Ein Abstiegs-Roguelike lebt davon, dass der @@ -73,8 +77,10 @@ impl Screen { } KeyCode::Char('.') => Response::Command(Command::Wait), KeyCode::Char('>') => Response::Command(Command::Descend), + KeyCode::Char('g') => Response::Command(Command::PickUp), // Screen-Navigation ist reine UI und fasst `Game` nie an. KeyCode::Char('c') => Response::Push(Screen::CharacterSheet), + KeyCode::Char('i') => Response::Push(Screen::Inventory { pick: 0 }), KeyCode::Esc => Response::Pop, _ => Response::None, }, @@ -118,6 +124,42 @@ impl Screen { } } + Screen::Inventory { pick } => { + let n = game.player.inventory.len(); + if n > 0 { + *pick = (*pick).min(n - 1); + } + + match key { + KeyCode::Up | KeyCode::Char('k') => { + *pick = pick.saturating_sub(1); + Response::None + } + KeyCode::Down | KeyCode::Char('j') => { + *pick = (*pick + 1).min(n.saturating_sub(1)); + Response::None + } + KeyCode::Enter | KeyCode::Char('u') if n > 0 => { + Response::Command(Command::UseItem(*pick)) + } + KeyCode::Char('e') if n > 0 => Response::Command(Command::Equip(*pick)), + KeyCode::Char('d') if n > 0 => Response::Command(Command::Drop(*pick)), + // Ablegen ueber die Slot-Reihenfolge aus der Registry — + // der Charakterbogen mauert kein Layout (Ticket 02). + KeyCode::Char(c @ '1'..='9') => { + let index = (c as u8 - b'1') as usize; + match reg.slots_ordered().get(index) { + Some(slot) => { + Response::Command(Command::Unequip(slot.id.clone())) + } + None => Response::None, + } + } + KeyCode::Esc | KeyCode::Char('i') | KeyCode::Char('q') => Response::Pop, + _ => Response::None, + } + } + Screen::CharacterSheet => match key { KeyCode::Esc | KeyCode::Char('c') | KeyCode::Char('q') => Response::Pop, _ => Response::None,