Files
rustkeep/src/data/registries.rs
MDietermann 69efa72d68 feat(charakterbau): Rasse und Klasse werden gewaehlt
Der Held war seit M1 als Zwergenkrieger verdrahtet, waehrend zwei Rassen
und zwei Klassen ungenutzt in der Registry lagen. Der Charakterbau ist
eine Schleife ueber categories_ordered() und options_of() — ein Mod, der
"Talent" mit pick = 2 ergaenzt, bekommt eine dritte Spalte ohne Code.

- CharacterOption bekommt abilities und on_create (Ticket 02, TODO seit M3)
- abilities_of liest jetzt Rasse und Klasse: Krieger traegt Wuchtschlag,
  Dieb Hinterhalt, Zwerg Steinsinn
- on_create gibt die Startausruestung als gewoehnliche Aktionen —
  starting_hero() schrumpft auf Creature::new(name)
- MainMenu trennt "neuer Held" von "Lauf fortsetzen"

Befund: Das Feld abilities fehlte, obwohl Ticket 15 sein ganzes Argument
darauf stuetzt — eine Klasse konnte nur flache Modifikatoren geben.
2026-08-13 08:03:19 +02:00

579 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<String>,
#[serde(default)]
pub stackable: bool,
#[serde(default)]
pub consumed_on_use: bool,
#[serde(default = "default_symbol")]
pub symbol: char,
#[serde(default)]
pub color: Option<String>,
#[serde(default)]
pub modifiers: Vec<Modifier>,
/// Was beim Benutzen passiert (Trank, Schriftrolle).
#[serde(default)]
pub actions: Vec<Action>,
/// 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<String>,
#[serde(default)]
pub triggers: Vec<Trigger>,
/// Ersetzt den genannten Eintrag und uebernimmt dessen ID (Ticket 03).
#[serde(default)]
pub overrides: Option<String>,
}
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<String>,
}
/// 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<String>,
}
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<Modifier>,
/// **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<String>,
/// **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<Action>,
#[serde(default)]
pub overrides: Option<String>,
}
/// 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<String>,
/// 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<Modifier>,
#[serde(default)]
pub triggers: Vec<Trigger>,
#[serde(default)]
pub overrides: Option<String>,
}
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<Condition>,
#[serde(default)]
pub actions: Vec<Action>,
#[serde(default)]
pub overrides: Option<String>,
}
/// 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<Condition>,
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<String>,
#[serde(default)]
pub tags: Vec<String>,
/// Grundwerte. Was fehlt, bleibt beim Standard.
#[serde(default)]
pub base: HashMap<String, f32>,
#[serde(default)]
pub abilities: Vec<String>,
#[serde(default)]
pub ai: Vec<AiEntry>,
#[serde(default)]
pub triggers: Vec<Trigger>,
/// Kurzform fuer einen Todes-Trigger, den sonst jede Gegnerdefinition
/// mitschleppen muesste (Ticket 15).
#[serde(default)]
pub exp_reward: u32,
#[serde(default)]
pub overrides: Option<String>,
}
/// 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<String>,
}
/// 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<Condition>,
#[serde(default)]
pub actions: Vec<Action>,
}
/// 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<String>,
#[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<String>,
#[serde(default)]
pub options: Vec<DialogOption>,
// 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<String>,
}
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<Modifier>,
#[serde(default)]
pub triggers: Vec<Trigger>,
#[serde(default)]
pub overrides: Option<String>,
}
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<String>,
/// 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<String>,
/// Dieselbe Maschinerie zieht Gegenstaende *und* Affixe.
#[serde(default)]
pub affix: Option<String>,
/// 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<String>,
/// Affix-Tabelle fuer diesen Gegenstand.
#[serde(default)]
pub affixes: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct LootTable {
pub id: String,
/// Wie viele Zuege, als Bereich `"1-3"`.
#[serde(default)]
pub rolls: Option<String>,
#[serde(default)]
pub entries: Vec<LootEntry>,
#[serde(default)]
pub overrides: Option<String>,
}
/// 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<String>,
pub layout: String,
/// Ersetzt die Etagentabelle fuer die `l`-Plaetze dieses Raums (Ticket 16).
#[serde(default)]
pub loot: Option<String>,
/// 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<String>,
#[serde(default)]
pub overrides: Option<String>,
}
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<String>,
#[serde(default)]
pub prefabs: Vec<WeightedTag>,
/// 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<WeightedId>,
/// Beutetabelle fuer die `l`-Plaetze (Ticket 16).
#[serde(default)]
pub loot: Option<String>,
#[serde(default)]
pub overrides: Option<String>,
}
#[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<String, Item>,
pub slots: HashMap<String, Slot>,
pub categories: HashMap<String, Category>,
pub options: HashMap<String, CharacterOption>,
pub prefabs: HashMap<String, Prefab>,
pub floors: HashMap<String, FloorDef>,
pub abilities: HashMap<String, Ability>,
pub creatures: HashMap<String, CreatureDef>,
pub modifiers: HashMap<String, ModifierDef>,
pub affixes: HashMap<String, Affix>,
pub loot_tables: HashMap<String, LootTable>,
pub npcs: HashMap<String, Npc>,
pub quests: HashMap<String, Quest>,
/// 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<crate::data::save::ModStamp>,
// 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)
}
}