//! Die Definitionen, die aus TOML geladen werden — Ticket 02. //! //! Einige Felder liest in M1 noch niemand: `base_price` wartet auf den Handel, //! `stackable` und `consumed_on_use` aufs Inventar, `symbol` auf Gegenstaende, //! die auf dem Boden liegen. Sie stehen trotzdem hier, weil sie **das //! Datenmodell** sind und `mods/core` sie bereits schreibt — sie erst spaeter //! einzufuehren hiesse, die TOML-Dateien zweimal anzufassen. //! //! ponytail: modulweites `allow` — es versteckt ab M2 auch echte Leichen. //! Raus, sobald Handel und Inventar die Felder tatsaechlich lesen. #![allow(dead_code)] use std::collections::HashMap; use serde::Deserialize; use crate::game::action::Action; use crate::game::condition::Condition; use crate::game::event::Trigger; use crate::game::Modifier; /// Ein Ding im Spiel, **einheitlich strukturiert**. Es gibt keinen Item-Typ: /// `enum ItemType` vermischte Slot, Verhalten und Kategorie (Ticket 02). #[derive(Debug, Clone, Deserialize)] pub struct Item { pub id: String, pub name: String, #[serde(default)] pub base_price: u32, /// Leer = nicht ausruestbar. pub slot: Option, #[serde(default)] pub stackable: bool, #[serde(default)] pub consumed_on_use: bool, #[serde(default = "default_symbol")] pub symbol: char, #[serde(default)] pub color: Option, #[serde(default)] pub modifiers: Vec, /// Was beim Benutzen passiert (Trank, Schriftrolle). #[serde(default)] pub actions: Vec, /// Faehigkeiten, die der Gegenstand **gewaehrt**, solange er getragen wird. /// Nicht im Inventar tragbar — sonst koennte ein Magier seine Zauber /// verkaufen (Ticket 02). #[serde(default)] pub abilities: Vec, #[serde(default)] pub triggers: Vec, /// Ersetzt den genannten Eintrag und uebernimmt dessen ID (Ticket 03). #[serde(default)] pub overrides: Option, } fn default_symbol() -> char { '?' } /// Eine benannte Stelle am Charakter. Moddbarer Inhalt, kein fester /// Aufzaehlungswert (Ticket 02). #[derive(Debug, Clone, Deserialize)] pub struct Slot { pub id: String, pub name: String, #[serde(default)] pub order: i32, #[serde(default)] pub overrides: Option, } /// Kategorie von Charakteroptionen — selbst Inhalt, mit `pick = N`. #[derive(Debug, Clone, Deserialize)] pub struct Category { pub id: String, pub name: String, #[serde(default)] pub order: i32, #[serde(default = "one")] pub pick: u32, #[serde(default)] pub overrides: Option, } fn one() -> u32 { 1 } /// Eine Wahl beim Charakterbau. Rasse und Klasse sind beides davon. #[derive(Debug, Clone, Deserialize)] pub struct CharacterOption { pub id: String, pub category: String, pub name: String, #[serde(default)] pub description: String, #[serde(default)] pub modifiers: Vec, /// **Was eine Charakteroption dauerhaft traegt** (Ticket 15): Modifikatoren /// veralten — „Zwerg: +2 Konstitution" ist auf Stufe 30 Rauschen —, /// Faehigkeiten nicht. Ein Magier bleibt ein Magier, weil er zaubern kann. #[serde(default)] pub abilities: Vec, /// **Keine neue Mechanik**, sondern gewoehnliche Aktionen, einmal beim /// Charakterbau ausgefuehrt (Ticket 02). Startgold, Startausruestung und /// Startbuff sind damit moddbar, ohne dass die Engine den Begriff /// „Startausruestung" kennt. #[serde(default)] pub on_create: Vec, #[serde(default)] pub overrides: Option, } /// Ein benannter, befristeter Modifikator — „Vergiftet", „Gesegnet". /// /// **Befund aus M3:** Ticket 01 laesst `apply_modifier` einen Modifikator per ID /// referenzieren (`core:poisoned`), aber keine der zwoelf Registries definiert /// benannte Modifikatoren. Das hier ist die **dreizehnte**. #[derive(Debug, Clone, Deserialize)] pub struct ModifierDef { pub id: String, pub name: String, #[serde(default = "default_symbol")] pub symbol: char, #[serde(default)] pub color: Option, /// Zuege bis zum Verfall. Eine Uhr: der Zug (Ticket 01) — Debuffs ticken /// auch beim Erkunden, damit „ich warte den Debuff draussen ab" eine /// Entscheidung mit Kosten bleibt. #[serde(default = "three")] pub turns: u32, /// Die Umrechnungen, die er traegt. Bewusst `attributes` und nicht /// `modifiers`, damit ein Modifikator nicht Modifikatoren enthaelt. #[serde(default)] pub attributes: Vec, #[serde(default)] pub triggers: Vec, #[serde(default)] pub overrides: Option, } fn three() -> u32 { 3 } /// Ein benanntes, wiederholt ausloesbares **Angebot** an den Spieler /// (Ticket 02) mit seiner Kampfsemantik aus Ticket 14. #[derive(Debug, Clone, Deserialize)] pub struct Ability { pub id: String, pub name: String, #[serde(default)] pub description: String, /// Ausdauer — kein zweiter Ressourcenpool (Ticket 14). #[serde(default)] pub cost: f32, /// Abklingzeit in Zuegen. #[serde(default)] pub cooldown: u32, #[serde(default)] pub condition: Vec, #[serde(default)] pub actions: Vec, #[serde(default)] pub overrides: Option, } /// Ein Eintrag der `ai`-Liste: erster passender **und verfuegbarer** gewinnt /// (Ticket 14). Ein Eintrag ohne `condition` greift immer. #[derive(Debug, Clone, Deserialize)] pub struct AiEntry { #[serde(default)] pub condition: Vec, pub ability: String, } /// Gegnerdefinition. Die Luecke lag seit Ticket 02 offen — `Creature` war der /// Laufzeittyp, woher seine Werte kommen, hat nie jemand gesagt (Ticket 14). #[derive(Debug, Clone, Deserialize)] pub struct CreatureDef { pub id: String, pub name: String, #[serde(default = "default_symbol")] pub symbol: char, #[serde(default)] pub color: Option, #[serde(default)] pub tags: Vec, /// Grundwerte. Was fehlt, bleibt beim Standard. #[serde(default)] pub base: HashMap, #[serde(default)] pub abilities: Vec, #[serde(default)] pub ai: Vec, #[serde(default)] pub triggers: Vec, /// Kurzform fuer einen Todes-Trigger, den sonst jede Gegnerdefinition /// mitschleppen muesste (Ticket 15). #[serde(default)] pub exp_reward: u32, #[serde(default)] pub overrides: Option, } /// Die Stellknoepfe aus Ticket 14 und 16. **Keine Registry**: ein einzelner /// Block mit festem Schema, der **feldweise** verschmilzt statt als ganzer /// Eintrag ersetzt zu werden — sonst muesste ein Mod, der nur `ausweich` dreht, /// alle Werte abschreiben, und ein neuer Knopf braeche ihn stillschweigend. /// /// Die Standardwerte liegen hier in der Engine, nicht in `core`. #[derive(Debug, Clone, Deserialize)] #[serde(default)] pub struct Balance { pub ausweich: f32, pub flucht: f32, pub stamina_regen: f32, /// Deckel, damit ein Wesen nicht immun wird — Immunitaet ist keine Zahl, /// sondern ein Spielabbruch (Ticket 14). pub resistance_cap: f32, pub hit_floor: f32, pub hit_ceiling: f32, /// Anteil des Preises, den ein Haendler beim Ankauf zahlt. **Die Spanne**: /// Ohne sie ist Handel ein Exploit oder ein No-op — kaufen und sofort /// zurueckverkaufen waere entweder gratis oder eine Geldmaschine. pub verkauf: f32, /// Wie stark `charisma` auf `price_modifier` wirkt (Ticket 02). pub charisma_preis: f32, /// Die Erfahrungskurve: `exp_base × level^exp_exponent`. **Zwei Zahlen /// statt fuenfzig Tabellenzeilen** (Ticket 15) — eine Tabelle liesse einen /// Modder Level 13 zum Sprung machen, und das ist der Ausbau, nicht der /// Anfang. pub exp_base: f32, pub exp_exponent: f32, /// `p_extra = clamp(luck × luck_factor, 0, 0.9)` — eine Chance auf einen /// zusaetzlichen Zug auf **jeder** Tabelle (Ticket 16). pub luck_factor: f32, /// Was ein Punkt in `strength` bzw. `constitution` tatsaechlich bewirkt. /// /// **Befund aus M5:** Ticket 15 verteilt Punkte auf fuenf Grundwerte, aber /// `strength` und `constitution` las bis dahin *niemand* — zwei der fuenf /// Wahlmoeglichkeiten waren Fallen. Die Umrechnung sitzt hier und nicht in /// `Game::attribute`, weil sie beim **Verteilen** einmal passiert und nicht /// bei jedem Auslesen: Ein Punkt in Konstitution hebt den Grundwert /// `max_health`, wie es Ticket 15 fuer die Zuwachs-Regel voraussetzt. pub damage_per_strength: f32, pub health_per_constitution: f32, } impl Default for Balance { fn default() -> Self { Self { ausweich: 0.02, flucht: 0.05, stamina_regen: 4.0, resistance_cap: 0.8, hit_floor: 0.05, hit_ceiling: 0.95, verkauf: 0.4, charisma_preis: 1.5, exp_base: 20.0, exp_exponent: 1.5, luck_factor: 0.02, damage_per_strength: 1.0, health_per_constitution: 8.0, } } } /// Die **siebte** Registry aus Ticket 08 (inzwischen die fuenfzehnte). /// /// Eine Quest **ist ein Trigger mit `count = N`** — kein zweiter Mechanismus. /// „Besitze 100 Gold" braucht deshalb keine eigene Form: `on = "item_picked_up"`, /// `condition = has_item core:gold >= 100`, `count = 1`. #[derive(Debug, Clone, Deserialize)] pub struct Quest { pub id: String, pub name: String, #[serde(default)] pub description: String, /// Wie oft das Ereignis passen muss. #[serde(default = "one")] pub count: u32, /// Der Trigger selbst — `on`, `when`, `condition`, `actions`, `chance` /// stehen flach in derselben Tabelle, weil die Quest *ist*, was er ist. #[serde(flatten)] pub trigger: Trigger, #[serde(default)] pub overrides: Option, } /// Eine Wahlmoeglichkeit im Gespraech. **Flach, kein Knotengraph** — was erst /// ein Graph koennte, ist Erzaehlung, und das Story-Modul steht unter /// *Out of scope*. #[derive(Debug, Clone, Deserialize)] pub struct DialogOption { pub text: String, #[serde(default)] pub condition: Vec, #[serde(default)] pub actions: Vec, } /// Die **vierzehnte** Registry. Bewusst kein `creatures`-Eintrag mit /// `hostile = false`: Ein Haendler truege sonst `health`, `initiative`, /// `cooldowns`, `ai` und `exp_reward` als dauerhaft toten Ballast. #[derive(Debug, Clone, Deserialize)] pub struct Npc { pub id: String, pub name: String, #[serde(default = "npc_symbol")] pub symbol: char, #[serde(default)] pub color: Option, #[serde(default)] pub greeting: String, /// **Der Warenbestand ist eine Beutetabelle** — dieselben Gewichte, /// dieselbe Tabelle-in-Tabelle, dieselben Affixe. Kein neuer /// „Waren"-Begriff. #[serde(default)] pub shop: Option, #[serde(default)] pub options: Vec, // TODO(M5): `triggers` — erst wenn `triggers_for` NPCs einsammelt. Als // Feld ohne Leser waere es eine Zusage, die niemand einloest. #[serde(default)] pub overrides: Option, } fn npc_symbol() -> char { 'n' } /// 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 { pub id: String, #[serde(default)] pub tags: Vec, pub layout: String, /// Ersetzt die Etagentabelle fuer die `l`-Plaetze dieses Raums (Ticket 16). #[serde(default)] pub loot: Option, /// Wer auf den `n`-Plaetzen steht. Anders als bei `e` nennt das **Prefab** /// den NPC, nicht die Etage: NPCs skalieren nicht mit der Tiefe — ein /// Schmied auf Etage 5 ist derselbe wie auf Etage 1 (Ticket 10). #[serde(default)] pub npcs: Vec, #[serde(default)] pub overrides: Option, } impl Prefab { /// Zeilen ohne fuehrende/abschliessende Leerzeile. TOML-Mehrzeiler beginnen /// mit einem Zeilenumbruch, sobald `"""` allein auf der Zeile steht. pub fn rows(&self) -> Vec<&str> { self.layout.lines().filter(|l| !l.trim().is_empty()).collect() } pub fn size(&self) -> (u16, u16) { let rows = self.rows(); (rows.first().map_or(0, |r| r.chars().count() as u16), rows.len() as u16) } } /// Gewichteter Verweis auf eine Prefab-**Tag**-Gruppe. Tags statt IDs, sonst /// muesste jede Etagentabelle jedes neue Modder-Prefab einzeln aufzaehlen. #[derive(Debug, Clone, Deserialize)] pub struct WeightedTag { pub tag: String, #[serde(default = "one")] pub weight: u32, } /// Eine Ebene des Dungeons und zugleich ihr Bauplan (Ticket 10). #[derive(Debug, Clone, Deserialize)] pub struct FloorDef { pub id: String, pub number: u8, /// Felder, nicht Zellen. pub size: (u16, u16), /// Zielzahl inklusive Pflichtraeume. pub rooms: usize, /// Tags, die garantiert vorkommen — zuerst platziert, das *ist* die Garantie. #[serde(default)] pub required: Vec, #[serde(default)] pub prefabs: Vec, /// Welche Gegner die `e`-Plaetze fuellen. Gewichtet, per **ID** — anders /// 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, } #[derive(Debug, Clone, Deserialize)] pub struct WeightedId { pub id: String, #[serde(default = "one")] pub weight: u32, } /// Alle geladenen Definitionen. Liegt **neben** `Game`, nicht darin — als Feld /// kollidierte `&mut game.player` mit `&game.registries` bei jedem /// Methodenaufruf (Ticket 07). #[derive(Debug, Default)] pub struct Registries { pub items: HashMap, pub slots: HashMap, pub categories: HashMap, pub options: HashMap, pub prefabs: HashMap, pub floors: HashMap, pub abilities: HashMap, pub creatures: HashMap, pub modifiers: HashMap, pub affixes: HashMap, pub loot_tables: HashMap, pub npcs: HashMap, pub quests: HashMap, /// Keine Registry — ein Block mit festem Schema, feldweise verschmolzen. pub balance: Balance, /// Wer geladen wurde, in Ladereihenfolge. **Einziger Leser ist der /// Save-Kopf** (Ticket 03/09): Er stempelt die Versionen, damit ein /// spaeterer Ladelauf „`meinmod` 1.0.0 → 1.2.0 seit dem Speichern" melden /// kann. Das ist die ganze Bilanz des `version`-Felds. pub mods: Vec, // TODO(M8): `scripts` (Ticket 13). } impl Registries { /// Alle Gegenstaende in stabiler Reihenfolge — fuer die Werkbank. Ohne das /// haengt die Liste an der HashMap-Iterationsreihenfolge und der Cursor /// zeigt bei jedem Start woanders hin. pub fn items_ordered(&self) -> Vec<&Item> { let mut v: Vec<&Item> = self.items.values().collect(); v.sort_by(|a, b| a.id.cmp(&b.id)); v } /// Slots in Anzeigereihenfolge. Der Charakterbogen schleift darueber, /// statt ein Layout zu mauern (Ticket 02). /// Wahlkategorien in Anzeigereihenfolge. Der Charakterbau schleift /// darueber, statt ein Layout zu mauern — ein Mod darf „Talent" ergaenzen /// (Ticket 02/11). pub fn categories_ordered(&self) -> Vec<&Category> { let mut v: Vec<&Category> = self.categories.values().collect(); v.sort_by(|a, b| (a.order, &a.id).cmp(&(b.order, &b.id))); v } /// Wie viele der gewaehlten IDs in diese Kategorie fallen. Die Frage /// stellen `rejects`, der Screen und die Anzeige — also steht sie einmal /// hier und nicht dreimal als Filterkette. pub fn picked_in(&self, category: &str, gewaehlt: &[String]) -> usize { gewaehlt.iter().filter(|id| self.options.get(*id).is_some_and(|o| o.category == category)).count() } /// Die Optionen einer Kategorie, in stabiler Reihenfolge. pub fn options_of(&self, category: &str) -> Vec<&CharacterOption> { let mut v: Vec<&CharacterOption> = self.options.values().filter(|o| o.category == category).collect(); v.sort_by(|a, b| a.id.cmp(&b.id)); v } pub fn slots_ordered(&self) -> Vec<&Slot> { let mut v: Vec<&Slot> = self.slots.values().collect(); v.sort_by_key(|s| (s.order, s.id.clone())); v } /// Prefabs mit einem bestimmten Tag, in stabiler Reihenfolge — sonst /// haengt die Etage von der HashMap-Iterationsreihenfolge ab und derselbe /// Seed erzeugt zwei verschiedene Dungeons. pub fn prefabs_tagged(&self, tag: &str) -> Vec<&Prefab> { let mut v: Vec<&Prefab> = self.prefabs.values().filter(|p| p.tags.iter().any(|t| t == tag)).collect(); v.sort_by(|a, b| a.id.cmp(&b.id)); v } pub fn floor_number(&self, number: u8) -> Option<&FloorDef> { // Stabil, falls zwei Mods dieselbe Nummer vergeben: die kleinere ID. // Ohne das entscheidet die HashMap-Reihenfolge, und derselbe Seed // erzeugte zwei verschiedene Dungeons. self.floors.values().filter(|f| f.number == number).min_by_key(|f| &f.id) } }