Merge branch 'feat/m4-handel'

This commit is contained in:
2026-08-12 20:36:23 +02:00
22 changed files with 818 additions and 26 deletions

View File

@@ -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

View File

@@ -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<String>`, 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<String>`, 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

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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" },
]

27
mods/core/npcs.toml Normal file
View File

@@ -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." },
]

View File

@@ -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 = """
####+####
#.......#

View File

@@ -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<Spanned<toml::Value>>,
#[serde(default)]
loot_tables: Vec<Spanned<toml::Value>>,
#[serde(default)]
npcs: Vec<Spanned<toml::Value>>,
/// **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<String>), 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

View File

@@ -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,
};

View File

@@ -212,7 +212,13 @@ 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`
/// 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.
}
@@ -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<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).
///
@@ -323,6 +373,11 @@ pub struct Prefab {
/// 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>,
}
@@ -397,6 +452,7 @@ pub struct Registries {
pub modifiers: HashMap<String, ModifierDef>,
pub affixes: HashMap<String, Affix>,
pub loot_tables: HashMap<String, LootTable>,
pub npcs: HashMap<String, Npc>,
/// Keine Registry — ein Block mit festem Schema, feldweise verschmolzen.
pub balance: Balance,
// TODO(M5+): `quests` (Ticket 08) und `scripts` (Ticket 13).

View File

@@ -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,
}
}
}

View File

@@ -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.

View File

@@ -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.

View File

@@ -20,11 +20,30 @@ pub struct ItemInstance {
pub def: String,
#[serde(default)]
pub affixes: Vec<String>,
/// 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<String>) -> Self {
Self { def: def.into(), affixes: Vec::new() }
Self { def: def.into(), affixes: Vec::new(), count: 1 }
}
pub fn stack(def: impl Into<String>, 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
}
}

View File

@@ -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<super::creature::ItemInstance>,
}
#[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<NpcInstance>,
pub cleared: bool,
}

View File

@@ -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,
});
}

View File

@@ -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 });
}
}
}

View File

@@ -48,7 +48,12 @@ pub struct Game {
pub last: String,
/// Kampflog, die letzten Zeilen.
pub journal: Vec<String>,
// TODO(M4): `quests`, `current_exp`, `unspent_points` (Ticket 15 — an
/// Mit wem gerade gesprochen wird — Index in `Room.npcs`.
pub talking_to: Option<usize>,
/// Absicht aus `open_shop`. Die Schleife liest sie und schiebt den Screen;
/// `game/` kennt keine Screens.
pub open_shop: bool,
// TODO(M5): `quests`, `current_exp`, `unspent_points` (Ticket 15 — an
// `Game`, nicht am `Creature`).
}
@@ -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<usize> {
let room = self.floor.player_room?;
self.floor.rooms[room].npcs.iter().position(|n| n.pos == (x, y))
}
fn npc_instance(&self) -> Option<&crate::game::floor::NpcInstance> {
self.floor.rooms[self.floor.player_room?].npcs.get(self.talking_to?)
}
/// Die **erfuellbaren** Optionen des laufenden Gesprraechs. Filtern gehoert
/// hierhin und nicht in den Screen — sonst wertete die Anzeige Bedingungen
/// aus (Ticket 11).
pub fn dialog_options<'r>(&self, reg: &'r Registries) -> Vec<&'r crate::data::DialogOption> {
let Some(npc) = self.talking_npc(reg) else { return Vec::new() };
npc.options
.iter()
.filter(|o| self.conditions_hold(&o.condition, CreatureRef::Player, None, reg))
.collect()
}
pub fn talking_npc<'r>(&self, reg: &'r Registries) -> Option<&'r crate::data::Npc> {
reg.npcs.get(&self.npc_instance()?.def)
}
pub fn shop_stock(&self) -> Option<&[ItemInstance]> {
Some(&self.npc_instance()?.stock)
}
/// `kauf = item_price × (1 − price_modifier/100)`.
///
/// Die Charisma-Formel lebt **hier**, nicht in `Game::attribute` — dort
/// waere `price_modifier` das einzige Attribut mit einem Sonderfall, und ein
/// generischer Pfad mit einer Ausnahme ist die Stelle, an der spaeter jemand
/// raet.
pub fn price_modifier(&self, reg: &Registries) -> f32 {
let p = CreatureRef::Player;
self.attribute(p, Attribute::PriceModifier, None, reg)
+ self.attribute(p, Attribute::Charisma, None, reg) * reg.balance.charisma_preis
}
pub fn buy_price(&self, inst: &ItemInstance, reg: &Registries) -> u32 {
let roh = self.item_price(inst, reg) as f32;
(roh * (1.0 - self.price_modifier(reg) / 100.0)).max(1.0).round() as u32
}
/// `verkauf = item_price × VERKAUF × (1 + price_modifier/100)` — die
/// **Spanne** verhindert die Geldmaschine, deshalb braucht der Haendler
/// keinen Goldvorrat.
pub fn sell_price(&self, inst: &ItemInstance, reg: &Registries) -> u32 {
let roh = self.item_price(inst, reg) as f32 * reg.balance.verkauf;
(roh * (1.0 + self.price_modifier(reg) / 100.0)).max(1.0).round() as u32
}
/// Alle Faehigkeiten eines Wesens, in stabiler Reihenfolge.
///
/// `core:attack` und `core:flee` sind **jedem Wesen implizit gewaehrt**
@@ -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<String> = 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, &reg), 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), &reg);
game.give(ItemInstance::stack("core:gold", 12), &reg);
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"), &reg);
game.give(scharf, &reg);
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, &reg);
let verkauf = game.sell_price(&schwert, &reg);
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, &reg, &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, &reg), Outcome::TurnPassed));
assert_eq!(game.talking_to, Some(0));
let laden = game.dialog_options(&reg);
assert!(laden.len() >= 3, "die bedingte Option faellt raus, drei bleiben");
assert!(matches!(game.execute(Command::Choose(0), &reg), 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), &reg), Outcome::Rejected(_)));
let preis = game.buy_price(&ware[0], &reg);
game.give(ItemInstance::stack("core:gold", preis), &reg);
assert!(matches!(game.execute(Command::Buy(0), &reg), 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));
}
}

View File

@@ -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<Line> = 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<Line> = 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).

View File

@@ -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)]

View File

@@ -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,
}
}