diff --git a/.scratch/rustkeep-1.0/issues/01-effekt-vokabular.md b/.scratch/rustkeep-1.0/issues/01-effekt-vokabular.md index 28078a1..91dab2e 100644 --- a/.scratch/rustkeep-1.0/issues/01-effekt-vokabular.md +++ b/.scratch/rustkeep-1.0/issues/01-effekt-vokabular.md @@ -151,7 +151,11 @@ Fähigkeiten. Das Vokabular bleibt hier unverändert. ### Der Katalog `damage` · `change_attribute` · `apply_modifier` · `remove_modifier` · `give_item` · `remove_item` · -`start_quest` (08) · `flee` (14) · `run_script` (13) · `give_exp` (15) — **zehn**. +`start_quest` (08) · `flee` (14) · `run_script` (13) · `give_exp` (15) · `open_shop` (M4) — +**elf**. + +`open_shop` kam mit dem Handelsmodell dazu: Eine Dialogoption oeffnet den Laden, und als Aktion steht +sie in derselben Liste wie `start_quest` statt als Sonderfeld daneben. ### Nachtrag aus M3: benannte Modifikatoren brauchen eine Registry diff --git a/.scratch/rustkeep-1.0/issues/07-game-state-baum.md b/.scratch/rustkeep-1.0/issues/07-game-state-baum.md index ee592da..1138ff1 100644 --- a/.scratch/rustkeep-1.0/issues/07-game-state-baum.md +++ b/.scratch/rustkeep-1.0/issues/07-game-state-baum.md @@ -116,10 +116,20 @@ Mit frei zusammengesetzten Modifikatoren je Exemplar könnte ein zufällig erzeu alles, praktisch aber nichts, das ein Modder benennen oder ein Händler bepreisen könnte — und die Anzeige hätte keinen Namen für das, was da liegt. -**Stapelregel:** `stackable` **und** gleiche `def` **und** gleiche `affixes`. Ein Vergleich zweier -`Vec`, eine Zeile, verbietet nichts. Affixe an ausrüstbare Gegenstände zu binden wäre eine -Regel, die dem Modder erklärt, was er darf — Bevormundung, die 02 abgeräumt hat. Dass ein Trank mit -Zufallsaffix nicht mehr stapelt, ist die logische Folge und das Problem dessen, der ihn so baut. +**Stapelregel:** `stackable` **und** gleiche `def` **und** gleiche `affixes`. + +Ein Vergleich zweier `Vec`, eine Zeile, verbietet nichts. Affixe an ausrüstbare Gegenstände zu +binden wäre eine Regel, die dem Modder erklärt, was er darf — Bevormundung, die 02 abgeräumt hat. Dass +ein Trank mit Zufallsaffix nicht mehr stapelt, ist die logische Folge und das Problem dessen, der ihn +so baut. + +**Befund aus M4: Die Regel war bis dahin nur beschrieben.** `ItemInstance` hatte kein `count`, und der +Beutezieher schob für `count = "50-120"` bis zu **120 einzelne Einträge** ins Inventar — 120 Zeilen im +Inventar-Screen, und ein Kauf hätte 45 davon einzeln entfernen müssen. `ItemInstance` trägt jetzt ein +`count`, und die Stapelregel wird beim Aufheben *ausgeführt* statt nur beschrieben. + +Damit bedeutet auch `stackable` aus Ticket 02 zum ersten Mal etwas — das Feld stand seit M1 in den +Daten und hat nie jemand gelesen. ### Ein Eingang, ein Ausgang diff --git a/.scratch/rustkeep-1.0/issues/11-ui-geruest-screen-modell.md b/.scratch/rustkeep-1.0/issues/11-ui-geruest-screen-modell.md index e617129..05b4715 100644 --- a/.scratch/rustkeep-1.0/issues/11-ui-geruest-screen-modell.md +++ b/.scratch/rustkeep-1.0/issues/11-ui-geruest-screen-modell.md @@ -143,6 +143,11 @@ Kein `ItemDetail`-Popup: Die Inventarliste zeigt Details in einer Seitenspalte, kein Screen. `Shop` bleibt getrennt von `Dialog`, weil Handel zwei Listen nebeneinander braucht und der Dialog eine Textfläche mit Antwortoptionen. +*Nachtrag aus M4:* Der `Dialog`-Screen zeigt **eine flache Optionsliste**, keinen Knotengraph — Text +des NPC oben, darunter die Optionen, deren `condition` gerade hält. Ein Graph waere die Maschine fuers +Story-Modul, und das steht unter *Out of scope*. `Talk` wirkt dabei auf das Feld, auf dem der Held +steht: dieselbe Regel wie `PickUp` und `Descend`. + `ListState` lebt in der Screen-Variante und wird beim `pop()` mitverworfen. Der Stack macht den wichtigen Fall gratis richtig: Solange ein Screen im Stack *liegt*, existiert sein Zustand weiter — Inventar → Detail → zurück landet wieder auf demselben Eintrag. Zurückgesetzt wird nur beim echten diff --git a/.scratch/rustkeep-1.0/map.md b/.scratch/rustkeep-1.0/map.md index 8afa2a9..9cfabd2 100644 --- a/.scratch/rustkeep-1.0/map.md +++ b/.scratch/rustkeep-1.0/map.md @@ -91,6 +91,28 @@ Lücke**: Ein Trigger kann die Nutzlast des Events, das ihn ausgelöst hat, **ni sieht nur Wesen. „Dornenpanzer reflektiert nur Feuer" ist damit nicht formulierbar. Zu entscheiden vor M4, weil dort Affix- und Gegenstands-Trigger kommen. +**M4 — Beute, Inventar, Affixe und Handel** (`feat/m4-beute`, `feat/m4-handel`, 56 Tests) + +| Zusage | Aus | Befund | +|---|---|---| +| Eine Maschinerie für Gegenstände *und* Affixe | 16 | **bestätigt** — dieselben Gewichte, dieselbe Tabelle-in-Tabelle | +| Beute erst beim Aufheben auflösen, ein Symbol für alles | 16 | **bestätigt** — die Anzeige verrät nichts, was noch nicht gewürfelt ist | +| Affix ist eine benannte Modifikatorliste am Exemplar | 07 | **bestätigt** — strukturell dasselbe wie eine Charakteroption, `attribute()` blieb unverändert | +| Speicherstand bleibt eine ID-Liste | 07 | **bestätigt** — `ItemInstance` trägt `def`, `affixes`, `count` | +| Stapelregel: gleiche `def` **und** gleiche `affixes` | 07 | **war nie umgesetzt** — siehe unten | +| Gold ist ein gewöhnlicher Gegenstand | 09 | **bestätigt** — Bezahlen läuft durch `take("core:gold", …)`, kein Sonderfeld | +| Flache Dialogliste statt Knotengraph | M4 | **bestätigt** — die bedingte Option ersetzt den zweiten Knoten, ohne Knoten-IDs im Save | +| `open_shop` als elfte Aktion, nicht als Sonderfeld | 01 | **bestätigt** — `game/` setzt eine Absicht, die Schleife schiebt den Screen | +| Warenkorb einmal bei der Erzeugung ziehen | M4 | **bestätigt** — Schließen und Öffnen liefert dasselbe Angebot | + +Ein Befund nachgetragen: **07/01/09** — die Stapelregel stand seit Ticket 07 auf dem Papier, aber +niemand hatte sie gebaut. Der Beutezieher schob für `count = "50-120"` bis zu **120 einzelne Einträge** +ins Inventar. Gefunden nicht beim Nachdenken, sondern beim Entwerfen des Handels: „Gold abziehen" +braucht einen Stapel, sonst ist Bezahlen eine Schleife über Einträge. + +Dazu **eine bewusst offene Stelle**: `Npc.triggers` ist nicht gebaut, weil `triggers_for` NPCs nicht +einsammelt — ein Feld ohne Leser wäre eine Zusage, die niemand einlöst. TODO steht im Code. + **Drei Befunde aus M1, die nur der Compiler liefern konnte** — alle in die Tickets zurückgetragen: - **11**: Der Grenztest in seiner wörtlichen Fassung schlägt beim eigenen Doc-Kommentar an. Muss Kommentarzeilen ausnehmen. @@ -150,11 +172,65 @@ auch über Ticket 13. - [15 — Progression und Level-up](issues/15-progression-und-levelup.md) ([#16](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/16)) — Ein Aufstieg gibt **genau einen** frei verteilbaren Punkt auf die fuenf Grundwerte; `AssignPoints` ist **ein** Command mit allen Punkten (drei Stufen waeren sonst drei Zuege im Menue). **Keine Obergrenze** — `clamp(1, 50)` faellt, die Kurve begrenzt sich ueber steigende Kosten (`exp_base`, `exp_exponent` in `[balance]`). Erfahrung aus Toetungen (`exp_reward` am Wesen) und Quests (`give_exp`, zehnte Aktion), **nicht** fuers Betreten einer Etage — das belohnte das Durchrennen. **Die Zuwachs-Regel bleibt eng**: nur Aufstieg und `AssignPoints` heben den Lebensstand mit `max_health`, Ausruestung nie — sonst „heilt ein Ring beim Anlegen" (01). Rasse und Klasse bleiben **flach**, `per_level` entfaellt ersatzlos; was sie traegt, sind Faehigkeiten. `level` darf modifiziert werden, aber Punkte haengen an `base.level` — ein `+1 level`-Ring schenkt keinen. Vier Nachtraege: `give_exp` an 01, `current_exp`/`unspent_points` an **`Game`** statt ans Wesen (07 hatte die Platzierung hierher delegiert), `AssignPoints` an 11, zwei Balance-Knoepfe plus `exp_reward` an 14. - [16 — Loot-Tabellen](issues/16-loot-tabellen.md) ([#17](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/17)) — Zwoelfte Registry `[[loot_tables]]`, **eine Maschinerie fuer Gegenstaende und Affixe**. **Gewichte statt Prozent**, weil Prozente sich auf 100 summieren muessen und ein hinzugefuegter Mod-Eintrag die Summe kaputt macht. Nieten sind gewoehnliche Eintraege (kein `drop_chance`), Tabelle-in-Tabelle liefert Seltenheitsstufen, ohne dass „Seltenheit" ein Begriff wird. **Affix-Stufen statt Affix-Werte** — eine Staerke je Exemplar haette Save-Format, Preisformel und Stapelregel aus 07 auf einmal gekostet. **`luck` hat genau eine Regel**: Chance auf einen zusaetzlichen Zug auf jeder Tabelle — wirkt dreifach (mehr Gegenstaende, mehr Affixe, haeufiger die staerkere Stufe), ohne dreimal geschrieben zu werden. Der **Mod-Einhaengepunkt existiert schon**: Tabelle-in-Tabelle plus zwei Ueberschreibungen, kein zweiter Mechanismus. Bodenbeute wird **erst beim Aufheben** gezogen (`Room` haelt eine `loot_table` plus Positionen), moeglich nur weil alle Haufen dasselbe Symbol zeigen — dadurch wirkt ein Gluecksring auch auf das, was schon liegt. `guaranteed = true` fuer Bosse, Mehrfachziehung erlaubt. Nachtraege: `Room`-Umbau und optionales Prefab-`loot` an 10, `LUCK_FACTOR` an 14. +## Nachentscheidungen + +Entscheidungen, die nach dem Schließen aller Tickets fällig wurden — beim Bauen, nicht beim Planen. + +### NPC- und Handelsmodell (während M4) + +Der letzte Punkt aus „Not yet specified". Er hing an nichts Offenem mehr: `Talk` stand in Ticket 11s +Command-Liste, `npc_talked` in Ticket 08s Eventliste, `Dialog` und `Shop` unter den elf Screens, und +Ticket 10 ließ das Prefab seine NPCs nennen. Was fehlte, waren die Definitionen dahinter. + +**NPCs sind die vierzehnte Registry.** Nicht `creatures` mit `hostile = false`: Ein Händler trüge dann +`health`, `initiative`, `cooldowns`, `ai` und `exp_reward` als dauerhaft toten Ballast, und die Frage +„kann ich den Händler erschlagen" wäre beantwortet, ohne dass sie jemand gestellt hat. Vereinheitlichen +lohnt, wenn Felder *geteilt* werden, nicht wenn sie mitgeschleppt werden. + +**Der Warenbestand ist eine Beutetabelle.** `shop = "core:blacksmith_stock"` verweist auf +`[[loot_tables]]` — dieselben Gewichte, dieselbe Tabelle-in-Tabelle, dieselben Affixe. Kein neuer +„Waren"-Begriff, und ein Mod hängt Waren mit demselben Handgriff ein wie Beute. Gezogen wird **einmal +beim Erzeugen der Etage**: Bei jedem `Talk` neu wäre ausnutzbar (weggehen, wiederkommen, bis das gute +Stück im Regal liegt) — dieselbe Klasse wie Save-Scumming, die Ticket 14 geschlossen hat. Der +Unterschied zur Bodenbeute, die *doch* erst beim Aufheben zieht: Dort war das Argument **Glück**, und +bei einem Laden hat Glück nichts zu suchen — ein Kauf ist kein Fund. + +**Preise mit Spanne**, sonst ist Handel ein Exploit oder ein No-op: + +``` +kauf = item_price × (1 − price_modifier/100) +verkauf = item_price × VERKAUF × (1 + price_modifier/100) # VERKAUF ≈ 0.4 +``` + +`charisma` speist `price_modifier` über eine Formel, die in der **Preisfunktion** lebt, nicht in +`Game::attribute` — dort wäre es das einzige Attribut mit einem Sonderfall, und ein generischer Pfad +mit einer Ausnahme ist die Stelle, an der später jemand rät. Der Händler kauft **alles und hat +unbegrenzt Gold**: Die Spanne verhindert die Geldmaschine bereits, ein Goldvorrat wäre eine zweite +Regel gegen dasselbe Problem. + +**Dialoge sind eine flache Optionsliste**, kein Knotengraph. Jede Option trägt Text, `condition` und +`actions` — damit sind Questvergabe, bedingte Optionen und Ladenzugang abgedeckt. Was erst ein Graph +könnte, ist **Erzählung**, und das Story-Modul steht ausdrücklich unter *Out of scope*. Der Ausbau ist +additiv: `goto` an der Option plus eine Knotenebene, die vorhandenen Optionen werden der Startknoten. + +**`open_shop` wird die elfte Aktion** statt eines Sonderfelds an der Option — sie steht dann in +derselben Liste wie `start_quest` und läuft über denselben Eingang. + +**Angesprochen wird auf dem eigenen Feld.** Eine Regel für drei Kommandos: `g` hebt auf, was hier +liegt, `>` steigt ab, wo hier eine Treppe ist, `t` spricht an, wer hier steht. Ein NPC blockiert nicht +— er steht in einem Laden, nicht in einer Tür. + +**Befund nebenbei: `stackable` war nie implementiert.** Ticket 02 gab Gegenständen das Feld, Ticket 07 +die Stapelregel, Ticket 09 erklärte Gold zum gewöhnlichen Gegenstand — und `loot.rs` schob für +`count = "50-120"` bis zu 120 einzelne Einträge ins Inventar. `ItemInstance` bekommt deshalb ein +`count`, und die Regel aus 07 (`stackable` **und** gleiche `def` **und** gleiche `affixes`) wird zum +ersten Mal ausgeführt statt nur beschrieben. + ## Not yet specified - **Balancing** — konkrete Zahlen für Attribute, Schaden, Preise und Erfahrung über fünf Etagen. Wartet auf Kampfformeln (14), Progression (15) und Loot (16); vorher gibt es nichts zu balancieren. - **Boss-Design** — ein Boss ist derzeit ein Wesen mit `ai`-Liste (14) in einem Prefab mit `core:boss`-Tag (10). Was fehlt, sind **Phasen**: „ab 50 % Leben ein anderes Verhalten" wäre mit der `ai`-Bedingungsliste sogar ausdrückbar, „ab 50 % ändern sich die Werte" nicht. Berührt Stufe (c) des Mod-Supports. -- **NPC- und Handelsmodell** — Warenbestand, Dialogdaten. Wartet auf das Event-System (08). Die Preisformel steht bereits in 07; weil 07 Backtracking ausgeschlossen hat, muss ein Händler auf **jeder** Etage erreichbar sein. +- ~~**NPC- und Handelsmodell**~~ — **entschieden während M4**, siehe unten. - **Inventar-UX** — Gewicht oder Kapazität (`carry_capacity` ist seit 01 ein Attribut ohne Regel), Sortierung, Vergleichsansicht. 11 hat nur den Screen und die Commands festgelegt, nicht was darin steht. - **Mod-Verteilung** — durch 03 auf „Ordner ins Profil entpacken" geschrumpft, ohne Versionsprüfung. Offen ist nur noch, ob 1.0 mehr braucht. diff --git a/CONTEXT.md b/CONTEXT.md index fe1aa3e..2207163 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -170,6 +170,24 @@ keine Zeit. Es gibt keine getrennte Kampfrunden-Uhr: Befristete [Modifikatoren](#modifikator) ticken auch beim Erkunden, damit „ich warte den Debuff draußen ab" eine Entscheidung mit Kosten bleibt. +## NPC + +Eine ansprechbare Figur auf einer [Etage](#etage) — kein [Wesen](#wesen): Ein NPC hat keine +[Attribute](#attribut), keine [Initiative](#initiative) und keinen Kampfzustand. Ihn in `[[creatures]]` +zu pressen hieße, jedem Gegner ein leeres Dialogfeld und jedem Händler leere Grundwerte zu geben. + +Seine Antwortliste ist **flach**, kein Knotengraph: Was erst nach einer Antwort erscheint, ist eine +Option mit einer [Bedingung](#bedingung). Jede Option trägt [Aktionen](#aktion) — `open_shop` ist die +elfte davon und öffnet den Laden, statt als Sonderfeld daneben zu stehen. + +Ein NPC mit `shop` hat einen **Warenkorb**, der einmal bei der Erzeugung der Etage aus einer +[Loot-Tabelle](#loot-tabelle) gezogen wird und danach feststeht — sonst würfelte jedes Öffnen neu, und +der Spieler könnte sich das Angebot herbeischließen. Er hat **kein Goldkonto**: Die Spanne zwischen +Kauf- und Verkaufspreis, nicht ein Vorrat, begrenzt den Handel. + +Angesprochen wird der NPC auf dem Feld, auf dem der Held **steht** — dieselbe Regel wie beim Aufheben +und beim Abstieg. + ## Prefab Ein von Hand gezeichneter Raum als ASCII-Block, moddbarer namespaced Inhalt. Wände, Boden und Türen @@ -237,8 +255,8 @@ Es gibt **keinen Seltenheitsbegriff**: Abstufungen sind eigene Einträge mit kle ## Registry -Eine benannte Sammlung moddbarer Definitionen, geschlüsselt nach namespaced ID. Es gibt **dreizehn**, -und das hier ist die kanonische Liste — sie ist über sieben Tickets und drei Meilensteine gewachsen und +Eine benannte Sammlung moddbarer Definitionen, geschlüsselt nach namespaced ID. Es gibt **vierzehn**, +und das hier ist die kanonische Liste — sie ist über sieben Tickets und vier Meilensteine gewachsen und stand sonst nirgends vollständig: | Registry | TOML-Tabelle | @@ -255,6 +273,7 @@ stand sonst nirgends vollständig: | [Etagen](#etage) | `[[floors]]` | | [Loot-Tabellen](#loot-tabelle) | `[[loot_tables]]` | | Benannte [Modifikatoren](#modifikator) | `[[modifiers]]` | +| [NPCs](#npc) | `[[npcs]]` | | Scripts | `[[scripts]]` | Jede dieser Tabellen darf in jeder beliebigen `*.toml` eines [Mods](#mod) stehen. diff --git a/mods/core/loot.toml b/mods/core/loot.toml index 34db25d..8224190 100644 --- a/mods/core/loot.toml +++ b/mods/core/loot.toml @@ -86,3 +86,18 @@ entries = [ { item = "core:gold", weight = 10, count = "50-120" }, { item = "core:health_potion", weight = 6, count = "2-4" }, ] + +# --- Laden ---------------------------------------------------------------- +# Der Warenkorb wird EINMAL bei der Erzeugung der Etage gezogen. Kein Gold in +# der Tabelle: Der Haendler verkauft Waren, er verschenkt keine Waehrung. + +[[loot_tables]] +id = "blacksmith_stock" +rolls = "2-4" +entries = [ + { item = "core:health_potion", guaranteed = true, count = "3-5" }, + { item = "core:iron_sword", weight = 5, affixes = "core:weapon_affixes" }, + { item = "core:leather_cap", weight = 5, affixes = "core:armor_affixes" }, + { item = "core:thorn_mail", weight = 2, affixes = "core:armor_affixes" }, + { item = "core:bone_blade", weight = 1, affixes = "core:weapon_affixes" }, +] diff --git a/mods/core/npcs.toml b/mods/core/npcs.toml new file mode 100644 index 0000000..cdc693a --- /dev/null +++ b/mods/core/npcs.toml @@ -0,0 +1,27 @@ +# NPCs — die vierzehnte Registry (M4). +# +# Ein NPC ist kein Wesen: Er hat keine Attribute, keine Initiative und keinen +# Kampfzustand. Ihn in `creatures` zu pressen hiesse, jedem Gegner ein leeres +# `options`-Feld und jedem Haendler leere Grundwerte zu geben. +# +# Die Antwortliste ist FLACH, kein Knotengraph: Was erst nach einer Antwort +# erscheint, ist eine Option mit einer Bedingung. Ein Graph braeuchte Knoten-IDs, +# einen Zeiger auf den aktuellen Knoten im Speicherstand und eine Kreispruefung +# beim Laden — fuer einen Haendler mit vier Saetzen. + +[[npcs]] +id = "blacksmith" +name = "Borin" +symbol = "B" +color = "yellow" +greeting = "Stahl kostet. Rede oder kauf." +shop = "core:blacksmith_stock" +options = [ + { text = "Zeig mir deine Waren.", actions = [ { action = "open_shop" } ] }, + { text = "Was gibt es hier unten?" }, + # Bedingte Option statt eines zweiten Dialogknotens — genau der Fall, fuer + # den der Knotengraph angeblich noetig waere. + { text = "Diese Knochenklinge — kennst du sie?", + condition = [ { check = "has_item", item = "core:bone_blade" } ] }, + { text = "Nichts weiter." }, +] diff --git a/mods/core/prefabs.toml b/mods/core/prefabs.toml index 8fd5be4..f2b96d6 100644 --- a/mods/core/prefabs.toml +++ b/mods/core/prefabs.toml @@ -36,6 +36,9 @@ layout = """ [[prefabs]] id = "market" tags = ["core:merchant"] +# Der i-te `n`-Platz bekommt den i-ten Eintrag. Anders als bei `e` nennt das +# Prefab den NPC: Ein Schmied skaliert nicht mit der Tiefe. +npcs = ["core:blacksmith"] layout = """ ####+#### #.......# diff --git a/src/data/loader.rs b/src/data/loader.rs index a4bfe42..efd8753 100644 --- a/src/data/loader.rs +++ b/src/data/loader.rs @@ -16,7 +16,7 @@ use toml::Spanned; use super::registries::{ Ability, Affix, Category, CharacterOption, CreatureDef, FloorDef, Item, LootTable, ModifierDef, - Prefab, Registries, Slot, + Npc, Prefab, Registries, Slot, }; /// Genau drei harte Fehler (Ticket 04). Alles Uebrige ist Warnung plus @@ -261,6 +261,8 @@ struct ModFile { affixes: Vec>, #[serde(default)] loot_tables: Vec>, + #[serde(default)] + npcs: Vec>, /// **Keine** Tabelle mit `[[...]]`, sondern ein einzelner Block, der /// feldweise verschmilzt — die ausdrueckliche Ausnahme zu Ticket 03. #[serde(default)] @@ -300,6 +302,7 @@ impl_entry!(CreatureDef); impl_entry!(ModifierDef); impl_entry!(Affix); impl_entry!(LootTable); +impl_entry!(Npc); struct FileCtx<'a> { module: &'a str, @@ -510,6 +513,7 @@ pub fn load_mods() -> Result<(Registries, Vec), LoadError> { 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); + parse_entries(parsed.npcs, &ctx, &mut reg.npcs, &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 81328a6..59ad288 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -6,5 +6,5 @@ pub mod registries; pub use loader::{load_mods, write_log}; pub use registries::{ - Balance, CreatureDef, FloorDef, LootEntry, Prefab, Registries, parse_range, + Balance, CreatureDef, FloorDef, DialogOption, LootEntry, Npc, Prefab, Registries, parse_range, }; diff --git a/src/data/registries.rs b/src/data/registries.rs index f39023a..dcb8ded 100644 --- a/src/data/registries.rs +++ b/src/data/registries.rs @@ -212,8 +212,14 @@ pub struct Balance { pub resistance_cap: f32, pub hit_floor: f32, pub hit_ceiling: f32, - // TODO(M4/M5): `exp_base`, `exp_exponent` (Ticket 15) und `luck_factor` - // (Ticket 16) — sie kommen mit ihren Lesern, nicht davor. + /// 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, + // TODO(M5): `exp_base`, `exp_exponent` (Ticket 15) und `luck_factor` + // (Ticket 16) — sie kommen mit ihren Lesern, nicht davor. } impl Default for Balance { @@ -225,10 +231,54 @@ impl Default for Balance { resistance_cap: 0.8, hit_floor: 0.05, hit_ceiling: 0.95, + verkauf: 0.4, + charisma_preis: 1.5, } } } +/// 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). /// @@ -323,6 +373,11 @@ pub struct Prefab { /// 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, } @@ -397,6 +452,7 @@ pub struct Registries { pub modifiers: HashMap, pub affixes: HashMap, pub loot_tables: HashMap, + pub npcs: HashMap, /// Keine Registry — ein Block mit festem Schema, feldweise verschmolzen. pub balance: Balance, // TODO(M5+): `quests` (Ticket 08) und `scripts` (Ticket 13). diff --git a/src/game/action.rs b/src/game/action.rs index 5faef3b..64e711d 100644 --- a/src/game/action.rs +++ b/src/game/action.rs @@ -66,8 +66,8 @@ pub enum Target { /// Der Aktionskatalog. Zehn Eintraege sind entschieden (01, 08, 13, 14, 15); /// M3 braucht fuenf davon. /// -// TODO(M4+): `give_item`, `remove_item` (Inventar), `start_quest` (08), -// `give_exp` (15), `run_script` (13). +// TODO(M5+): `give_item`, `remove_item`, `start_quest` (08), `give_exp` (15), +// `run_script` (13). #[derive(Debug, Clone, Deserialize)] #[serde(tag = "action", rename_all = "snake_case")] pub enum Action { @@ -109,6 +109,9 @@ pub enum Action { /// Ticket 14: achte Aktion, weil Ticket 11 Flucht zu einer Faehigkeit /// gemacht hat und die etwas braucht, das sie ausfuehrt. Flee, + /// Elfte Aktion (M4): Eine Dialogoption oeffnet den Laden. Als Aktion steht + /// sie in derselben Liste wie `start_quest`, statt als Sonderfeld daneben. + OpenShop, } /// Die beiden Zustandsgroessen, die eine Aktion aendern darf. Bewusst **kein** @@ -127,7 +130,7 @@ impl Action { | Action::ChangeAttribute { target, .. } | Action::ApplyModifier { target, .. } | Action::RemoveModifier { target, .. } => *target, - Action::Flee => Target::Zelf, + Action::Flee | Action::OpenShop => Target::Zelf, } } } diff --git a/src/game/attributes.rs b/src/game/attributes.rs index 717707e..a1a3dc8 100644 --- a/src/game/attributes.rs +++ b/src/game/attributes.rs @@ -124,7 +124,7 @@ pub struct Modifier { pub flat: f32, #[serde(default)] pub percent: f32, - // TODO(M4): `target` fuer Auren (`all_enemies`) und `condition` fuer + // TODO(M5): `target` fuer Auren (`all_enemies`) und `condition` fuer // bedingte Modifikatoren („+5 gegen Untote", Ticket 08). Beide // brauchen `Game::attribute`s `against`-Parameter, der bereits // in der Signatur steht. diff --git a/src/game/command.rs b/src/game/command.rs index 903e6ab..2d4d4d9 100644 --- a/src/game/command.rs +++ b/src/game/command.rs @@ -13,7 +13,7 @@ pub enum Direction { /// Ticket 11 hat zwoelf Commands festgelegt; M1 braucht zwei davon. /// -// TODO(M5+): `Talk`, `Buy`, `Sell` — und `AssignPoints` aus Ticket 15. +// TODO(M5+): `AssignPoints` aus Ticket 15. #[derive(Debug, Clone)] pub enum Command { Move(Direction), @@ -27,6 +27,15 @@ pub enum Command { Equip(usize), Unequip(String), Drop(usize), + /// Wirkt auf das Feld, **auf dem der Held steht** — dieselbe Regel wie + /// `PickUp` und `Descend`. Ein NPC blockiert nicht: Er steht in einem Laden, + /// nicht in einer Tuer. + Talk, + /// Index in `Game::dialog_options` — die **gefilterte** Liste, damit der + /// Screen keine Bedingungen auswerten muss. + Choose(usize), + Buy(usize), + Sell(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/creature.rs b/src/game/creature.rs index f55a7f5..9f80c18 100644 --- a/src/game/creature.rs +++ b/src/game/creature.rs @@ -20,11 +20,30 @@ pub struct ItemInstance { pub def: String, #[serde(default)] pub affixes: Vec, + /// Stapelmenge. Stand bis M4 nicht hier — der Beutezieher schob fuer + /// „50-120 Gold" bis zu 120 einzelne Eintraege ins Inventar. + #[serde(default = "one")] + pub count: u32, +} + +fn one() -> u32 { + 1 } impl ItemInstance { pub fn new(def: impl Into) -> Self { - Self { def: def.into(), affixes: Vec::new() } + Self { def: def.into(), affixes: Vec::new(), count: 1 } + } + + pub fn stack(def: impl Into, count: u32) -> Self { + Self { def: def.into(), affixes: Vec::new(), count } + } + + /// Die Stapelregel aus Ticket 07: gleiche `def` **und** gleiche `affixes`. + /// Dass ein Trank mit Zufallsaffix nicht mehr stapelt, ist die logische + /// Folge — und das Problem dessen, der ihn so gebaut hat. + pub fn stacks_with(&self, other: &ItemInstance) -> bool { + self.def == other.def && self.affixes == other.affixes } } diff --git a/src/game/floor.rs b/src/game/floor.rs index d4856e4..43571cb 100644 --- a/src/game/floor.rs +++ b/src/game/floor.rs @@ -38,6 +38,16 @@ impl Rect { } } +/// Ein NPC auf der Etage. Sein Warenkorb wird **einmal bei der Erzeugung** +/// gezogen und liegt danach fest — sonst wuerfelte jedes Oeffnen des Ladens neu +/// und der Spieler koennte sich das Angebot herbeischliessen (M4). +#[derive(Debug)] +pub struct NpcInstance { + pub pos: (u16, u16), + pub def: String, + pub stock: Vec, +} + #[derive(Debug)] pub struct Room { /// Fuer `room_entered` und die Trigger am Prefab (Ticket 08/10). @@ -57,6 +67,9 @@ pub struct Room { /// Die `l`-Plaetze. Ticket 16: erst beim **Aufheben** aufgeloest, deshalb /// stehen hier Positionen und keine Gegenstaende. pub loot: Vec<(u16, u16)>, + /// Die `n`-Plaetze, schon aufgeloest. Anders als bei `e` nennt das Prefab + /// den NPC — ein Schmied skaliert nicht mit der Tiefe. + pub npcs: Vec, pub cleared: bool, } diff --git a/src/game/generate.rs b/src/game/generate.rs index 5cb0abc..235b4eb 100644 --- a/src/game/generate.rs +++ b/src/game/generate.rs @@ -107,6 +107,7 @@ pub fn generate(def: &FloorDef, reg: &Registries, rng: &mut Rng) -> Floor { for p in &placed { let mut enemy_spawns = Vec::new(); let mut loot = Vec::new(); + let mut npc_plaetze = Vec::new(); for y in 0..p.stamp.h { for x in 0..p.stamp.w { @@ -124,6 +125,7 @@ pub fn generate(def: &FloorDef, reg: &Registries, rng: &mut Rng) -> Floor { match c { 'e' => enemy_spawns.push((gx, gy)), 'l' => loot.push((gx, gy)), + 'n' => npc_plaetze.push((gx, gy)), _ => {} } } @@ -152,6 +154,23 @@ pub fn generate(def: &FloorDef, reg: &Registries, rng: &mut Rng) -> Floor { .or_else(|| def.loot.clone()) .unwrap_or_default(); + // Der i-te `n`-Platz bekommt den i-ten Eintrag aus `prefab.npcs` — + // Positionsbindung statt eines zweiten Markers je NPC. + let npcs = npc_plaetze + .into_iter() + .enumerate() + .filter_map(|(i, pos)| { + let id = reg.prefabs.get(&p.prefab)?.npcs.get(i)?.clone(); + let stock = reg + .npcs + .get(&id) + .and_then(|n| n.shop.as_deref()) + .map(|t| crate::game::loot::roll(t, reg, rng)) + .unwrap_or_default(); + Some(super::floor::NpcInstance { pos, def: id, stock }) + }) + .collect(); + rooms.push(Room { prefab: p.prefab.clone(), rect: p.rect, @@ -159,6 +178,7 @@ pub fn generate(def: &FloorDef, reg: &Registries, rng: &mut Rng) -> Floor { enemies, loot_table, loot, + npcs, cleared: false, }); } diff --git a/src/game/loot.rs b/src/game/loot.rs index 17e6a1c..6b96afc 100644 --- a/src/game/loot.rs +++ b/src/game/loot.rs @@ -87,9 +87,24 @@ fn emit( None => Vec::new(), }; + // Ein Eintrag mit `count = "50-120"` liefert **einen** Stapel, nicht 120 + // Eintraege — die Stapelregel aus Ticket 07 wird hier zum ersten Mal + // gebraucht (Befund aus M4). 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() }); + let menge = rng.range(lo, hi); + if menge == 0 { + return; + } + let stapelbar = reg.items.get(item).is_some_and(|d| d.stackable); + + if stapelbar { + out.push(ItemInstance { def: item.clone(), affixes, count: menge }); + } else { + // Nicht stapelbar: jedes Exemplar einzeln, damit Affixe je Stueck + // unterschiedlich sein koennten. + for _ in 0..menge { + out.push(ItemInstance { def: item.clone(), affixes: affixes.clone(), count: 1 }); + } } } diff --git a/src/game/mod.rs b/src/game/mod.rs index 5b830f5..8c8cd18 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -48,7 +48,12 @@ pub struct Game { pub last: String, /// Kampflog, die letzten Zeilen. pub journal: Vec, - // TODO(M4): `quests`, `current_exp`, `unspent_points` (Ticket 15 — an + /// Mit wem gerade gesprochen wird — Index in `Room.npcs`. + pub talking_to: Option, + /// 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`). } @@ -73,6 +78,8 @@ impl Game { events: std::collections::VecDeque::new(), last: String::from("—"), journal: Vec::new(), + talking_to: None, + open_shop: false, }) } @@ -372,6 +379,10 @@ impl Game { 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 @@ -556,10 +567,85 @@ impl Game { Command::Unequip(slot) => { (!self.player.equipment.contains_key(slot)).then_some("Dort ist nichts") } + Command::Talk if in_combat => Some("Nicht im Kampf"), + Command::Talk => { + let (x, y) = self.player_pos; + self.npc_here(x, y).is_none().then_some("Hier steht niemand") + } + Command::Choose(i) => { + (*i >= self.dialog_options(reg).len()).then_some("Keine solche Antwort") + } + Command::Buy(i) => { + let Some(stock) = self.shop_stock() else { + return Some("Kein Laden offen"); + }; + let Some(inst) = stock.get(*i) else { return Some("Nicht im Angebot") }; + let preis = self.buy_price(inst, reg); + (self.gold() < preis).then_some("Zu wenig Gold") + } + Command::Sell(i) if *i >= self.player.inventory.len() => { + Some("Kein solcher Gegenstand") + } + // Sein eigenes Gold zurueckzuverkaufen ergibt keinen Sinn. + Command::Sell(i) => (self.player.inventory[*i].def == "core:gold") + .then_some("Gold verkauft man nicht"), Command::Wait => None, } } + pub fn npc_here(&self, x: u16, y: u16) -> Option { + 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** @@ -646,6 +732,10 @@ impl Game { Command::UseItem(i) => self.use_item(*i, reg), Command::Equip(i) => self.equip(*i, reg), Command::Unequip(slot) => self.unequip(&slot.clone()), + Command::Talk => self.talk(reg), + Command::Choose(i) => self.choose(*i, reg), + Command::Buy(i) => self.buy(*i, reg), + Command::Sell(i) => self.sell(*i, reg), Command::Drop(i) => { let inst = self.player.inventory.remove(*i); let name = self.item_label(&inst, reg); @@ -677,11 +767,74 @@ impl Game { } let namen: Vec = beute.iter().map(|i| self.item_label(i, reg)).collect(); - self.player.inventory.extend(beute); + for inst in beute { + self.give(inst, reg); + } self.log(format!("Aufgehoben: {}", namen.join(", "))); self.events.push_back(Event::new(EventKind::ItemPickedUp, CreatureRef::Player)); } + fn talk(&mut self, reg: &Registries) { + let (x, y) = self.player_pos; + let Some(i) = self.npc_here(x, y) else { return }; + let room = self.floor.rooms.get(self.floor.player_room.unwrap()).unwrap(); + let id = room.npcs[i].def.clone(); + self.talking_to = Some(i); + + if let Some(npc) = reg.npcs.get(&id) + && !npc.greeting.is_empty() + { + self.log(format!("{}: {}", npc.name, npc.greeting)); + } + } + + fn choose(&mut self, index: usize, reg: &Registries) { + let Some(option) = self.dialog_options(reg).get(index).copied() else { return }; + let text = option.text.clone(); + let actions = option.actions.clone(); + self.log(format!("> {text}")); + for action in &actions { + self.apply(action, CreatureRef::Player, CreatureRef::Player, reg); + } + } + + fn buy(&mut self, index: usize, reg: &Registries) { + let Some(stock) = self.shop_stock() else { return }; + let inst = stock[index].clone(); + let preis = self.buy_price(&inst, reg); + + // Wird erst genommen, dann gegeben: Ein Fehlschlag hier waere ein + // Gegenstand aus dem Nichts. `rejects` hat die Deckung schon geprueft. + if !self.take("core:gold", preis) { + return; + } + let name = self.item_label(&inst, reg); + self.give(inst, reg); + let room = self.floor.player_room.unwrap(); + self.floor.rooms[room].npcs[self.talking_to.unwrap()].stock.remove(index); + self.log(format!("{name} gekauft fuer {preis} Gold")); + } + + fn sell(&mut self, index: usize, reg: &Registries) { + // Ein Stapel wird stueckweise verkauft — sonst gaebe es keinen Weg, + // fuenf von fuenfzig Traenken loszuwerden. + let inst = ItemInstance { count: 1, ..self.player.inventory[index].clone() }; + let preis = self.sell_price(&inst, reg); + let name = self.item_label(&inst, reg); + + // Ueber den Index, nicht ueber `take`: Zwei Traenke mit verschiedenen + // Affixen teilen sich die `def`, und `take` naehme den erstbesten. + let slot = &mut self.player.inventory[index]; + slot.count -= 1; + if slot.count == 0 { + self.player.inventory.remove(index); + } + self.give(ItemInstance::stack("core:gold", preis), reg); + // Der Haendler hat kein Gold-Konto: Die Spanne, nicht ein Vorrat, + // begrenzt den Handel. + self.log(format!("{name} verkauft fuer {preis} Gold")); + } + fn use_item(&mut self, index: usize, reg: &Registries) { let inst = self.player.inventory[index].clone(); let Some(def) = reg.items.get(&inst.def) else { return }; @@ -724,6 +877,49 @@ impl Game { } } + /// 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") + } + /// Der Anzeigename eines Exemplars: Affixe werden vorangestellt — /// „Flammendes Eisenschwert" (Ticket 07). pub fn item_label(&self, inst: &ItemInstance, reg: &Registries) -> String { @@ -734,7 +930,8 @@ impl Game { .filter_map(|a| reg.affixes.get(a).map(|d| d.name.as_str())) .collect(); teile.push(base); - teile.join(" ") + let name = teile.join(" "); + if inst.count > 1 { format!("{name} ×{}", inst.count) } else { name } } /// Preis eines Exemplars: `base_price × Π price_factor` der Affixe @@ -1275,4 +1472,91 @@ mod tests { 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)); + } } diff --git a/src/ui/app.rs b/src/ui/app.rs index f9dea6b..7c92572 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -38,6 +38,23 @@ impl App { self.screens.retain(|s| !matches!(s, Screen::Combat { .. })); } + // Ticket 11: `Game.talking_to` ist die Wahrheit, der Screen folgt — + // dieselbe Regel wie beim Kampf. + let talking = self.game.talking_to.is_some(); + let shows_dialog = self.screens.iter().any(|s| matches!(s, Screen::Dialog { .. })); + if talking && !shows_dialog { + self.screens.push(Screen::Dialog { pick: 0 }); + } else if !talking { + self.screens + .retain(|s| !matches!(s, Screen::Dialog { .. } | Screen::Shop { .. })); + } + + // Die Aktion `open_shop` setzt nur eine Absicht; den Screen schiebt die + // Schleife, weil `game/` keine Screens kennt. + if std::mem::take(&mut self.game.open_shop) { + self.screens.push(Screen::Shop { pick: 0, selling: false }); + } + if self.game.over.is_some() && !self.screens.iter().any(|s| matches!(s, Screen::GameOver)) { @@ -75,6 +92,11 @@ pub fn run(game: Game, registries: Registries, warnings: usize, log_name: Option match screen.handle_key(key.code, game, registries) { Response::None => {} Response::Pop => { + // Das Gespraech zu verlassen beendet es auch im Kern — + // sonst schoebe `sync_screens` den Screen sofort zurueck. + if matches!(app.screens.last(), Some(Screen::Dialog { .. })) { + app.game.talking_to = None; + } app.screens.pop(); if app.screens.is_empty() { break Ok(()); @@ -113,6 +135,8 @@ fn draw(frame: &mut Frame, app: &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::Dialog { pick } => draw_dialog(frame, area, app, *pick), + Screen::Shop { pick, selling } => draw_shop(frame, area, app, *pick, *selling), Screen::GameOver => draw_game_over(frame, area, app), } } @@ -150,7 +174,11 @@ fn draw_explore(frame: &mut Frame, area: Rect, app: &App) { let [grid, bar] = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(inner); frame.render_widget( - GridWidget { floor: &app.game.floor, player: app.game.player_pos }, + GridWidget { + floor: &app.game.floor, + player: app.game.player_pos, + reg: &app.registries, + }, grid, ); @@ -226,6 +254,94 @@ fn draw_inventory(frame: &mut Frame, area: Rect, app: &App, pick: usize) { ); } +/// **Flache Optionsliste** — was erst nach einer Antwort erscheint, ist eine +/// Option mit einer Bedingung (M4). +fn draw_dialog(frame: &mut Frame, area: Rect, app: &App, pick: usize) { + let Some(npc) = app.game.talking_npc(&app.registries) else { return }; + + let mut zeilen = vec![ + Line::from(npc.greeting.clone()).style(Style::new().fg(Color::Gray)), + Line::from(""), + ]; + for (i, option) in app.game.dialog_options(&app.registries).iter().enumerate() { + let zeiger = if i == pick { "›" } else { " " }; + let farbe = if i == pick { Color::Yellow } else { Color::Reset }; + zeilen.push( + Line::from(format!("{zeiger} {}. {}", i + 1, option.text)) + .style(Style::new().fg(farbe)), + ); + } + zeilen.push(Line::from("")); + zeilen.push( + Line::from("↑↓ wählen · Enter/1-9 antworten · Esc beenden") + .style(Style::new().fg(Color::DarkGray)), + ); + + frame.render_widget( + Paragraph::new(zeilen).block(Block::bordered().title(format!(" {} ", npc.name))), + area, + ); +} + +/// Zwei Spalten, damit die **Spanne** sichtbar ist: derselbe Gegenstand steht +/// links teurer als er rechts einbringt. +fn draw_shop(frame: &mut Frame, area: Rect, app: &App, pick: usize, selling: bool) { + let block = Block::bordered().title(format!(" Laden · {} Gold ", app.game.gold())); + let inner = block.inner(area); + frame.render_widget(block, area); + let [links, rechts] = + Layout::horizontal([Constraint::Percentage(50), Constraint::Min(0)]).areas(inner); + + let zeile = |i: usize, text: String, aktiv: bool| { + let zeiger = if aktiv && i == pick { "›" } else { " " }; + let farbe = if aktiv && i == pick { Color::Yellow } else { Color::Reset }; + Line::from(format!("{zeiger} {text}")).style(Style::new().fg(farbe)) + }; + + let mut angebot: Vec = app + .game + .shop_stock() + .unwrap_or(&[]) + .iter() + .enumerate() + .map(|(i, inst)| { + let name = app.game.item_label(inst, &app.registries); + let preis = app.game.buy_price(inst, &app.registries); + zeile(i, format!("{name:<28} {preis:>5} G"), !selling) + }) + .collect(); + if angebot.is_empty() { + angebot.push(Line::from("ausverkauft").style(Style::new().fg(Color::DarkGray))); + } + + let mut eigenes: Vec = app + .game + .player + .inventory + .iter() + .enumerate() + .map(|(i, inst)| { + let name = app.game.item_label(inst, &app.registries); + let preis = app.game.sell_price(inst, &app.registries); + zeile(i, format!("{name:<28} {preis:>5} G"), selling) + }) + .collect(); + eigenes.push(Line::from("")); + eigenes.push( + Line::from("Tab wechseln · Enter handeln · Esc") + .style(Style::new().fg(Color::DarkGray)), + ); + + frame.render_widget( + Paragraph::new(angebot).block(Block::bordered().title(" Angebot ")), + links, + ); + frame.render_widget( + Paragraph::new(eigenes).block(Block::bordered().title(" Verkaufen ")), + 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 df50af9..7ce5a5f 100644 --- a/src/ui/grid.rs +++ b/src/ui/grid.rs @@ -7,11 +7,15 @@ use ratatui::layout::Rect; use ratatui::style::{Color, Modifier, Style}; use ratatui::widgets::Widget; +use crate::data::Registries; use crate::game::{Floor, Tile}; pub struct GridWidget<'a> { pub floor: &'a Floor, pub player: (u16, u16), + /// Nur fuer die NPC-Symbole. Anders als Beutehaufen tragen NPCs ihr eigenes + /// Zeichen — wer im Laden steht, soll man vor dem Ansprechen sehen. + pub reg: &'a Registries, } impl Widget for GridWidget<'_> { @@ -53,6 +57,24 @@ impl Widget for GridWidget<'_> { } } + for room in &self.floor.rooms { + for npc in &room.npcs { + let (nx, ny) = npc.pos; + if nx < ox || ny < oy { + continue; + } + let (px, py) = (area.x + nx - ox, area.y + ny - oy); + if px < area.right() && py < area.bottom() { + let def = self.reg.npcs.get(&npc.def); + let symbol = def.map_or('n', |n| n.symbol); + let color = super::theme::color(def.and_then(|n| n.color.as_deref())); + buf[(px, py)] + .set_symbol(&symbol.to_string()) + .set_style(Style::new().fg(color).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 b5f3ef7..214f33a 100644 --- a/src/ui/screen.rs +++ b/src/ui/screen.rs @@ -25,8 +25,20 @@ pub enum Screen { /// Meldungszeile im Hauptmenue: Ein Abstiegs-Roguelike lebt davon, dass der /// Lauf am Ende *erzaehlt* wird (Ticket 14). GameOver, - // TODO(M3+): `CharacterCreation`, `Combat`, `Inventory`, `QuestLog`, - // `Shop`, `Dialog`, `Confirm` — elf sind es am Ende (11/14). + /// **Flache Optionsliste**, kein Knotengraph (M4): Was erst nach einer + /// Antwort erscheint, ist eine Option mit einer Bedingung. + Dialog { + pick: usize, + }, + /// Zwei Spalten: Angebot links, eigenes Inventar rechts. `Tab` wechselt. + Shop { + pick: usize, + /// `false` = Angebot, `true` = Inventar. Ein `bool` statt eines Enums + /// mit zwei Werten, die nie mehr werden. + selling: bool, + }, + // TODO(M5+): `CharacterCreation`, `QuestLog`, `Confirm` — elf sind es am + // Ende (11/14). } /// Der Screen **gibt zurueck, statt selbst zu schieben**: sonst haelt er ein @@ -78,6 +90,7 @@ impl Screen { KeyCode::Char('.') => Response::Command(Command::Wait), KeyCode::Char('>') => Response::Command(Command::Descend), KeyCode::Char('g') => Response::Command(Command::PickUp), + KeyCode::Char('t') => Response::Command(Command::Talk), // 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 }), @@ -165,6 +178,65 @@ impl Screen { _ => Response::None, }, + Screen::Dialog { pick } => { + let options = game.dialog_options(reg); + match key { + KeyCode::Up | KeyCode::Char('k') => { + *pick = pick.saturating_sub(1); + Response::None + } + KeyCode::Down | KeyCode::Char('j') => { + *pick = (*pick + 1).min(options.len().saturating_sub(1)); + Response::None + } + KeyCode::Enter | KeyCode::Char('1'..='9') => { + let i = match key { + KeyCode::Char(c @ '1'..='9') => (c as u8 - b'1') as usize, + _ => *pick, + }; + match options.get(i) { + Some(_) => Response::Command(Command::Choose(i)), + None => Response::None, + } + } + KeyCode::Esc | KeyCode::Char('q') => Response::Pop, + _ => Response::None, + } + } + + Screen::Shop { pick, selling } => { + let n = if *selling { + game.player.inventory.len() + } else { + game.shop_stock().map_or(0, |s| s.len()) + }; + if n > 0 { + *pick = (*pick).min(n - 1); + } + + match key { + KeyCode::Tab | KeyCode::Left | KeyCode::Right => { + *selling = !*selling; + *pick = 0; + Response::None + } + 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 if n > 0 && *selling => { + Response::Command(Command::Sell(*pick)) + } + KeyCode::Enter if n > 0 => Response::Command(Command::Buy(*pick)), + KeyCode::Esc | KeyCode::Char('q') => Response::Pop, + _ => Response::None, + } + } + Screen::GameOver => Response::Quit, } }