3 Commits

64 changed files with 674 additions and 10578 deletions

View File

@@ -116,73 +116,6 @@ color = "cyan"
`data/` kennt nur `char` und ein Farbnamen-Enum, `ui/theme.rs` übersetzt in `ratatui::Color` — `game/` `data/` kennt nur `char` und ein Farbnamen-Enum, `ui/theme.rs` übersetzt in `ratatui::Color` — `game/`
und `data/` bleiben damit weiterhin ratatui-frei. und `data/` bleiben damit weiterhin ratatui-frei.
### Nachtrag aus Ticket 14: `damage` skaliert, und `flee` ist die achte Aktion
Die `damage`-Aktion oben kennt nur `amount = 12`, also einen festen Wert. Ein Waffenangriff soll aber
das `damage`-Attribut des Angreifers verwenden — das war gar nicht ausdrückbar. Die Menge ist deshalb
immer:
```
menge = amount + scale * damage-Attribut(Verursacher) # beide optional, Standard 0
```
Eine Formel für alles: Basisangriff `scale = 1.0` · Feuerball `amount = 20` · Wuchtschlag
`scale = 1.5` · Mischformen beides. Keine zwei Modi, kein Sonderfall für `core:attack`.
Dazu **`flee` als achte Aktion** — Ticket 11 hat Flucht zu einer Fähigkeit gemacht, und die braucht
eine Aktion, die sie ausführt.
### Nachtrag aus Ticket 13: `run_script`
`{ action = "run_script", script = "meinmod:feuerregen" }` als **neunte Aktion**. Ein Script ist damit
überall verwendbar, wo eine Aktion stehen darf, und braucht keine eigenen Platzierungsregeln. Es gibt
Aktionen zurück, die in die Warteschlange aus 08 wandern — der Schreibpfad bleibt also genau einer.
### Nachtrag aus Ticket 15: `give_exp`
`{ action = "give_exp", amount = 50, target = "self" }` als **zehnte Aktion**, für Questbelohnungen.
Erfahrung aus Tötungen läuft dagegen über das Feld `exp_reward` an der `creatures`-Registry — die
Kurzform für einen Todes-Trigger, den sonst jede Gegnerdefinition mitschleppen müsste.
Ticket 15 hat außerdem geprüft, ob ein Modifikator „je Level" skalieren können muss (`per_level`).
**Antwort: nein** — Charakteroptionen geben flache Boni, und was sie dauerhaft relevant hält, sind
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) · `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
Oben steht `{ action = "apply_modifier", modifier = "core:poisoned", … }` — eine Aktion referenziert
einen Modifikator **per ID**. Aufgefallen beim Bauen von M3: **Keine der zwölf Registries definiert
benannte Modifikatoren.** `core:poisoned` hatte kein Zuhause.
Das ist die **dreizehnte Registry**, `[[modifiers]]`:
```toml
[[modifiers]]
id = "poisoned"
name = "Vergiftet"
symbol = "☠"
turns = 4
attributes = [ { attribute = "damage", percent = -15 } ]
triggers = [ { on = "turn_started",
actions = [ { action = "damage", amount = 2, damage_type = "poison", target = "self" } ] } ]
```
Das innere Feld heißt bewusst `attributes` und nicht `modifiers` — ein Modifikator, der Modifikatoren
enthält, liest sich wie ein Fehler. Und `effects` wäre der Begriff, den dieses Ticket ausdrücklich
verworfen hat.
Ein Modifikator ist damit ein **Bündel**: mehrere Umrechnungen plus Trigger plus Dauer. Gift senkt den
Schaden *und* tickt — das wäre mit einer einzelnen Umrechnung nicht ausdrückbar.
### Bewusst nicht entschieden ### Bewusst nicht entschieden
Das **Bedingungsvokabular** für `condition`. Es kommt aus Ticket 08, damit Quests und Modifikatoren Das **Bedingungsvokabular** für `condition`. Es kommt aus Ticket 08, damit Quests und Modifikatoren

View File

@@ -93,17 +93,6 @@ Engine den Begriff „Startausrüstung" kennt.
`Item.value` → `base_price` (Grundwert des Gegenstands). Attribut `shop_price` → `price_modifier` `Item.value` → `base_price` (Grundwert des Gegenstands). Attribut `shop_price` → `price_modifier`
(Charaktereigenschaft, über die Charisma wirkt). Zwei Dinge, zwei Namen. (Charaktereigenschaft, über die Charisma wirkt). Zwei Dinge, zwei Namen.
### Nachtrag aus Ticket 09: zwei Fähigkeiten sind implizit
Oben steht, Fähigkeiten würden „von Charakteroptionen und Gegenständen gewährt". Genau das erzeugt eine
Lücke: Ein Held, dessen Klasse nach einem Mod-Wechsel fehlt und der nichts trägt, hätte **gar keine**
Fähigkeit — und könnte seit Ticket 11 nicht einmal angreifen, weil `core:attack` eine Fähigkeit ist.
Deshalb: **`core:attack` und `core:flee` sind jedem [Wesen](../../../CONTEXT.md) implizit gewährt.**
Das ist keine Sonderregel für den Ladefall. Es trifft ebenso jeden Gegner ohne `abilities`-Eintrag, vor
dessen leerer Liste die KI aus Ticket 14 sonst stünde.
### Auflagen an andere Tickets ### Auflagen an andere Tickets
- **11 UI-Gerüst und Screen-Modell**: Charakterbogen und Charakterbau sind Schleifen über Registries, - **11 UI-Gerüst und Screen-Modell**: Charakterbogen und Charakterbau sind Schleifen über Registries,
@@ -116,28 +105,3 @@ dessen leerer Liste die KI aus Ticket 14 sonst stünde.
`src/items/weapon.rs` und `src/items/armor.rs` werden gelöscht, `enum ItemType` entfällt, `src/items/weapon.rs` und `src/items/armor.rs` werden gelöscht, `enum ItemType` entfällt,
`weapons.toml` und `armors.toml` verschmelzen zu einer gewöhnlichen Gegenstandsdatei, `items.rs` `weapons.toml` und `armors.toml` verschmelzen zu einer gewöhnlichen Gegenstandsdatei, `items.rs`
schrumpft auf Laden plus Registry. schrumpft auf Laden plus Registry.
### Befunde aus M8
**`abilities` und `on_create` standen seit M3 als TODO — und `abilities` war eine echte Lücke.**
Ticket 15 hat später argumentiert: *„Was Charakteroptionen dauerhaft trägt, sind Fähigkeiten — ein
Magier bleibt ein Magier, weil er zaubern kann."* Genau das Feld, auf dem dieses Argument ruht, gab es
nicht; eine Klasse konnte bis M8 ausschließlich flache Modifikatoren geben, also genau die Hälfte, die
laut demselben Ticket auf Stufe 30 zu Rauschen wird. Beide Felder sind jetzt da, `abilities_of` liest
sie, und Krieger wie Dieb tragen eine eigene Fähigkeit.
**`on_create` hat sich als das erwiesen, was es sein sollte: keine Mechanik.** Startausrüstung kommt
über `give_item`, Startgold über dasselbe — die Engine kennt den Begriff nicht. `starting_hero()` in
`main.rs`, das seit M1 Schwert, Kappe und Trank von Hand anlegte, ist auf `Creature::new(name)`
geschrumpft. Das war der eigentliche Test der Entscheidung: Der hartcodierte Held *war* die
Startausrüstungs-Sonderregel, nur an einer Stelle, an der niemand sie so genannt hat.
**„Registry-Schleife statt gemauertem Layout" trägt.** Der Charakterbau liest `categories_ordered()`
und `options_of()`; ein Mod, der „Talent" mit `pick = 2` ergänzt, bekommt eine dritte Spalte, ohne dass
eine Zeile Code davon weiß. Geprüft wird `pick` in `rejects`, nicht im Screen — der Screen zählt gar
nicht mit.
**Der Startpunkt braucht ein Flag, keine Herleitung.** Naheliegend wäre gewesen, „Held ist noch nicht
gebaut" aus `player.options.is_empty()` zu folgern. Das ist falsch: Ein Speicherstand, dem der
Rassen-Mod fehlt, hat nach der Verwerf-Regel aus Ticket 09 ebenfalls keine Optionen — und würde neu
gebaut statt fortgesetzt. `MainMenu { neu }` weiß es stattdessen von `main`, das den Unterschied kennt.

View File

@@ -125,16 +125,6 @@ in die Welt gelegt.
Überschreiben zwei Mods dasselbe, entscheidet die Ladereihenfolge — der letzte gewinnt. `overrides` Überschreiben zwei Mods dasselbe, entscheidet die Ladereihenfolge — der letzte gewinnt. `overrides`
ist ein Feld an *jedem* Registry-Eintrag, nicht nur an Gegenständen. ist ein Feld an *jedem* Registry-Eintrag, nicht nur an Gegenständen.
**Befund aus M1: die Referenzseite war offen.** Oben steht, was mit der eigenen `id` passiert — nicht,
was mit **Verweisen** passiert (`slot = "core:hand"`, `category = "core:race"`). Die wörtliche Fassung
ist umgesetzt: Nur `id` wird präfixiert, Verweise stehen voll qualifiziert. Das ist asymmetrisch, und
ein Modder wird darüber stolpern — er schreibt `id = "schwert"` und zwei Zeilen darunter
`slot = "core:hand"`.
Die Alternative wäre, dieselbe Regel auf jedes ID-förmige Feld anzuwenden: **kein Doppelpunkt heißt
eigener Namensraum.** Das ist bequemer und macht mod-interne Verweise kürzer, verlangt aber, dass der
Lader weiß, welche Felder IDs sind. Offen — zu entscheiden, bevor der erste Fremdmod existiert.
### Drei Phasen ### Drei Phasen
1. **Manifeste** — alle Ordner beider Pfade scannen, `manifest.toml` lesen, Reihenfolge aus 1. **Manifeste** — alle Ordner beider Pfade scannen, `manifest.toml` lesen, Reihenfolge aus

View File

@@ -85,17 +85,6 @@ meinmod/items.toml:42: core:iron_sword — unknown field `base_pric`
Die **Mod-ID steht vorne**: Bei zwölf installierten Mods ist sie die einzige Angabe, mit der ein Die **Mod-ID steht vorne**: Bei zwölf installierten Mods ist sie die einzige Angabe, mit der ein
Spieler weiß, wen er anschreiben muss. Spieler weiß, wen er anschreiben muss.
**Befund aus M1:** `Spanned` liefert den Anfang des *Eintrags* — also die `[[items]]`-Zeile —, nicht
die Zeile des schuldigen Feldes. Das deckt sich mit der Zusage oben („liefert dessen Zeilenbereich"),
sieht in der Praxis aber so aus:
```
test/items.toml:6: [1] — invalid type: integer `42`, expected a string
```
Zeile 6 ist der Eintragskopf, das kaputte Feld steht in Zeile 8. Den Feldnamen liefert `message()`,
weshalb die Meldung trotzdem eindeutig ist — die Genauigkeit reicht, solange ein Eintrag kurz bleibt.
`toml::de::Error` liefert `span() -> Option<Range<usize>>` und `message()`, aber keine Zeile — die ist `toml::de::Error` liefert `span() -> Option<Range<usize>>` und `message()`, aber keine Zeile — die ist
`input[..span.start].lines().count()`, eine Zeile Code. Keine Spalte: `message()` nennt das Feld `input[..span.start].lines().count()`, eine Zeile Code. Keine Spalte: `message()` nennt das Feld
bereits im Klartext, die Spalte wäre doppelt gemoppelt und bräuchte eine zweite Rechnung. Ein Eintrag bereits im Klartext, die Spalte wäre doppelt gemoppelt und bräuchte eine zweite Rechnung. Ein Eintrag

View File

@@ -52,21 +52,3 @@ Vollständiger Bericht: [`research/06-ratatui-architektur.md`](../research/06-ra
Sieben Punkte sind im Bericht ausdrücklich als nicht verifiziert markiert. Sechs Anschlussfragen bleiben Sieben Punkte sind im Bericht ausdrücklich als nicht verifiziert markiert. Sechs Anschlussfragen bleiben
offen und sind zu Ticket 11 und 12 graduiert. offen und sind zu Ticket 11 und 12 graduiert.
## Nachtrag aus Ticket 12: der Windows-Fallstrick ist gemessen
Der `KeyEventKind::Press`-Filter stand hier als Faktum, aber ungeprüft. Der Durchstich zählt
Tasten-Events ungefiltert **und** gefiltert mit. Unter Windows 11:
```
Tasten 14 → Press 7 → Züge 7
```
Jeder Tastendruck erzeugt zwei `Event::Key` — Press und Release. Ohne den Filter kostete **jeder
Schritt zwei Züge**, und weil ein Zug befristete Modifikatoren tickt, wäre das kein kosmetischer
Fehler, sondern doppelte Spielzeit.
Ebenfalls im Durchstich bestätigt: `ratatui::run`, der Buffer-Zugriff über `buf[(x, y)].set_symbol(…)`
für ein eigenes Grid-Widget, `Clear` auf ein Sub-`Rect` für Popups, und dass `ratatui` die Crate
`crossterm` re-exportiert — ein eigener `Cargo.toml`-Eintrag ist tatsächlich unnötig. Gebaut gegen
ratatui 0.30.2 mit Rust 1.96.

View File

@@ -116,20 +116,10 @@ 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 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. Anzeige hätte keinen Namen für das, was da liegt.
**Stapelregel:** `stackable` **und** gleiche `def` **und** gleiche `affixes`. **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
Ein Vergleich zweier `Vec<String>`, eine Zeile, verbietet nichts. Affixe an ausrüstbare Gegenstände zu Regel, die dem Modder erklärt, was er darf — Bevormundung, die 02 abgeräumt hat. Dass ein Trank mit
binden wäre eine Regel, die dem Modder erklärt, was er darf — Bevormundung, die 02 abgeräumt hat. Dass Zufallsaffix nicht mehr stapelt, ist die logische Folge und das Problem dessen, der ihn so baut.
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 ### Ein Eingang, ein Ausgang
@@ -172,34 +162,6 @@ nicht auswertbar — die Funktion wüsste nicht, gegen wen geschlagen wird. Der
Dazu kommt `events: Vec<Event>` als Feld in `Game`: Ticket 08 arbeitet Events als Warteschlange ab, Dazu kommt `events: Vec<Event>` als Feld in `Game`: Ticket 08 arbeitet Events als Warteschlange ab,
weil synchrones Feuern über `self.quests` iterieren müsste, während `self.apply` erneut läuft. weil synchrones Feuern über `self.quests` iterieren müsste, während `self.apply` erneut läuft.
### Nachtrag aus Ticket 14
**`enemies` ist der laufende Kampf, nicht die Etage.** Das stand oben nie ausdrücklich da und wäre
sonst falsch geraten worden: Bei Grid-Erkundung mit Menü-Kampf griffe sonst der Skelettkrieger drei
Räume weiter jeden Zug mit an. Leer bedeutet kein Kampf; gefüllt wird die Liste von der Etage (10).
Zwei neue Felder am `Creature`:
- `initiative: i32` — einmal beim Betreten des Kampfes gewürfelt. Bewusst ein Zahlenwert am Wesen und
keine Reihenfolgeliste aus `CreatureRef`: Die wäre eine **zweite** Indexliste mit demselben
Ungültigkeitsproblem beim Tod eines Gegners.
- `cooldowns: HashMap<AbilityId, u32>` — Abklingzeiten in Zügen.
Dazu der RNG-Seed als Feld in `Game`, damit Nachladen nicht neu würfelt.
Ticket 15 beantwortet außerdem die Frage, die 07 offen an es weitergereicht hatte — wo der
EXP-Fortschritt wohnt: **`current_exp` und `unspent_points: u32` gehören an `Game`**, nicht an
`Creature`. `base.level` bleibt am Wesen, weil Gegner echte Level haben und die Etagen-Skalierung sie
liest; Erfahrung und unverteilte Punkte hat dagegen ausschließlich der Held, und ein Feld, das für
99 % der Exemplare bedeutungslos ist, schriebe der Save für jeden Gegner mit.
`base.level` ist dabei die **einzige** Quelle für Punkte; `attribute(wer, "level")` darf durch
Modifikatoren höher ausfallen, ohne welche zu schenken.
Und die Antwort auf die Frage, die 07 offen an 14 gereicht hatte: **Tote Gegner werden am Ende von
`Game::execute` entfernt**, nach dem Leerarbeiten der Event-Warteschlange. Innerhalb eines Zuges
bleiben Indizes stabil.
### Auflagen an andere Tickets ### Auflagen an andere Tickets
- **08 Event- und Trigger-System**: Form von `QuestProgress`; das Bedingungsvokabular, das 01 bereits - **08 Event- und Trigger-System**: Form von `QuestProgress`; das Bedingungsvokabular, das 01 bereits
@@ -217,24 +179,3 @@ bleiben Indizes stabil.
`Stats` zerfällt in `Attributes` (Katalog aus 01) und Zustandsfelder — heute stehen `max_health` und `Stats` zerfällt in `Attributes` (Katalog aus 01) und Zustandsfelder — heute stehen `max_health` und
`current_health` nebeneinander in derselben Struktur. `Player` wird zu `Creature`. Neben `Item` als `current_health` nebeneinander in derselben Struktur. `Player` wird zu `Creature`. Neben `Item` als
Definition entsteht `ItemInstance` als Laufzeitform. Definition entsteht `ItemInstance` als Laufzeitform.
### Befund aus dem Spielen — der Stapel hatte keine Verbraucher
Ticket 07 beschreibt die Stapelregel, M4 hat sie gebaut: `ItemInstance.count`, `give` verschmilzt,
`take` zieht ab. Gebaut wurde damit die **Erzeugungsseite** — Beute, Kauf, Verkauf. Die drei älteren
Verbraucher blieben, wie sie waren, und die nahmen alle denselben Weg: `inventory.remove(index)`.
Im Spiel sichtbar an zwei Heiltränken, von denen ein Schluck beide verbrauchte. Derselbe Fehler steckte
in `equip` (legt den ganzen Stapel an) und dieselbe Lücke in `unequip` (schob am Verschmelzen vorbei
zurück ins Inventar).
Entschieden: **ein** Griff für alle Verbraucher — `take_at(index)` nimmt ein Stück aus dem Eintrag und
räumt ihn ab, wenn er leer ist. Über den Index und nicht über die `def`, weil zwei Tränke mit
verschiedenen Affixen sich die `def` teilen. `unequip` läuft jetzt durch `give`.
Die Lehre ist nicht „Stapel sind schwierig", sondern: Ein Feld, das die Bedeutung eines Eintrags
ändert, macht **jeden** bestehenden Leser zum Kandidaten. `count` einzuführen war ein Vierzeiler; die
Verbraucher nachzuziehen war die eigentliche Arbeit, und sie wurde beim ersten Mal vergessen.
Nebenbei aufgefallen: Der Heiltrank hatte nie `actions` — er war seit M1 verbrauchbar, ohne zu wirken.
Kein Test hat es gemerkt, weil kein Test ihn getrunken hat.

View File

@@ -71,58 +71,6 @@ Level ≥ 3" wird zu zwei Einträgen dupliziert — oder der Modder hängt beide
Bedingungen werden an **zwei** Stellen ausgewertet: beim Feuern eines Triggers und beim Auslesen eines Bedingungen werden an **zwei** Stellen ausgewertet: beim Feuern eines Triggers und beim Auslesen eines
Attributs (bedingte Modifikatoren aus 01). Letzteres erzwingt den Nachtrag an Ticket 07, siehe unten. Attributs (bedingte Modifikatoren aus 01). Letzteres erzwingt den Nachtrag an Ticket 07, siehe unten.
### Nachträge aus M3
Beim Bauen kamen zwei Prüfungen dazu, die oben fehlten:
- **`health_below`** mit einem Anteil (0.0–1.0). Die `ai`-Listen aus 14 brauchen „unter 30 % Leben", und
das ist mit `attribute` nicht ausdrückbar: `health` ist **Zustand**, kein Attribut (01), und ein
*Verhältnis* lässt sich mit `at_least`/`below` auf einem absoluten Wert nicht formulieren.
- **`chance`** am Trigger. Dieses Ticket nennt „Flammendes Schwert: 20 % Chance, Brennen auszulösen" als
Motivbeispiel, hat aber kein Feld dafür festgelegt. Ohne es bräuchte jeder Wahrscheinlichkeitseffekt
ein Script — und Scripting liegt laut 13 hinter 1.0.
### Nachtrag: `when` — der Ereignisfilter am Trigger
Die Lücke, die M3 aufgedeckt hat: Die Events oben tragen Nutzlast, aber **die Bedingungsgrammatik kann
sie nicht prüfen** — `Check` fragt immer ein *Wesen* (`who`), nie das Ereignis. „Dornenpanzer
reflektiert nur Feuer" war damit nicht formulierbar.
Gelöst wird das mit einem **eigenen Feld am Trigger**, nicht mit einer weiteren Prüfung in `condition`:
```toml
[[triggers]]
on = "damage_taken"
when = { damage_type = "fire" }
condition = [ { check = "has_tag", who = "target", value = "core:undead" } ]
actions = [ { action = "damage", amount = 3, damage_type = "fire", target = "target" } ]
```
**`condition` fragt Wesen, `when` filtert das Ereignis.** Der Grund für die Trennung ist der
Auswertungskontext: Dieselbe Bedingungsgrammatik läuft auch beim **Auslesen eines Attributs** (bedingte
Modifikatoren aus 01), und dort gibt es kein Event. Eine Nutzlast-Prüfung in `condition` wäre also eine
Prüfung, deren Wahrheitswert vom Kontext abhängt — im Trigger sinnvoll, am Modifikator immer falsch. Der
Modder sähe das nicht, er sähe nur, dass sein Modifikator nie greift. Genau die stille Falle, die dieses
Projekt an anderen Stellen bewusst vermieden hat (Ring heilt beim Anlegen; `level` gegen `base.level`).
Was Ticket 08 ursprünglich verhindern wollte, waren **zwei Bedingungsgrammatiken über Wesen**, die
auseinanderdriften. Ein Ereignisfilter ist keine zweite Fassung davon, sondern eine andere Frage: nicht
*wer*, sondern *was gerade passiert ist*.
**Nutzlast, die zurückkommt:** `damage_type` (bei `attack_hit` und `damage_taken`) und `prefab` (bei
`room_entered`, sonst wäre die Quest „betrete die Bibliothek" nicht formulierbar). `critical` und
`amount` bleiben draußen — für beide gibt es kein Beispiel, das jemand *gefordert* hat, und das ist der
Unterschied zwischen einem Bedarf und einer Idee. Sie kosten später je ein Feld, ohne dass der Filter
sich ändert.
**Form:** eine Tabelle mit optionalen Feldern, alle vorhandenen müssen zutreffen (implizit UND, wie bei
`condition`). **Ein Wert je Feld, keine Negation** — dasselbe Argument wie beim ODER-Verbot oben: Wer
Feuer *und* Eis reflektieren will, schreibt zwei Trigger. Fehlt ein Feld, wird nicht gefiltert; das ist
die Negation, die man meistens meint.
Die Decke: Häufen sich Filter mit fünf Feldern und Ausnahmen, ist das das Signal, dass Stufe (c) fällig
ist — nicht, dass der Filter eine Sprache werden soll.
### Neun Events, feste Liste ### Neun Events, feste Liste
| Event | Nutzlast über `source`/`target` hinaus | | Event | Nutzlast über `source`/`target` hinaus |
@@ -154,34 +102,6 @@ Stufe (c) kann niemand ein eigenes Event *auslösen* — ein namespaced Event w
Kommt Scripting (Ticket 13), wird `emit_event` eine gewöhnliche Aktion und die Liste bekommt eine Kommt Scripting (Ticket 13), wird `emit_event` eine gewöhnliche Aktion und die Liste bekommt eine
`Custom(String)`-Variante. Ein Zusatz, kein Umbau. `Custom(String)`-Variante. Ein Zusatz, kein Umbau.
### Nachtrag aus Ticket 10: zwölf Events
`room_entered { prefab_id }` kommt dazu. `floor_entered` deckt nur die Etage ab — „die Tür fällt hinter
dir zu", ein Hinterhalt oder eine Quest „finde die Bibliothek" brauchen den einzelnen Raum. Prefabs
tragen Trigger wie jeder andere Registry-Inhalt, neue Maschinerie kostet es also nicht.
Das Event trägt zugleich den Kampfbeginn: Ein `room_entered` in einen Raum mit lebenden Gegnern
verschiebt sie nach `Game.enemies`.
**Beobachtung zur Liste selbst:** Sie wurde hier als „fest und geschlossen" festgelegt, und seither hat
*jedes* Ticket, das echte Spielsituationen beschreibt, einen Eintrag gebraucht — 14 zwei, 10 einen. Das
spricht nicht gegen die Entscheidung (namespaced Mod-Events wären ohne Scripting weiterhin sinnlos),
aber wer 15 oder 16 bearbeitet, sollte mit einem weiteren rechnen.
### Nachtrag aus Ticket 14: elf Events statt neun
`combat_started` und `combat_ended { outcome: Victory | Fled }` kommen dazu. Ohne sie ist „gewinne drei
Kämpfe" nicht formulierbar, und `combat_ended` **braucht** die Unterscheidung — sonst zählt Weglaufen
als Erfolg.
Beide mit `source = Player`, ohne `target`. Platzierung im Zugablauf aus 14: `combat_started` bei
Schritt 1 neben `turn_started`, `combat_ended` bei Schritt 5 direkt nach dem `retain` — dann ist die
Gegnerliste bereits leer und ein Trigger sieht den Endzustand statt einer halb aufgeräumten
Zwischenstufe.
Keine dritte Variante für den Tod des Helden: `creature_died` mit `source = Player` deckt ihn ab, und
danach gibt es ohnehin keinen Trigger mehr, der etwas davon hätte.
### Warteschlange statt Rekursion ### Warteschlange statt Rekursion
Events landen in `Game.events: Vec<Event>` und werden nach der laufenden Aktion in einer Schleife Events landen in `Game.events: Vec<Event>` und werden nach der laufenden Aktion in einer Schleife
@@ -196,12 +116,6 @@ Gegen Zyklen — Trigger A löst B aus, B löst A aus — ein schlichter Zähler
Zug**, danach Abbruch mit Warnzeile. Kein Zyklenerkennungs-Graph; der Zähler fängt denselben Fall und Zug**, danach Abbruch mit Warnzeile. Kein Zyklenerkennungs-Graph; der Zähler fängt denselben Fall und
ist eine Zeile. ist eine Zeile.
**Befund aus M3: die Warteschlange war ein Stack.** Die erste Umsetzung nahm `Vec::pop()`, also LIFO —
ein Folge-Event drängelte sich vor die schon wartenden. Genau das sollte die Warteschlange verhindern
(„hängt es hinten an, statt den Stack zu vertiefen"). Jetzt `VecDeque` mit `pop_front`. Die Formulierung
oben war richtig, die Umsetzung nicht, und **kein Test hat es gemerkt** — Reihenfolge fällt erst auf,
wenn zwei Trigger um dasselbe Ziel konkurrieren.
Trigger werden bei jedem Event **gesucht, nicht indiziert** — dieselbe Entscheidung wie „kein Trigger werden bei jedem Event **gesucht, nicht indiziert** — dieselbe Entscheidung wie „kein
Attribut-Cache" in 07 und aus demselben Grund: Ein Index müsste bei jedem An- und Ablegen, jedem Loot Attribut-Cache" in 07 und aus demselben Grund: Ein Index müsste bei jedem An- und Ablegen, jedem Loot
und jedem Affix-Wechsel gepflegt werden, also genau das Invalidierungsproblem, das 07 vermieden hat. und jedem Affix-Wechsel gepflegt werden, also genau das Invalidierungsproblem, das 07 vermieden hat.
@@ -272,25 +186,3 @@ Dazu `Game.events: Vec<Event>` als neues Feld im Baum.
nicht den einzelnen Raum. nicht den einzelnen Raum.
- **13 Script-Engine wählen**: `emit_event` als Aktion und namespaced Events, sobald Stufe (c) steht. - **13 Script-Engine wählen**: `emit_event` als Aktion und namespaced Events, sobald Stufe (c) steht.
- **14 Kampfformeln und Rundenablauf**: wo genau `attack_hit` und `damage_taken` im Zug feuern. - **14 Kampfformeln und Rundenablauf**: wo genau `attack_hit` und `damage_taken` im Zug feuern.
### Befunde aus M7
**„Eine Quest ist ein Trigger" hält — und zwar bis in den Code.** `Quest` trägt den `Trigger` per
`#[serde(flatten)]`, `on`/`when`/`condition`/`actions`/`chance` stehen also flach in derselben
TOML-Tabelle. Dieselbe Frage „feuert dieser Trigger auf dieses Ereignis?" beantwortet eine einzige
Funktion für Gegenstand, Modifikator, Gegner *und* Quest. Beim ersten Bauen stand die Prüfung zweimal
da; das war der Moment, in dem die Behauptung des Tickets fast nur eine Formulierung geblieben wäre.
**Der feste Träger ist der Grund, warum Quests neben der Trägerschleife stehen.** Das Ticket sagt
„für eine Quest ist der Träger der Held". Im Code heißt das: Der Questlauf gehört *neben* die Schleife
über die Beteiligten, nicht hinein — sonst würde jede Quest einmal pro Gegner gezählt.
**Die Kette braucht keinen Sonderfall.** Eine Belohnung, die `start_quest` ausführt, verlängert
`Game.quests` mitten im Zählen. Weil die Schleife über den Index läuft und ihre Länge vorher feststeht,
wird der neue Eintrag erst beim nächsten Ereignis gezählt — genau richtig, er war beim Auslöser noch
nicht da. Kein Zwischenpuffer nötig, nur die Reihenfolge richtig herum.
**Die Ordnungszahlen im Aktionskatalog widersprechen sich.** Ticket 08 nennt `start_quest` die
„siebte", Ticket 14 `flee` die „achte", M4 `open_shop` die „elfte" — jedes Ticket zählte von seinem
eigenen Stand aus, und keine der Zahlen stimmt mit den heute neun gebauten Varianten überein. Die
Ordnungszahlen sind aus dem Code raus; `CONTEXT.md` führt die Liste, und die ist zählbar.

View File

@@ -1,7 +1,7 @@
# 09 — Save-Schema # 09 — Save-Schema
Type: grilling Type: grilling
Status: resolved Status: open
Blocked by: 07 Blocked by: 07
## Question ## Question
@@ -33,150 +33,3 @@ Ergebnis: Save-Schema als kommentiertes TOML-Beispiel plus Regeln für fehlende
**eine** Etage, keine besuchten Vorgänger. `Creature.options` (Rasse, Klasse) muss mit, sonst hat der **eine** Etage, keine besuchten Vorgänger. `Creature.options` (Rasse, Klasse) muss mit, sonst hat der
Held nach dem Laden keine Herkunft mehr — womit die Frage „was, wenn die Klasse fehlt?" oben scharf Held nach dem Laden keine Herkunft mehr — womit die Frage „was, wenn die Klasse fehlt?" oben scharf
wird. wird.
## Answer
Begriffe im [Glossar](../../../CONTEXT.md).
### Die Etage wird gespeichert, nicht gewürfelt
Ticket 14 hat einen seedbaren Generator geliefert, Regenerieren *wäre* also möglich. Drei Dinge
sprechen dagegen:
1. Regenerieren verlangt **dieselben Prefabs und denselben Generator-Code**. Ein Mod-Update oder eine
Engine-Änderung erzeugt ein anderes Layout — und der gespeicherte Held steht dann in einer Wand.
2. Der Raumzustand aus 10 (`cleared`, gefallene Gegner, genommene Beute, Bodenloot) lässt sich nicht
regenerieren und muss ohnehin mit. Der Seed spart also nur das Kachelgitter.
3. Die Rechnung: 80×40 Felder sind als ASCII-Zeilen **rund 3 KB** — im selben Format wie Prefabs, also
lesbar und im Notfall von Hand reparierbar.
Der Seed bleibt trotzdem im Save, aber für künftige Würfe (14), nicht zum Regenerieren.
### Ein Speicherstand, Permadeath
Autosave beim **Betreten einer Etage** und beim **Beenden**. Kein manuelles Speichern: Es brächte nur
die Wahl des Zeitpunkts, und genau die hat Ticket 14 mit dem Seed im Save bereits abgeräumt. Mehrere
benannte Slots wären eine Verwaltungsansicht für ein Spiel mit einem einzigen Helden.
Speichern **im Kampf ist erlaubt**, weil es beim Beenden ohnehin passieren muss; der Kampfzustand fällt
bei serde-Ableitung gratis mit ab.
**Bei Tod wird der Speicherstand gelöscht** — aber erst beim Erscheinen des `GameOver`-Screens, nicht
beim Fallen der Lebenspunkte. Stürzt das Spiel in der Sekunde dazwischen ab, ist der letzte Autosave
noch da, und da er vom Betreten der Etage stammt, verschenkt das nichts.
### Der Save *ist* der `Game`-Baum
`#[derive(Serialize, Deserialize)]` durchgehend, mit **einer** handgeschriebenen Ausnahme: dem
Kachelgitter als ASCII-Zeilen statt eines Arrays mit 3200 Einträgen.
Eigene Save-Structs hießen, jede Struktur zweimal zu pflegen und bei jeder Änderung daran zu denken —
die Sorte Duplikat, die genau dann auseinanderläuft, wenn es niemand merkt. Der Preis ist ehrlich: Der
Save hängt an internen Feldnamen, ein Umbenennen bricht alte Stände. Dafür gibt es die Schema-Version.
```toml
[save]
schema = 1 # das einzige Feld, das die Engine auswertet
game = "0.1.0" # Diagnose bei Fehlermeldungen
[[save.mods]]
id = "core"
version = "1.0.0"
```
Bewusst **kein** `floor` und `turn` im Header: Der `Game`-Baum serialisiert beide ohnehin ein paar
Zeilen weiter unten. Sie oben zu wiederholen hätte nur einem Speicherstand-Browser genützt, der sie
ohne Parsen des Rumpfs anzeigen will — und den gibt es bei einem Slot nicht.
Bei abweichender `schema`: **ablehnen** mit Klartextmeldung („Speicherstand stammt aus Schema 1, dieses
Spiel liest 2"), und die **Datei bleibt liegen**. Migration zu schreiben, bevor ein einziger
Speicherstand ausgeliefert wurde, ist Code für ein Problem, das noch niemand hat; Best-Effort auf einem
strukturell anderen Schema erzeugt stille Verfälschung statt eines Fehlers. Und eine Datei zu löschen,
weil man sie nicht lesen kann, wäre die schlechteste aller Reaktionen.
### Fehlende Mods: verwerfen, nie ablehnen
| Fehlt | Folge |
|---|---|
| Gegenstand im Inventar | Eintrag entfällt |
| Gegenstand in der Ausrüstung | Slot bleibt leer |
| Affix | entfällt, Gegenstand bleibt |
| Quest | Fortschritt entfällt (bereits in 08) |
| Prefab | egal — das Layout ist gespeichert |
| Charakteroption | entfällt, Held bleibt spielbar |
Ein Spieler soll nicht seinen ganzen Lauf verlieren, weil er einen Mod deinstalliert hat.
**Die Mod-Version wird verglichen und protokolliert, mehr nicht.** Verschwundene IDs fangen die Regeln
oben ab, geänderte Werte gelten einfach. Der Vergleich leistet reine Diagnose — eine Zeile
„`meinmod` 1.0.0 → 1.2.0 seit dem Speichern", die genau dann Gold wert ist, wenn jemand meldet „mein
Spielstand ist seit dem Update kaputt". Das ist die ehrliche Bilanz des `version`-Felds aus Ticket 03:
Fiele es weg, verlöre man diese Zeile und sonst nichts.
Warnungen gehen in **dieselbe Sitzungsdatei** wie die des Mod-Ladelaufs, nicht in eine eigene
`save-*.log`-Familie: Zwei Dateinamensmuster hießen zwei Rotationsregeln und zwei Orte zum Nachsehen.
Die Warnliste aus 04 lebt stattdessen für die ganze Sitzung und wird neu geschrieben, wenn sie wächst
— das ist sogar weniger Logik als zwei getrennte Läufe.
Dazu eine **Rückfrage vor dem Betreten des Spiels**: „12 Einträge fehlen (siehe `logs/load-….log`).
Fortfahren?"
Das weicht bewusst von 04s Regel „eine Zeile, kein Screen" ab, und zwar aus einem Grund: Der Spieler
will an dieser Stelle vielleicht gar nicht weiterspielen, sondern den fehlenden Mod nachinstallieren.
Diese Wahl hat er nur *vor* dem Laden — danach ist der verkrüppelte Held gespeichert.
### Atomar schreiben
`data_dir()/rustkeep/save.toml`, dasselbe Verzeichnis wie Mods und `load_order.toml` (03 hat die
Aufteilung in Daten und Konfiguration verworfen). Geschrieben wird nach `save.toml.tmp` und dann per
`fs::rename` darübergezogen — das ersetzt die Zieldatei auf Windows 10 1607+ ebenso wie auf Unix
(gegen die std-Doku geprüft).
**Das ist die eine Stelle in diesem Ticket, an der bewusst nicht lazy entschieden wurde.** Der Autosave
überschreibt den einzigen Speicherstand, den es gibt; bricht das Schreiben ab — Absturz, voller
Datenträger, geschlossener Deckel —, ist ein Lauf von einer Stunde weg. Temporärdatei plus `rename`
sind zwei Zeilen und machen den Verlust unmöglich: Es liegt entweder die alte Datei da oder die neue,
nie eine halbe.
**Gold braucht kein eigenes Feld** — es ist seit Ticket 01 ein gewöhnlicher stapelbarer Gegenstand
(`core:gold`) und liegt im Inventar wie alles andere.
### Zwei Nachträge an geschlossene Tickets
| Ticket | Nachtrag |
|---|---|
| **02** | **`core:attack` und `core:flee` sind jedem Wesen implizit gewährt.** Fähigkeiten kommen bisher ausschließlich von Charakteroptionen und Gegenständen — ein Held, dessen Klasse fehlt und der nichts trägt, hätte gar keine und könnte nicht einmal angreifen. Das ist keine Sonderregel für den Ladefall: Auch jeder Gegner ohne `abilities`-Eintrag trifft es, und die KI aus 14 stünde vor einer leeren Liste |
| **11** | `Confirm { text, on_yes: Box<Response> }` statt `on_yes: Command`. Die Rückfrage beim Laden braucht keinen Command, sondern einen Screen-Wechsel. `Response::Command` ist bereits eine Variante, der bisherige Fall bleibt also erhalten — der Sonderfall entfällt ersatzlos, die Struktur wird dabei *kleiner* |
### Befunde aus M6
**Die Ereigniswarteschlange gehört nicht in den Save.** „Der Kampfzustand fällt bei serde-Ableitung
gratis mit ab" stimmt für `enemies`, `initiative` und `cooldowns` — nicht für `Game.events`. Die ist
Zwischenstand *innerhalb* eines Zuges: `drain_events` leert sie nach jeder einzelnen Handlung, und
gespeichert wird ausschließlich zwischen Zügen. Sie mitzuschreiben hieße, immer eine leere Liste zu
speichern. Sie steht jetzt auf `#[serde(skip)]`, mit einem `debug_assert` in `save()`, das die
Behauptung festhält statt sie nur zu behaupten.
Angenehmer Nebeneffekt: Ohne `events` taucht `CreatureRef` im Save gar nicht auf — und damit auch kein
`{ Enemy = 3 }` als Inline-Tabelle in einer Datei, die lesbar sein soll.
**Die Feldreihenfolge ist Teil des Formats.** TOML verlangt Werte vor Tabellen. „Der Save *ist* der
Baum" heißt deshalb: Die Reihenfolge der Felder in `Game`, `Creature`, `Room` und `Floor` ist keine
Kosmetik mehr, sondern eine Bedingung. `Game`, `Creature` und `Room` mussten umsortiert werden — alles
Skalare nach oben, alles Verschachtelte nach unten. Der Rundlauf-Test fällt sofort um, wenn jemand das
wieder vermischt; das ist die einzige Absicherung, die es dafür gibt.
**`Serialize` für `Floor` muss von Hand.** Der Zeilenumbruch im Kachelgitter braucht die Breite, und
ein feldweises `#[serde(with = …)]` sieht nur sein eigenes Feld. `Deserialize` bleibt abgeleitet (die
Zeilen werden einfach aneinandergehängt), `Serialize` sind fünfzehn Zeilen. Das ist genau die eine
handgeschriebene Ausnahme, die das Ticket vorgesehen hat — nur an einer anderen Stelle als gedacht.
**Die Rückfrage braucht `on_no`.** Ticket 11 hatte `Confirm { text, on_yes: Box<Response> }` beschlossen
und „Nein" implizit als Wegklicken gedacht. Im Ladefall heißt Nein aber *beenden und den Mod
nachinstallieren* — genau die Wahl, die es nach dem Laden nicht mehr gibt. `on_no` ist ein Feld mehr
und spart eine ungeschriebene Regel.
**Die Rückfrage gilt nur für Verworfenes aus dem Save.** Beim ersten Bauen hing sie an *allen*
Warnungen, also auch an denen des Mod-Ladelaufs — und damit hätte ein kaputter TOML-Eintrag in einem
Mod den Start blockiert. Ticket 04 hat dafür ausdrücklich „eine Zeile, kein Screen" entschieden. Beide
Zahlen werden jetzt getrennt gezählt, obwohl beide in dieselbe Logdatei laufen.

View File

@@ -1,7 +1,7 @@
# 10 — Raum-Prefab-Format # 10 — Raum-Prefab-Format
Type: grilling Type: grilling
Status: resolved Status: open
Blocked by: 03 Blocked by: 03
## Question ## Question
@@ -35,203 +35,3 @@ Prefabs sind eine **weitere Registry über dieselbe Ladepipeline**, kein eigener
beantwortet die letzte Frage oben halb: Hinzufügen ist der Normalfall, Ersetzen geht über `overrides`. beantwortet die letzte Frage oben halb: Hinzufügen ist der Normalfall, Ersetzen geht über `overrides`.
Offen bleibt, ob **Generator-Parameter** (Etagen-Tabellen, Gewichtungen, Pflichträume) ebenfalls Offen bleibt, ob **Generator-Parameter** (Etagen-Tabellen, Gewichtungen, Pflichträume) ebenfalls
Registry-Einträge sind — dann mit derselben Maschine moddbar — oder ein Sonderfall. Registry-Einträge sind — dann mit derselben Maschine moddbar — oder ein Sonderfall.
## Answer
Begriffe im [Glossar](../../../CONTEXT.md).
### Etagenaufbau: freie Platzierung mit Korridoren
Prefabs werden nacheinander an zufälliger Position gesetzt, Überlappung (plus ein Feld Rand)
verworfen, nach N Fehlversuchen aufgegeben — der klassische Rogue-Ansatz.
Verbunden wird **Raum `i` mit Raum `i−1`** in Platzierungsreihenfolge, danach ein paar zufällige
Zusatzkorridore für Schleifen. Korridore als L-Form, erst horizontal, dann vertikal.
Die Kette statt eines minimalen Spannbaums, weil sie **Erreichbarkeit strukturell garantiert**: Hängt
jeder Raum am vorigen, ist jeder Raum von jedem erreichbar — ohne Flutfüllung und ohne
Reparaturschleife. Das beantwortet die Garantiefrage aus dem Ticketkörper nebenbei mit. Prim wären
zwanzig Zeilen für dasselbe Ergebnis mit hübscheren Korridorlängen.
**Rotation und Spiegelung sind erlaubt** — acht Varianten je Prefab, und das Drehen eines ASCII-Blocks
ist eine Transposition. Achtmal so viel Inhalt aus derselben Handarbeit.
Kein Opt-out-Feld: 1.0 hat kein Prefab, dessen Ausrichtung etwas bedeutet. Taucht eines auf, ist
`rotate = false` eine Zeile — vorher wäre es ein Schalter, den niemand stellt.
### Ein Prefab
```toml
[[prefabs]]
id = "core:kreuzung"
tags = ["core:normal"]
layout = """
####+####
#.......#
#..e...e#
+.......+
#..l....#
#.......#
####+####
"""
```
| Zeichen | Bedeutung | | Zeichen | Bedeutung |
|---|---|---|---|---|
| `#` | Wand | | `l` | Beute-Platz |
| `.` | Boden | | `>` | Treppe |
| `+` | Tür | | `<` | Eingang |
| `e` | Gegner-Platz | | `n` | NPC-Platz |
Kein eigener Händler-Marker: **Ein Händler ist ein NPC.** Ein zweites Zeichen für denselben Platz wäre
dieselbe Sorte Vermischung, die Ticket 02 beim `ItemType` abgeräumt hat.
Marker stehen **im Block**, nicht als Koordinatenliste daneben. Der ganze Sinn von ASCII ist, dass ein
Modder den Raum *sieht*; Koordinaten daneben zu pflegen wäre die schlechtere Hälfte von beidem.
**Türen sind explizit.** Ohne sie stanzt der Generator Löcher in eine von Hand gezeichnete Wand — wer
Prefabs baut, will bestimmen, wo man hineinkommt, sonst hätte man auch würfeln können. Korridore
dürfen nur an `+` andocken.
**Befund aus M2: „nur an Türen andocken" ist teurer als es klingt.** Der Test, der Wandfelder zählt,
schlug beim ersten Versuch mit **2340 durchgegrabenen Feldern** an. Zwei Ursachen:
1. Der Korridor startete *auf* der Tür — und weil eine Tür auf der Raumwand liegt, grub der waagerechte
Abschnitt die ganze Wandreihe auf. Erst einen Schritt **nach außen** treten: 2340 → 352.
2. Zwei L-Varianten reichen nicht. Bei neun Räumen findet sich oft keine, die alle Rechtecke umfährt —
sie senken die Trefferquote, garantieren aber nichts.
Was hält: eine **Wegsuche über den freien Raum**. Raumfelder liegen nicht im Suchraum, also kann sie
eine Wand gar nicht berühren. Die L-Form bleibt davor, weil sie Korridore erzeugt, die wie Korridore
aussehen statt wie Treppenmuster; die Wegsuche greift nur, wenn keine L-Variante frei ist. Findet auch
sie keinen Weg, wird **nichts** gegraben — dann schlägt der Erreichbarkeitstest an, und das ist die
richtige Fehlerrichtung: laut statt still.
Ohne den zählenden Test wäre nichts davon aufgefallen. Die Etagen waren begehbar, zusammenhängend und
plausibel — der Fehler war rein optisch, und optische Fehler findet niemand durch Nachdenken.
**Ein `e` sagt „hier steht ein Gegner", nicht *welcher*.** Was einzieht, entscheidet die Etage. Sonst
wäre jedes Prefab an eine Schwierigkeitsstufe gebunden und derselbe hübsche Raum ließe sich nicht auf
Etage 1 *und* auf Etage 5 verwenden — genau die Wiederverwendung, für die man Prefabs baut.
Bei `n` ist es **umgekehrt**: Den NPC nennt das Prefab selbst (`npcs = ["core:blacksmith"]`), nicht
die Etage. NPCs skalieren nicht mit der Tiefe — ein Schmied auf Etage 5 ist derselbe Schmied wie auf
Etage 1, und ein `core:merchant`-Raum, dessen Händler ausgewürfelt würde, wäre kein Händlerraum. Das
Feld selbst gehört dem NPC-Ticket; hier steht nur, auf welcher Seite die Entscheidung liegt.
### Die Etage
```toml
[[floors]]
id = "core:floor_3"
number = 3
size = [80, 40] # Felder
rooms = 9 # Zielzahl inklusive Pflichtraeume
required = ["core:entrance", "core:stairs", "core:merchant"] # Tags
prefabs = [ { tag = "core:normal", weight = 10 }, { tag = "core:treasure", weight = 2 } ]
creatures = [ { id = "core:skeleton", weight = 5 }, { id = "core:ghoul", weight = 2 } ]
loot = "core:floor_3_table"
```
Ablauf: **Pflichträume zuerst** auf die leere Fläche, danach gewichtet auffüllen bis `rooms` erreicht
oder die Versuche erschöpft sind.
Ausgewählt wird durchgehend über **Tags**, auch bei den Pflichträumen. IDs würden bedeuten, dass ein
Mod keinen eigenen Eingangsraum beisteuern kann, ohne die ganze Etagen-Tabelle zu überschreiben.
Dass Pflichträume zuerst kommen, *ist* die Garantie — auf einer leeren Fläche passen sie praktisch
immer. Optionale Räume, die nicht mehr passen, entfallen einfach; das ist der Normalfall, kein Fehler.
Passt die Flächensumme der Pflicht-Tags **grundsätzlich** nicht in `size`, ist das ein Datenfehler und
beim Laden erkennbar: Warnzeile über die Maschinerie aus 04. Zur Laufzeit dann kein
Wiederholungszähler und kein Rückfallpfad — die Prüfung sitzt dort, wo der Fehler gemacht wurde.
`core:merchant` steht unter `required` und nicht unter `prefabs`: Ticket 07 hat Backtracking
ausgeschlossen, der Händler muss also garantiert auftauchen statt gewichtet.
Damit beantwortet sich auch die Frage aus der 03-Vorgabe: **Generator-Parameter sind Daten.**
`prefabs` und `floors` sind zwei gewöhnliche Registries — ein Mod ergänzt Etage 6 oder ersetzt Etage 3
über `overrides`, ohne dass hier etwas erfunden wird. Die Gesamtzahl steigt damit auf **zehn**.
### Kampf beginnt beim Betreten eines Raums
Die Bestellung aus 14, wer `Game.enemies` füllt: **Gegner gehören zum Raum, nicht zur Etage.** Ein
`room_entered` in einen Raum mit lebenden Gegnern verschiebt sie nach `Game.enemies` und startet den
Kampf; nach dem Sieg gilt der Raum als geräumt und ein erneutes Betreten startet nichts mehr.
Der Grund ist, was dadurch **nicht** gebaut werden muss: Frei umherlaufende Gegner bräuchten Pfadsuche,
Sichtlinien, Verfolgung und ein Bewegungsbudget pro Zug — ein eigenes System, das niemand bestellt hat
und das die Charter mit „Kampf im Menü" auch nicht nahelegt. Die `e`-Plätze aus dem Prefab sind
schlicht die Gegnerliste des Raums.
### `Floor` — der Innenbau aus Ticket 07
```rust
struct Floor {
number: u8,
tiles: Vec<Tile>, width: u16, height: u16,
rooms: Vec<Room>,
player_room: Option<usize>,
}
struct Room {
prefab: PrefabId, // fuer room_entered und Trigger
rect: Rect,
enemies: Vec<Creature>,
loot_table: LootTableId, // Nachtrag 16: Prefab-loot oder Etagentabelle
loot: Vec<Pos>, // Haufen, erst beim Aufheben aufgeloest
cleared: bool,
}
enum Tile { Wall, Floor, Door, Stairs, Entrance }
```
Korridore gehören zu **keinem** Raum — sie sind schlicht `Tile::Floor` außerhalb aller `rect`. Dadurch
feuert `room_entered` sauber beim Übergang Korridor → Raum, ohne Sonderfall, und `player_room: None`
ist ein echter Zustand statt eines Fehlers.
Gegner werden bei Kampfbeginn **verschoben, nicht kopiert** — sonst gäbe es zwei Wahrheiten über
denselben Gegner.
Ladeprüfung über die 04-Maschinerie: **`layout`-Zeilen müssen gleich lang sein**, sonst ist der Block
kein Rechteck und die Platzierung rechnet mit Müll. Ein Zweizeiler, der eine ganze Klasse von
Modder-Frust abfängt.
### Nachtrag aus Ticket 16
**Bodenbeute wird erst beim Aufheben gezogen**, deshalb hält `Room.loot` nur noch Positionen und der
Raum *eine* `loot_table` — alle Haufen eines Raums ziehen ohnehin aus derselben Tabelle (dem
Prefab-`loot` oder, wenn es fehlt, der Etagentabelle). Die *Zahl* der Haufen steht weiterhin bei der
Erzeugung fest, es sind die `l`-Zeichen; ihr Inhalt nicht. Der Save wird dadurch kleiner.
Möglich ist das nur, weil **alle Haufen dasselbe generische Symbol zeigen**: Die Anzeige verrät damit
nichts, was noch nicht gewürfelt ist. Der Gewinn ist, dass ein Glücksring auch auf Beute wirkt, die auf
dieser Etage schon liegt.
Dazu bekommt ein Prefab ein **optionales `loot`-Feld**, das die Etagentabelle für seine `l`-Plätze
ersetzt — sonst wäre ein `core:treasure`-Raum nur ein Raum mit mehr `l`-Zeichen. Es nennt eine
Tabellen-ID, keine Gegenstände, und bleibt damit über alle Tiefen verwendbar.
### Siegbedingung — bisher nirgends definiert
Kein Ticket hatte je festgelegt, was beim Abstieg von der letzten Etage passiert.
**`Descend` auf der letzten definierten Etage gewinnt den Lauf.** Es gibt keine Zahl „5" im Code: Die
Engine sucht `[[floors]]` mit `number = aktuell + 1` und findet sie keine, ist Schluss. Ein Mod, der
Etage 6 ergänzt, verlängert das Spiel ohne eine Zeile Engine-Code — dieselbe Bewegung wie bei
`core:attack` in Ticket 11: Was wie Engine-Logik aussieht, ist Inhalt.
### Zwei Nachträge an geschlossene Tickets
| Ticket | Nachtrag |
|---|---|
| **08** | `room_entered { prefab_id }` als **zwölftes** Event. `floor_entered` deckt nur die Etage ab; „die Tür fällt hinter dir zu", ein Hinterhalt oder eine Quest „finde die Bibliothek" brauchen den einzelnen Raum. Neue Maschinerie kostet es nicht — Prefabs sind gewöhnlicher Registry-Inhalt und dürfen Trigger tragen wie alles andere |
| **14** | `GameOver { outcome: Died \| Won }` statt nur `GameOver`. Der Bildschirm zeigt in beiden Fällen dasselbe — Etage, Zug, Gold, Verlauf —, nur die Überschrift unterscheidet sich. Zwei Screens für zwei Texte wären Verdopplung |
### Auflagen an andere Tickets
- **09 Save-Schema**: Layout oder Seed? Ticket 14 hat einen seedbaren Generator geliefert, Regenerieren
*wäre* also möglich — aber der Raumzustand (`cleared`, gefallene Gegner, genommene Beute) lässt sich
nicht regenerieren und muss ohnehin mit. 09 entscheidet, ob sich der Seed dann noch lohnt.
- **16 Loot-Tabellen**: `loot` an der Etage verweist auf eine Tabelle, die `l`-Plätze ziehen daraus.
- **Boss-Design**: Ein Boss ist derzeit ein Gegner in einem Prefab mit `core:boss`-Tag. Phasen fehlen
weiterhin.

View File

@@ -59,33 +59,13 @@ hätte es vergessen.
`Unequip(SlotId)` · `Drop(usize, u32)` · `PickUp(usize)` · `Descend` · `Talk(NpcId)` · `Unequip(SlotId)` · `Drop(usize, u32)` · `PickUp(usize)` · `Descend` · `Talk(NpcId)` ·
`Buy(ItemId, u32)` · `Sell(usize, u32)` · `Wait` `Buy(ItemId, u32)` · `Sell(usize, u32)` · `Wait`
*Nachtrag aus Ticket 15:* **`AssignPoints`** kommt als dreizehnter Command dazu — ein Aufstieg gibt
einen frei verteilbaren Attributpunkt, verteilt wird im `CharacterSheet`. Bewusst **ein** Command mit
allen Punkten auf einmal statt einer je Punkt: Drei Stufen wären sonst drei Züge, in denen ein
Gift-Debuff tickt, während man ein Menü bedient. Dasselbe Muster wie `Buy(item, 10)`, und damit ohne
Ausnahme von der Zugregel.
Weder Angriff noch Flucht sind **eigene Varianten** — beides sind Fähigkeiten: Weder Angriff noch Flucht sind **eigene Varianten** — beides sind Fähigkeiten:
`UseAbility("core:attack", ziel)` und `UseAbility("core:flee", …)`. Ticket 02 hat Fähigkeiten als `UseAbility("core:attack", ziel)` und `UseAbility("core:flee", …)`. Ticket 02 hat Fähigkeiten als
Registry festgelegt, also gehören sie dorthin, und ein Mod kann beide ersetzen: eine Klasse, die Registry festgelegt, also gehören sie dorthin, und ein Mod kann beide ersetzen: eine Klasse, die
besser flieht, ist damit eine Datenänderung statt eines Engine-Sonderfalls. besser flieht, ist damit eine Datenänderung statt eines Engine-Sonderfalls.
**Korrektur aus dem Durchstich (Ticket 12): „alle Commands" heißt „alle *ausgeführten*".** Gegen eine Auch Handel kostet einen Zug. Ein `fn costs_turn(&self) -> bool` wäre eine Ausnahmeliste, die mit
Wand zu laufen darf keinen Zug kosten — sonst tickt ein Gift-Debuff, weil jemand danebengetippt hat. jeder neuen Variante gepflegt werden muss und bei der irgendwann jemand falsch rät. Zehn Züge beim
```rust
pub enum Outcome { TurnPassed, Rejected(&'static str) }
fn execute(&mut self, cmd: Command) -> Outcome
```
Der Zug endet nur bei `TurnPassed`, und der Grund reist mit zurück in die Statuszeile — sonst tut das
Spiel schweigend nichts. Das betrifft mehr als Wände: kaufen ohne Gold, eine Fähigkeit ohne Ausdauer,
absteigen ohne Treppe. Overlays schließen sich trotzdem, auch bei `Rejected`: Die Rückfrage *wurde*
beantwortet.
Davon unberührt: Auch Handel kostet einen Zug. Ein `fn costs_turn(&self) -> bool` wäre eine
Ausnahmeliste, die mit jeder neuen Variante gepflegt werden muss und bei der irgendwann jemand falsch
rät. Zehn Züge beim
Einkauf stören nur, wenn im Laden gekämpft wird; dagegen hilft, dass `Buy(item, 10)` bereits *ein* Einkauf stören nur, wenn im Laden gekämpft wird; dagegen hilft, dass `Buy(item, 10)` bereits *ein*
Command ist. Command ist.
@@ -114,16 +94,8 @@ ohne Command hat den Spielzustand also nachweislich nicht angefasst.
**Ein `Response::Command` schließt alle Overlay-Screens.** Das Flag existiert bereits fürs Rendern und **Ein `Response::Command` schließt alle Overlay-Screens.** Das Flag existiert bereits fürs Rendern und
beantwortet damit auch „schließt sich nach einer Rückfrage von selbst" — eine Zeile in der Schleife, beantwortet damit auch „schließt sich nach einer Rückfrage von selbst" — eine Zeile in der Schleife,
keine zusätzliche `Response`-Variante und keine Antwortliste. `Confirm { text, on_yes: Box<Response> }` wird keine zusätzliche `Response`-Variante und keine Antwortliste. `Confirm { text, on_yes: Command }` wird
dadurch für jede Rückfrage wiederverwendbar, ohne eigene Variante je Anlass — **`Command` muss dafür dadurch für jede Rückfrage wiederverwendbar, ohne eigene Variante je Anlass. Dass `Inventory` als
`Clone` sein**, sonst kommt das Kommando nicht aus dem Screen heraus, ohne ihn zu zerstören
(Befund aus Ticket 12).
*Nachtrag aus Ticket 09:* Das Feld hieß ursprünglich `on_yes: Command`. Die Rückfrage beim Laden eines
Speicherstands („Einträge fehlen, fortfahren?") braucht aber keinen Command, sondern einen
Screen-Wechsel. `Response` ist die allgemeinere Form und enthält `Command` bereits als Variante — der
bisherige Fall bleibt erhalten, der Sonderfall entfällt, und die Struktur wird dabei kleiner statt
größer. Dass `Inventory` als
Vollbild-Screen nach `UseItem` offen bleibt, fällt aus derselben Regel — man trinkt selten genau einen Vollbild-Screen nach `UseItem` offen bleibt, fällt aus derselben Regel — man trinkt selten genau einen
Trank. Trank.
@@ -132,22 +104,10 @@ Trank.
`MainMenu` (trägt die Ladewarnungs-Zeile aus 04) · `CharacterCreation` (Registry-Schleife aus 02) · `MainMenu` (trägt die Ladewarnungs-Zeile aus 04) · `CharacterCreation` (Registry-Schleife aus 02) ·
`Explore` · `Combat` · `Inventory` · `CharacterSheet` · `QuestLog` · `Shop` · `Dialog` · `Confirm` `Explore` · `Combat` · `Inventory` · `CharacterSheet` · `QuestLog` · `Shop` · `Dialog` · `Confirm`
**Nachtrag aus Ticket 14: `GameOver { outcome: Died | Won }` als elfter Screen**, Vollbild. Das
`outcome` kam aus Ticket 10 dazu — der Abstieg von der letzten definierten Etage gewinnt den Lauf, und
Sieg wie Tod zeigen dieselben Zahlen unter einer anderen Überschrift. Die lazy Variante — zurück ins
`MainMenu` mit einer Meldungszeile — wurde bewusst verworfen: Ein Abstiegs-Roguelike lebt davon, dass
der Lauf am Ende *erzählt* wird (Etage, Zug, Gold, Todesursache). Das ist der Moment, für den man fünf
Etagen gespielt hat, und kein anderer Screen kann ihn übernehmen.
Kein `ItemDetail`-Popup: Die Inventarliste zeigt Details in einer Seitenspalte, das ist ein Layout und Kein `ItemDetail`-Popup: Die Inventarliste zeigt Details in einer Seitenspalte, das ist ein Layout und
kein Screen. `Shop` bleibt getrennt von `Dialog`, weil Handel zwei Listen nebeneinander braucht und kein Screen. `Shop` bleibt getrennt von `Dialog`, weil Handel zwei Listen nebeneinander braucht und
der Dialog eine Textfläche mit Antwortoptionen. 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 `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 — 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 Inventar → Detail → zurück landet wieder auf demselben Eintrag. Zurückgesetzt wird nur beim echten
@@ -195,20 +155,6 @@ Die ratatui-Freiheit prüft ein `#[test]`, der `src/game/**/*.rs` liest und auf
Zeilen; ein Cargo-Workspace mit `game` als eigener Crate wäre echte Compiler-Durchsetzung und ist die Zeilen; ein Cargo-Workspace mit `game` als eigener Crate wäre echte Compiler-Durchsetzung und ist die
Decke, falls es je ernst wird. Decke, falls es je ernst wird.
**Befund aus M2: das Grid braucht einen mitlaufenden Bildausschnitt.** Ticket 06 hat recherchiert, dass
es kein Grid-Widget gibt und Eigenbau nötig ist — aber niemand hat gefragt, was passiert, wenn die
**Karte größer als der Bildschirm** ist. Etage 5 ist 88 Felder breit, ein 80-Spalten-Terminal schneidet
ab, und der Held läuft aus dem Bild.
Der Ausschnitt folgt dem Helden und wird am Kartenrand geklemmt; passt die Karte ganz hinein, bleibt er
bei (0, 0). Zehn Zeilen in `ui/grid.rs`, getestet. Kein Scrollbalken, keine Minimap — das wäre die
Decke, falls Orientierung je zum Problem wird.
**Befund aus M1:** Die wörtliche Fassung des Grenztests trägt nicht. Sie schlägt sofort an — nämlich beim
Doc-Kommentar von `game/mod.rs`, der erklärt, dass die Grenze existiert. Der Test muss
**Kommentarzeilen ausnehmen** und sollte Datei, Zeile und Inhalt nennen statt nur „ist gefallen".
Steht so in `tests/boundaries.rs`.
### Nachtrag an Ticket 01: Darstellung muss moddbar sein ### Nachtrag an Ticket 01: Darstellung muss moddbar sein
01 hat `symbol` als *Schlüssel* definiert, dessen Zuordnung `ui/theme.rs` macht. Darin steckt eine 01 hat `symbol` als *Schlüssel* definiert, dessen Zuordnung `ui/theme.rs` macht. Darin steckt eine
@@ -233,60 +179,3 @@ und `data/` bleiben ratatui-frei, weil ein Farbname eine Zeichenkette ist.
Kulisse. Kulisse.
- **14 Kampfformeln**: `core:attack` und `core:flee` müssen als Fähigkeiten im Kernspiel existieren — - **14 Kampfformeln**: `core:attack` und `core:flee` müssen als Fähigkeiten im Kernspiel existieren —
ohne sie hat `UseAbility` weder Basisangriff noch Flucht. ohne sie hat `UseAbility` weder Basisangriff noch Flucht.
### Nachtrag — zwei Cursor im Kampf-Screen (nach M4)
Ticket 11 hatte die Zielwahl den Zifferntasten überlassen: `Enter` traf den ersten Gegner, `1`–`9`
feuerten sofort auf den n-ten. Das reicht, solange man nur zuschlägt, und bricht, sobald die Wahl des
Ziels eine Entscheidung ist — dieselbe Taste hätte je nach Absicht zwei Bedeutungen.
Der Screen trägt jetzt **zwei Cursor**, `pick` für die Fähigkeit und `target` für den Gegner, und ein
`targeting: bool` sagt, welcher aktiv ist. `Tab` schaltet um, der Titel des aktiven Rahmens trägt
`· aktiv`. Das ist dieselbe Bedienung wie im Laden — **eine** Umschaltregel für alle zweispaltigen
Screens statt einer je Screen.
Die Ziffern bleiben, aber sie *springen* nur noch aufs Ziel, statt zu feuern; ausgelöst wird
ausschließlich mit `Enter`. Zwei gleichzeitig sichtbare Cursor mit Pfeilen auf beiden Achsen wären die
Alternative gewesen — weniger Tastendrücke, aber eine Bedienung, die es sonst nirgends im Spiel gibt.
Dazu zeigt die Gegnerliste unter jedem Lebensbalken die **Ansage** aus Ticket 14 (`↳ Wuchtschlag →
dich`, sonst `↳ zögert`). Sie steht bewusst als eigene Zeile und nicht als Kürzel hinter dem Namen:
Sie ist die Information, auf der die Entscheidung dieses Zuges beruht.
### Nachtrag — die Werkbank (Debug-Screen, nach M4)
Ein zwölfter Screen, `F12` aus Erkundung und Kampf: Gegenstandsliste mit Cursor, dazu vier Tasten für
heilen, absteigen, Raum leeren.
**Sie geht durch dieselbe Tür wie alles andere.** Es wäre kürzer gewesen, den Screen direkt auf
`Game` schreiben zu lassen — er hat den `&mut` ohnehin nicht, aber man hätte ihn ihm geben können. Das
hätte die Zusage aus Ticket 11 gebrochen, dass `Response::Command` der einzige Weg nach `game/` ist,
und zwar ausgerechnet an der Stelle, die am seltensten gelesen wird. Stattdessen ein Kommando
`Debug(Cheat)` mit vier Varianten — **eins**, nicht vier, sonst zögen sich Cheats durch `rejects` und
`perform` wie echte Kommandos. In `rejects` steht genau eine Zeile: Ein Cheat wird nie abgewiesen.
`ClearRoom` setzt die Lebenspunkte auf null, statt `enemies` zu leeren. So läuft der normale Weg samt
Todes-Triggern, Erfahrung (später) und „Der Raum ist geräumt" — ein Cheat, der am Spielablauf
vorbeigeht, testet den Spielablauf nicht.
**Immer mitgebaut**, nicht hinter `cfg(debug_assertions)`. Das wäre ein `#[cfg]` an der Variante *und*
an jedem `match` darüber — für ein Spiel, das niemand ausliefert. Ein `#[cfg]` reicht später nach,
wenn es je einen Release-Build gibt.
### Befund aus M5 — die Naht zwischen Screen-Zustand und Spielzustand
Ticket 11 trennt sauber: Ein Screen fasst `Game` nicht an, er gibt ein `Command` zurück. Der
Charakterbogen hält seit M5 aber selbst Zustand — die **vorgemerkten** Punkte, die erst mit `Enter`
abgeschickt werden. Beim Abschicken zieht das Spiel sie ab, der Screen behielt sie: `unspent_points`
stand auf 0, `pending` auf 1, und die Anzeige rechnete `0 - 1` auf `u32`. Absturz.
Das ist keine Schwäche der Trennung, sondern ihr Preis: Wer Zustand doppelt hält, muss sagen, wann er
abgeglichen wird. Die Antwort hier ist die einfachste mögliche — **vorgemerkt wird beim Abschicken
verbraucht**, der Screen leert sein Feld, während er das Kommando übergibt. Dazu `saturating_sub` an
den zwei Lesestellen, weil `unspent_points` sich auch unter dem offenen Screen ändern kann (ein
Aufstieg im selben Zug).
Dabei ist der **erste UI-Test** des Projekts entstanden. Bis M5 war `ui/` ungetestet, weil dort
angeblich nur Anzeige liegt — der Absturz zeigt, dass das nicht stimmt: Sobald ein Screen Zustand hält,
ist die Naht zum Spielzustand eine Fehlerquelle wie jede andere. `handle_key` ist ohne Terminal
aufrufbar, der Test kostet zwanzig Zeilen.

View File

@@ -1,7 +1,7 @@
# 12 — Prototyp: ratatui-Durchstich # 12 — Prototyp: ratatui-Durchstich
Type: prototype Type: prototype
Status: resolved Status: open
Blocked by: 11 Blocked by: 11
## Question ## Question
@@ -21,56 +21,3 @@ Der Umbau von `print_box` auf ratatui berührt jedes bestehende Modul (`items.rs
Bewusst nicht im Prototyp: Kampf, Inventar, Shop, Speichern, Mods. Bewusst nicht im Prototyp: Kampf, Inventar, Shop, Speichern, Mods.
Ergebnis: lauffähiger Durchstich plus Urteil, ob das Modell trägt oder Ticket 11 nachgebessert wird. Ergebnis: lauffähiger Durchstich plus Urteil, ob das Modell trägt oder Ticket 11 nachgebessert wird.
## Answer
**Urteil: Das Modell trägt.** Ticket 11 wird an genau einer Stelle nachgebessert, und die hatte nichts
mit dem Screen-Stack zu tun.
Der Prototyp liegt als Primärquelle auf dem Branch **`prototyp/12-ratatui-durchstich`**
(`examples/durchstich.rs`, 342 Zeilen, `cargo run --example durchstich`). Er bleibt dort und kommt
nicht nach `main` — Wegwerfcode, der eine Frage beantwortet hat.
### Die fünf Zusagen
| Zusage | Befund |
|---|---|
| `mod game` bleibt ratatui-frei | **hält.** Null Treffer, und zwar ohne dass irgendwer aufpassen musste — die Trennung ergibt sich, wenn `Command` und Spiellogik zusammenliegen |
| Dungeon-Grid als eigenes `impl Widget` | **trägt.** `buf[(x, y)].set_symbol(…)` ist der ganze Trick; kein fertiges Widget wurde vermisst |
| `Vec<Screen>` für Screens *und* Popups | **trägt.** Overlay-Flag entscheidet Rendern *und* Auto-Schließen, wie in 11 abgeleitet |
| `Response` statt Stack-Mutation im Screen | **trägt.** Kein Borrow-Konflikt, weil der Screen den Stack nie sieht |
| `KeyEventKind::Press`-Filter | **gemessen statt geglaubt** — siehe unten |
Popup über Grid, `x` dann `j`: Der Zug zählt hoch und das Popup schließt sich in derselben Iteration.
Die Regel „ein `Response::Command` schließt alle Overlays" ist damit real und kostet die eine Zeile,
die in 11 dafür veranschlagt war.
### Der Windows-Fallstrick ist jetzt eine Zahl
Der Bericht aus 06 führte ihn als Faktum, aber ungemessen. Der Prototyp zählt Tasten-Events
ungefiltert **und** gefiltert mit. Ergebnis unter Windows 11:
```
Tasten 14 → Press 7 → Züge 7
```
Jeder Tastendruck erzeugt zwei `Event::Key` (Press und Release). **Ohne den Filter würde jeder Schritt
zwei Züge kosten** — und weil ein Zug befristete Modifikatoren tickt, wäre das kein kosmetischer
Fehler, sondern doppelte Spielzeit. Der Filter arbeitet, `Press` und `Züge` laufen synchron.
### Zwei Befunde, beide nach Ticket 11 zurückgetragen
**Gegen eine Wand zu laufen kostete einen Zug.** Aufgefallen beim Spielen, nicht beim Nachdenken: Der
Zugzähler lief hoch, obwohl sich nichts bewegte — ein Gift-Debuff hätte getickt, weil jemand
danebengetippt hat. Die Regel und `Outcome { TurnPassed, Rejected(&str) }` stehen jetzt in
[Ticket 11](11-ui-geruest-screen-modell.md).
**`Command` muss `Clone` sein**, sonst kommt das Kommando nicht aus `Confirm { on_yes }` heraus, ohne
den Screen zu zerstören — im Prototyp brauchte das ein `std::mem::replace`.
### Was der Prototyp nicht beantwortet
Kampf, Inventar, Shop, Speichern, Mods — bewusst ausgelassen. Ebenso ungetestet: die
`Registries`-neben-`Game`-Entscheidung aus 07, weil der Durchstich gar keine Registries lädt. Der
Borrow-Konflikt, den sie vermeiden soll, tritt erst auf, wenn `Game::execute` beides gleichzeitig
braucht — das prüft erst die Implementierung.

View File

@@ -1,7 +1,7 @@
# 13 — Script-Engine wählen und Sandbox härten # 13 — Script-Engine wählen und Sandbox härten
Type: grilling Type: grilling
Status: resolved Status: open
Blocked by: 01 Blocked by: 01
## Question ## Question
@@ -27,146 +27,3 @@ reichen sie tiefer in den Game-State, ist sie es nicht. Zu klären:
- **Wann fällt die Entscheidung endgültig** — jetzt, oder erst wenn Mod-Stufe (c) gebaut wird? - **Wann fällt die Entscheidung endgültig** — jetzt, oder erst wenn Mod-Stufe (c) gebaut wird?
Ergebnis: gewählte Engine mit Begründung, Härtungs-Checkliste, und die Regel, was Scripts sehen dürfen. Ergebnis: gewählte Engine mit Begründung, Härtungs-Checkliste, und die Regel, was Scripts sehen dürfen.
## Answer
Begriffe im [Glossar](../../../CONTEXT.md).
### Die Wahl ist eine Implementierungsentscheidung — deshalb fällt sie leicht
Scripts lesen über **eine Handvoll Funktionen** und schreiben ausschließlich über **Aktionen**. Weder
lesen sie direkt im `Game`-Baum noch halten sie Zustand außerhalb des Vokabulars.
Damit ist die Schnittstelle klein genug, um sie in jeder Engine nachzubauen — genau die Leitplanke aus
Ticket 05. Ein Engine-Wechsel kostet die Bindungsschicht, nicht die Architektur.
Die strikte Variante — Script bekommt Daten, gibt Aktionen zurück, liest gar nichts — wäre sauberer
gewesen und macht Scripts sinnlos: Was ein Script gegenüber einem Trigger *hinzufügt*, ist Rechnen
(„Schaden skaliert mit der Zahl der Untoten im Raum"), und dafür muss es lesen können.
### Rhai — unbequem begründet
**Für ein unveröffentlichtes Terminal-Roguelike schreibt niemand Mods.** Lua gewinnt bei der
Vertrautheit deutlich (Factorio, GMod, WoW, Noita, Don't Starve), aber das zahlt sich erst aus, wenn es
eine Modder-Gemeinde gibt — und die entsteht nicht durch die Sprachwahl, sondern durch ein Spiel, das
Leute spielen wollen. Bis dahin ist der Modder, den es zu bedienen gilt, der Autor selbst, und die
Charter nennt „Rust lernen" als halbe Motivation.
Dazu der schärfste Einzelbefund aus 05: Ein Lua-Script hat unter `StdLib::ALL_SAFE` per `io.open` eine
echte Datei gelesen — „safe" heißt dort speichersicher, nicht fähigkeitsbeschränkt. Rhai hat Datei-,
Netz- und Prozesszugriff gar nicht erst in der Standard-Engine.
**Mods gelten als vertrauenswürdig.** Kein Spiel dieser Größenordnung behandelt sie anders — Factorio,
Minecraft, Skyrim und Noita vertrauen alle. Der Spieler entpackt einen Ordner in sein Profil, dieselbe
Vertrauensentscheidung wie bei jeder heruntergeladenen Software; eine Sandbox schützt ihn nicht davor,
sondern verschiebt nur, wo er sie trifft. Damit scheidet Luau samt C++17-Toolchain und Lua-*Dialekt*
aus — der Dialekt, den die vertrauten Lua-Modder gerade nicht können.
### Härtung — gegen Unfälle, nicht gegen Absicht
```rust
engine.set_module_resolver(DummyModuleResolver::new()); // kein ./*.rhai von der Platte
engine.disable_symbol("eval"); // kein Code aus Strings
engine.set_max_operations(100_000); // CPU pro Aufruf
engine.set_max_call_levels(32); // Rekursion
engine.set_max_string_size(8_000);
engine.set_max_array_size(1_000);
engine.set_max_map_size(1_000);
engine.set_max_expr_depths(64, 32); // Parser-Tiefe
```
Alle Methodennamen gegen die Rhai-Doku geprüft. `on_progress` bleibt weg — es ist der Abbruchhaken für
lang laufende Scripts, und `set_max_operations` deckt denselben Fall mit einem Wert statt mit einer
Callback-Schleife ab.
**`set_max_operations` gehört ausdrücklich *nicht* in `[balance]`** aus Ticket 14: Ein Mod, der sein
eigenes CPU-Limit hochsetzen kann, invertiert die Härtung.
Ein Script, das an ein Limit stößt, ist ein **Eintrags-Fehler** nach der Regel aus 04: Warnzeile mit
Mod-ID, Script-Name und Zeile, Trigger verworfen, Spiel läuft weiter. Rhais Fehlermeldungen mit Zeile
*und* Spalte sind laut 05 genau dafür da.
### Die Schnittstelle — was Scripts sehen dürfen
```
attribute(wer, name) -> f64 has_tag(wer, tag) -> bool
health(wer) -> f64 has_item(wer, id) -> i64
stamina(wer) -> f64 floor() -> i64
rand() -> f64
```
Alle Leseoperationen sind an der Bedingungsgrammatik aus 08 gespiegelt — **Scripts sehen nichts, was
eine Bedingung nicht auch sähe**, sie können nur damit rechnen. Das ist die Regel; die Liste selbst
wird endgültig, wenn Stufe (c) gebaut wird.
Bewusst gestrichen: `turn()` und `enemy_count()`. Für beide gibt es kein benanntes Beispiel, und
`enemy_count()` deckt sein eigenes Motivbeispiel („Schaden skaliert mit der Zahl der Untoten im Raum")
nicht einmal ab — dafür bräuchte es Iteration. Die Umkehrbarkeit aus Q1 hängt daran, dass diese
Schnittstelle klein bleibt; sie auf Vorrat wachsen zu lassen, während die Stufe verschoben ist, wäre
das Gegenteil.
`health` und `stamina` sind **eigene** Funktionen statt `attribute("health")`, weil Ticket 01 scharf
trennt: `max_health` ist ein Attribut, der Lebensstand ist Zustand. Diese Trennung darf im Script-API
nicht verwaschen.
**`rand()` läuft durch den Generator aus `Game`**, und Rhais eigenes `rand`-Paket wird nicht
registriert. Sonst gilt „Nachladen würfelt nicht neu" aus Ticket 14 für alles außer für Scripts — und
ausgerechnet die interessanten Effekte würfelten beim Laden neu.
Geschrieben wird ausschließlich über Aktionen: Ein Script **gibt Aktionen zurück**, die in die
Warteschlange aus 08 wandern. Damit deckelt der 100-Events-Zähler auch ein Script, das sich selbst
aufruft.
### Scripts sind die elfte Registry
```toml
{ action = "run_script", script = "meinmod:feuerregen" }
```
`run_script` als **neunte Aktion**. Dadurch entstehen keine neuen Platzierungsregeln: Ein Script ist
überall verwendbar, wo eine Aktion stehen darf — an einem Gegenstand, einer Fähigkeit, einer
Questbelohnung, einem `ai`-Eintrag, einem Raum-Trigger. Dieselbe Bewegung wie `core:attack` in Ticket
11 und `flee` in 14: Was wie Engine-Logik aussieht, ist Inhalt.
Die Alternative — Scripts registrieren sich selbst auf Events — wäre genau die Variante, bei der ein
Script Zustand außerhalb des Vokabulars hält, und die macht die Engine-Wahl unumkehrbar.
### Risiken: beobachtet, mit benanntem Ausstiegssignal
„Beobachten" ohne Kriterium ist ein Wort ohne Folge, deshalb ausgeschrieben:
| Signal | Reaktion |
|---|---|
| Keine Rhai-Release in zwölf Monaten | wechseln — Stand 1.25.1 vom 29.05.2026, frisch, 3,4 Mio. Downloads in 90 Tagen |
| Unsoundness in einem Pfad, den wir tatsächlich nutzen | wechseln — Issue #894 ist das bisher nicht |
| `smartstring` unmaintained (RUSTSEC-2026-0249) | **kein** Ausstiegsgrund |
Zu `smartstring`: Das Repo wurde am 03.05.2026 archiviert, die Advisory kam am 10.08.2026 und ist ein
INFO-Eintrag über fehlende Wartung, keine Verwundbarkeit (genannte Alternativen: `compact_str`,
`smol_str`). Rhai 1.25.1 erschien danach ohne Wechsel. `cargo audit` wird das melden, und das ist die
angemessene Menge Ärger dafür.
### Stufe (c) wandert hinter 1.0
Die **Entscheidung** steht jetzt, die **Umsetzung** nicht.
Stufen (a) und (b) sind durch 01–03 vollständig beschrieben und liefern bereits zehn Registries,
Trigger, Bedingungen, Affixe und `[balance]` — sehr viel Moddbarkeit ohne eine Zeile Script. Stufe (c)
ist die einzige, die eine neue Dependency, eine Bindungsschicht und eine Härtungsliste braucht.
Ticket 02 hat die Regel selbst formuliert: „Braucht ein Gegenstand etwas, das Modifikator, Aktion oder
Fähigkeit nicht ausdrücken können, ist das eine Lücke im Vokabular." Eine Script-Engine einzubauen,
*bevor* jemand an diese Lücke gestoßen ist, baut die Antwort vor der Frage — und jede Lücke, die ein
Script stopfen würde, ist zuerst ein Hinweis, dass das Vokabular sie stopfen sollte.
Die Entscheidung jetzt zu treffen kostet dagegen nichts und legt fest, **wogegen** das Vokabular gebaut
wird. Genau das ist die Leitplanke aus 05.
**Folge: Die Destination der Map ändert sich** — 1.0 liefert Mod-Stufen (a) und (b), Stufe (c) kommt
danach.
### Nachtrag an Ticket 01
`run_script` als **neunte Aktion**. Der Katalog hat damit: `damage`, `change_attribute`,
`apply_modifier`, `remove_modifier`, `give_item`, `remove_item`, `start_quest` (08), `flee` (14),
`run_script` (13).

View File

@@ -1,7 +1,7 @@
# 14 — Kampfformeln und Rundenablauf # 14 — Kampfformeln und Rundenablauf
Type: grilling Type: grilling
Status: resolved Status: open
Blocked by: 01 Blocked by: 01
## Question ## Question
@@ -41,295 +41,3 @@ Eine Frage fällt zusätzlich an, die 07 nur benennen konnte: **Wann werden tote
nicht — eine Aktion, die mitten im Zug einen Gegner tötet, verschiebt die Adressen aller nicht — eine Aktion, die mitten im Zug einen Gegner tötet, verschiebt die Adressen aller
nachfolgenden. Der Rundenablauf muss festlegen, ob am Zugende, am Rundenende oder gar nicht nachfolgenden. Der Rundenablauf muss festlegen, ob am Zugende, am Rundenende oder gar nicht
kompaktiert wird (Alternative: Tote bleiben mit einem Flag stehen). kompaktiert wird (Alternative: Tote bleiben mit einem Flag stehen).
## Answer
Begriffe im [Glossar](../../../CONTEXT.md). **Zahlen gehören nicht hierher** — die Map hält Balancing
getrennt; hier stehen Formeln und Ablauf.
### Rundenablauf
**Ein Zug = Spielerhandlung plus alle Gegnerreaktionen.** Nicht jede Handlung ein eigener Zug: Bei drei
Gegnern tickte ein Gift-Debuff sonst viermal pro Runde, und ein Tastendruck erzeugte vier Züge — das
bräche die Zusage aus 11, die Ticket 12 gerade gemessen hat.
Reihenfolge über **Initiative**, einmal beim Betreten des Kampfes aus `dexterity` plus Wurf gewürfelt
und als `i32` **am Wesen** gehalten. Ausdrücklich *nicht* als Reihenfolgeliste aus `CreatureRef`: Die
wäre eine zweite Indexliste, die beim Tod eines Gegners genauso ungültig wird wie die erste. Ein
Zahlenwert am Wesen verschwindet mit ihm, die Sortierung stimmt weiter. Gleichstand geht an den
Spieler — deterministisch und zu seinen Gunsten.
`dexterity` speist damit Initiative *und* Ausweichen. Absicht, aber 15 sollte es beim Balancing wissen.
```
Game::execute(cmd, reg):
1. turn_started (+ combat_started, falls der Kampf gerade beginnt) in die Warteschlange
2. befristete Modifikatoren ticken, Ausdauer regeneriert, Abklingzeiten sinken
3. Teilnehmer handeln in Initiativreihenfolge, Spieler-Command an seinem Platz
-> Abbruch, sobald der Held tot ist
4. nach JEDER einzelnen Handlung die Warteschlange leerarbeiten (bis leer oder 100)
5. retain auf tote Gegner, danach ggf. combat_ended
6. turn += 1
```
Schritt 4 nach jeder Handlung statt einmal am Zugende: Sonst schlägt ein Gegner zu, den ein
Dornenpanzer eine Handlung zuvor getötet hat. Schritt 5 erst **nach** dem Leeren: Sonst zeigt ein
Trigger auf `creature_died` auf einen bereits entfernten Gegner. Innerhalb eines Zuges müssen Indizes
stabil bleiben — über Zuggrenzen hinweg hält ohnehin niemand ein `CreatureRef`, die UI liest neu.
Genau **eine** Handlung pro Zug, kein Ausdauer-Budget. `max_stamina` ist die Ressource für
Fähigkeiten — das beantwortet die Frage „wozu sonst" aus dem Ticketkörper.
Stirbt der Held mitten im Zug, bricht der Zug ab (laufende Handlung samt Warteschlange noch zu Ende).
Zwei weitere Gegner, die auf eine Leiche einschlagen, erzeugen nur Kampflog und Trigger auf ein Wesen,
das die Runde nicht mehr erlebt.
### Formeln
```
p_treffer = clamp(hit_chance(A, gegen=B) - dexterity(B) * AUSWEICH, 0.05, 0.95)
roh = attribute(damage, A, gegen=B) # 01-Stapelung, bedingte Modifikatoren inklusive
krit = rand() < clamp(critical_chance(A), 0.0, 1.0)
roh = roh * critical_factor(A) falls krit
r = clamp(resistance(B, typ), -1.0, 0.8)
final = max(1, round(roh * (1.0 - r)))
p_flucht = clamp(0.5 + (dexterity(Held) - dexterity(schnellster Gegner)) * FLUCHT, 0.1, 0.9)
```
**Die Frage aus dem Ticketkörper löst sich auf.** Ob `critical_factor` den Rohschaden oder den
geminderten multipliziert, ist mathematisch belanglos: `roh × kf × (1−r)` ist dasselbe wie
`roh × (1−r) × kf`. Festzulegen war nur, dass beide multiplikativ *bleiben*.
**Befund aus M3: die Trefferformel war toter Code.** `hit_chance` existierte, war getestet — und wurde
nie aufgerufen. **Jeder Angriff traf.** Gemeldet hat es nur der Compiler
(`warning: function 'hit_chance' is never used`); kein Test schlug an, weil alle Tests die Formel
*direkt* prüften statt über einen Angriff.
Lehre für die Formeln unten: Eine Formel, die nur von ihrem eigenen Unit-Test aufgerufen wird, ist
nicht verdrahtet. Der Test, der das jetzt abfängt, führt 200 Angriffe gegen einen absurd flinken
Gegner und verlangt, dass mindestens einer verfehlt *und* mindestens einer trifft.
Drei Klemmbereiche, jeder mit Grund:
- **Resistenz bei `0.8`** — bei `1.0` wäre ein Wesen immun, und Immunität ist keine Zahl, sondern ein
Spielabbruch. Nach unten bis `-1.0`, damit Verwundbarkeit ausdrückbar bleibt („Untote nehmen
doppelten heiligen Schaden").
- **`max(1, …)`** — ein Treffer macht nie null, sonst gibt es Kämpfe, die rechnerisch nie enden.
- **`0.05 / 0.95` beim Treffen** — sonst wird ein hochgezüchteter Verteidiger unangreifbar und ein
hochgezüchteter Angreifer unfehlbar. Beides Sackgassen, die man erst auf Etage 5 bemerkt.
Keine Schadensstreuung für 1.0: Der Krit liefert die Varianz bereits, Streuung wäre ein zweiter
Zufallsknopf ohne eigene Entscheidung dahinter.
### Zufall
Eigener xorshift64* — acht Zeilen statt einer Dependency. Der Seed liegt als Feld in `Game` und damit
**im Speicherstand**.
Das ist keine Implementierungsfrage, sondern eine Spielentscheidung: **Nachladen würfelt nicht neu.**
Derselbe Angriff führt nach dem Laden zum selben Ergebnis, Save-Scumming läuft ins Leere. Ein
Abstiegs-Roguelike lebt von Entscheidungen mit Konsequenzen.
### Fähigkeiten — die Bestellung aus Ticket 02
```toml
[[abilities]]
id = "core:power_strike"
name = "Wuchtschlag"
symbol = "!"
color = "red"
cost = 15 # Ausdauer
cooldown = 3 # Zuege
condition = [ … ] # optional, Grammatik aus 08
actions = [ { action = "damage", scale = 1.5, damage_type = "blunt", target = "target" } ]
```
**Nur Ausdauer, kein zweiter Ressourcenpool.** Mana wäre ein zweites Attribut, eine zweite
Regeneration, eine zweite Anzeige und eine zweite Sperrregel. Die Decke: Ein Mod, der Mana will,
bräuchte ein Engine-Attribut — ein Nachtrag an 01, kein Problem, das ein Modder selbst lösen kann.
**Abklingzeit in Zügen**, als `cooldowns: HashMap<AbilityId, u32>` am `Creature`. Sie als befristete
Modifikatoren zu tarnen wäre clever und genau die Sorte Cleverness, die man um drei Uhr nachts
entziffert.
**Gesperrt heißt**: Ausdauer < `cost`, oder `cooldown > 0`, oder `condition` unerfüllt. Auslösen ergibt
dann `Outcome::Rejected("Nicht genug Ausdauer")` aus Ticket 12 und **kostet keinen Zug**. Die Regel
steht im Spielkern, nicht in der UI — sonst kennt ein Script sie nicht.
**Ausdauer regeneriert pro Zug** um einen festen Betrag. Das gibt `Wait` aus Ticket 11 seinen Zweck und
macht Ausdauer zu einer Entscheidung statt zu einem Countdown.
Eine misslungene Flucht ist übrigens `TurnPassed`, keine Zurückweisung: Der Versuch hat stattgefunden,
die Gegner haben gehandelt. `Rejected` bleibt dem Fall vorbehalten, dass gar nichts passiert ist.
### Gegner — die achte Registry
Beim Aufschreiben der KI fiel eine Lücke auf, die seit 02 offen lag: **Wesen stehen in keiner
Registry.** 02 hatte fünf, 07 ergänzte Affixe, 08 Quests — woher Gegnerdefinitionen kommen, hat nie
jemand gesagt, obwohl 10 sie in Räume setzen und 16 Beute an sie hängen muss.
```toml
[[creatures]]
id = "core:skeleton"
name = "Skelett"
symbol = "s"
color = "white"
tags = ["core:undead"]
base = { max_health = 30, damage = 6, hit_chance = 0.7, dexterity = 3 }
abilities = ["core:attack", "core:bone_throw"]
ai = [
{ condition = [ { check = "attribute", who = "self", attribute = "health", below = 0.3 } ],
ability = "core:flee" },
{ ability = "core:attack" }, # ohne condition = greift immer
]
```
`ai` ist eine **geordnete Liste**, der erste passende *und verfügbare* Eintrag gewinnt — dieselbe
Bedingungsgrammatik wie überall sonst, kein neues Vokabular. Passt nichts, **setzt der Gegner aus**;
einer, der die Runde blockiert, wäre schlimmer als einer, der nichts tut.
Eine `ai`-Liste **ohne bedingungslosen letzten Eintrag** gibt eine Ladewarnung über die Maschinerie aus
04. Das ist fast immer ein Versehen, und zur Laufzeit erkennt man es nur daran, dass ein Gegner
gelegentlich stillsteht — die teuerste Sorte Fehler.
Boss-Phasen sind damit *nicht* gelöst, aber vorbereitet: Die Liste trägt sie, was fehlt, ist der
Phasenbegriff.
### Balance als Daten — ausdrückliche Ausnahme zu Ticket 03
Fünf Stellknöpfe entstehen hier: `AUSWEICH`, `FLUCHT`, Ausdauer-Regeneration, Resistenz-Deckel und
Treffer-Klemmbereich. Sie liegen als `[balance]`-Tabelle in TOML und verschmelzen **feldweise** über
die Ladereihenfolge.
Das widerspricht der Ganz-oder-gar-nicht-Regel aus 03, und zwar bewusst: Ein Mod, der nur `AUSWEICH`
dreht, müsste sonst alle fünf Werte abschreiben, und ein sechster Knopf in der nächsten Engine-Version
bräche ihn stillschweigend.
Die Ausnahme bleibt eng, weil ihre Begründung eng ist: **`[balance]` hat ein festes, der Engine
bekanntes Schema.** Genau das fehlte bei Inhalt mit offenen Feldern — Modifikator- und Aktionslisten
bräuchten einen Merge-Algorithmus, und das war 03s Argument gegen feldweises Patchen. Hier greift es
nicht.
Standardwerte liegen in der Engine, nicht in `core`. Dann ist ein Mod, der einen Knopf dreht, eine
Zeile lang.
**Nachtrag aus Ticket 15:** Zwei weitere Knöpfe kommen dazu — `exp_base` und `exp_exponent`, die die
Erfahrungskurve bilden. Eine Tabelle je Level wäre die Alternative gewesen; zwei Zahlen schlagen
fünfzig Zeilen. Dazu bekommt die `creatures`-Registry ein Feld `exp_reward`.
Ausdrücklich **nicht** dazugekommen ist `points_per_level`: Ein Aufstieg gibt genau einen Punkt, das
ist eine Regel und kein Stellknopf.
**Nachtrag aus Ticket 16:** `LUCK_FACTOR` kommt dazu — `luck` gewährt eine Chance auf einen
zusätzlichen Zug auf jeder Beutetabelle. Damit hat `[balance]` acht Felder.
### Vier Nachträge an geschlossene Tickets
| Ticket | Nachtrag |
|---|---|
| **01** | `damage`-Aktion: `menge = amount + scale * damage-Attribut(Verursacher)`, beide Felder optional (Standard 0). Deckt Basisangriff (`scale = 1.0`), Feuerball (`amount = 20`) und Wuchtschlag (`scale = 1.5`) mit *einer* Formel. Dazu `flee` als **achte Aktion** |
| **07** | `initiative: i32` und `cooldowns: HashMap<AbilityId, u32>` am `Creature`; RNG-Seed in `Game`; **`enemies` ist der laufende Kampf**, nicht die Etage |
| **08** | `combat_started` und `combat_ended { outcome: Victory \| Fled }` — die Eventliste wächst auf **elf**. Der Tod des Helden braucht keine dritte Variante, `creature_died` mit `source = Player` deckt ihn ab |
| **11** | **`GameOver`** als elfter Screen, Vollbild. Bewusst nicht die lazy Variante mit einer Meldungszeile im Hauptmenü: Ein Abstiegs-Roguelike lebt davon, dass der Lauf am Ende erzählt wird — Etage, Zug, Gold, Todesursache. *Ticket 10 hat daraus `GameOver { outcome: Died \| Won }` gemacht, weil der Abstieg von der letzten Etage den Lauf gewinnt* |
### Auflagen an andere Tickets
- **10 Raum-Prefab-Format**: füllt `Game.enemies`; Kampfbeginn ist ein Prefab-Ereignis.
- **15 Progression**: was bei Sieg passiert (EXP), und die Zahlen hinter allen Formeln inklusive der
fünf `[balance]`-Knöpfe.
- **16 Loot-Tabellen**: Beute hängt an der `creatures`-Registry, die hier entstanden ist.
### Befund aus M4 — die Initiativreihenfolge ist ein Schnappschuss
Der Zugablauf legt die Reihenfolge **einmal vor dem Zug** fest und arbeitet sie danach ab. Das war
richtig gedacht — sonst könnte ein Gegner sich durch Handeln in der Reihenfolge nach vorn schieben.
Nur passt es nicht zu einer Zusage aus demselben Ticket: **Flucht beendet den Kampf sofort**, leert
also `enemies` mitten in der Schleife. Die Reihenfolge zeigt danach auf Indizes, die es nicht mehr
gibt.
Im Spiel sichtbar als Absturz nach mehrfachem Fluchtversuch — die Flucht muss glücken, *bevor* ein
Gegner in der Reihenfolge an der Reihe war, sonst fällt es nicht auf. Kein Test hatte den Fall, weil
alle Kampftests die Flucht am Ende der Reihenfolge auslösten.
Entschieden: Ein leerer `enemies` **bricht den Zug ab**, statt still übersprungen zu werden. Kein
Gegner mehr da heißt, es gibt nichts mehr zu handeln — ein `continue` würde denselben Absturz nur
verschieben, wenn später jemand die Liste wieder füllt.
Damit ist die Regel dieses Tickets vollständig: Die Reihenfolge steht vor dem Zug fest, *und* sie
endet, sobald ihre Grundlage weg ist.
### Nachtrag — die Ansage (Taktik-Ebene, nach M4)
Der Zugablauf bekommt einen **siebten Schritt**: Am Ende jedes Zuges legt jeder lebende Gegner fest,
was er im nächsten tun wird. Ausgewertet wird dieselbe `ai`-Liste nach derselben Regel — erster
passender *und verfügbarer* Eintrag —, nur eine Runde früher. Das Ergebnis steht als `intent` am
`Creature` und wird im Kampf-Screen unter dem Lebensbalken angezeigt.
**Verbindlich, nicht als Vorschau.** Der Gegner führt genau das aus, was dasteht. Die Alternative wäre
gewesen, die `ai`-Liste bei jedem Bild neu auszuwerten und das Ergebnis als Vermutung anzuzeigen — das
hätte keinen Zustand gekostet, aber auch keine Taktik erzeugt: Eine Anzeige, die sich mit der Lage
mitdreht, sagt nur, was *jetzt* gälte, und ist genau dann falsch, wenn der Spieler etwas unternimmt.
Der Gewinn liegt im **Fenster zwischen Ansage und Ausführung**. Vor dem Ausführen wird `ability_block`
erneut geprüft; schlägt es an, verliert der Gegner den Zug (`„X kommt nicht dazu — Nicht genug
Ausdauer."`). Damit wird jede Wirkung auf Ausdauer, Abklingzeit oder Bedingung zu einem *Konter*, und
die Sperrgründe, die Ticket 14 ohnehin schon berechnet, bekommen einen zweiten Nutzen.
Eine Ansage wird beim Ausführen verbraucht (`take`). Wer ohne Ansage an die Reihe kommt, tut nichts —
das trifft nur Gegner, die mitten im Zug auftauchen, und die stehen ohnehin nicht in der
Initiativreihenfolge dieses Zuges. **Ein** Ansagepunkt, kein zweiter beim Kampfbeginn.
Ausdrücklich **nicht** dazugekommen ist ein Ziel an der Ansage: Die `ai` greift ausschließlich den
Helden an, `intent` trägt deshalb nur die Fähigkeits-ID. Ein Zielfeld käme mit dem ersten Gegner, der
seine Verbündeten heilt.
### Befunde aus der zweiten Balancing-Runde — drei Fehler, ein Symptom
Gemeldet wurde „Ratten vergiften zu 100 %, kaum zu überleben". Dahinter lagen drei unabhängige Fehler,
und der größte hatte mit Gift nichts zu tun.
**1. Jeder Gegner hatte seit M3 die Standardwerte.** `Creature::from_def` las die Attributnamen mit
`toml::from_str::<Attribute>("\"max_health\"")` — und `toml::from_str` erwartet eine ganze TOML-*Datei*,
kein nacktes Skalar. Der Aufruf schlug für **jeden** Namen fehl, der Zweig hieß `Err(_) => continue`,
und damit behielt jedes Wesen still die Voreinstellung: Die Riesenratte hatte **100 Leben statt 14** und
2 Schaden statt 3. Der Knochenfürst war eine große Ratte. Ein Kampf gegen den ersten Gegner dauerte 22
Züge.
Kein Test hat das gesehen, weil alle Kampftests ihre Zahlen selbst setzen und nie eine
Gegnerdefinition nachschlugen. Der Fehler ist ein stiller `continue` — die Sorte, gegen die Ticket 04
„jeder Verlust bekommt eine Zeile" beschlossen hat, nur eben in `game/` statt im Lader.
**2. Seitengebundene Ereignisse feuerten bei allen.** Dieses Ticket und 08 trennen `attack_hit`
(feuert beim Angreifer) von `damage_taken` (beim Getroffenen), ausdrücklich damit *„kein Trigger erst
prüfen muss, auf welcher Seite er steht"*. Die Trägerschleife in `drain_events` legte aber jedes
Ereignis jedem Beteiligten vor — der Gift-Trigger der Ratte feuerte deshalb auch, wenn der **Held**
zuschlug. Dieselbe Lücke hätte den Dornenpanzer des Angreifers reflektieren lassen.
`EventKind::bound_to_source()` benennt die beiden. Alle übrigen bleiben offen: `turn_started` muss bei
jedem ticken, sonst vergiftet sich nur der Spieler.
**3. Man verfehlte sich selbst.** Der M3-Befund „die Trefferformel war toter Code" wurde behoben, indem
`Action::Damage` sie *immer* würfelt. Ein Gift-Tick ist aber kein Angriff, und die Formel liest die
Geschicklichkeit des Ziels — ein flinker Held wich also seinem eigenen Gift aus. Im Log stand
„M verfehlt M". Neue Regel, eine Zeile: **Man verfehlt sich nicht selbst.**
**Dazu ein Datenfehler, kein Codefehler:** `rat_bite` legte `core:poisoned` in seiner *Aktionsliste* ab,
und Aktionen haben kein `chance` — das gibt es nur am Trigger (Ticket 08). Jeder Biss vergiftete also,
und weil erneutes Auflegen die Dauer auffrischt, war der Held ab dem ersten Treffer dauerhaft vergiftet.
Das Gift hängt jetzt am Trigger der Ratte mit `chance = 0.25` — genau der Fall, für den Ticket 08 das
Feld erfunden hat („20 % Chance, Brennen auszulösen, braucht sonst ein Script").
**Danach gemessen**, Elf-Dieb auf Stufe 1, je 40 Kämpfe:
| Gegner | Züge | Lebensverlust | Tode |
|---|---|---|---|
| Riesenratte | 4,2 | 6 | 0/40 |
| Skelett | 6,6 | 18 | 0/40 |
| Ghul | 15,8 | 94 | 24/40 |
| Knochenfürst | 9,9 | 110 | 40/40 |
Die Kurve *existiert* damit zum ersten Mal — vorher war jeder Gegner derselbe. Ob Etage 3 bis 5 fair
sind, sagt diese Tabelle **nicht**: Sie vergleicht einen Helden auf Stufe 1 mit Startdolch gegen Gegner,
denen er erst mit mehreren Aufstiegen und besserer Ausrüstung begegnet. Das entscheidet Spielen.

View File

@@ -1,7 +1,7 @@
# 15 — Progression und Level-up # 15 — Progression und Level-up
Type: grilling Type: grilling
Status: resolved Status: open
Blocked by: 02 Blocked by: 02
## Question ## Question
@@ -23,157 +23,3 @@ Attributkatalog aus 01 steht. Offen ist, was ein Level überhaupt gibt:
Folgen für die Etagen-Länge. Folgen für die Etagen-Länge.
Ergebnis: Aufstiegsregel, Kurvenform (Daten oder Formel), Erfahrungsquellen und die Obergrenze. Ergebnis: Aufstiegsregel, Kurvenform (Daten oder Formel), Erfahrungsquellen und die Obergrenze.
## Answer
Begriffe im [Glossar](../../../CONTEXT.md). Dieses Ticket entscheidet **Formen und Regeln** — die
konkreten Zahlen bleiben beim Balancing, das ein spielbares Spiel voraussetzt.
### Ein Aufstieg gibt genau einen Punkt
Frei verteilbar auf die **fünf Grundwerte**: `strength` · `dexterity` · `constitution` · `charisma` ·
`luck`. Abgeleitete Größen wie `damage` oder `hit_chance` bleiben ausgeschlossen — Punkte direkt
dorthin zu setzen umginge das ganze Modifikatorsystem aus 01.
**`current_exp` und `unspent_points` leben an `Game`, nicht am `Creature`.** Ticket 07 hatte die
Platzierung ausdrücklich hierher delegiert; `base.level` bleibt am Wesen (Gegner haben echte Level, die
Etagen-Skalierung liest sie), aber Erfahrung und unverteilte Punkte hat ausschließlich der Held. Ein
Feld, das für 99 % der Exemplare bedeutungslos ist, wäre ein Kommentar in Feldform — und der Save
schriebe es für jeden Gegner mit. Beide Werte werden ohnehin nur beim Aufstieg und in `AssignPoints`
angefasst, und beides läuft über `Game`.
Verteilt wird im `CharacterSheet` aus Ticket 11, kein zwölfter Screen. **Kein Umverteilen** — ein
Permadeath-Lauf ist kurz und endet endgültig, eine Respec-Mechanik löst ein Problem, das dabei gar
nicht entsteht.
`AssignPoints` ist **ein** Command mit allen Punkten auf einmal und kostet damit **einen** Zug. Ein
Command je Punkt hieße bei drei Stufen drei Züge, in denen ein Gift-Debuff tickt, während man ein Menü
bedient; eine Ausnahme von der Zugregel wollte Ticket 11 ausdrücklich vermeiden. Dasselbe Muster wie
`Buy(item, 10)`.
### Keine Obergrenze
`Stats::level_up()`s `clamp(1, 50)` fällt ersatzlos. Fünfzig Stufen über fünf Etagen wären
bedeutungslos, und eine harte Grenze wäre der einzige Teil der Progression, der einen späteren
**Endlosmodus** blockierte.
Die Kurve begrenzt sich selbst über steigende Kosten: `exp_base` und `exp_exponent` als zwei Felder in
`[balance]` aus Ticket 14 — zwei Zahlen statt fünfzig Tabellenzeilen. Eine Tabelle ließe einen Modder
Level 13 zum Sprung machen; falls das je gewünscht ist, ist sie der Ausbau.
### Erfahrung aus Tötungen und Quests
`exp_reward` als Feld an der `creatures`-Registry aus 14, plus **`give_exp` als zehnte Aktion** für
Questbelohnungen.
Das sind zwei Wege zum selben Ziel, gerechtfertigt nach demselben Muster, mit dem Ticket 01 `armor` als
Kurzschreibweise für Resistenz zugelassen hat: `exp_reward` ist die Kurzform für einen Todes-Trigger,
den sonst **jede einzelne** Gegnerdefinition mitschleppen müsste — samt einer `is_self`-Prüfung, die es
gar nicht gibt.
**Keine Erfahrung fürs Betreten einer Etage.** Das belohnte das Durchrennen; wer Räume auslässt, soll
schwächer ankommen — das ist die Spannung, von der ein Abstieg lebt.
Geprüft wird am Ende von `Game::execute`, nach dem Leerarbeiten der Warteschlange aus 08 — dann wirkt
eine Questbelohnung noch im selben Zug. Reicht die Erfahrung für mehrere Stufen, wird in einer
**Schleife** aufgestiegen; drei Stufen geben drei Punkte. Eine Stufe pro Ereignis erzeugte sonst die
Frage, wann der Rest nachgeholt wird, plus einen Zustand „aufstiegsbereit", den niemand bestellt hat.
### Die Zuwachs-Regel bleibt eng
Steigt `max_health` durch Progression um 10, steigt der Lebensstand um 10 mit — **aber nur durch
Progression.**
Ticket 01 hat davor ausdrücklich gewarnt: *„Ein Modifikator wirkt auf `max_health`, eine Aktion ändert
den Lebensstand. Die Trennung muss scharf bleiben, sonst heilt ein Ring beim Anlegen."* Eine allgemeine
Regel „max ändert sich → Stand ändert sich mit" bräche das sofort.
Genau **zwei** Stellen erhöhen den Stand um den Zuwachs, beide im Spielkern benannt statt aus einer
allgemeinen Regel gefolgert:
1. der Aufstieg,
2. `AssignPoints` — und das ist die unauffällige: `max_health` steigt erst, wenn `constitution`
tatsächlich erhöht wird, also beim Verteilen und nicht schon beim Aufstieg.
Ausrüstung nie. Der Unterschied ist die Dauerhaftigkeit: Ein Ring kommt und geht, ein Punkt in
Konstitution bleibt.
### Rasse und Klasse bleiben flach
Das beantwortet die Skalierungsfrage aus dem Ticketkörper mit „gar nicht": **`per_level` als Nachtrag
an 01 entfällt ersatzlos.** Charakteroptionen geben einen einmaligen, flachen Bonus.
Dass „Zwerg: +2 Konstitution" auf Level 30 nur noch Rauschen ist, ist gewollt. Was Charakteroptionen
dauerhaft trägt, sind **Fähigkeiten** — die veralten nicht. Ticket 02 hat sie als „Modifikatoren *und*
Fähigkeiten" definiert, und der interessante Teil ist die zweite Hälfte: Ein Magier bleibt ein Magier,
weil er zaubern kann, nicht weil er +2 auf etwas hat.
### `level` darf modifiziert werden
`Creature.base.level` steigt beim Aufstieg und ist die **einzige** Quelle für Punkte.
`attribute(wer, "level")` darf durch Modifikatoren höher ausfallen und wird von Bedingungen („Level ≥
3"), Formeln und Etagen-Skalierung gelesen.
Ein Ring mit `+1 level` macht den Helden also für alles, was Level *liest*, eine Stufe stärker — ohne
einen Punkt zu schenken. Dieselbe Trennung wie zwischen `max_health` und Lebensstand. Modifikatoren auf
`level` zu verbieten wäre die Bevormundung, die Ticket 02 abgeräumt hat, und nähme einem Modder ein
hübsches Item, das keinen Schaden anrichtet.
### Endlosrunner — neu aufgetaucht, bewusst hinter 1.0
In dieser Sitzung kam auf, dass das Fernziel ein **Endlosrunner** ist. Das berührt drei geschlossene
Entscheidungen: die Siegbedingung aus 10, `GameOver { outcome: Won }` aus 14, und die
`floors`-Registry, die einen Eintrag *je Nummer* hat — Etage 500 bräuchte eine Definition.
**1.0 bleibt endlich.** Die endliche Fassung ist vollständig durchentschieden, und ein Endlosmodus ist
additiv: ein Feld `repeat = true` an der letzten Etage plus eine Skalierungsformel. Umgekehrt hieße es,
10 und 16 gegen ein Zielbild zu bauen, das noch niemand gespielt hat.
Die fehlende Level-Obergrenze ist der Teil, der davon **jetzt schon** profitiert — sie wäre der einzige
Blocker gewesen.
### Vier Nachträge an geschlossene Tickets
| Ticket | Nachtrag |
|---|---|
| **01** | `give_exp` als **zehnte** Aktion |
| **07** | `current_exp` und `unspent_points: u32` an **`Game`**, nicht am `Creature` — die Platzierung, die 07 hierher delegiert hatte |
| **11** | `AssignPoints` als **dreizehnter** Command |
| **14** | `exp_base` und `exp_exponent` in `[balance]`, dazu `exp_reward` an der `creatures`-Registry |
### Auflage an Ticket 16
Die Beute-Skalierung darf **nicht gegen fünf feste Etagen fest verdrahtet** werden — der Endlosmodus
kommt später, und eine Skalierung, die nur bis Etage 5 denkt, wäre dann ein Umbau statt einer
Erweiterung.
### Befunde aus M5
**Zwei der fünf verteilbaren Grundwerte waren Fallen.** Das Ticket verteilt Punkte auf `strength`,
`dexterity`, `constitution`, `charisma` und `luck` — und begründet sauber, warum abgeleitete Größen
ausgeschlossen bleiben. Nur las `strength` und `constitution` bis M5 **niemand**: `damage` und
`max_health` sind eigene Attribute ohne Herleitung, `dexterity` steckt in Flucht und Initiative,
`charisma` seit M4 im Preis, `luck` jetzt in der Beute. Ein Punkt in Stärke hätte nichts getan.
Entschieden: `damage_per_strength` und `health_per_constitution` als `[balance]`-Knöpfe, angewandt
**beim Verteilen** und nicht beim Auslesen. Damit hebt ein Punkt in Konstitution den *Grundwert*
`max_health` — genau das, was der Satz „`max_health` steigt erst, wenn `constitution` tatsächlich
erhöht wird, also beim Verteilen" ohnehin voraussetzt. In `Game::attribute` gehört die Umrechnung
nicht: Dort wäre sie ein Sonderfall im generischen Pfad, dieselbe Begründung, mit der M4 die
Charisma-Formel in die Preisfunktion gelegt hat.
**Von den „zwei Stellen" ist nur eine echt.** Das Ticket nennt Aufstieg *und* `AssignPoints` als
Stellen, an denen der Lebensstand einem `max_health`-Zuwachs folgt. Im Code ist die erste beweisbar ein
No-op: Der Aufstieg hebt `base.level` und `unspent_points`, sonst nichts, und `per_level` hat dasselbe
Ticket ersatzlos gestrichen. Die vorsorgliche Vorher/Nachher-Klammer im Aufstieg ist raus — sie
suggerierte eine Regel, die es nicht gibt.
**`Rng::chance(0.0)` verbrauchte eine Zahl.** Aufgefallen, weil die Glücksregel aus Ticket 16 auch beim
Erzeugen einer Etage greift (Händlerregal, `luck = 0`) und damit den ganzen Zufallsstrom verschob — ein
bestehender Kampftest schlug fehl, weil sein Angriff plötzlich danebenging. `chance` kürzt jetzt bei
0 und 1 ab. Ohne das erzeugte derselbe Seed je nach Glück des Helden eine andere Etage, und die Zusage
aus Ticket 14 („Seed im Save ⇒ Nachladen würfelt nicht neu") wäre stillschweigend gebrochen gewesen.
**`Cheat::ClearRoom` ging am Todesweg vorbei.** Die Werkbank setzte den Lebensstand auf null und ließ
das Aufräumen am Zugende erledigen — ohne `check_death`, also ohne Todes-Trigger und ohne Erfahrung.
Genau der Fehler, gegen den ihr eigener Kommentar argumentierte. Sie ruft jetzt `check_death`.

View File

@@ -1,7 +1,7 @@
# 16 — Loot-Tabellen # 16 — Loot-Tabellen
Type: grilling Type: grilling
Status: resolved Status: open
Blocked by: 02 Blocked by: 02
## Question ## Question
@@ -35,147 +35,3 @@ Seltenheit, oder gar nicht?
Affixe sind eine eigene Registry (die sechste), also gilt für sie derselbe Mod-Einhängepunkt wie für Affixe sind eine eigene Registry (die sechste), also gilt für sie derselbe Mod-Einhängepunkt wie für
Gegenstände. Gegenstände.
## Answer
Begriffe im [Glossar](../../../CONTEXT.md). Der Zufall war bereits entschieden: derselbe gesetzte
Generator aus Ticket 14, dessen Seed im Save liegt — Beute würfelt beim Nachladen nicht neu.
### Eine Maschinerie für Gegenstände und Affixe
Zwölfte Registry `[[loot_tables]]`:
```toml
[[loot_tables]]
id = "core:weapon_affixes"
rolls = "0-1"
entries = [
{ affix = "core:flaming", weight = 5 },
{ affix = "core:greater_flaming", weight = 1 }, # Stufe, kein Wert
{ nothing = true, weight = 20 },
]
[[loot_tables]]
id = "core:floor_3_table"
rolls = "1-3"
entries = [
{ item = "core:health_potion", weight = 10, count = "1-2" },
{ item = "core:iron_sword", weight = 3, affixes = "core:weapon_affixes" },
{ table = "core:rare_table", weight = 1 },
{ nothing = true, weight = 20 },
{ item = "core:boss_blade", guaranteed = true },
]
```
**Gewichte statt Prozent** — keine Geschmacksfrage, sondern eine Modding-Frage: Prozentwerte müssen
sich auf 100 summieren, und ein hinzugefügter Eintrag macht die Summe kaputt, woraufhin jemand alle
anderen anpassen muss. Gewichte verdünnen sich anteilig, ohne dass irgendwer rechnet.
**Nieten sind gewöhnliche Einträge**, kein eigenes `drop_chance`-Feld. Ein Gegner, der selten etwas
fallen lässt, bekommt ein hohes `nothing`-Gewicht — die Engine kennt eine Mechanik statt zweier.
**Tabelle-in-Tabelle** ist eine Zeile und liefert Seltenheitsstufen, ohne dass „Seltenheit" ein Begriff
werden muss.
**`guaranteed = true`** wird immer ausgegeben und nimmt an der Gewichtung nicht teil. Mit Gewichten
allein lässt sich „sicher" nicht ausdrücken, und Bosse stehen in der Destination; Questgegenstände sind
derselbe Fall.
**Mehrfachziehung erlaubt**, kein `unique`-Flag. Drei Heiltränke sind ein normales Ergebnis, und
stapelbare Gegenstände (02) tragen das ohne Zutun. Wer Einzelstücke will, gibt ihnen eine eigene
Tabelle mit `rolls = "1-1"`. Die Decke: Falls „nie zweimal dasselbe Schwert" nötig wird, ist `unique`
ein Flag — jetzt wäre es eine Regel ohne Fall.
### Affix-Stufen statt Affix-Werte
Ticket 07 hat festgelegt, dass eine `ItemInstance` **nur Affix-IDs** trägt. „Flammend" und „Stark
flammend" sind deshalb zwei Registry-Einträge mit unterschiedlichen Modifikatoren und Preisfaktoren,
keine Zahl am Exemplar.
Eine Stärke je Exemplar hätte drei Dinge auf einmal gekostet: das Save-Format (ID-Liste), die
Preisformel `base_price × Π price_factor` und die Stapelregel — zwei Schwerter „Flammend 3" und
„Flammend 4" stapeln nicht mehr, aber wie zeigt man den Unterschied an? Mit Stufen kann der Modder
seine Abstufungen **benennen**, statt eine Zahl auszuliefern, die keine Anzeige hat.
### `luck` hat genau eine Regel
**Eine Chance auf einen zusätzlichen Zug — auf jeder Tabelle.**
```
p_extra = clamp(luck * LUCK_FACTOR, 0.0, 0.9)
```
Ein Knopf mehr in `[balance]`. Die Regel wirkt dreifach, ohne dreimal geschrieben zu werden: mehr
Gegenstände, mehr Affixe, und häufiger die stärkere Stufe — weil die als eigener Eintrag mit kleinem
Gewicht danebensteht.
Der Grund gegen „Glück verschiebt zur Seltenheit" ist ein Ausschluss: **Es gibt keinen
Seltenheitsbegriff.** Das Schema kennt nur Gewichte; Seltenheit erst zu definieren und dann Gewichte
umzurechnen wäre ein zweites Konzept für dasselbe Gefühl.
### Der Mod-Einhängepunkt existiert bereits
Ticket 03 gilt unverändert: ganzer Eintrag wird ersetzt. Ein neuer Mechanismus wird trotzdem nicht
gebraucht — **Tabelle-in-Tabelle ist der Einhängepunkt**, zwei gewöhnliche Überschreibungen und null
neue Begriffe:
```toml
[[loot_tables]]
id = "meinmod:floor_3"
entries = [ { table = "core:floor_3_table", weight = 100 },
{ item = "meinmod:zauberstab", weight = 5 } ]
[[floors]]
id = "core:floor_3"
overrides = "core:floor_3"
loot = "meinmod:floor_3"
```
Decke: Zwei Mods, die dieselbe Etage umhängen, kollidieren — der letzte gewinnt. Das ist derselbe
Konflikt wie bei jeder anderen Überschreibung in 03. Falls er häufig wird, ist `[[loot_additions]]` der
Nachbau; ihn *vorher* zu bauen hieße, einen zweiten Mechanismus für etwas einzuführen, das der erste
schon kann.
### Beute wird beim Aufheben gezogen
`Room` aus Ticket 10 bekommt **eine `loot_table` und `loot: Vec<Pos>`** statt fertiger Instanzen — alle
Haufen eines Raums ziehen ohnehin aus derselben Tabelle, die ID je Haufen zu wiederholen wäre
Redundanz. Aufgelöst wird erst beim Aufheben; der Save wird dadurch sogar kleiner.
Die **Zahl** der Haufen steht bei der Erzeugung fest (die `l`-Zeichen im Prefab), ihr Inhalt nicht.
Alle zeigen **dasselbe generische Symbol** — deshalb verrät die Anzeige nichts, und ein Glücksring
wirkt auch auf das, was auf dieser Etage bereits liegt. Bei Ziehung zur Erzeugungszeit wäre der Wurf
für diese Etage schon gefallen, bevor der Spieler den Ring findet.
Fällt der Wurf beim Aufheben auf eine Niete, **verschwindet der Haufen mit einer Meldung**. Kein
Neuwürfeln — das hängt sich bei einer reinen Nieten-Tabelle auf und bräuchte dann doch einen Zähler.
Keine Ladeprüfung „Tabellen für `l`-Plätze dürfen keine Nieten enthalten": Das wäre die Bevormundung,
die Ticket 02 abgeräumt hat. Wer leere Haufen nicht will, lässt Nieten aus der Tabelle weg.
### Etagenskalierung — die Auflage aus 15 ist strukturell erfüllt
Eine Tabelle je Etage, wie Ticket 10 es mit dem `loot`-Feld bereits angelegt hat. **Nirgends steht eine
Fünf**: Etagen sind Registry-Einträge, jeder bringt seine Tabelle mit, ein Mod mit Etage 6 bringt ihre
gleich mit, und der Endlosmodus wiederholt mit der letzten Etage auch deren Tabelle.
Bedingungen am Eintrag (`floor at_least 3` auf einer gemeinsamen Tabelle) klingen flexibler, lösen das
Endlos-Problem aber gerade *nicht*: Ein Eintrag mit `at_least = 3` ist auf Etage 500 genauso gültig wie
auf Etage 3. Echte Tiefenskalierung bräuchte Gewichte als *Funktion* der Etage — eine Formel, keine
Daten, und die wird gebaut, wenn der Endlosmodus kommt.
Ein Prefab darf über ein **optionales** `loot`-Feld seine eigene Tabelle nennen. Sonst ist ein
Schatzkammer-Raum nur ein Raum mit mehr `l`-Zeichen — mehr vom Gleichen statt etwas Besserem. Es nennt
dabei eine Tabellen-**ID**, keine Gegenstände, und bleibt damit über alle Tiefen verwendbar; dieselbe
Begründung, mit der 10 festgelegt hat, dass ein `e` nicht sagt, *welcher* Gegner.
### Wo Beute hängt
An der `creatures`-Registry (14), an der Etage (10) und optional am Prefab. Alle drei nennen schlicht
eine Tabellen-ID.
### Zwei Nachträge an geschlossene Tickets
| Ticket | Nachtrag |
|---|---|
| **10** | `Room` hält `loot_table: LootTableId` plus `loot: Vec<Pos>` statt `Vec<(Pos, ItemInstance)>`; optionales `loot`-Feld am Prefab; generischer Beute-Marker im Grid |
| **14** | `LUCK_FACTOR` in `[balance]` |

View File

@@ -17,245 +17,11 @@ dort als Konvention im Body. Zuordnung Ticket → Gitea-Issue:
| 13 Script-Engine wählen | [#14](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/14) | 14 Kampfformeln | [#15](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/15) | | 13 Script-Engine wählen | [#14](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/14) | 14 Kampfformeln | [#15](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/15) |
| 15 Progression | [#16](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/16) | 16 Loot-Tabellen | [#17](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/17) | | 15 Progression | [#16](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/16) | 16 Loot-Tabellen | [#17](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/17) |
## Destination — **erreicht am 2026-08-12** ## Destination
Alle sechzehn Tickets sind geschlossen. Damit ist die Entscheidungsphase vorbei und die Umsetzung
läuft — Stand siehe „Verification" weiter unten.
Sechs Meilensteine statt neuer Tickets: **M1** vertikaler Durchstich · **M2** Etagengenerierung und
Prefabs · **M3** Kampf mit Events und Triggern · **M4** Inventar, Ausrüstung, Affixe, Handel ·
**M5** Quests und Save/Laden · **M6** Inhalt für fünf Etagen und Balancing. Die Map hat sich für
*Entscheidungen* gelohnt; dieselbe Zeremonie über Implementierungsaufgaben zu stülpen wäre Prozess um
seiner selbst willen.
Alle Architektur- und Design-Entscheidungen für rustkeep 1.0 sind getroffen — nicht gebaut, entschieden.
1.0 heißt: 5 Etagen mit Boss, Solo-Held mit Rasse und Klasse, Grid-Erkundung mit Menü-Kampf, 1.0 heißt: 5 Etagen mit Boss, Solo-Held mit Rasse und Klasse, Grid-Erkundung mit Menü-Kampf,
Inventar und Ausrüstung, Quests, NPCs, Handel, Speichern/Laden, **Mod-Support auf den Stufen (a) und Inventar und Ausrüstung, Quests, NPCs, Handel, Speichern/Laden, Mod-Support auf drei Stufen.
(b)**.
Stufe (c) — Scripting — ist entschieden, aber nicht Teil von 1.0: Engine (Rhai) und Schnittstelle
stehen, gebaut wird sie, wenn ein Modder an eine Lücke im Vokabular stößt (13).
## Verification — was der Compiler bestätigt hat
Sechzehn Tickets Papier, davon hatte bis M1 nur das Screen-Modell (12) Kontakt zu einem Compiler.
Diese Liste wächst mit jedem Meilenstein und ist der einzige Ort, an dem steht, welche Entscheidung
*gehalten* hat.
**M1 — vertikaler Durchstich** (`feat/m1-durchstich`, 10 Tests)
| Zusage | Aus | Befund |
|---|---|---|
| `Registries` neben `Game`, disjunkte Borrows | 07 | **bestätigt** — `game.execute(cmd, &reg)` und `game.attribute(…, &reg)` nebeneinander. Ticket 12 konnte das nicht prüfen, weil der Durchstich keine Registries lud |
| Zweistufige Deserialisierung, Eintrag als Fehlereinheit | 04 | **bestätigt** — ein kaputtes Feld tötet seinen Eintrag, die Nachbarn überleben |
| `Game::attribute` sammelt aus mehreren Quellen | 07 | **bestätigt** — Basis + Optionen + Ausrüstung, Stapelregel aus 01 stimmt |
| `game/` bleibt ratatui-frei | 11 | **bestätigt** — aber der *Test* dafür musste nachgebessert werden |
| Trigger-Suche, Event-Warteschlange | 08 | **ungeprüft** — es gibt weder Trigger noch Events. M3 |
**M2 — Etagengenerierung** (`feat/m2-etagen`, 21 Tests)
| Zusage | Aus | Befund |
|---|---|---|
| Kette `i → i−1` garantiert Erreichbarkeit strukturell | 10 | **bestätigt** über 29 Seeds, per Flutfüllung gezählt |
| Pflichträume zuerst *ist* die Garantie | 10 | **bestätigt** über 39 Seeds — Eingang, Treppe, Händler immer da |
| Türen explizit, keine Löcher in gezeichneten Wänden | 10 | **erst nach zwei Korrekturen** — siehe unten |
| Seed im Save ⇒ Nachladen würfelt nicht neu | 14 | **bestätigt** — gleicher Seed, gleiche Etage |
| Sieg über das Fehlen der nächsten Etage | 10 | **bestätigt** — keine Fünf im Code |
Zwei Befunde nachgetragen: **10** (der Korridor startete auf der Tür und grub die Wandreihe auf — 2340
Wandfelder; zwei L-Varianten reichen nicht, es braucht eine Wegsuche über den freien Raum) und **11**
(das Grid braucht einen mitlaufenden Bildausschnitt, weil Etage 5 breiter ist als ein
80-Spalten-Terminal — das stand in keinem Ticket).
**M3 — Kampf, Events und Trigger** (`feat/m3-kampf`, 43 Tests)
| Zusage | Aus | Befund |
|---|---|---|
| Trigger werden **gesucht, nicht indiziert** | 08 | **bestätigt** — die letzte offene Papierzusage. Gift tickt über einen `turn_started`-Trigger, ohne Script |
| Warteschlange statt Rekursion | 08 | **erst nach Korrektur** — die Umsetzung war ein Stack |
| Zug = Spieler plus alle Gegner, Initiative am Wesen | 14 | **bestätigt**, Gleichstand geht an den Spieler |
| Tote erst am Zugende entfernen | 14 | **bestätigt** |
| Gesperrte Fähigkeit ⇒ `Rejected`, kein Zug | 14 | **bestätigt** |
| Resistenz typ-indiziert, bei 0.8 gedeckelt | 01/14 | **bestätigt**, Verwundbarkeit bei −1.0 ausdrückbar |
| Krit-Reihenfolge ist belanglos | 14 | **bestätigt**, direkt nachgerechnet |
| Gegner gehören zum Raum, verschoben nicht kopiert | 10 | **bestätigt** |
| Trefferformel | 14 | **war toter Code** — jeder Angriff traf, siehe unten |
Drei Befunde nachgetragen: **14** (die Trefferformel wurde nie aufgerufen — nur der Compiler hat es
gemeldet, weil alle Tests sie *direkt* prüften statt über einen Angriff), **08** (die Warteschlange war
`Vec::pop`, also LIFO — die Formulierung war richtig, die Umsetzung nicht, und kein Test hat es
gemerkt) und **01** (benannte Modifikatoren wie `core:poisoned` hatten keine Registry; jetzt die
**dreizehnte**).
Dazu zwei Ergänzungen am Bedingungsvokabular (`health_below`, `chance` am Trigger) und **eine offene
Lücke**: Ein Trigger kann die Nutzlast des Events, das ihn ausgelöst hat, **nicht prüfen** — `Check`
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.
Und **ein Absturz, den erst das Spielen gefunden hat**: Die Initiativreihenfolge ist ein Schnappschuss
vor dem Zug, eine geglückte Flucht leert `enemies` aber mitten darin — die Reihenfolge zeigt danach ins
Leere. Zwei Zusagen aus **14**, jede für sich richtig, die zusammen nicht geprüft worden waren. Ein
leerer `enemies` bricht den Zug jetzt ab.
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.
**Nach M4 — die Ansage** (58 Tests)
Kein Meilenstein, eine Nachforderung aus dem Spielen: Gegner sagen am Zugende an, was sie im nächsten
tun, und der Spieler wählt sein Ziel mit einem eigenen Cursor. Zwei Entscheidungen dahinter, beide in
die Tickets zurückgetragen:
| Frage | Entschieden | Warum |
|---|---|---|
| Ansage verbindlich oder Vorschau? | **verbindlich** (14) | Eine Anzeige, die sich mit der Lage mitdreht, ist genau dann falsch, wenn der Spieler handelt. Verbindlich heißt störbar — das Fenster zwischen Ansage und Ausführung *ist* die Taktik |
| Zielwahl mit Pfeilen auf zwei Achsen oder mit Tab? | **Tab** (11) | Dieselbe Umschaltregel wie im Laden. Eine Bedienung für alle zweispaltigen Screens statt einer je Screen |
Der Zugablauf aus **14** hat damit einen siebten Schritt, `Creature` ein Feld `intent`, und die
Sperrgründe, die `ability_block` ohnehin berechnete, haben einen zweiten Nutzen bekommen.
**M5 — Progression** (`feat/m5-progression`, 65 Tests)
| Zusage | Aus | Befund |
|---|---|---|
| `current_exp`/`unspent_points` an `Game`, nicht am `Creature` | 15 | **bestätigt** — der Save schreibt sie nicht für jeden Gegner mit |
| `exp_reward` als Kurzform für einen Todes-Trigger | 15 | **bestätigt**, stand seit M3 ungelesen an vier Gegnern |
| Aufstieg in einer Schleife, drei Stufen = drei Punkte | 15 | **bestätigt** |
| Zwei Zahlen statt fünfzig Tabellenzeilen für die Kurve | 15 | **bestätigt** — `exp_base × level^exp_exponent` |
| `AssignPoints` als **ein** Kommando, **ein** Zug | 15 | **bestätigt** |
| Verteilen im Charakterbogen, kein zwölfter Screen | 11/15 | **bestätigt** |
| Punkte nur auf die fünf Grundwerte | 15 | **zwei davon waren Fallen** — siehe unten |
| Lebensstand folgt dem Zuwachs an **zwei** Stellen | 15 | **es ist eine** — der Aufstieg ändert `max_health` gar nicht |
| `luck` = eine Chance auf einen Zusatzzug, auf jeder Tabelle | 16 | **bestätigt**, wirkt dreifach aus einer Formel |
Ein fünfter kam beim Spielen dazu: **11** — der Charakterbogen hält seit M5 selbst Zustand (die
vorgemerkten Punkte). Beim Abschicken zog das Spiel sie ab, der Screen behielt sie, und die Anzeige
rechnete `0 - 1` auf `u32`. Daraus der **erste UI-Test** des Projekts: Sobald ein Screen Zustand hält,
ist die Naht zum Spielzustand eine Fehlerquelle wie jede andere.
Vier Befunde nachgetragen: **15** (`strength` und `constitution` las niemand — zwei der fünf
Wahlmöglichkeiten taten nichts), **15** (von den zwei Zuwachs-Stellen ist eine beweisbar ein No-op),
**14/16** (`Rng::chance(0.0)` verbrauchte eine Zahl und verschob den Zufallsstrom — derselbe Seed
erzeugte je nach Glück des Helden eine andere Etage) und **11** (`Cheat::ClearRoom` ging am Todesweg
vorbei, also ohne Trigger und ohne Erfahrung).
Der dritte ist der interessanteste: Gefunden hat ihn ein **bestehender** Kampftest, dessen Angriff
plötzlich danebenging. Kein neuer Test, keine Überlegung — eine Zusage aus Ticket 14 wäre sonst
stillschweigend gebrochen gewesen.
**M6 — Speicherstand** (`feat/m6-speicherstand`, 70 Tests)
| Zusage | Aus | Befund |
|---|---|---|
| Der Save **ist** der `Game`-Baum, keine eigenen Save-Structs | 09 | **bestätigt** — ein Rundlauf-Test hält es fest |
| Die Etage wird gespeichert, nicht gewürfelt | 09 | **bestätigt** — Kacheln als ASCII-Zeilen wie ein Prefab |
| Eine handgeschriebene Ausnahme fürs Kachelgitter | 09 | **bestätigt**, aber an anderer Stelle: `Serialize for Floor`, weil die Breite feldweise unsichtbar ist |
| Atomar schreiben (tmp + `rename`) | 09 | **bestätigt** — der einzige Speicherstand überlebt einen Abbruch |
| Falsches Schema: ablehnen, Datei bleibt liegen | 09 | **bestätigt** |
| Verwerfen, nie ablehnen | 09 | **bestätigt** — fehlender Mod kostet Einträge, nicht den Lauf |
| Mod-Versionen nur vergleichen und protokollieren | 03/09 | **bestätigt** — die ganze Bilanz des `version`-Felds |
| Kampfzustand fällt gratis mit ab | 09 | **fast** — die Ereigniswarteschlange gehört nicht hinein |
| `Confirm { text, on_yes }` | 11 | **braucht `on_no`** — Nein heißt hier beenden, nicht wegklicken |
Fünf Befunde nachgetragen: **09** (die Ereigniswarteschlange ist Zwischenstand *innerhalb* eines Zuges
und beim Speichern immer leer), **09** (die Feldreihenfolge ist Teil des Formats — TOML verlangt Werte
vor Tabellen, „der Save ist der Baum" macht die Reihenfolge damit zur Bedingung), **09** (`Serialize`
für `Floor` von Hand statt feldweise), **11** (`Confirm` braucht `on_no`) und **04/09** (die Rückfrage
galt zuerst für alle Warnungen und hätte einen kaputten Mod-Eintrag zum Startblocker gemacht).
**M7 — Quests** (`feat/m7-quests`, 71 Tests)
| Zusage | Aus | Befund |
|---|---|---|
| Eine Quest **ist** ein Trigger mit `count = N` | 08 | **bestätigt** — `#[serde(flatten)]`, und eine Funktion entscheidet für alle Träger, wann ein Trigger feuert |
| Fester Träger: für eine Quest der Held | 08 | **bestätigt** — deshalb steht der Questlauf *neben* der Trägerschleife, nicht darin |
| Zählen erst ab `start_quest` | 08 | **bestätigt** — ein Kill vor der Annahme zählt nicht nach |
| Erledigte bleiben mit `done = true` stehen | 08 | **bestätigt** — der Zähler lebt nicht wieder auf, die Belohnung kommt genau einmal |
| Die Belohnung startet die Folgequest — das *ist* die Kette | 08 | **bestätigt**, ohne Zwischenpuffer |
| „Besitze N" braucht keine zweite Mechanik | 08 | **bestätigt** — dieselbe Form, anderes Ereignis |
| Unbekannte Quest-ID im Save verwerfen | 08/09 | **bestätigt** |
Ein Befund nachgetragen: **01/08/14** — die **Ordnungszahlen im Aktionskatalog widersprechen sich**.
Ticket 08 nennt `start_quest` die „siebte", Ticket 14 `flee` die „achte", M4 `open_shop` die „elfte";
gebaut sind neun Varianten. Jedes Ticket zählte von seinem eigenen Stand aus. Die Zahlen sind aus dem
Code raus, `CONTEXT.md` führt jetzt die Liste — die ist zählbar.
**M8 — Charaktererschaffung** (`feat/m8-charakterbau`, 72 Tests)
Nicht Scripting: **Ticket 13 hat Stufe (c) ausdrücklich hinter 1.0 gelegt**, und die Destination oben
sagt dasselbe. Der Vorschlag „M8 = Scripts" kam aus den TODO-Kommentaren im Code, nicht aus dem Ticket.
Offen für 1.0 war stattdessen „Solo-Held mit **Rasse und Klasse**" — gebaut war davon nichts.
| Zusage | Aus | Befund |
|---|---|---|
| Charakterbau ist eine **Registry-Schleife**, kein gemauertes Layout | 02/11 | **bestätigt** — „Talent" mit `pick = 2` bräuchte keine Zeile Code |
| `pick = N` an der Kategorie | 02 | **bestätigt**, geprüft in `rejects`, nicht im Screen |
| `on_create` ist **keine neue Mechanik** | 02 | **bestätigt** — der hartcodierte `starting_hero()` *war* die Sonderregel und ist ersatzlos weg |
| Was eine Option dauerhaft trägt, sind Fähigkeiten | 15 | **das Feld gab es nicht** — siehe unten |
| `core:attack`/`core:flee` implizit gewährt | 02/09 | **bestätigt** — ein Held ohne Klasse kann trotzdem angreifen |
Zwei Befunde nachgetragen: **02/15** (`CharacterOption.abilities` fehlte — das Argument „ein Magier
bleibt ein Magier, weil er zaubern kann" ruhte auf einem Feld, das nie gebaut wurde; eine Klasse konnte
nur flache Modifikatoren geben, also genau die Hälfte, die laut demselben Ticket veraltet) und **02/09**
(„Held ist noch nicht gebaut" darf **nicht** aus `options.is_empty()` gefolgert werden — ein
Speicherstand ohne Rassen-Mod hat nach der Verwerf-Regel ebenfalls keine Optionen und würde neu gebaut
statt fortgesetzt).
**Erste Balancing-Runde** (nach M8, 73 Tests) — ausgelöst durch „der erste Gegner ist sehr schwer,
Elf Dieb". Gemessen statt geschätzt, und es waren nicht die Zahlen:
| Ursache | Wirkung |
|---|---|
| `on_create` konnte nur `give_item` | Die Startwaffe lag im **Beutel**. Jeder Held schlug mit bloßen Händen zu: Schaden 2 statt 7,7 — zehn Züge pro Ratte statt zweieinhalb |
| Der Dieb bekam gar keine Waffe | Nur Kappe und Gold, und die Kappe war ebenfalls nicht angelegt |
| `critical_chance percent = 15` auf Basis 0 | **No-op.** Die Signaturzahl der Klasse tat nichts, und keine Zeile Code war daran falsch |
Behoben mit `equip_item` als **zehnter Aktion** (kein Engine-Begriff „Startausrüstung" — eine Aktion
wie jede andere, überall verwendbar), einem Dolch für den Dieb, und einer Basis von 5 auf
`critical_chance`. Die Prozent-auf-Null-Falle steht jetzt im Glossar unter *Modifikator*: Sie folgt
direkt aus der Stapelregel aus Ticket 01 und wird wieder zuschnappen, wenn sie nirgends steht.
Startkampf danach: Krieger 2,5 Züge je Ratte, Dieb 3,9 bei 20 % Krit und 108 Leben.
**Zweite Balancing-Runde** (76 Tests) — gemeldet als „Ratten vergiften zu 100 %". Dahinter lagen
**drei unabhängige Fehler**, und der größte hatte mit Gift nichts zu tun:
| Fehler | Wirkung |
|---|---|
| `Creature::from_def` parste Attributnamen mit `toml::from_str`, das eine ganze Datei erwartet | **Jeder Gegner hatte seit M3 die Standardwerte.** Die Riesenratte: 100 Leben statt 14. Der Knochenfürst war eine große Ratte. Ein stilles `Err(_) => continue` |
| `attack_hit` feuerte bei *allen* Trägern statt nur beim Angreifer | Der Gift-Trigger der Ratte feuerte auch, wenn der **Held** zuschlug. Dieselbe Lücke hätte den Dornenpanzer des Angreifers reflektieren lassen |
| `Action::Damage` würfelte die Trefferformel auch bei Selbstschaden | „M verfehlt M" — ein flinker Held wich seinem eigenen Gift aus |
Dazu ein reiner Datenfehler: Das Gift stand in der *Aktionsliste* von `rat_bite`, und Aktionen haben
kein `chance`. Es hängt jetzt am Trigger — genau der Fall, für den Ticket 08 das Feld erfunden hat.
Der erste ist der teuerste Befund des Projekts und der einzige, den **kein** Werkzeug gemeldet hat:
kein Compiler, kein clippy, kein Test. Die Kampftests setzen ihre Zahlen selbst und schlugen nie eine
Gegnerdefinition nach. Gefunden hat es das Spielen, und zwar erst, als alles andere schon stimmte.
**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.
- **03**: Die **Referenzseite** war nie entschieden. Präfixiert wird nur `id`; Verweise stehen voll qualifiziert. Asymmetrisch — zu klären, bevor der erste Fremdmod existiert.
- **04**: `Spanned` zeigt auf den Eintragskopf, nicht auf das schuldige Feld. Deckt sich mit der Zusage, ist aber gut zu wissen.
## Notes ## Notes
@@ -278,7 +44,7 @@ Feststehende Rahmenentscheidungen aus der Charter-Session:
| Etagen | Hybrid — Raum-Prefabs in TOML, prozedural zusammengesetzt | | Etagen | Hybrid — Raum-Prefabs in TOML, prozedural zusammengesetzt |
| Daten | Alles TOML, zur Laufzeit geladen. `include_str!` und `enum ItemType` fallen | | Daten | Alles TOML, zur Laufzeit geladen. `include_str!` und `enum ItemType` fallen |
| Mod-IDs | Namespaced (`core:iron_sword`), Präfix aus dem Manifest und automatisch vorangestellt. Ladereihenfolge aus einer Spielerliste, nicht aus dem Manifest (03). Das Kernspiel ist selbst ein Mod | | Mod-IDs | Namespaced (`core:iron_sword`), Präfix aus dem Manifest und automatisch vorangestellt. Ladereihenfolge aus einer Spielerliste, nicht aus dem Manifest (03). Das Kernspiel ist selbst ein Mod |
| Mod-Stufen | Daten → Typen → Scripting, in dieser Reihenfolge. Scripting setzt auf der Typ-Registry auf. *13: Rhai gewählt, Umsetzung aber hinter 1.0 — gebaut wird sie, wenn das Vokabular an eine Grenze stößt* | | Mod-Stufen | Daten → Typen → Scripting, in dieser Reihenfolge. Scripting setzt auf der Typ-Registry auf |
| Effekte | Ein generisches Effekt-System mit Wirkung auf Stats. Rasse, Klasse, Items, Statuseffekte, Quest-Belohnungen und später Scripts nutzen dasselbe Vokabular — dieses Vokabular *ist* die Mod-API | | Effekte | Ein generisches Effekt-System mit Wirkung auf Stats. Rasse, Klasse, Items, Statuseffekte, Quest-Belohnungen und später Scripts nutzen dasselbe Vokabular — dieses Vokabular *ist* die Mod-API |
| Quests | Bedingung + Belohnung als Daten, generisches Event-/Trigger-System | | Quests | Bedingung + Belohnung als Daten, generisches Event-/Trigger-System |
| Weltzustand | `Game`-Ownership-Baum, Verweise über `String`-IDs (keine Indizes, 07). `Registries` liegt daneben, nicht darin. Kein ECS | | Weltzustand | `Game`-Ownership-Baum, Verweise über `String`-IDs (keine Indizes, 07). `Registries` liegt daneben, nicht darin. Kein ECS |
@@ -301,79 +67,16 @@ auch über Ticket 13.
- [06 — Research: ratatui-App-Architektur](issues/06-research-ratatui-architektur.md) ([#7](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/7)) — Screen-Stack über zentralem `Game`-State statt Elm oder Component-Trait; `game/` bleibt ratatui-frei; Popups per `Clear` auf Sub-`Rect` statt Widget-Stack; blockierendes `event::read()` ohne Tick; Dungeon-Grid ist Eigenbau, es gibt kein Widget dafür; ratatui 0.30.2 mit MSRV 1.88; `KeyEventKind::Press`-Filter unter Windows Pflicht. Voller Bericht in [research/06](research/06-ratatui-architektur.md). - [06 — Research: ratatui-App-Architektur](issues/06-research-ratatui-architektur.md) ([#7](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/7)) — Screen-Stack über zentralem `Game`-State statt Elm oder Component-Trait; `game/` bleibt ratatui-frei; Popups per `Clear` auf Sub-`Rect` statt Widget-Stack; blockierendes `event::read()` ohne Tick; Dungeon-Grid ist Eigenbau, es gibt kein Widget dafür; ratatui 0.30.2 mit MSRV 1.88; `KeyEventKind::Press`-Filter unter Windows Pflicht. Voller Bericht in [research/06](research/06-ratatui-architektur.md).
- [07 — Game-State-Baum](issues/07-game-state-baum.md) ([#8](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/8)) — `Registries` lebt **neben** `Game`, nicht darin: als Feld kollidierte `&mut game.player` mit `&game.registries` bei jedem Methodenaufruf. Verweise per `String`-ID, keine Indizes (die hängen an der Ladereihenfolge). Ein `Creature` für Held und Gegner, aber **zwei Felder** `player` und `enemies` statt einer `Vec` — Solo-Held heißt, „Gegner greift Gegner an" existiert nicht, und zwei Felder sind ein disjunkter Borrow. Ein `Floor`, kein Backtracking; Händler müssen deshalb auf jeder Etage vorkommen können. **Instanzzustand kommt**: `ItemInstance { def, affixes }`, und **Affixe sind die sechste Registry** — dadurch bleibt der Save eine ID-Liste und der Preis `base_price × Π price_factor`. Stapeln bei gleicher `def` und gleicher `affixes`. `enum CreatureRef`, dazu je ein Eingang `Game::apply` und `Game::attribute` — Letzteres auf `Game`, weil Auren beim Träger wohnen und ein `Creature` seine Attribute allein gar nicht ausrechnen kann. Kein Attribut-Cache. - [07 — Game-State-Baum](issues/07-game-state-baum.md) ([#8](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/8)) — `Registries` lebt **neben** `Game`, nicht darin: als Feld kollidierte `&mut game.player` mit `&game.registries` bei jedem Methodenaufruf. Verweise per `String`-ID, keine Indizes (die hängen an der Ladereihenfolge). Ein `Creature` für Held und Gegner, aber **zwei Felder** `player` und `enemies` statt einer `Vec` — Solo-Held heißt, „Gegner greift Gegner an" existiert nicht, und zwei Felder sind ein disjunkter Borrow. Ein `Floor`, kein Backtracking; Händler müssen deshalb auf jeder Etage vorkommen können. **Instanzzustand kommt**: `ItemInstance { def, affixes }`, und **Affixe sind die sechste Registry** — dadurch bleibt der Save eine ID-Liste und der Preis `base_price × Π price_factor`. Stapeln bei gleicher `def` und gleicher `affixes`. `enum CreatureRef`, dazu je ein Eingang `Game::apply` und `Game::attribute` — Letzteres auf `Game`, weil Auren beim Träger wohnen und ein `Creature` seine Attribute allein gar nicht ausrechnen kann. Kein Attribut-Cache.
- [08 — Event- und Trigger-System](issues/08-event-trigger-system.md) ([#9](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/9)) — **Der Trigger ist die vierte Wirkungsform** neben Modifikator, Aktion und Fähigkeit: `{ on, condition, actions }` darf überall stehen, wo `modifiers` und `actions` stehen — sonst entstünde dieselbe Maschine ein zweites Mal in 14 als `on_hit`. Eine Bedingungsgrammatik für Trigger *und* Modifikatoren: getaggtes Enum, Liste ist UND, `not` je Eintrag, **kein ODER** (zwei Einträge oder ein gemeinsames Tag). Neun feste Events ohne Namensraum, jedes mit `source`/`target`; `attack_hit` und `damage_taken` bleiben getrennt, weil der eine beim Angreifer und der andere beim Getroffenen hängt. Im Trigger ist `self` immer der Träger. **Warteschlange statt synchron** — synchrones Feuern wäre ein Borrow-Fehler (über `self.quests` iterieren, während `self.apply` läuft) — mit Zähler bei 100 Events pro Zug statt Zyklengraph. Trigger werden gesucht, nicht indiziert. **Quest = Trigger mit `count`**, siebte Registry, erledigte bleiben markiert stehen. Zwei Nachträge an geschlossene Tickets: `start_quest` als siebte Aktion in 01, und `against: Option<CreatureRef>` plus `events: Vec<Event>` in 07. - [08 — Event- und Trigger-System](issues/08-event-trigger-system.md) ([#9](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/9)) — **Der Trigger ist die vierte Wirkungsform** neben Modifikator, Aktion und Fähigkeit: `{ on, condition, actions }` darf überall stehen, wo `modifiers` und `actions` stehen — sonst entstünde dieselbe Maschine ein zweites Mal in 14 als `on_hit`. Eine Bedingungsgrammatik für Trigger *und* Modifikatoren: getaggtes Enum, Liste ist UND, `not` je Eintrag, **kein ODER** (zwei Einträge oder ein gemeinsames Tag). Neun feste Events ohne Namensraum, jedes mit `source`/`target`; `attack_hit` und `damage_taken` bleiben getrennt, weil der eine beim Angreifer und der andere beim Getroffenen hängt. Im Trigger ist `self` immer der Träger. **Warteschlange statt synchron** — synchrones Feuern wäre ein Borrow-Fehler (über `self.quests` iterieren, während `self.apply` läuft) — mit Zähler bei 100 Events pro Zug statt Zyklengraph. Trigger werden gesucht, nicht indiziert. **Quest = Trigger mit `count`**, siebte Registry, erledigte bleiben markiert stehen. Zwei Nachträge an geschlossene Tickets: `start_quest` als siebte Aktion in 01, und `against: Option<CreatureRef>` plus `events: Vec<Event>` in 07.
- [09 — Save-Schema](issues/09-save-schema.md) ([#10](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/10)) — **Layout wird gespeichert, nicht gewuerfelt**: Regenerieren aus dem Seed braeuchte dieselben Prefabs und denselben Generator-Code, sonst steht der Held nach einem Mod-Update in einer Wand — und der Raumzustand muss ohnehin mit, der Seed spart also nur 3 KB ASCII. **Ein Slot, Autosave bei Etagenbetreten und Beenden, Permadeath** (geloescht erst beim `GameOver`-Screen, nicht beim Tod). Der Save *ist* der `Game`-Baum per serde-Ableitung, eine Ausnahme fuers Kachelgitter. Schema-Version ist das einzige ausgewertete Header-Feld; abweichend ⇒ **ablehnen, Datei bleibt liegen** (Migration vor dem ersten ausgelieferten Save waere Code ohne Problem). Fehlende Mods: **verwerfen und warnen, nie ablehnen** — auch bei der Klasse. Mod-Version wird nur protokolliert, das ist die ehrliche Bilanz des Felds aus 03. Warnungen in dieselbe Sitzungsdatei wie der Mod-Ladelauf, plus **Rueckfrage vor dem Betreten** (der Spieler will vielleicht nachinstallieren statt weiterzuspielen). **Atomar geschrieben** ueber `.tmp` und `fs::rename` — die eine Stelle, an der bewusst nicht lazy entschieden wurde. Nachtraege: `core:attack`/`core:flee` implizit an 02, `Confirm { on_yes: Box<Response> }` an 11.
- [10 — Raum-Prefab-Format](issues/10-raum-prefab-format.md) ([#11](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/11)) — Freie Platzierung mit L-Korridoren; verbunden wird **Raum `i` mit Raum `i−1`**, weil eine Kette Erreichbarkeit *strukturell* garantiert statt sie nachtraeglich zu pruefen. Prefab als ASCII-Block mit Markern im Block, **Tueren explizit** (sonst stanzt der Generator Loecher in gezeichnete Waende); Rotation und Spiegelung erlaubt, `rotate = false` als Opt-out. **Ein `e` sagt „hier steht ein Gegner", nicht welcher** — das entscheidet die Etage, sonst waere jedes Prefab an eine Schwierigkeitsstufe gebunden. `[[prefabs]]` und `[[floors]]` sind die neunte und zehnte Registry; Auswahl durchgehend ueber **Tags**, Pflichtraeume zuerst platziert (das *ist* die Garantie), Flaechenpruefung beim Laden. **Kampf beginnt bei `room_entered`**, Gegner gehoeren zum Raum — das erspart Pfadsuche, Sichtlinien und Verfolgung. `Floor` mit `rooms`, `player_room: Option<usize>` (Korridor ist ein echter Zustand). **Siegbedingung erstmals definiert**: kein `[[floors]]` mit der naechsten Nummer = gewonnen, die Zahl „5" steht nirgends im Code. Nachtraege: `room_entered` an 08 (zwoelf Events), `GameOver { outcome }` an 14.
- [11 — UI-Gerüst und Screen-Modell](issues/11-ui-geruest-screen-modell.md) ([#12](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/12)) — **„Aktion" hiess zwei Dinge**: `Action` bleibt das TOML-Effektvokabular aus 01, `Command` wird das UI-Kommando, Eingang `Game::execute(cmd, reg)` — die eine Stelle, an der ein Zug endet (Modifikatoren ticken, Event-Warteschlange leerarbeiten). Zwölf Commands, **alle kosten einen Zug** (ein `costs_turn`-Flag waere eine Ausnahmeliste); Angriff und Flucht sind keine Varianten, sondern `UseAbility("core:attack")` und `UseAbility("core:flee")` — damit moddbar. Ein gemeinsamer `Vec<Screen>` fuer Screens und Popups; Screens **geben `Response` zurueck statt selbst zu schieben**, sonst haelt ein Screen ein `&mut` auf die `Vec`, in der er liegt. Ein `Response::Command` schliesst alle Overlays — dasselbe Flag, das schon fuers Rendern existiert. Zehn Screens, `ListState` in der Variante (der Stack erhaelt den Cursor gratis, solange der Screen liegt). Nur `KeyEventKind::Press`: bei `Repeat` bestimmte die Tastaturwiederholrate die Spielzeit; gegen zaehes Laufen hilft ein Renn-Kommando in `game/`. Tastenbelegung fest, Pfeile und `hjkl`. ratatui-Freiheit von `game/` prueft ein `#[test]` statt eines Workspace. Nachtrag an 01: `symbol` traegt das Zeichen und einen Farbnamen selbst — `ui/theme.rs` ist Code und damit fuer Modder unerreichbar. - [11 — UI-Gerüst und Screen-Modell](issues/11-ui-geruest-screen-modell.md) ([#12](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/12)) — **„Aktion" hiess zwei Dinge**: `Action` bleibt das TOML-Effektvokabular aus 01, `Command` wird das UI-Kommando, Eingang `Game::execute(cmd, reg)` — die eine Stelle, an der ein Zug endet (Modifikatoren ticken, Event-Warteschlange leerarbeiten). Zwölf Commands, **alle kosten einen Zug** (ein `costs_turn`-Flag waere eine Ausnahmeliste); Angriff und Flucht sind keine Varianten, sondern `UseAbility("core:attack")` und `UseAbility("core:flee")` — damit moddbar. Ein gemeinsamer `Vec<Screen>` fuer Screens und Popups; Screens **geben `Response` zurueck statt selbst zu schieben**, sonst haelt ein Screen ein `&mut` auf die `Vec`, in der er liegt. Ein `Response::Command` schliesst alle Overlays — dasselbe Flag, das schon fuers Rendern existiert. Zehn Screens, `ListState` in der Variante (der Stack erhaelt den Cursor gratis, solange der Screen liegt). Nur `KeyEventKind::Press`: bei `Repeat` bestimmte die Tastaturwiederholrate die Spielzeit; gegen zaehes Laufen hilft ein Renn-Kommando in `game/`. Tastenbelegung fest, Pfeile und `hjkl`. ratatui-Freiheit von `game/` prueft ein `#[test]` statt eines Workspace. Nachtrag an 01: `symbol` traegt das Zeichen und einen Farbnamen selbst — `ui/theme.rs` ist Code und damit fuer Modder unerreichbar.
- [12 — Prototyp: ratatui-Durchstich](issues/12-prototyp-ratatui-durchstich.md) ([#13](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/13)) — **Das Modell aus 11 traegt.** Primaerquelle: Branch `prototyp/12-ratatui-durchstich`, `cargo run --example durchstich`. Bestaetigt: `mod game` bleibt ratatui-frei ohne Disziplin, Grid als eigenes `impl Widget` ueber `buf[(x,y)].set_symbol(…)`, `Vec<Screen>` mit Overlay-Flag fuer Rendern *und* Auto-Schliessen, `Response` statt Stack-Mutation (kein Borrow-Aerger). **Der Windows-Fallstrick ist jetzt eine Zahl**: `Tasten 14 → Press 7 → Zuege 7` — ohne Filter kostete jeder Schritt zwei Zuege und damit doppelte Spielzeit. Zwei Korrekturen: 11 sagte „alle Commands kosten einen Zug", richtig ist **alle ausgefuehrten** (`Outcome { TurnPassed, Rejected(&str) }`, Grund reist mit zurueck); und `Command` muss `Clone` sein, damit `Confirm` es herausgeben kann. Ungetestet blieb `Registries` neben `Game` — der Durchstich laedt keine.
- [13 — Script-Engine wählen](issues/13-script-engine-waehlen.md) ([#14](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/14)) — **Rhai**, aber die Wahl ist dank der Leitplanke aus 05 nur noch eine *Implementierungs*-Entscheidung: Scripts lesen ueber eine Handvoll Funktionen (gespiegelt an der Bedingungsgrammatik aus 08 — sie sehen nichts, was eine Bedingung nicht auch saehe) und schreiben ausschliesslich ueber Aktionen. Begruendung unbequem, aber ehrlich — **fuer ein unveroeffentlichtes Terminal-Roguelike schreibt niemand Mods**, der Lua-Vorsprung zahlt sich erst mit einer Gemeinde aus. Mods gelten als **vertrauenswuerdig** (wie bei Factorio, Minecraft, Skyrim), gehaertet wird gegen Unfaelle: acht Engine-Schalter, gegen die Doku geprueft; `set_max_operations` ausdruecklich **nicht** in `[balance]`, sonst hebt ein Mod sein eigenes Limit auf. `rand()` laeuft durch den Seed aus 14, sonst wuerfelten ausgerechnet Scripts beim Laden neu. `run_script` als neunte Aktion, Scripts als **elfte Registry**. Risiken beobachtet mit benanntem Ausstiegssignal; `smartstring` ist ein INFO-Advisory, kein Ausstiegsgrund. **Stufe (c) wandert hinter 1.0** — eine Script-Engine zu bauen, bevor jemand an eine Vokabular-Luecke gestossen ist, baut die Antwort vor der Frage.
- [14 — Kampfformeln und Rundenablauf](issues/14-kampfformeln-und-rundenablauf.md) ([#15](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/15)) — **Ein Zug = Spielerhandlung plus alle Gegnerreaktionen**, sonst tickte ein Debuff bei drei Gegnern viermal. Initiative als `i32` **am Wesen** statt als Reihenfolgeliste (die waere eine zweite Indexsammlung mit demselben Ungueltigkeitsproblem); tote Gegner erst am Ende von `execute`, nach dem Leeren der Warteschlange. Trefferformel und Schadenskette mit drei Klemmbereichen — `0.8` bei Resistenz, weil `1.0` Immunitaet und damit ein Spielabbruch waere. **Die Krit-Reihenfolge-Frage loest sich auf**: multiplikativ ist kommutativ. Eigener xorshift, **Seed im Save** — Nachladen wuerfelt nicht neu. Faehigkeiten kosten Ausdauer, Abklingzeit in Zuegen, gesperrt ⇒ `Rejected` ohne Zug. **Achte Registry: Wesen** — die Luecke lag seit 02 offen; `ai` als geordnete Bedingungsliste. `[balance]` verschmilzt **feldweise**, ausdrueckliche Ausnahme zu 03 (festes Schema). Vier Nachtraege: `scale` und `flee` an 01, drei Felder an 07, zwei Events an 08, `GameOver` an 11.
- [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 ## 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. - **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. - **Boss-Design** — ob Bosse nur Gegner mit besseren Werten sind oder eigene Mechanik brauchen (Phasen, Skripte). Berührt Stufe (c) des Mod-Supports.
- ~~**NPC- und Handelsmodell**~~ — **entschieden während M4**, siehe unten. - **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.
- **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. - **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. - **Mod-Verteilung** — durch 03 auf „Ordner ins Profil entpacken" geschrumpft, ohne Versionsprüfung. Offen ist nur noch, ob 1.0 mehr braucht.
- **Endlosmodus** — das erklärte Fernziel (aufgekommen in Ticket 15). 1.0 bleibt endlich; endlos ist **additiv**: ein Feld `repeat = true` an der letzten Etage plus eine Skalierungsformel. Berührt drei getroffene Entscheidungen — die Siegbedingung (10), `GameOver { outcome: Won }` (14) und die `floors`-Registry, die einen Eintrag *je Nummer* hat. Die fehlende Level-Obergrenze aus 15 ist bereits darauf ausgelegt.
## Out of scope ## Out of scope
- **Story-Modul** — kommt später als eigenes Modul, linear statt prozedural, ebenfalls moddbar. Eigene Map, wenn 1.0 steht. - **Story-Modul** — kommt später als eigenes Modul, linear statt prozedural, ebenfalls moddbar. Eigene Map, wenn 1.0 steht.

View File

@@ -11,22 +11,6 @@ Held oder Gegner — im Code derselbe Typ. Träger von [Attributen](#attribut),
Ein Wesen ist dabei nicht in sich abgeschlossen: Eine Aura wohnt beim Träger und wird erst beim Ein Wesen ist dabei nicht in sich abgeschlossen: Eine Aura wohnt beim Träger und wird erst beim
Auslesen des Ziels eingesammelt. Auslesen des Ziels eingesammelt.
Gegner werden in der [Registry](#registry) `[[creatures]]` definiert — mit Grundwerten, Tags,
Fähigkeiten und einer geordneten `ai`-Liste, deren erster passender und verfügbarer Eintrag gewinnt.
## Ansage
Was ein [Wesen](#wesen) im nächsten [Zug](#zug) tun wird — eine [Fähigkeits](#fähigkeit)-ID, am Ende
des vorigen Zuges aus der `ai`-Liste bestimmt und im Kampf sichtbar.
Sie ist **verbindlich**: Der Gegner führt genau das aus, was dasteht. Eine Vorschau, die sich mit der
Lage mitdreht, wäre genau dann falsch, wenn der Spieler etwas unternimmt — und damit als Grundlage
einer Entscheidung wertlos.
Verbindlich heißt zugleich **störbar**: Vor dem Ausführen wird erneut geprüft, ob die Fähigkeit
verfügbar ist. Wer dem Gegner dazwischen die Ausdauer nimmt oder die [Bedingung](#bedingung) kippt,
kostet ihn den Zug. Das Fenster zwischen Ansage und Ausführung ist der taktische Griff.
## Attribut ## Attribut
Eine benannte, veränderbare Größe an einem [Wesen](#wesen). Der Katalog ist fest und wird von der Engine Eine benannte, veränderbare Größe an einem [Wesen](#wesen). Der Katalog ist fest und wird von der Engine
@@ -46,16 +30,6 @@ Klasse, ausgerüstete Gegenstände und befristete Zustände.
Beispiele: „Zwerg: +2 Konstitution", „Eisenschwert: +5 Schaden", „Segen: +10 % Kritische Chance". Beispiele: „Zwerg: +2 Konstitution", „Eisenschwert: +5 Schaden", „Segen: +10 % Kritische Chance".
Ein **benannter** Modifikator (`core:poisoned`) ist ein Bündel: mehrere Umrechnungen, eigene
[Trigger](#trigger) und eine Dauer in [Zügen](#zug). Er lebt in der Registry `[[modifiers]]` und wird
von der [Aktion](#aktion) `apply_modifier` aufgelegt. Er **wohnt beim Träger** — stirbt der, verschwindet
er mit ihm.
Die Stapelregel ist `(Basis + Σ flach) × (1 + Σ prozentual)`, und daraus folgt eine Falle, die schon
einmal zugeschnappt hat: **`percent` auf einer Basis von 0 bleibt 0.** „+15 % kritische Chance" an einer
Klasse tat nichts, weil `critical_chance` beim Helden bei 0 anfing. Wer ein Attribut prozentual
verändern will, braucht dort eine Basis ungleich null — sonst gehört der Wert als `flat` hin.
## Aktion ## Aktion
Eine einmalige Zustandsänderung. Sie feuert, wirkt, ist vorbei. Quellen sind Angriffe, Eine einmalige Zustandsänderung. Sie feuert, wirkt, ist vorbei. Quellen sind Angriffe,
@@ -66,14 +40,6 @@ Beispiele: „12 Schaden zufügen", „20 Leben heilen", „50 Gold geben", „G
Eine Aktion kann einen befristeten [Modifikator](#modifikator) erzeugen — so hängen die beiden Eine Aktion kann einen befristeten [Modifikator](#modifikator) erzeugen — so hängen die beiden
Begriffe zusammen, ohne dasselbe zu sein. Begriffe zusammen, ohne dasselbe zu sein.
**Zehn** sind gebaut: `damage`, `change_attribute`, `apply_modifier`, `remove_modifier`, `flee`,
`open_shop`, `give_exp`, `start_quest`, `give_item`, `equip_item`. Offen bleiben `remove_item` und
`run_script`.
Der Katalog trägt **keine Ordnungszahlen** mehr. Vier Tickets haben ihn erweitert und dabei jeweils vom
eigenen Stand aus gezählt — „`start_quest` als siebte", „`flee` als achte", „`open_shop` als elfte" —,
und diese Zahlen widersprechen sich inzwischen gegenseitig. Die Liste hier ist die Wahrheit.
## Command ## Command
Ein Kommando der Oberfläche an das Spiel — „geh nach Norden", „leg das an", „kauf drei Tränke". Ein Kommando der Oberfläche an das Spiel — „geh nach Norden", „leg das an", „kauf drei Tränke".
@@ -191,126 +157,9 @@ auf welcher Seite er steht.
## Zug ## Zug
Die einzige Zeiteinheit des Spiels. Ein Feld weitergehen ist ein Zug, eine Kampfaktion ist ein Zug. Die einzige Zeiteinheit des Spiels. Ein Feld weitergehen ist ein Zug, eine Kampfaktion ist ein Zug.
Ein **abgewiesenes** [Command](#command) kostet dagegen keinen — gegen eine Wand zu laufen verbraucht
keine Zeit.
Es gibt keine getrennte Kampfrunden-Uhr: Befristete [Modifikatoren](#modifikator) ticken auch beim 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. 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
stehen als Zeichen darin, dazu Plätze für Gegner, Beute, Treppe, Eingang und NPCs.
Ein Gegner-Platz sagt „hier steht ein Gegner", **nicht welcher** — das entscheidet die [Etage](#etage).
Nur so lässt sich derselbe Raum auf Etage 1 und Etage 5 verwenden. Bei NPCs ist es umgekehrt: Die nennt
das Prefab selbst, denn ein Schmied ist auf jeder Etage derselbe Schmied.
Türen sind ausdrücklich markiert, damit der Generator keine Löcher in eine gezeichnete Wand stanzt.
Prefabs dürfen gedreht und gespiegelt werden.
## Etage
Eine Ebene des Dungeons und zugleich ihr Bauplan: Fläche, Raumzahl, Pflichträume, gewichtete
[Prefab](#prefab)- und [Wesen](#wesen)-Auswahl, Beutetabelle. Ausgewählt wird über [Tags](#tag), nie
über IDs — sonst könnte ein Mod keinen eigenen Eingangsraum beisteuern.
Es existiert immer nur **eine** Etage; beim Abstieg wird die vorige verworfen, es gibt kein
Zurückgehen. Gibt es keine Etage mit der nächsten Nummer, ist der Lauf **gewonnen** — die Zahl der
Etagen steht nirgends im Code.
## Erfahrung
Der Fortschrittszähler des Helden. Quellen sind **Tötungen** (Feld `exp_reward` am
[Wesen](#wesen)) und **Quests** (Aktion `give_exp`) — nicht das Betreten einer [Etage](#etage), denn
das belohnte das Durchrennen.
Ein Aufstieg gibt **genau einen** frei verteilbaren Punkt auf einen der fünf Grundwerte. Es gibt
**keine Obergrenze**: Die Kurve begrenzt sich über steigende Kosten. Erfahrung hat nur der Held —
Gegner tragen zwar ein Level, aber keinen Fortschritt.
## Initiative
Ein Zahlenwert am [Wesen](#wesen), einmal beim Betreten eines Kampfes aus `dexterity` plus Wurf
gewürfelt. Er bestimmt die Reihenfolge innerhalb eines [Zuges](#zug); bei Gleichstand handelt der Held
zuerst.
Initiative ist bewusst eine Zahl *am Wesen* und keine Reihenfolgeliste — eine Liste wäre eine zweite
Sammlung von Verweisen, die beim Tod eines Gegners ungültig würde.
## Speicherstand
Der serialisierte Spielzustand, TOML, **genau einer**. Er wird beim Betreten einer [Etage](#etage) und
beim Beenden automatisch geschrieben; von Hand speichern kann man nicht, und bei Tod wird er
**gelöscht** — der Lauf ist dann vorbei.
Das Etagen-Layout steht als Text darin, nicht als Generator-Seed: Ein Mod-Update würde sonst ein
anderes Layout erzeugen und den Helden in eine Wand stellen.
Fehlt beim Laden ein [Mod](#mod), wird der betroffene Eintrag verworfen und gewarnt — nie der ganze
Speicherstand abgelehnt.
## Loot-Tabelle
Eine gewichtete Liste, aus der gezogen wird. Ein Eintrag ist ein [Gegenstand](#gegenstand), ein
[Affix](#affix), eine andere Tabelle oder eine **Niete** — dieselbe Maschinerie zieht damit Beute
*und* Affixe.
Gewichtet wird mit **Gewichten, nicht mit Prozenten** — Prozente müssten sich auf 100 summieren, und
ein Eintrag, den ein [Mod](#mod) hinzufügt, machte die Summe kaputt.
Es gibt **keinen Seltenheitsbegriff**: Abstufungen sind eigene Einträge mit kleinerem Gewicht
(`core:flaming` neben `core:greater_flaming`).
## Registry
Eine benannte Sammlung moddbarer Definitionen, geschlüsselt nach namespaced ID. Es gibt **fünfzehn**,
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 |
|---|---|
| [Gegenstände](#gegenstand) | `[[items]]` |
| [Slots](#slot) | `[[slots]]` |
| [Charakteroption-Kategorien](#charakteroption) | `[[categories]]` |
| [Charakteroptionen](#charakteroption) | `[[options]]` |
| [Fähigkeiten](#fähigkeit) | `[[abilities]]` |
| [Affixe](#affix) | `[[affixes]]` |
| [Quests](#quest) | `[[quests]]` |
| [Wesen](#wesen) | `[[creatures]]` |
| [Prefabs](#prefab) | `[[prefabs]]` |
| [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.
Die Einstellungstabelle `[balance]` ist **keine** Registry: ein einzelner Block mit festem Schema, der
feldweise verschmilzt, statt als ganzer Eintrag ersetzt zu werden.
`[[scripts]]` gehört zu Mod-Stufe (c) und ist entschieden, aber noch nicht gebaut — Scripts lesen über
neun Funktionen und schreiben ausschließlich über [Aktionen](#aktion), damit die Engine austauschbar
bleibt.
## Mod ## Mod
Ein Ordner mit einer `manifest.toml` und beliebig vielen weiteren TOML-Dateien. Das Manifest gibt dem Ein Ordner mit einer `manifest.toml` und beliebig vielen weiteren TOML-Dateien. Das Manifest gibt dem

59
Cargo.lock generated
View File

@@ -291,27 +291,6 @@ dependencies = [
"crypto-common", "crypto-common",
] ]
[[package]]
name = "directories"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d"
dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs-sys"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys",
]
[[package]] [[package]]
name = "document-features" name = "document-features"
version = "0.2.12" version = "0.2.12"
@@ -431,17 +410,6 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.3.4" version = "0.3.4"
@@ -598,15 +566,6 @@ version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libredox"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "line-clipping" name = "line-clipping"
version = "0.3.8" version = "0.3.8"
@@ -765,12 +724,6 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]] [[package]]
name = "ordered-float" name = "ordered-float"
version = "4.6.0" version = "4.6.0"
@@ -1103,17 +1056,6 @@ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.1",
] ]
[[package]]
name = "redox_users"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror 2.0.20",
]
[[package]] [[package]]
name = "regex" name = "regex"
version = "1.13.1" version = "1.13.1"
@@ -1169,7 +1111,6 @@ dependencies = [
name = "rustkeep" name = "rustkeep"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"directories",
"ratatui", "ratatui",
"serde", "serde",
"toml", "toml",

View File

@@ -6,6 +6,8 @@ edition = "2024"
[dependencies] [dependencies]
serde = { version = "1.0.229", features = ["derive"] } serde = { version = "1.0.229", features = ["derive"] }
toml = "1.1.4" toml = "1.1.4"
# crossterm kommt als Re-Export mit (Ticket 06/12 — kein eigener Eintrag noetig)
# Nur fuer examples/durchstich.rs (Ticket 12, Wegwerf-Prototyp).
# Wandert nach [dependencies], sobald der Durchstich als tragfaehig gilt.
[dev-dependencies]
ratatui = "0.30.2" ratatui = "0.30.2"
directories = "6.0.0"

373
examples/durchstich.rs Normal file
View File

@@ -0,0 +1,373 @@
//! # PROTOTYP — Wegwerfcode (Ticket 12)
//!
//! Beantwortet genau eine Frage: **Traegt das Screen-Modell aus Ticket 11?**
//! Start mit `cargo run --example durchstich`.
//!
//! Geprueft werden fuenf Zusagen aus 11 und dem Bericht aus 06:
//!
//! 1. `Vec<Screen>` fuer Screens *und* Popups, Overlay per Flag
//! 2. Screens geben `Response` zurueck, statt den Stack selbst zu mutieren
//! 3. `Response::Command` schliesst Overlays automatisch
//! 4. Dungeon-Grid als eigenes `impl Widget` mit direktem Buffer-Zugriff
//! 5. `mod game` bleibt ratatui-frei (in dieser Datei sichtbar: kein `use ratatui` darin)
//!
//! Der Zaehler oben rechts macht den Windows-Fallstrick aus 06 sichtbar: Wenn
//! ein Tastendruck zwei Zuege erzeugt, laufen "Events" und "Zuege" auseinander.
//!
//! Bewusst nicht drin: Kampf, Inventar, Shop, Speichern, Mods, Registries.
// ============================================================================
// mod game — KENNT RATATUI NICHT. Das ist die halbe Frage dieses Prototyps.
// ============================================================================
mod game {
#[derive(Clone, Copy)]
pub enum Direction {
North,
South,
West,
East,
}
/// Ticket 11: Kommando der UI ans Spiel. Nicht zu verwechseln mit `Action`
/// (dem TOML-Effektvokabular aus Ticket 01), das hier gar nicht vorkommt.
pub enum Command {
Move(Direction),
Wait,
}
/// BEFUND aus dem Durchstich: Ticket 11 sagte "alle Commands kosten einen
/// Zug". Richtig ist "alle *ausgefuehrten*". Gegen eine Wand zu laufen darf
/// keine Zeit verbrauchen — sonst tickt ein Gift-Debuff, weil jemand
/// danebengetippt hat. Der Grund muss mit zurueck, sonst steht der Spieler
/// vor einem Spiel, das schweigend nichts tut.
pub enum Outcome {
TurnPassed,
Rejected(&'static str),
}
pub struct Game {
pub map: Vec<&'static str>,
pub player: (u16, u16),
pub turn: u64,
/// Zaehlt Zuege getrennt von Tasten-Events — siehe Modul-Doku.
pub moves_executed: u64,
pub last: String,
}
impl Game {
pub fn new() -> Self {
// ponytail: feste Karte als &str-Zeilen. Ticket 10 entscheidet das
// echte Prefab-Format; hier braucht es nur etwas zum Dagegenlaufen.
let map = vec![
"########################",
"#......#........#......#",
"#......#........#......#",
"#..##..#..####..#..##..#",
"#..##.....#..#.....##..#",
"#......#..#..#..#......#",
"####...#..####..#...####",
"#......#........#......#",
"#..#####........#####..#",
"#......................#",
"########################",
];
Self {
map,
player: (2, 1),
turn: 0,
moves_executed: 0,
last: String::from("—"),
}
}
pub fn tile(&self, x: u16, y: u16) -> char {
self.map
.get(y as usize)
.and_then(|row| row.chars().nth(x as usize))
.unwrap_or('#')
}
/// Ticket 11: der **eine** Eingang, an dem ein Zug endet.
pub fn execute(&mut self, cmd: Command) -> Outcome {
let outcome = match cmd {
Command::Move(dir) => {
let (x, y) = self.player;
let (nx, ny) = match dir {
Direction::North => (x, y.saturating_sub(1)),
Direction::South => (x, y + 1),
Direction::West => (x.saturating_sub(1), y),
Direction::East => (x + 1, y),
};
if self.tile(nx, ny) == '#' {
Outcome::Rejected("Da ist eine Wand")
} else {
self.player = (nx, ny);
self.last = format!("nach {},{}", nx, ny);
Outcome::TurnPassed
}
}
Command::Wait => {
self.last = String::from("gewartet");
Outcome::TurnPassed
}
};
// Ticket 01: eine Uhr, der Zug. Hier endet er — aber nur, wenn
// wirklich etwas geschehen ist. TODO: befristete Modifikatoren
// ticken lassen und die Event-Warteschlange aus Ticket 08
// leerarbeiten — beides gehoert genau hierhin.
if let Outcome::TurnPassed = outcome {
self.turn += 1;
self.moves_executed += 1;
}
outcome
}
}
}
// ============================================================================
// mod ui — ab hier darf ratatui vorkommen
// ============================================================================
use game::{Command, Direction, Game, Outcome};
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Style, Stylize};
use ratatui::text::Line;
use ratatui::widgets::{Block, Clear, Paragraph, Widget};
use ratatui::{Frame, buffer::Buffer};
/// Ticket 11: Popups sind gewoehnliche Screens, die sich als Overlay ausweisen.
enum Screen {
MainMenu,
Explore,
Confirm { text: String, on_yes: Command },
}
/// Ticket 11: Der Screen schiebt nicht selbst — er antwortet, die Schleife fuehrt aus.
enum Response {
None,
Pop,
Push(Screen),
Command(Command),
Quit,
}
impl Screen {
fn is_overlay(&self) -> bool {
matches!(self, Screen::Confirm { .. })
}
fn handle_key(&mut self, key: KeyCode) -> Response {
match self {
Screen::MainMenu => match key {
KeyCode::Enter => Response::Push(Screen::Explore),
KeyCode::Char('q') | KeyCode::Esc => Response::Quit,
_ => Response::None,
},
Screen::Explore => match key {
KeyCode::Up | KeyCode::Char('k') => Response::Command(Command::Move(Direction::North)),
KeyCode::Down | KeyCode::Char('j') => Response::Command(Command::Move(Direction::South)),
KeyCode::Left | KeyCode::Char('h') => Response::Command(Command::Move(Direction::West)),
KeyCode::Right | KeyCode::Char('l') => Response::Command(Command::Move(Direction::East)),
KeyCode::Char('.') => Response::Command(Command::Wait),
// Prueft Zusage 3: Der Confirm traegt den Command mit sich.
KeyCode::Char('x') => Response::Push(Screen::Confirm {
text: String::from("Einen Zug verstreichen lassen?"),
on_yes: Command::Wait,
}),
KeyCode::Esc => Response::Pop,
_ => Response::None,
},
Screen::Confirm { on_yes, .. } => match key {
KeyCode::Char('j') | KeyCode::Char('y') | KeyCode::Enter => {
// `on_yes` herauszunehmen erzwingt ein Ersetzen — Command ist
// nicht Copy. Im echten Code wird das ein `Clone`.
let cmd = std::mem::replace(on_yes, Command::Wait);
Response::Command(cmd)
}
KeyCode::Char('n') | KeyCode::Esc => Response::Pop,
_ => Response::None,
},
}
}
}
/// Zusage 4: Es gibt kein Grid-Widget, also Eigenbau mit direktem Buffer-Zugriff.
struct GridWidget<'a> {
game: &'a Game,
}
impl Widget for GridWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
for (y, row) in self.game.map.iter().enumerate() {
for (x, ch) in row.chars().enumerate() {
let (px, py) = (area.x + x as u16, area.y + y as u16);
if px >= area.right() || py >= area.bottom() {
continue;
}
let (symbol, color) = match ch {
'#' => ("█", Color::DarkGray),
_ => ("·", Color::Rgb(60, 60, 70)),
};
buf[(px, py)].set_symbol(symbol).set_style(Style::new().fg(color));
}
}
let (px, py) = self.game.player;
let (sx, sy) = (area.x + px, area.y + py);
if sx < area.right() && sy < area.bottom() {
buf[(sx, sy)]
.set_symbol("@")
.set_style(Style::new().fg(Color::Yellow).bold());
}
}
}
struct App {
game: Game,
screens: Vec<Screen>,
/// Instrumentierung: `total` zaehlt jedes `Event::Key`, `press` nur die
/// gefilterten. Unter Windows laufen die beiden auseinander — genau das
/// belegt, dass der Filter aus Ticket 11 kein Aberglaube ist.
key_events_total: u64,
key_events_press: u64,
/// Grund des letzten abgewiesenen Kommandos, sonst `None`.
rejected: Option<&'static str>,
}
fn main() -> std::io::Result<()> {
let mut app = App {
game: Game::new(),
screens: vec![Screen::MainMenu],
key_events_total: 0,
key_events_press: 0,
rejected: None,
};
ratatui::run(|terminal| {
loop {
terminal.draw(|frame| draw(frame, &app))?;
let Event::Key(key) = event::read()? else {
continue;
};
app.key_events_total += 1;
// Ticket 11 / Bericht 06: Pflichtfilter unter Windows.
// Zum Gegentest diese drei Zeilen auskommentieren — dann laufen
// die Zaehler in der Statuszeile auseinander.
if key.kind != KeyEventKind::Press {
continue;
}
app.key_events_press += 1;
let Some(screen) = app.screens.last_mut() else {
break Ok(());
};
match screen.handle_key(key.code) {
Response::None => {}
Response::Pop => {
app.screens.pop();
if app.screens.is_empty() {
break Ok(());
}
}
Response::Push(s) => app.screens.push(s),
Response::Command(cmd) => {
app.rejected = match app.game.execute(cmd) {
Outcome::TurnPassed => None,
Outcome::Rejected(reason) => Some(reason),
};
// Zusage 3: Ein Command schliesst alle Overlays — eine Zeile,
// keine Sonderbehandlung je Screen. Auch bei Rejected: die
// Rueckfrage *wurde* beantwortet, sie darf nicht kleben
// bleiben, nur weil das Kommando ins Leere lief.
while app.screens.last().is_some_and(Screen::is_overlay) {
app.screens.pop();
}
}
Response::Quit => break Ok(()),
}
}
})
}
fn draw(frame: &mut Frame, app: &App) {
let area = frame.area();
// Zusage 1: von unten nach oben zeichnen, ab dem letzten Vollbild-Screen.
let first = app
.screens
.iter()
.rposition(|s| !s.is_overlay())
.unwrap_or(0);
for screen in &app.screens[first..] {
match screen {
Screen::MainMenu => {
let text = vec![
Line::from("rustkeep — Durchstich (Ticket 12)".bold()),
Line::from(""),
Line::from("Enter Dungeon betreten"),
Line::from("q beenden"),
];
frame.render_widget(
Paragraph::new(text).block(Block::bordered().title(" Hauptmenü ")),
area,
);
}
Screen::Explore => {
let block = Block::bordered().title(" Etage 1 ");
let inner = block.inner(area);
frame.render_widget(block, area);
// Statuszeile unter das Grid, sonst liegt sie auf der Nordwand.
let [grid, bar] =
Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(inner);
frame.render_widget(GridWidget { game: &app.game }, grid);
frame.render_widget(status(app), bar);
}
Screen::Confirm { text, .. } => {
let popup = centered(area, 44, 5);
// Bericht 06: kein Widget-Stack, sondern `Clear` auf ein Sub-Rect.
frame.render_widget(Clear, popup);
frame.render_widget(
Paragraph::new(vec![Line::from(text.as_str()), Line::from(""), Line::from("j / n")])
.block(Block::bordered().title(" Rückfrage ")),
popup,
);
}
}
}
}
/// Regel 5 des Prototyp-Skills: Zustand nach jeder Aktion sichtbar machen.
fn status(app: &App) -> Paragraph<'static> {
let (x, y) = app.game.player;
let (note, color) = match app.rejected {
Some(reason) => (reason.to_string(), Color::Red),
None => (app.game.last.clone(), Color::Cyan),
};
Paragraph::new(format!(
"Zug {} │ Pos {},{} │ Stack {} │ Tasten {} → Press {} → Züge {} │ {}",
app.game.turn,
x,
y,
app.screens.len(),
app.key_events_total,
app.key_events_press,
app.game.moves_executed,
note,
))
.right_aligned()
.style(Style::new().fg(color))
}
fn centered(area: Rect, w: u16, h: u16) -> Rect {
Rect {
x: area.x + (area.width.saturating_sub(w)) / 2,
y: area.y + (area.height.saturating_sub(h)) / 2,
width: w.min(area.width),
height: h.min(area.height),
}
}

View File

@@ -1,77 +0,0 @@
# Eine Faehigkeit ist ein benanntes, wiederholt ausloesbares ANGEBOT an den
# Spieler, das Aktionen ausfuehrt (Ticket 02) — kein Gegenstand im Inventar,
# sonst koennte ein Magier seine Zauber verkaufen.
#
# Angriff und Flucht sind bewusst keine Engine-Sonderfaelle, sondern gewoehnliche
# Faehigkeiten (Ticket 11/14). Ein Mod kann beide ersetzen.
#
# Menge = amount + scale * damage-Attribut(Verursacher) — eine Formel fuer
# Basisangriff, Feuerball und Wuchtschlag (Nachtrag aus Ticket 14).
[[abilities]]
id = "attack"
name = "Angriff"
cost = 0
actions = [ { action = "damage", scale = 1.0, damage_type = "slash", target = "target" } ]
[[abilities]]
id = "flee"
name = "Fliehen"
cost = 10
actions = [ { action = "flee" } ]
[[abilities]]
id = "power_strike"
name = "Wuchtschlag"
description = "Holt weit aus. Kostet Luft."
cost = 15
cooldown = 3
actions = [ { action = "damage", scale = 1.5, damage_type = "blunt", target = "target" } ]
[[abilities]]
id = "bone_throw"
name = "Knochenwurf"
cost = 8
cooldown = 2
actions = [ { action = "damage", amount = 6, damage_type = "blunt", target = "target" } ]
[[abilities]]
id = "rat_bite"
name = "Biss"
cost = 0
actions = [ { action = "damage", scale = 1.0, damage_type = "slash", target = "target" } ]
[[abilities]]
id = "ghoul_claw"
name = "Klauenhieb"
cost = 0
actions = [ { action = "damage", scale = 1.2, damage_type = "slash", target = "target" } ]
[[abilities]]
id = "second_wind"
name = "Zweiter Atem"
description = "Sammelt sich, wenn es eng wird."
cost = 0
cooldown = 8
condition = [ { check = "health_below", who = "self", value = 0.4 } ]
actions = [ { action = "change_attribute", attribute = "health", amount = 25, target = "self" } ]
# Die Haelfte einer Charakteroption, die NICHT veraltet (Ticket 15): "+3 Staerke"
# ist auf Stufe 30 Rauschen, ein Wuchtschlag nicht.
[[abilities]]
id = "backstab"
name = "Hinterhalt"
description = "Trifft, wo es wehtut. Nur bei vollem Atem."
cost = 12
cooldown = 4
condition = [ { check = "attribute", who = "self", attribute = "dexterity", at_least = 2 } ]
actions = [ { action = "damage", scale = 2.0, damage_type = "slash", target = "target" } ]
[[abilities]]
id = "steinsinn"
name = "Steinsinn"
description = "Der Zwerg horcht ins Gestein und sammelt sich."
cost = 0
cooldown = 6
actions = [ { action = "change_attribute", attribute = "stamina", amount = 30, target = "self" } ]

View File

@@ -1,46 +0,0 @@
# Affixe (Ticket 07/16): eine benannte Modifikatorliste, die an einem EINZELNEN
# Exemplar haengt. Strukturell dasselbe wie eine Charakteroption — der
# Unterschied ist, woran sie haengt.
#
# Abstufungen sind EIGENE EINTRAEGE, keine Zahl am Exemplar: Eine Staerke je
# Exemplar haette Save-Format (ID-Liste), Preisformel und Stapelregel auf einmal
# gekostet. Mit Stufen kann der Modder seine Abstufungen benennen.
[[affixes]]
id = "sharp"
name = "Scharfes"
price_factor = 1.3
modifiers = [ { attribute = "damage", flat = 2 } ]
[[affixes]]
id = "flaming"
name = "Flammendes"
price_factor = 1.6
modifiers = [ { attribute = "damage", flat = 3 } ]
triggers = [
{ on = "attack_hit", chance = 0.25,
actions = [ { action = "damage", amount = 4, damage_type = "fire", target = "target" } ] },
]
[[affixes]]
id = "greater_flaming"
name = "Stark flammendes"
price_factor = 2.4
modifiers = [ { attribute = "damage", flat = 6 } ]
triggers = [
{ on = "attack_hit", chance = 0.4,
actions = [ { action = "damage", amount = 9, damage_type = "fire", target = "target" } ] },
]
[[affixes]]
id = "sturdy"
name = "Robuste"
price_factor = 1.4
modifiers = [ { attribute = "max_health", flat = 12 } ]
[[affixes]]
id = "warding"
name = "Schirmende"
price_factor = 1.8
modifiers = [ { attribute = "resistance", damage_type = "fire", flat = 20 },
{ attribute = "resistance", damage_type = "ice", flat = 20 } ]

View File

@@ -1,20 +0,0 @@
# Die Stellknoepfe aus Ticket 14 und 16.
#
# `[balance]` ist KEINE Registry: ein einzelner Block mit festem Schema, der
# FELDWEISE verschmilzt statt als ganzer Eintrag ersetzt zu werden — die
# ausdrueckliche Ausnahme zur Ganz-oder-gar-nicht-Regel aus Ticket 03.
#
# Die Begruendung bleibt eng: Das Schema ist der Engine bekannt. Genau das
# fehlte bei Inhalt mit offenen Feldern, und das war Ticket 03s Argument gegen
# feldweises Patchen.
#
# Ein Mod, der einen Knopf dreht, schreibt EINE Zeile. Standardwerte liegen in
# der Engine — was hier fehlt, gilt trotzdem.
[balance]
ausweich = 0.02 # wie stark dexterity des Ziels die Trefferchance senkt
flucht = 0.05 # wie stark der Geschicklichkeitsunterschied die Flucht traegt
stamina_regen = 4.0 # pro Zug — gibt `warten` seinen Zweck
resistance_cap = 0.8 # 1.0 waere Immunitaet, und das ist ein Spielabbruch
hit_floor = 0.05
hit_ceiling = 0.95

View File

@@ -1,61 +0,0 @@
# Rasse und Klasse sind Charakteroptionen, keine eigenen Konzepte (Ticket 02).
# Die Kategorien sind selbst Inhalt und tragen `pick = N`, damit ein Mod eine
# dritte Wahl ergaenzen kann, ohne dass die Engine davon weiss.
[[categories]]
id = "race"
name = "Rasse"
order = 10
pick = 1
[[categories]]
id = "class"
name = "Klasse"
order = 20
pick = 1
[[options]]
id = "dwarf"
category = "core:race"
name = "Zwerg"
description = "Zäh, misstrauisch, trinkfest."
modifiers = [ { attribute = "constitution", flat = 2 },
{ attribute = "max_health", flat = 10 } ]
abilities = ["core:steinsinn"]
[[options]]
id = "elf"
category = "core:race"
name = "Elf"
description = "Flink, hochmütig, langlebig."
modifiers = [ { attribute = "dexterity", flat = 2 },
{ attribute = "luck", flat = 1 } ]
[[options]]
id = "warrior"
category = "core:class"
name = "Krieger"
description = "Schlägt zu, wo andere nachdenken."
modifiers = [ { attribute = "strength", flat = 3 },
{ attribute = "damage", percent = 10 } ]
abilities = ["core:power_strike"]
# `on_create` ist KEINE neue Mechanik, sondern gewoehnliche Aktionen, einmal
# beim Bau ausgefuehrt (Ticket 02). Die Engine kennt den Begriff
# "Startausruestung" deshalb gar nicht.
on_create = [ { action = "equip_item", item = "core:iron_sword" },
{ action = "give_item", item = "core:health_potion", count = 2 } ]
[[options]]
id = "thief"
category = "core:class"
name = "Dieb"
description = "Nimmt, was nicht niet- und nagelfest ist."
modifiers = [ { attribute = "dexterity", flat = 2 },
{ attribute = "luck", flat = 2 },
# `flat`, nicht `percent`: Die Basis war 0, und Prozent auf 0
# bleibt 0 — die Signaturzahl des Diebs tat nichts (Befund M9).
{ attribute = "critical_chance", flat = 10 } ]
abilities = ["core:backstab"]
on_create = [ { action = "equip_item", item = "core:dagger" },
{ action = "equip_item", item = "core:leather_cap" },
{ action = "give_item", item = "core:gold", count = 40 } ]

View File

@@ -1,72 +0,0 @@
# Gegner. Die Luecke lag seit Ticket 02 offen — `Creature` war der Laufzeittyp,
# woher seine Werte kommen, hat nie jemand gesagt (Ticket 14).
#
# `ai` ist eine GEORDNETE Liste: der erste passende UND verfuegbare Eintrag
# gewinnt. Ein Eintrag ohne `condition` greift immer und gehoert ans Ende —
# fehlt er, setzt der Gegner gelegentlich aus.
[[creatures]]
id = "rat"
name = "Riesenratte"
symbol = "r"
color = "yellow"
tags = ["core:beast"]
base = { max_health = 14, damage = 3, hit_chance = 0.65, dexterity = 4, critical_chance = 5 }
abilities = ["core:rat_bite"]
ai = [ { ability = "core:rat_bite" } ]
# Das Gift haengt am TRIGGER, nicht in der Aktionsliste des Bisses: Aktionen
# haben kein `chance`, Trigger schon (Ticket 08). In der Faehigkeit stand es
# ohne Wahrscheinlichkeit — jeder Biss vergiftete, und weil ein erneutes
# Auflegen die Dauer auffrischt, war der Held ab dem ersten Treffer dauerhaft
# vergiftet (Befund M9).
triggers = [
{ on = "attack_hit", chance = 0.25,
actions = [ { action = "apply_modifier", modifier = "core:poisoned", target = "target" } ] },
]
exp_reward = 4
[[creatures]]
id = "skeleton"
name = "Skelett"
symbol = "s"
color = "white"
tags = ["core:undead"]
base = { max_health = 26, damage = 6, hit_chance = 0.7, dexterity = 3, critical_chance = 5 }
abilities = ["core:attack", "core:bone_throw"]
ai = [
{ condition = [ { check = "health_below", who = "self", value = 0.35 } ],
ability = "core:bone_throw" },
{ ability = "core:attack" },
]
exp_reward = 9
[[creatures]]
id = "ghoul"
name = "Ghul"
symbol = "G"
color = "magenta"
tags = ["core:undead"]
base = { max_health = 40, damage = 9, hit_chance = 0.72, dexterity = 5, critical_chance = 10 }
abilities = ["core:ghoul_claw", "core:second_wind"]
ai = [
{ condition = [ { check = "health_below", who = "self", value = 0.4 } ],
ability = "core:second_wind" },
{ ability = "core:ghoul_claw" },
]
exp_reward = 18
[[creatures]]
id = "bone_lord"
name = "Knochenfürst"
symbol = "K"
color = "red"
tags = ["core:undead", "core:boss"]
base = { max_health = 90, damage = 14, hit_chance = 0.78, dexterity = 6, critical_chance = 12, critical_factor = 2.0 }
abilities = ["core:attack", "core:power_strike", "core:second_wind"]
ai = [
{ condition = [ { check = "health_below", who = "self", value = 0.3 } ],
ability = "core:second_wind" },
{ ability = "core:power_strike" },
{ ability = "core:attack" },
]
exp_reward = 60

View File

@@ -1,63 +0,0 @@
# Etagen sind Registry-Eintraege (Ticket 10). Ausgewaehlt wird durchgehend
# ueber TAGS, nie ueber IDs — sonst kann ein Mod keinen eigenen Eingangsraum
# beisteuern, ohne die ganze Tabelle zu ueberschreiben.
#
# `core:merchant` steht unter `required`, nicht unter `prefabs`: Ticket 07 hat
# Backtracking ausgeschlossen, der Haendler muss also garantiert auftauchen.
#
# Es gibt keine Zahl "5" im Code — findet die Engine keine Etage mit der
# naechsten Nummer, ist der Lauf gewonnen.
[[floors]]
id = "floor_1"
number = 1
size = [72, 34]
rooms = 7
required = ["core:entrance", "core:stairs", "core:merchant"]
prefabs = [ { tag = "core:normal", weight = 10 } ]
creatures = [ { id = "core:rat", weight = 10 } ]
loot = "core:floor_1_table"
[[floors]]
id = "floor_2"
number = 2
size = [72, 34]
rooms = 8
required = ["core:entrance", "core:stairs", "core:merchant"]
prefabs = [ { tag = "core:normal", weight = 10 },
{ tag = "core:treasure", weight = 2 } ]
creatures = [ { id = "core:rat", weight = 6 }, { id = "core:skeleton", weight = 6 } ]
loot = "core:floor_1_table"
[[floors]]
id = "floor_3"
number = 3
size = [80, 38]
rooms = 9
required = ["core:entrance", "core:stairs", "core:merchant"]
prefabs = [ { tag = "core:normal", weight = 10 },
{ tag = "core:treasure", weight = 3 } ]
creatures = [ { id = "core:rat", weight = 3 }, { id = "core:skeleton", weight = 8 }, { id = "core:ghoul", weight = 3 } ]
loot = "core:floor_3_table"
[[floors]]
id = "floor_4"
number = 4
size = [80, 38]
rooms = 10
required = ["core:entrance", "core:stairs", "core:merchant"]
prefabs = [ { tag = "core:normal", weight = 10 },
{ tag = "core:treasure", weight = 3 } ]
creatures = [ { id = "core:skeleton", weight = 6 }, { id = "core:ghoul", weight = 8 } ]
loot = "core:floor_3_table"
[[floors]]
id = "floor_5"
number = 5
size = [88, 40]
rooms = 11
required = ["core:entrance", "core:stairs", "core:merchant"]
prefabs = [ { tag = "core:normal", weight = 10 },
{ tag = "core:treasure", weight = 4 } ]
creatures = [ { id = "core:ghoul", weight = 8 }, { id = "core:bone_lord", weight = 2 } ]
loot = "core:floor_5_table"

View File

@@ -1,76 +0,0 @@
# Es gibt keinen Item-Typ (Ticket 02). Ein Schwert ist ein Gegenstand mit einem
# Modifikator auf `damage`, ein Helm einer mit einem Modifikator auf Rüstung —
# der Unterschied steht in den Modifikatoren, nicht in einer Kategorie.
[[items]]
id = "iron_sword"
name = "Eisenschwert"
base_price = 45
slot = "core:hand"
symbol = "/"
color = "white"
modifiers = [ { attribute = "damage", flat = 5 } ]
[[items]]
id = "dagger"
name = "Dolch"
base_price = 28
slot = "core:hand"
symbol = "/"
color = "white"
modifiers = [ { attribute = "damage", flat = 3 },
{ attribute = "critical_chance", flat = 5 } ]
[[items]]
id = "leather_cap"
name = "Lederkappe"
base_price = 18
slot = "core:head"
symbol = "^"
color = "yellow"
modifiers = [ { attribute = "max_health", flat = 8 } ]
[[items]]
id = "health_potion"
name = "Heiltrank"
base_price = 12
stackable = true
consumed_on_use = true
symbol = "!"
color = "red"
actions = [ { action = "change_attribute", attribute = "health", amount = 25, target = "self" } ]
[[items]]
id = "gold"
name = "Gold"
base_price = 1
stackable = true
symbol = "$"
color = "yellow"
# Belegt den `when`-Filter in Daten: Der Panzer reflektiert NUR Feuer.
# Zwei Schadenstypen braeuchten zwei Trigger — ODER ist im Vokabular
# ausdruecklich nicht vorgesehen (Ticket 08).
[[items]]
id = "thorn_mail"
name = "Dornenpanzer"
base_price = 90
slot = "core:body"
symbol = "["
color = "red"
modifiers = [ { attribute = "resistance", damage_type = "fire", flat = 25 } ]
triggers = [
{ on = "damage_taken",
when = { damage_type = "fire" },
actions = [ { action = "damage", amount = 3, damage_type = "fire", target = "target" } ] },
]
[[items]]
id = "bone_blade"
name = "Knochenklinge"
base_price = 210
slot = "core:hand"
symbol = "/"
color = "magenta"
modifiers = [ { attribute = "damage", flat = 11 },
{ attribute = "critical_chance", flat = 8 } ]

View File

@@ -1,103 +0,0 @@
# Beutetabellen (Ticket 16). EINE Maschinerie fuer Gegenstaende und Affixe:
# dieselben Gewichte, dieselbe Tabelle-in-Tabelle, dieselben Nieten.
#
# Gewichte statt Prozent — Prozente muessten sich auf 100 summieren, und ein
# Eintrag, den ein Mod hinzufuegt, macht die Summe kaputt.
#
# Der MOD-EINHAENGEPUNKT ist Tabelle-in-Tabelle: Ein Mod legt seine eigene
# Tabelle an, bindet `core:floor_3_table` als Untertabelle ein und haengt die
# Etage um. Zwei gewoehnliche Ueberschreibungen, kein zweiter Mechanismus.
# --- Affixe ---------------------------------------------------------------
# Abstufungen sind EIGENE Eintraege mit kleinerem Gewicht, keine Zahl am
# Exemplar. Deshalb erhoeht ein zusaetzlicher Zug auch die Chance auf die
# staerkere Stufe.
[[loot_tables]]
id = "weapon_affixes"
rolls = "0-1"
entries = [
{ affix = "core:sharp", weight = 6 },
{ affix = "core:flaming", weight = 3 },
{ affix = "core:greater_flaming", weight = 1 },
{ nothing = true, weight = 14 },
]
[[loot_tables]]
id = "armor_affixes"
rolls = "0-1"
entries = [
{ affix = "core:sturdy", weight = 5 },
{ affix = "core:warding", weight = 2 },
{ nothing = true, weight = 16 },
]
# --- Etagen ---------------------------------------------------------------
[[loot_tables]]
id = "floor_1_table"
rolls = "1-2"
entries = [
{ item = "core:health_potion", weight = 12, count = "1-2" },
{ item = "core:gold", weight = 10, count = "3-9" },
{ item = "core:iron_sword", weight = 2, affixes = "core:weapon_affixes" },
{ item = "core:leather_cap", weight = 2, affixes = "core:armor_affixes" },
{ nothing = true, weight = 8 },
]
[[loot_tables]]
id = "floor_3_table"
rolls = "1-3"
entries = [
{ item = "core:health_potion", weight = 10, count = "1-2" },
{ item = "core:gold", weight = 10, count = "6-18" },
{ item = "core:iron_sword", weight = 3, affixes = "core:weapon_affixes" },
{ item = "core:thorn_mail", weight = 1, affixes = "core:armor_affixes" },
{ table = "core:rare_table", weight = 2 },
{ nothing = true, weight = 6 },
]
[[loot_tables]]
id = "floor_5_table"
rolls = "2-3"
entries = [
{ item = "core:health_potion", weight = 8, count = "2-3" },
{ item = "core:gold", weight = 10, count = "15-40" },
{ item = "core:iron_sword", weight = 4, affixes = "core:weapon_affixes" },
{ item = "core:thorn_mail", weight = 3, affixes = "core:armor_affixes" },
{ table = "core:rare_table", weight = 4 },
]
[[loot_tables]]
id = "rare_table"
rolls = "1-1"
entries = [
{ item = "core:bone_blade", weight = 3, affixes = "core:weapon_affixes" },
{ item = "core:thorn_mail", weight = 4, affixes = "core:armor_affixes" },
]
# `guaranteed` nimmt an der Gewichtung nicht teil und kommt immer — mit
# Gewichten allein laesst sich "sicher" nicht ausdruecken.
[[loot_tables]]
id = "boss_table"
rolls = "1-2"
entries = [
{ item = "core:bone_blade", guaranteed = true, affixes = "core:weapon_affixes" },
{ item = "core:gold", weight = 10, count = "50-120" },
{ item = "core:health_potion", weight = 6, count = "2-4" },
]
# --- 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" },
]

View File

@@ -1,4 +0,0 @@
id = "core"
name = "rustkeep"
version = "0.1.0"
depends = []

View File

@@ -1,43 +0,0 @@
# Benannte, befristete Modifikatoren — die dreizehnte Registry.
#
# Ticket 01 laesst `apply_modifier` einen Modifikator per ID referenzieren
# (`core:poisoned`), aber keine der zwoelf Registries definierte sie. Aufgefallen
# beim Bauen von M3.
#
# Der Schadenstyp `poison` ist NICHT der Modifikator `core:poisoned`: Der Typ
# sagt, wie gemindert wird; der Modifikator ist ein Zustand, der ueber Zuege
# tickt (Ticket 01).
#
# Eine Uhr: der Zug. Debuffs ticken auch beim Erkunden, damit "ich warte den
# Debuff draussen ab" eine Entscheidung mit Kosten bleibt.
[[modifiers]]
id = "poisoned"
name = "Vergiftet"
symbol = "☠"
color = "green"
turns = 4
attributes = [ { attribute = "damage", percent = -15 } ]
triggers = [
{ on = "turn_started",
actions = [ { action = "damage", amount = 2, damage_type = "poison", target = "self" } ] },
]
[[modifiers]]
id = "blessed"
name = "Gesegnet"
symbol = "+"
color = "yellow"
turns = 6
attributes = [ { attribute = "damage", percent = 20 },
{ attribute = "critical_chance", flat = 10 } ]
[[modifiers]]
id = "warded"
name = "Abgeschirmt"
symbol = "o"
color = "cyan"
turns = 5
# Resistenz ist typ-indiziert und der EINZIGE Minderungsmechanismus (Ticket 01).
attributes = [ { attribute = "resistance", damage_type = "fire", flat = 40 },
{ attribute = "resistance", damage_type = "ice", flat = 40 } ]

View File

@@ -1,29 +0,0 @@
# 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?" },
{ text = "Hast du Arbeit für mich?",
actions = [ { action = "start_quest", quest = "core:rattenplage" } ] },
# 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

@@ -1,98 +0,0 @@
# Handgezeichnete Raeume (Ticket 10). Marker stehen IM Block — der Sinn von
# ASCII ist, dass der Modder den Raum sieht.
#
# # Wand e Gegner-Platz > Treppe n NPC-Platz
# . Boden l Beute-Platz < Eingang
# + Tuer (Korridore duerfen nur hier andocken)
#
# Ein `e` sagt "hier steht ein Gegner", NICHT welcher — das entscheidet die
# Etage. Nur so laesst sich derselbe Raum auf Etage 1 und Etage 5 verwenden.
[[prefabs]]
id = "entrance_hall"
tags = ["core:entrance"]
layout = """
#####+#####
#.........#
#....<....#
#.........#
+.........+
#.........#
#####+#####
"""
[[prefabs]]
id = "stair_room"
tags = ["core:stairs"]
layout = """
#######
#.....#
#..>..#
+.....+
#..l..#
#######
"""
[[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 = """
####+####
#.......#
#...n...#
+.......+
#..l.l..#
####+####
"""
[[prefabs]]
id = "crossing"
tags = ["core:normal"]
layout = """
####+####
#.......#
#..e...e#
+.......+
#..l....#
#.......#
####+####
"""
[[prefabs]]
id = "pillars"
tags = ["core:normal"]
layout = """
#####+#####
#.........#
#.##...##.#
+.##.e.##.+
#.........#
#..l...l..#
#####+#####
"""
[[prefabs]]
id = "closet"
tags = ["core:normal"]
layout = """
#####
#...#
+.l.+
#.e.#
#####
"""
[[prefabs]]
id = "vault"
tags = ["core:treasure"]
layout = """
#######
#.....#
#.lll.#
+..e..#
#.....#
#######
"""

View File

@@ -1,34 +0,0 @@
# Quests (Ticket 08). Eine Quest **ist ein Trigger mit `count = N`** — kein
# zweiter Mechanismus, dieselben Felder wie an Gegenstand, Modifikator oder
# Gegner: `on`, `when`, `condition`, `actions`, `chance`.
#
# Gezaehlt wird ERST AB DEM START (`start_quest`). Alle Quests ab dem ersten Zug
# mitlaufen zu lassen waere kuerzer und macht das Spiel kaputt: Eine Kette
# "bring mir fuenf Schwaenze" -> "jetzt in den Keller" waere beim Betreten des
# Kellers schon erfuellt, bevor sie jemand erwaehnt hat.
[[quests]]
id = "rattenplage"
name = "Rattenplage"
description = "Borin zahlt für fünf Riesenratten."
on = "creature_died"
count = 5
condition = [ { check = "has_tag", who = "target", value = "core:beast" } ]
actions = [
{ action = "give_item", item = "core:gold", count = 60 },
{ action = "give_exp", amount = 25 },
# Die Belohnung startet die naechste Quest — DAS ist die Kette, und sie
# braucht keinen eigenen Begriff.
{ action = "start_quest", quest = "core:tiefer_hinab" },
]
# "Besitze N" braucht keine zweite Mechanik, sondern dieselbe Form: ein Ereignis,
# eine Bedingung, count = 1.
[[quests]]
id = "tiefer_hinab"
name = "Tiefer hinab"
description = "Steig bis zur dritten Etage ab."
on = "floor_entered"
count = 1
condition = [ { check = "attribute", who = "self", attribute = "level", at_least = 1 } ]
actions = [ { action = "give_exp", amount = 40 } ]

View File

@@ -1,17 +0,0 @@
# Slots sind Inhalt, kein Aufzaehlungswert — der Charakterbogen rendert die
# vorhandenen, statt ein festes Layout zu mauern (Ticket 02).
[[slots]]
id = "head"
name = "Kopf"
order = 10
[[slots]]
id = "body"
name = "Rumpf"
order = 20
[[slots]]
id = "hand"
name = "Hand"
order = 30

View File

@@ -1,673 +0,0 @@
//! Mod-Ladepipeline (Ticket 03) und Fehlerstrategie (Ticket 04).
//!
//! Drei Phasen: Manifeste → Einlesen → Aufloesen. In Phase 2 wird nichts
//! aufgeloest, dadurch gibt es **keine Vorwaertsreferenzen** — ein Mod darf auf
//! etwas zeigen, das erst ein spaeterer liefert.
use std::collections::HashMap;
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::Deserialize;
use serde::de::DeserializeOwned;
use toml::Spanned;
use super::registries::{
Ability, Affix, Category, CharacterOption, CreatureDef, FloorDef, Item, LootTable, ModifierDef,
Npc, Prefab, Quest, Registries, Slot,
};
/// Genau drei harte Fehler (Ticket 04). Alles Uebrige ist Warnung plus
/// Verwerfen des **einzelnen Eintrags**.
///
/// Handgeschrieben statt `thiserror`: drei Varianten, ein Konsument — und in
/// einem Ticket, das Rust lernen soll, ist dieses `match` der Zweck.
#[derive(Debug)]
pub enum LoadError {
CoreMissing,
DependencyMissing { module: String, dep: String },
DependencyOrder { module: String, dep: String },
}
impl fmt::Display for LoadError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
LoadError::CoreMissing => write!(
f,
"Das Kernspiel wurde nicht gefunden. Erwartet wird ein Ordner \
'mods/core' mit einer manifest.toml."
),
LoadError::DependencyMissing { module, dep } => {
write!(f, "'{module}' braucht '{dep}', aber '{dep}' ist nicht installiert.")
}
LoadError::DependencyOrder { module, dep } => write!(
f,
"'{module}' muss nach '{dep}' geladen werden, steht in der \
load_order.toml aber davor."
),
}
}
}
// ============================================================================
// Manifest und Mod-Erkennung
// ============================================================================
#[derive(Debug, Deserialize)]
struct Manifest {
id: String,
#[allow(dead_code)]
name: String,
/// Einziger Leser ist der Save-Kopf (Ticket 03/09).
version: String,
#[serde(default)]
depends: Vec<String>,
}
#[derive(Debug)]
struct ModInfo {
id: String,
/// Nur fuer den Save-Kopf (Ticket 09) — wird verglichen und protokolliert,
/// nie geprueft.
version: String,
depends: Vec<String>,
path: PathBuf,
}
/// Zwei Suchpfade (Ticket 03): `mods/` neben der Binary und `mods/` im
/// Benutzerprofil. Bei gleicher Mod-ID **gewinnt das Profil** — es existiert
/// genau dafuer, dass die Sachen des Spielers eine Neuinstallation ueberleben.
fn search_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Ok(exe) = std::env::current_exe()
&& let Some(dir) = exe.parent()
{
paths.push(dir.join("mods"));
}
// Entwicklungsfall: `cargo run` legt die Binary nach target/debug/.
paths.push(PathBuf::from("mods"));
if let Some(dir) = profile_dir() {
paths.push(dir.join("mods"));
}
paths
}
/// `data_dir()` ueber die Crate `directories` — Windows, Linux *und* macOS.
/// Keine Trennung von Daten und Konfiguration: Mods, `load_order.toml`, Save
/// und Logs liegen in einem Ordner (Ticket 03).
pub fn profile_dir() -> Option<PathBuf> {
directories::ProjectDirs::from("", "", "rustkeep").map(|d| d.data_dir().to_path_buf())
}
fn discover(warnings: &mut Vec<String>) -> Vec<ModInfo> {
let mut found: HashMap<String, ModInfo> = HashMap::new();
for base in search_paths() {
let Ok(entries) = fs::read_dir(&base) else { continue };
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let manifest_path = path.join("manifest.toml");
let Ok(raw) = fs::read_to_string(&manifest_path) else {
// Ordner ohne Manifest ist kein Mod — sonst legt eine vergessene
// screenshots/-Kopie das Spiel lahm (Ticket 03).
warnings.push(format!("{}: kein manifest.toml, übersprungen", path.display()));
continue;
};
match toml::from_str::<Manifest>(&raw) {
Ok(m) => {
if let Some(old) = found.get(&m.id) {
warnings.push(format!(
"'{}' liegt zweimal: {} gewinnt gegen {}",
m.id,
path.display(),
old.path.display()
));
}
found.insert(
m.id.clone(),
ModInfo { id: m.id, version: m.version, depends: m.depends, path },
);
}
Err(e) => warnings.push(format!("{}: {}", manifest_path.display(), e.message())),
}
}
}
found.into_values().collect()
}
// ============================================================================
// Ladereihenfolge — selbstheilend (Ticket 03)
// ============================================================================
#[derive(Debug, Default, Deserialize, serde::Serialize)]
struct LoadOrder {
#[serde(default)]
mods: Vec<String>,
}
fn resolve_order(found: &[ModInfo], warnings: &mut Vec<String>) -> Vec<String> {
let file = profile_dir().map(|d| d.join("load_order.toml"));
let mut order: Vec<String> = file
.as_ref()
.and_then(|p| fs::read_to_string(p).ok())
.and_then(|raw| toml::from_str::<LoadOrder>(&raw).ok())
.map(|lo| lo.mods)
.unwrap_or_default();
// Eintrag ohne Ordner: Warnzeile und ueberspringen. Das ist der Normalfall
// nach dem Deinstallieren und darf den Start nicht verhindern.
order.retain(|id| {
let exists = found.iter().any(|m| &m.id == id);
if !exists {
warnings.push(format!("'{id}' steht in load_order.toml, ist aber nicht installiert"));
}
exists
});
// Ordner ohne Eintrag: hinten anhaengen. Ein entpackter Mod laeuft damit,
// ohne dass der Spieler von der Sortierdatei wissen muss.
let mut fresh: Vec<&str> = found
.iter()
.filter(|m| !order.contains(&m.id))
.map(|m| m.id.as_str())
.collect();
fresh.sort_unstable();
if order.is_empty() {
// Erststart: core zuerst, Rest alphabetisch.
if let Some(pos) = fresh.iter().position(|id| *id == "core") {
order.push(fresh.remove(pos).to_string());
}
}
order.extend(fresh.into_iter().map(str::to_string));
if let Some(path) = file {
let _ = fs::create_dir_all(path.parent().unwrap());
if let Ok(text) = toml::to_string(&LoadOrder { mods: order.clone() }) {
// Schreibfehler wird verschluckt — ein Spiel, das wegen seiner
// Sortierdatei nicht startet, waere die Karikatur guter
// Fehlerbehandlung (Ticket 04).
let _ = fs::write(&path, text);
}
}
order
}
/// `depends` sortiert nicht, es **prueft** — das ist der Preis dafuer, dass ein
/// Mensch die Reihenfolge pflegt (Ticket 03).
fn check_depends(found: &[ModInfo], order: &[String]) -> Result<(), LoadError> {
for (i, id) in order.iter().enumerate() {
let Some(info) = found.iter().find(|m| &m.id == id) else { continue };
for dep in &info.depends {
match order.iter().position(|o| o == dep) {
None => {
return Err(LoadError::DependencyMissing {
module: id.clone(),
dep: dep.clone(),
});
}
Some(j) if j > i => {
return Err(LoadError::DependencyOrder {
module: id.clone(),
dep: dep.clone(),
});
}
Some(_) => {}
}
}
}
Ok(())
}
// ============================================================================
// Phase 2 — Einlesen, zweistufig (Ticket 04)
// ============================================================================
/// Jede Datei darf jede Registry-Tabelle enthalten (Ticket 03). `toml::Value`
/// als Zwischenstufe ist der Grund, warum ein kaputter Eintrag nur *sich selbst*
/// mitreisst und nicht die ganze Datei.
#[derive(Debug, Default, Deserialize)]
struct ModFile {
#[serde(default)]
items: Vec<Spanned<toml::Value>>,
#[serde(default)]
slots: Vec<Spanned<toml::Value>>,
#[serde(default)]
categories: Vec<Spanned<toml::Value>>,
#[serde(default)]
options: Vec<Spanned<toml::Value>>,
#[serde(default)]
prefabs: Vec<Spanned<toml::Value>>,
#[serde(default)]
floors: Vec<Spanned<toml::Value>>,
#[serde(default)]
abilities: Vec<Spanned<toml::Value>>,
#[serde(default)]
creatures: Vec<Spanned<toml::Value>>,
#[serde(default)]
modifiers: Vec<Spanned<toml::Value>>,
#[serde(default)]
affixes: Vec<Spanned<toml::Value>>,
#[serde(default)]
loot_tables: Vec<Spanned<toml::Value>>,
#[serde(default)]
npcs: Vec<Spanned<toml::Value>>,
#[serde(default)]
quests: Vec<Spanned<toml::Value>>,
/// **Keine** Tabelle mit `[[...]]`, sondern ein einzelner Block, der
/// feldweise verschmilzt — die ausdrueckliche Ausnahme zu Ticket 03.
#[serde(default)]
balance: Option<toml::Value>,
}
/// Damit die vier Registry-Typen durch dieselbe Einlese-Schleife passen.
trait Entry: DeserializeOwned {
fn id(&self) -> &str;
fn set_id(&mut self, id: String);
fn overrides(&self) -> Option<&str>;
}
macro_rules! impl_entry {
($t:ty) => {
impl Entry for $t {
fn id(&self) -> &str {
&self.id
}
fn set_id(&mut self, id: String) {
self.id = id;
}
fn overrides(&self) -> Option<&str> {
self.overrides.as_deref()
}
}
};
}
impl_entry!(Item);
impl_entry!(Slot);
impl_entry!(Category);
impl_entry!(CharacterOption);
impl_entry!(Prefab);
impl_entry!(FloorDef);
impl_entry!(Ability);
impl_entry!(CreatureDef);
impl_entry!(ModifierDef);
impl_entry!(Affix);
impl_entry!(LootTable);
impl_entry!(Npc);
impl_entry!(Quest);
struct FileCtx<'a> {
module: &'a str,
/// Pfad relativ zum Mod-Ordner — die Mod-ID steht ohnehin vorn.
file: String,
source: &'a str,
}
/// Zeile aus dem Byte-Offset. `toml::de::Error` und `Spanned` liefern nur
/// Offsets, keine Zeilen (gegen die Doku geprueft).
fn line_of(source: &str, offset: usize) -> usize {
source[..offset.min(source.len())].matches('\n').count() + 1
}
fn parse_entries<T: Entry>(
raw: Vec<Spanned<toml::Value>>,
ctx: &FileCtx<'_>,
into: &mut HashMap<String, T>,
warnings: &mut Vec<String>,
) {
for (index, spanned) in raw.into_iter().enumerate() {
let line = line_of(ctx.source, spanned.span().start);
let value = spanned.into_inner();
let mut entry = match T::deserialize(value) {
Ok(e) => e,
Err(e) => {
// Ein Eintrag ohne lesbare `id` kann sich nicht benennen und
// heisst nach seiner Position (Ticket 04).
warnings.push(format!(
"{}/{}:{}: [{}] — {}",
ctx.module, ctx.file, line, index, e.message()
));
continue;
}
};
// Der Lader stellt manifest.id automatisch voran (Ticket 03).
let own_id = format!("{}:{}", ctx.module, entry.id());
// `overrides` ersetzt den genannten Eintrag und uebernimmt dessen ID —
// die eigene wird dann gar nicht registriert (Ticket 03).
let key = match entry.overrides() {
Some(target) => {
let target = target.to_string();
if !into.contains_key(&target) {
warnings.push(format!(
"{}/{}:{}: {} überschreibt '{}', das es nicht gibt — verworfen",
ctx.module, ctx.file, line, own_id, target
));
continue;
}
warnings.push(format!("{} überschreibt '{}'", ctx.module, target));
target
}
None => {
if into.contains_key(&own_id) {
warnings.push(format!("{} überschreibt '{}'", ctx.module, own_id));
}
own_id
}
};
entry.set_id(key.clone());
into.insert(key, entry);
}
}
// ============================================================================
// Phase 3 — Aufloesen
// ============================================================================
fn resolve(reg: &mut Registries, warnings: &mut Vec<String>) {
let known_slots: Vec<String> = reg.slots.keys().cloned().collect();
reg.items.retain(|id, item| match &item.slot {
Some(slot) if !known_slots.contains(slot) => {
warnings.push(format!("{id}: Slot '{slot}' existiert nicht — verworfen"));
false
}
_ => true,
});
let known_categories: Vec<String> = reg.categories.keys().cloned().collect();
reg.options.retain(|id, option| {
if known_categories.contains(&option.category) {
true
} else {
warnings.push(format!(
"{id}: Kategorie '{}' existiert nicht — verworfen",
option.category
));
false
}
});
// Ticket 10: `layout`-Zeilen muessen gleich lang sein, sonst ist der Block
// kein Rechteck und die Platzierung rechnet mit Muell. Ein Zweizeiler, der
// eine ganze Klasse von Modder-Frust abfaengt.
reg.prefabs.retain(|id, prefab| {
let rows = prefab.rows();
if rows.is_empty() {
warnings.push(format!("{id}: layout ist leer — verworfen"));
return false;
}
let width = rows[0].chars().count();
match rows.iter().position(|r| r.chars().count() != width) {
Some(i) => {
warnings.push(format!(
"{id}: layout-Zeile {} ist {} Zeichen breit, erwartet {width} — verworfen",
i + 1,
rows[i].chars().count()
));
false
}
None => true,
}
});
// Ticket 10: Passen die Pflicht-Tags grundsaetzlich nicht in `size`, ist das
// ein **Datenfehler und beim Laden erkennbar**. Zur Laufzeit gibt es dafuer
// dann keinen Wiederholungszaehler und keinen Rueckfallpfad — die Pruefung
// sitzt dort, wo der Fehler gemacht wurde.
let prefabs = &reg.prefabs;
reg.floors.retain(|id, floor| {
let mut needed = 0u32;
for tag in &floor.required {
let smallest = prefabs
.values()
.filter(|p| p.tags.iter().any(|t| t == tag))
.map(|p| {
let (w, h) = p.size();
(w as u32 + 1) * (h as u32 + 1)
})
.min();
match smallest {
None => {
warnings.push(format!("{id}: kein Prefab mit dem Pflicht-Tag '{tag}'"));
return false;
}
Some(area) => needed += area,
}
}
let available = floor.size.0 as u32 * floor.size.1 as u32;
if needed > available {
warnings.push(format!(
"{id}: Pflichträume brauchen mindestens {needed} Felder, size bietet {available}"
));
return false;
}
true
});
}
// ============================================================================
// Einstiegspunkt
// ============================================================================
pub fn load_mods() -> Result<(Registries, Vec<String>), LoadError> {
let mut warnings = Vec::new();
// Phase 1 — Manifeste, Reihenfolge, depends
let found = discover(&mut warnings);
if !found.iter().any(|m| m.id == "core") {
return Err(LoadError::CoreMissing);
}
let order = resolve_order(&found, &mut warnings);
check_depends(&found, &order)?;
// Phase 2 — Einlesen in Ladereihenfolge
let mut reg = Registries::default();
let mut balance_raw = toml::value::Table::new();
for id in &order {
let Some(info) = found.iter().find(|m| &m.id == id) else { continue };
for file in toml_files(&info.path) {
let name = file
.strip_prefix(&info.path)
.unwrap_or(&file)
.to_string_lossy()
.into_owned();
let Ok(source) = fs::read_to_string(&file) else { continue };
// Kaputte Syntax reisst die ganze Datei mit — da kommt der Parser
// schlicht nicht weiter. Das ist keine Entscheidung, sondern eine
// Tatsache (Ticket 04).
let parsed: ModFile = match toml::from_str(&source) {
Ok(p) => p,
Err(e) => {
let line = e.span().map(|s| line_of(&source, s.start)).unwrap_or(0);
warnings.push(format!("{}/{}:{}: {}", info.id, name, line, e.message()));
continue;
}
};
let ctx = FileCtx { module: &info.id, file: name, source: &source };
parse_entries(parsed.items, &ctx, &mut reg.items, &mut warnings);
parse_entries(parsed.slots, &ctx, &mut reg.slots, &mut warnings);
parse_entries(parsed.categories, &ctx, &mut reg.categories, &mut warnings);
parse_entries(parsed.options, &ctx, &mut reg.options, &mut warnings);
parse_entries(parsed.prefabs, &ctx, &mut reg.prefabs, &mut warnings);
parse_entries(parsed.floors, &ctx, &mut reg.floors, &mut warnings);
parse_entries(parsed.abilities, &ctx, &mut reg.abilities, &mut warnings);
parse_entries(parsed.creatures, &ctx, &mut reg.creatures, &mut warnings);
parse_entries(parsed.modifiers, &ctx, &mut reg.modifiers, &mut warnings);
parse_entries(parsed.affixes, &ctx, &mut reg.affixes, &mut warnings);
parse_entries(parsed.loot_tables, &ctx, &mut reg.loot_tables, &mut warnings);
parse_entries(parsed.npcs, &ctx, &mut reg.npcs, &mut warnings);
parse_entries(parsed.quests, &ctx, &mut reg.quests, &mut warnings);
// `[balance]` verschmilzt **feldweise** (Ticket 14): Ein Mod, der
// einen Knopf dreht, schreibt eine Zeile — und ein neuer Knopf in
// der naechsten Version bricht ihn nicht.
if let Some(table) = parsed.balance {
merge_balance(table, &mut balance_raw);
}
}
}
// `[balance]` erst am Ende deserialisieren: Fehlende Felder fallen dann auf
// die Engine-Standards zurueck, nicht auf die des zuletzt geladenen Mods.
match toml::Value::Table(balance_raw).try_into() {
Ok(b) => reg.balance = b,
Err(e) => warnings.push(format!("[balance]: {} — Standardwerte gelten", e.message())),
}
reg.mods = order
.iter()
.filter_map(|id| found.iter().find(|m| &m.id == id))
.map(|m| super::save::ModStamp { id: m.id.clone(), version: m.version.clone() })
.collect();
// Phase 3 — Referenzen aufloesen
resolve(&mut reg, &mut warnings);
Ok((reg, warnings))
}
/// Feldweises Verschmelzen. Die Ausnahme bleibt eng, weil ihre Begruendung eng
/// ist: `[balance]` hat ein **festes, der Engine bekanntes Schema** — genau das
/// fehlte bei Inhalt mit offenen Feldern, und das war Ticket 03s Argument gegen
/// feldweises Patchen.
fn merge_balance(from: toml::Value, into: &mut toml::value::Table) {
if let toml::Value::Table(t) = from {
for (k, v) in t {
into.insert(k, v);
}
}
}
fn toml_files(dir: &Path) -> Vec<PathBuf> {
let mut files: Vec<PathBuf> = fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == "toml"))
.filter(|p| p.file_name().is_some_and(|n| n != "manifest.toml"))
.collect();
files.sort();
files
}
// ============================================================================
// Warnungen ins Log (Ticket 04)
// ============================================================================
/// Schreibt die Warnliste, behaelt die letzten zehn Laeufe und gibt den
/// Dateinamen zurueck. Ein sauberer Lauf legt keine Datei an.
pub fn write_log(warnings: &[String]) -> Option<String> {
if warnings.is_empty() {
return None;
}
let dir = profile_dir()?.join("logs");
fs::create_dir_all(&dir).ok()?;
// ponytail: Sekunden seit Epoch statt JJJJMMTT-HHMMSS. Sortierbar, und die
// Alternative waere Datumsarithmetik von Hand oder eine Crate dafuer.
let stamp = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
let name = format!("load-{stamp}.log");
fs::write(dir.join(&name), warnings.join("\n")).ok()?;
let mut old: Vec<PathBuf> = fs::read_dir(&dir)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("load-") && n.ends_with(".log"))
})
.collect();
old.sort();
for path in old.iter().rev().skip(10) {
let _ = fs::remove_file(path);
}
Some(name)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn zeilennummer_aus_offset() {
let src = "a\nb\nc";
assert_eq!(line_of(src, 0), 1);
assert_eq!(line_of(src, 2), 2);
assert_eq!(line_of(src, 4), 3);
}
/// Der Check aus Ticket 04: Ein kaputtes Kernspiel faellt beim `cargo test`
/// auf, nicht beim Spieler — und die Ladelogik bleibt frei von Sonderregeln.
#[test]
fn core_laedt_ohne_warnungen() {
let (reg, warnings) = load_mods().expect("core muss laden");
let relevant: Vec<&String> =
warnings.iter().filter(|w| !w.contains("load_order.toml")).collect();
assert!(relevant.is_empty(), "core lädt nicht sauber: {relevant:#?}");
assert!(reg.items.contains_key("core:iron_sword"));
assert!(reg.slots.contains_key("core:hand"));
assert!(reg.options.contains_key("core:dwarf"));
}
/// Ticket 04 versprach, dass ein kaputter Eintrag nur *sich selbst*
/// mitreisst. Das ist mit serde nicht gratis — hier wird es geprueft.
#[test]
fn kaputter_eintrag_reisst_die_nachbarn_nicht_mit() {
let source = r#"
[[items]]
id = "gut"
name = "Gut"
[[items]]
id = "kaputt"
name = 42
[[items]]
id = "auch_gut"
name = "Auch gut"
"#;
let parsed: ModFile = toml::from_str(source).expect("Syntax ist in Ordnung");
let ctx = FileCtx { module: "test", file: "items.toml".into(), source };
let mut items: HashMap<String, Item> = HashMap::new();
let mut warnings = Vec::new();
parse_entries(parsed.items, &ctx, &mut items, &mut warnings);
assert_eq!(items.len(), 2, "die beiden heilen Eintraege muessen ueberleben");
assert!(items.contains_key("test:gut"));
assert!(items.contains_key("test:auch_gut"));
assert_eq!(warnings.len(), 1);
// `Spanned` zeigt auf den Anfang des Eintrags (`[[items]]`, Zeile 6),
// nicht auf das schuldige Feld — genau der Zeilenbereich, den Ticket 04
// zugesagt hat. Der Feldname steht dafuer in `message()`.
assert!(warnings[0].contains("items.toml:6"), "Zeile fehlt: {}", warnings[0]);
assert!(warnings[0].contains("expected a string"));
}
}

View File

@@ -1,11 +0,0 @@
//! Definitionen und Mod-Lader. Kennt ratatui nicht — Farben sind hier Namen,
//! uebersetzt wird in `ui/theme.rs` (Ticket 11).
pub mod loader;
pub mod registries;
pub mod save;
pub use loader::{load_mods, write_log};
pub use registries::{
Balance, CreatureDef, FloorDef, DialogOption, LootEntry, Npc, Prefab, Registries, parse_range,
};

View File

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

View File

@@ -1,406 +0,0 @@
//! Speicherstand — Ticket 09.
//!
//! **Der Save ist der `Game`-Baum.** Eigene Save-Structs hiessen, jede Struktur
//! zweimal zu pflegen und bei jeder Aenderung daran zu denken — die Sorte
//! Duplikat, die genau dann auseinanderlaeuft, wenn es niemand merkt. Der Preis
//! ist ehrlich: Der Save haengt an internen Feldnamen, ein Umbenennen bricht
//! alte Staende. Dafuer gibt es `schema`.
use std::collections::HashMap;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::game::{Creature, Game, ItemInstance};
use super::Registries;
/// Steigt, wenn sich die **Struktur** aendert. Ein abweichender Wert wird
/// abgelehnt, nicht migriert: Migration zu schreiben, bevor ein einziger
/// Speicherstand ausgeliefert wurde, ist Code fuer ein Problem, das noch
/// niemand hat.
pub const SCHEMA: u32 = 1;
#[derive(Debug, Serialize, Deserialize)]
pub struct Header {
pub schema: u32,
/// Reine Diagnose bei Fehlermeldungen.
pub game: String,
#[serde(default)]
pub mods: Vec<ModStamp>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModStamp {
pub id: String,
pub version: String,
}
/// Zwei Typen statt eines: Geschrieben wird aus Referenzen, gelesen in Besitz.
/// Ein gemeinsamer Typ braeuchte entweder `Clone` auf dem ganzen `Game` — also
/// eine Kopie der Etage bei jedem Autosave — oder einen Zeigertrick.
#[derive(Serialize)]
struct SaveOut<'a> {
save: &'a Header,
game: &'a Game,
}
/// Ohne `save`-Feld: Der Kopf wird vorab einzeln gelesen, und unbekannte
/// Schluessel ignoriert serde ohnehin.
#[derive(Deserialize)]
struct SaveIn {
game: Game,
}
#[derive(Debug)]
pub enum SaveError {
/// Kein Speicherstand da — der Normalfall beim ersten Start.
Missing,
/// Falsches Schema. Die **Datei bleibt liegen**: Sie zu loeschen, weil man
/// sie nicht lesen kann, waere die schlechteste aller Reaktionen.
Schema { found: u32 },
Unreadable(String),
}
impl std::fmt::Display for SaveError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SaveError::Missing => write!(f, "Kein Speicherstand vorhanden"),
SaveError::Schema { found } => write!(
f,
"Speicherstand stammt aus Schema {found}, dieses Spiel liest {SCHEMA}"
),
SaveError::Unreadable(why) => write!(f, "Speicherstand nicht lesbar: {why}"),
}
}
}
fn path() -> Option<PathBuf> {
// Dasselbe Verzeichnis wie Mods und `load_order.toml` — Ticket 03 hat die
// Aufteilung in Daten und Konfiguration verworfen.
super::loader::profile_dir().map(|d| d.join("save.toml"))
}
/// Schreibt **atomar**: erst `save.toml.tmp`, dann `rename` darueber. Das ist
/// die eine Stelle in Ticket 09, an der bewusst nicht lazy entschieden wurde —
/// der Autosave ueberschreibt den einzigen Speicherstand, den es gibt. Bricht
/// das Schreiben ab, liegt entweder die alte Datei da oder die neue, nie eine
/// halbe.
pub fn save(game: &Game, reg: &Registries) -> std::io::Result<()> {
debug_assert!(
game.events.is_empty(),
"gespeichert wird zwischen Zuegen — die Warteschlange muss leer sein"
);
let Some(ziel) = path() else {
return Err(std::io::Error::other("kein Datenverzeichnis"));
};
if let Some(dir) = ziel.parent() {
std::fs::create_dir_all(dir)?;
}
let kopf = Header {
schema: SCHEMA,
game: env!("CARGO_PKG_VERSION").to_string(),
mods: reg.mods.clone(),
};
let text = toml::to_string_pretty(&SaveOut { save: &kopf, game })
.map_err(std::io::Error::other)?;
write_atomic(&ziel, &text)
}
/// Erst daneben schreiben, dann darueberziehen. `rename` ersetzt die Zieldatei
/// auf Windows 10 1607+ ebenso wie auf Unix.
fn write_atomic(ziel: &std::path::Path, text: &str) -> std::io::Result<()> {
let tmp = ziel.with_extension("toml.tmp");
std::fs::write(&tmp, text)?;
std::fs::rename(&tmp, ziel)
}
pub fn load(reg: &Registries) -> Result<(Game, Vec<String>), SaveError> {
let Some(quelle) = path() else { return Err(SaveError::Missing) };
let Ok(text) = std::fs::read_to_string(&quelle) else {
return Err(SaveError::Missing);
};
// Erst nur den Kopf lesen: Bei falschem Schema ist der Rumpf strukturell
// anders, und Best-Effort darauf erzeugt stille Verfaelschung.
#[derive(Deserialize)]
struct NurKopf {
save: Header,
}
let kopf: NurKopf = toml::from_str(&text).map_err(|e| SaveError::Unreadable(e.to_string()))?;
if kopf.save.schema != SCHEMA {
return Err(SaveError::Schema { found: kopf.save.schema });
}
let save: SaveIn = toml::from_str(&text).map_err(|e| SaveError::Unreadable(e.to_string()))?;
let mut warnings = version_drift(&kopf.save.mods, reg);
let mut game = save.game;
prune(&mut game, reg, &mut warnings);
Ok((game, warnings))
}
pub fn delete() {
if let Some(p) = path() {
let _ = std::fs::remove_file(p);
}
}
/// Reine Diagnose — eine Zeile, die genau dann Gold wert ist, wenn jemand
/// meldet „mein Spielstand ist seit dem Update kaputt". Geaenderte Werte gelten
/// einfach, verschwundene IDs faengt `prune` ab.
fn version_drift(damals: &[ModStamp], reg: &Registries) -> Vec<String> {
let jetzt: HashMap<&str, &str> =
reg.mods.iter().map(|m| (m.id.as_str(), m.version.as_str())).collect();
damals
.iter()
.filter_map(|alt| match jetzt.get(alt.id.as_str()) {
Some(neu) if *neu != alt.version => {
Some(format!("`{}` {} → {neu} seit dem Speichern", alt.id, alt.version))
}
Some(_) => None,
None => Some(format!("`{}` {} fehlt jetzt", alt.id, alt.version)),
})
.collect()
}
/// **Verwerfen, nie ablehnen** (Ticket 09): Ein Spieler soll nicht seinen
/// ganzen Lauf verlieren, weil er einen Mod deinstalliert hat.
fn prune(game: &mut Game, reg: &Registries, warnings: &mut Vec<String>) {
// Ein Fortschritt ohne Definition ist eine Zahl ohne Bedeutung — die Regel
// steht in Ticket 08, das Detail gehoert hierher.
game.quests.retain(|q| {
let da = reg.quests.contains_key(&q.quest);
if !da {
warnings.push(format!("Quest `{}` fehlt, Fortschritt entfällt", q.quest));
}
da
});
prune_creature(&mut game.player, reg, warnings);
for e in &mut game.enemies {
prune_creature(e, reg, warnings);
}
for room in &mut game.floor.rooms {
for e in &mut room.enemies {
prune_creature(e, reg, warnings);
}
for npc in &mut room.npcs {
prune_items(&mut npc.stock, reg, warnings);
}
}
}
fn prune_creature(c: &mut Creature, reg: &Registries, warnings: &mut Vec<String>) {
prune_items(&mut c.inventory, reg, warnings);
// Ausruestung: Der Slot bleibt leer, der Held bleibt spielbar.
c.equipment.retain(|slot, inst| {
let da = reg.items.contains_key(&inst.def) && reg.slots.contains_key(slot);
if !da {
warnings.push(format!("Ausrüstung `{}` fehlt, Slot bleibt leer", inst.def));
}
da
});
for inst in c.equipment.values_mut() {
prune_affixes(inst, reg, warnings);
}
c.options.retain(|id| {
let da = reg.options.contains_key(id);
if !da {
warnings.push(format!("Charakteroption `{id}` fehlt"));
}
da
});
// Ein Modifikator, dessen Definition fehlt, laeuft trotzdem ab — er traegt
// seine Zahlen selbst (Ticket 07). Nichts zu tun.
}
fn prune_items(v: &mut Vec<ItemInstance>, reg: &Registries, warnings: &mut Vec<String>) {
v.retain(|inst| {
let da = reg.items.contains_key(&inst.def);
if !da {
warnings.push(format!("Gegenstand `{}` fehlt", inst.def));
}
da
});
for inst in v.iter_mut() {
prune_affixes(inst, reg, warnings);
}
}
fn prune_affixes(inst: &mut ItemInstance, reg: &Registries, warnings: &mut Vec<String>) {
inst.affixes.retain(|a| {
let da = reg.affixes.contains_key(a);
if !da {
warnings.push(format!("Affix `{a}` fehlt, Gegenstand bleibt"));
}
da
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data::load_mods;
use crate::game::{Attribute, CreatureRef, Rng};
fn lauf() -> (Game, Registries) {
let (reg, _) = load_mods().expect("core muss laden");
let mut held = Creature::new("Testheld");
held.options = vec![String::from("core:dwarf")];
held.equipment
.insert(String::from("core:hand"), ItemInstance::new("core:iron_sword"));
let game = Game::new(held, &reg, Rng::new(4711)).expect("Etage 1");
(game, reg)
}
fn durch_toml(game: &Game, reg: &Registries) -> (String, Game) {
let kopf = Header {
schema: SCHEMA,
game: String::from("test"),
mods: reg.mods.clone(),
};
let text = toml::to_string_pretty(&SaveOut { save: &kopf, game })
.expect("TOML verlangt Werte vor Tabellen — die Feldreihenfolge muss stimmen");
let zurueck: SaveIn = toml::from_str(&text).expect("und wieder einlesbar");
(text, zurueck.game)
}
/// Der Save **ist** der Baum: Was rausgeht, muss unveraendert zurueckkommen.
/// Faellt sofort um, wenn jemand ein Tabellenfeld vor ein Wertfeld schiebt.
#[test]
fn ein_lauf_ueberlebt_die_runde_durch_toml() {
let (mut game, reg) = lauf();
game.turn = 42;
game.current_exp = 17;
game.unspent_points = 2;
game.give(ItemInstance::stack("core:gold", 130), &reg);
game.player.health = 33.0;
game.quests.push(crate::game::QuestProgress {
quest: String::from("core:rattenplage"),
count: 3,
done: false,
});
let (text, zurueck) = durch_toml(&game, &reg);
assert_eq!(zurueck.turn, 42);
assert_eq!(zurueck.current_exp, 17);
assert_eq!(zurueck.unspent_points, 2);
assert_eq!(zurueck.player.health, 33.0);
assert_eq!(zurueck.player_pos, game.player_pos);
assert_eq!(zurueck.gold(), 130, "der Stapel kommt als Stapel zurueck");
assert_eq!(
zurueck.player.equipment.get("core:hand").map(|i| i.def.as_str()),
Some("core:iron_sword")
);
assert_eq!(
zurueck.attribute(CreatureRef::Player, Attribute::Level, None, &reg),
game.attribute(CreatureRef::Player, Attribute::Level, None, &reg)
);
// Die Etage kommt Kachel fuer Kachel zurueck — und steht als lesbare
// Zeilen in der Datei, nicht als 3200 Enum-Eintraege (Ticket 09).
assert_eq!(zurueck.quests.len(), 1, "der Questfortschritt ueberlebt");
assert_eq!(zurueck.quests[0].count, 3);
assert_eq!(zurueck.floor.rows(), game.floor.rows());
assert_eq!(zurueck.floor.rooms.len(), game.floor.rooms.len());
assert!(text.contains("####"), "die Kacheln muessen als Zeilen dastehen");
assert!(!text.contains("\"wall\""), "und nicht als Enum-Namen");
}
/// **Verwerfen, nie ablehnen**: Ein fehlender Mod kostet Eintraege, nicht
/// den Lauf.
#[test]
fn fehlende_eintraege_werden_verworfen_nicht_abgelehnt() {
let (mut game, reg) = lauf();
game.player.inventory.push(ItemInstance::new("weg:phantom"));
game.player.options.push(String::from("weg:klasse"));
let mut verhext = ItemInstance::new("core:iron_sword");
verhext.affixes = vec![String::from("weg:affix"), String::from("core:sharp")];
game.player.inventory.push(verhext);
game.player
.equipment
.insert(String::from("core:head"), ItemInstance::new("weg:helm"));
game.quests.push(crate::game::QuestProgress {
quest: String::from("weg:auftrag"),
count: 3,
done: false,
});
let mut warnungen = Vec::new();
prune(&mut game, &reg, &mut warnungen);
assert!(game.quests.is_empty(), "ein Fortschritt ohne Definition ist eine Zahl ohne Sinn");
assert!(!game.player.inventory.iter().any(|i| i.def == "weg:phantom"));
assert!(!game.player.options.iter().any(|o| o == "weg:klasse"));
assert!(!game.player.equipment.contains_key("core:head"), "Slot bleibt leer");
let schwert = game
.player
.inventory
.iter()
.find(|i| i.def == "core:iron_sword")
.expect("der Gegenstand bleibt, nur das Affix faellt");
assert_eq!(schwert.affixes, vec![String::from("core:sharp")]);
assert_eq!(warnungen.len(), 5, "jeder Verlust bekommt eine Zeile: {warnungen:?}");
}
/// Falsches Schema: ablehnen mit Klartext, **Datei bleibt liegen**.
#[test]
fn fremdes_schema_wird_abgelehnt() {
let (game, _reg) = lauf();
let kopf = Header { schema: SCHEMA + 1, game: String::from("test"), mods: Vec::new() };
let text = toml::to_string_pretty(&SaveOut { save: &kopf, game: &game }).expect("schreibt");
#[derive(Deserialize)]
struct NurKopf {
save: Header,
}
let gelesen: NurKopf = toml::from_str(&text).expect("der Kopf ist immer lesbar");
assert_ne!(gelesen.save.schema, SCHEMA);
let fehler = SaveError::Schema { found: gelesen.save.schema };
assert!(fehler.to_string().contains("Schema 2"), "{fehler}");
}
/// Der Versionsvergleich leistet **reine Diagnose** — die Zeile, die Gold
/// wert ist, wenn jemand meldet „seit dem Update kaputt".
#[test]
fn mod_versionen_werden_nur_protokolliert() {
let (_, reg) = lauf();
let damals = vec![
ModStamp { id: String::from("core"), version: String::from("0.0.1") },
ModStamp { id: String::from("weg"), version: String::from("1.0.0") },
];
let zeilen = version_drift(&damals, &reg);
assert_eq!(zeilen.len(), 2, "{zeilen:?}");
assert!(zeilen[0].contains("core"));
assert!(zeilen[1].contains("fehlt jetzt"));
}
/// Die eine Stelle, an der Ticket 09 bewusst nicht lazy entschieden hat:
/// Der Autosave ueberschreibt den **einzigen** Speicherstand. Geprueft wird
/// das Beobachtbare — die alte Datei wird ersetzt, und es bleibt kein
/// halber Rest liegen.
#[test]
fn atomar_geschrieben_bleibt_kein_rest_liegen() {
let dir = std::env::temp_dir().join(format!("rustkeep-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("Testverzeichnis");
let ziel = dir.join("save.toml");
write_atomic(&ziel, "alt").expect("erstes Schreiben");
assert_eq!(std::fs::read_to_string(&ziel).unwrap(), "alt");
write_atomic(&ziel, "neu").expect("darueberziehen");
assert_eq!(std::fs::read_to_string(&ziel).unwrap(), "neu", "rename ersetzt");
assert!(!ziel.with_extension("toml.tmp").exists(), "keine halbe Datei daneben");
let _ = std::fs::remove_dir_all(&dir);
}
}

View File

@@ -1,236 +0,0 @@
//! Aktionen und Schadenstypen — Ticket 01.
//!
//! **Neun Aktionen.** Bewusst ohne Ordnungszahlen im Text: Vier Tickets haben
//! den Katalog erweitert und dabei jeweils von ihrem eigenen Stand aus gezaehlt
//! („siebte", „achte", „elfte"), sodass sich die Zahlen inzwischen
//! widersprechen. Wer wissen will, wie viele es sind, zaehlt die Varianten.
//!
//! Eine **Aktion** ist eine einmalige Zustandsaenderung. Sie feuert, wirkt, ist
//! vorbei. Getrennte Maschine vom Modifikator, der nie ausgefuehrt, sondern beim
//! Auslesen aufsummiert wird.
use serde::{Deserialize, Serialize};
use super::attributes::Attribute;
/// Sieben feste Kennzeichnungen, wie Schaden gemindert wird. Engine-Vokabular
/// ohne Namensraum. Zu jedem Typ gehoert eine Resistenz, und Resistenz ist der
/// *einzige* Minderungsmechanismus (Ticket 01).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[repr(usize)]
pub enum DamageType {
Fire,
Ice,
Poison,
Shock,
Holy,
Slash,
Blunt,
}
impl DamageType {
pub const ALL: [DamageType; 7] = [
DamageType::Fire,
DamageType::Ice,
DamageType::Poison,
DamageType::Shock,
DamageType::Holy,
DamageType::Slash,
DamageType::Blunt,
];
pub fn name(self) -> &'static str {
match self {
DamageType::Fire => "Feuer",
DamageType::Ice => "Eis",
DamageType::Poison => "Gift",
DamageType::Shock => "Blitz",
DamageType::Holy => "Heilig",
DamageType::Slash => "Schnitt",
DamageType::Blunt => "Stumpf",
}
}
}
/// Wen eine Aktion trifft. Aufgeloest wird gegen die Adressen des laufenden
/// Zuges (Ticket 01).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Target {
#[default]
#[serde(rename = "self")]
Zelf,
/// Heisst in der TOML `target`; der Rust-Name weicht ab, weil
/// `Target::Target` sich liest wie ein Tippfehler.
#[serde(rename = "target")]
Other,
AllEnemies,
}
/// Der Aktionskatalog. Zehn Eintraege sind entschieden (01, 08, 13, 14, 15);
/// M3 braucht fuenf davon.
///
// TODO(M8): `remove_item` und `run_script` (13).
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum Action {
/// Eigenstaendig statt `change_attribute health -12`, weil Schaden durch
/// eine Kette laeuft: Resistenz mindert, Krit vervielfacht, „bei erlittenem
/// Schaden" ist ein Event (Ticket 01).
///
/// Menge = `amount + scale * damage-Attribut(Verursacher)` — eine Formel
/// fuer Basisangriff (`scale = 1.0`), Feuerball (`amount = 20`) und
/// Wuchtschlag (`scale = 1.5`), Nachtrag aus Ticket 14.
Damage {
#[serde(default)]
amount: f32,
#[serde(default)]
scale: f32,
damage_type: DamageType,
#[serde(default)]
target: Target,
},
/// Aendert **Zustand**, nicht Attribute: `health` und `stamina`. Ein
/// Modifikator wirkt auf `max_health`, eine Aktion auf den Lebensstand —
/// die Trennung muss scharf bleiben, sonst heilt ein Ring beim Anlegen.
ChangeAttribute {
attribute: StateField,
amount: f32,
#[serde(default)]
target: Target,
},
ApplyModifier {
modifier: String,
#[serde(default)]
target: Target,
},
RemoveModifier {
modifier: String,
#[serde(default)]
target: Target,
},
/// Aus Ticket 14: Ticket 11 hat Flucht zu einer Faehigkeit gemacht, und
/// die braucht etwas, das sie ausfuehrt.
Flee,
/// Aus M4: Eine Dialogoption oeffnet den Laden. Als Aktion steht sie in
/// derselben Liste wie `start_quest`, statt als Sonderfeld daneben.
OpenShop,
/// Aus Ticket 15: Erfahrung fuer Questbelohnungen. Der zweite Weg neben
/// `exp_reward` — das ist die Kurzform fuer einen Todes-Trigger, den sonst
/// jede Gegnerdefinition mitschleppen muesste.
GiveExp {
amount: u32,
},
/// Aus Ticket 08: Eine Quest zaehlt **erst ab dem Start**. Alle ab dem ersten
/// Zug mitzaehlen zu lassen waere lazier gewesen und macht das Spiel
/// kaputt — eine Kette „bring mir 5 Schwaenze" → „jetzt in den Keller"
/// waere beim Betreten des Kellers schon erfuellt, bevor sie jemand
/// erwaehnt hat.
StartQuest {
quest: String,
},
/// Questbelohnungen zahlen sonst nur Erfahrung. Gold ist seit Ticket 01
/// ein gewoehnlicher Gegenstand, also braucht es keine zweite Form.
GiveItem {
item: String,
#[serde(default = "one")]
count: u32,
},
/// Gibt **und legt an**. Aus M9: `on_create` konnte bis dahin nur `give_item`,
/// also startete jeder Held mit der Waffe im Beutel und schlug mit blossen
/// Haenden zu. „Startausruestung" als eigener Engine-Begriff waere die
/// falsche Antwort gewesen — das hier ist eine Aktion wie jede andere und
/// steht ueberall, wo eine stehen darf.
EquipItem {
item: String,
},
}
fn one() -> u32 {
1
}
/// Die beiden Zustandsgroessen, die eine Aktion aendern darf. Bewusst **kein**
/// [`Attribute`]: Attribute werden gerechnet, Zustand wird gesetzt.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StateField {
Health,
Stamina,
}
impl Action {
pub fn target(&self) -> Target {
match self {
Action::Damage { target, .. }
| Action::ChangeAttribute { target, .. }
| Action::ApplyModifier { target, .. }
| Action::RemoveModifier { target, .. } => *target,
Action::Flee
| Action::OpenShop
| Action::GiveExp { .. }
| Action::StartQuest { .. }
| Action::GiveItem { .. }
| Action::EquipItem { .. } => Target::Zelf,
}
}
}
/// Ein befristeter Modifikator, der auf einem Wesen liegt. Er **wohnt beim
/// Traeger** (Ticket 01) — stirbt der Traeger, verschwindet die Aura von selbst.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActiveModifier {
/// ID des Registry-Eintrags, damit `remove_modifier` ihn findet.
pub id: String,
pub attribute: Attribute,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub damage_type: Option<DamageType>,
pub flat: f32,
pub percent: f32,
pub turns_left: u32,
pub name: String,
pub symbol: char,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn aktionen_lesen_sich_aus_toml() {
// Genau die Schreibweise aus Ticket 01.
let toml = r#"
actions = [
{ action = "damage", amount = 12, damage_type = "fire", target = "target" },
{ action = "change_attribute", attribute = "health", amount = 20, target = "self" },
{ action = "apply_modifier", modifier = "core:poisoned", target = "target" },
{ action = "flee" },
]
"#;
#[derive(Deserialize)]
struct Wrap {
actions: Vec<Action>,
}
let w: Wrap = toml::from_str(toml).expect("Vokabular aus Ticket 01 muss lesbar sein");
assert_eq!(w.actions.len(), 4);
assert!(matches!(
w.actions[0],
Action::Damage { amount: 12.0, damage_type: DamageType::Fire, target: Target::Other, .. }
));
// `target` fehlt beim vierten Eintrag und faellt auf `self` zurueck.
assert_eq!(w.actions[3].target(), Target::Zelf);
}
#[test]
fn skalierender_schaden_ohne_amount() {
let a: Action = toml::from_str(
r#"action = "damage"
scale = 1.5
damage_type = "blunt""#,
)
.expect("scale allein muss reichen");
assert!(matches!(a, Action::Damage { amount: 0.0, scale: 1.5, .. }));
}
}

View File

@@ -1,191 +0,0 @@
//! Attributkatalog und Modifikatoren — Ticket 01.
//!
//! Der Katalog ist **fest** und Engine-Vokabular ohne Namensraum. Ein Modder
//! kann ihn nicht erweitern; das ist Absicht (siehe Glossar, „Attribut").
use serde::{Deserialize, Serialize};
/// Fester Katalog aus Ticket 01. `level` steht hier, weil Bedingungen und
/// Formeln es lesen — die Quelle fuer Aufstiegspunkte ist aber `base.level`
/// und nicht dieser gelesene Wert (Ticket 15).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[repr(usize)]
pub enum Attribute {
Strength,
Dexterity,
Constitution,
Charisma,
Luck,
Level,
MaxHealth,
MaxStamina,
Damage,
HitChance,
CriticalChance,
CriticalFactor,
PriceModifier,
CarryCapacity,
/// **Typ-indiziert**: Ein Modifikator darauf traegt zusaetzlich einen
/// `damage_type`. Die Basis in `Attributes` bleibt ungenutzt — Resistenz
/// kommt ausschliesslich aus Modifikatoren (Ticket 01).
Resistance,
}
impl Attribute {
pub const COUNT: usize = 15;
pub const ALL: [Attribute; Self::COUNT] = [
Attribute::Strength,
Attribute::Dexterity,
Attribute::Constitution,
Attribute::Charisma,
Attribute::Luck,
Attribute::Level,
Attribute::MaxHealth,
Attribute::MaxStamina,
Attribute::Damage,
Attribute::HitChance,
Attribute::CriticalChance,
Attribute::CriticalFactor,
Attribute::PriceModifier,
Attribute::CarryCapacity,
Attribute::Resistance,
];
/// Worauf ein Aufstiegspunkt gesetzt werden darf (Ticket 15). Abgeleitete
/// Groessen wie `damage` fehlen bewusst: Punkte direkt dorthin zu setzen
/// umginge das Modifikatorsystem aus Ticket 01.
pub const ASSIGNABLE: [Attribute; 5] = [
Attribute::Strength,
Attribute::Dexterity,
Attribute::Constitution,
Attribute::Charisma,
Attribute::Luck,
];
pub fn name(self) -> &'static str {
match self {
Attribute::Strength => "Stärke",
Attribute::Dexterity => "Geschicklichkeit",
Attribute::Constitution => "Konstitution",
Attribute::Charisma => "Charisma",
Attribute::Luck => "Glück",
Attribute::Level => "Level",
Attribute::MaxHealth => "Leben (max)",
Attribute::MaxStamina => "Ausdauer (max)",
Attribute::Damage => "Schaden",
Attribute::HitChance => "Trefferchance",
Attribute::CriticalChance => "Kritische Chance",
Attribute::CriticalFactor => "Kritischer Faktor",
Attribute::PriceModifier => "Preis-Modifikator",
Attribute::CarryCapacity => "Tragfähigkeit",
Attribute::Resistance => "Resistenz",
}
}
}
/// Die Grundwerte eines Wesens. Bewusst ein Array statt vierzehn benannter
/// Felder: `get` und `set` sind damit je eine Zeile statt je eines
/// vierzehnarmigen `match`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Attributes([f32; Attribute::COUNT]);
impl Attributes {
pub fn get(&self, a: Attribute) -> f32 {
self.0[a as usize]
}
pub fn set(&mut self, a: Attribute, v: f32) {
self.0[a as usize] = v;
}
}
impl Default for Attributes {
/// Startwerte eines frischen Helden. Zahlen sind bewusst grob — Balancing
/// setzt ein spielbares Spiel voraus (siehe Map, „Not yet specified").
fn default() -> Self {
let mut a = Attributes([0.0; Attribute::COUNT]);
a.set(Attribute::Strength, 1.0);
a.set(Attribute::Dexterity, 1.0);
a.set(Attribute::Constitution, 1.0);
a.set(Attribute::Charisma, 1.0);
a.set(Attribute::Luck, 1.0);
a.set(Attribute::Level, 1.0);
a.set(Attribute::MaxHealth, 100.0);
a.set(Attribute::MaxStamina, 100.0);
a.set(Attribute::Damage, 2.0);
a.set(Attribute::HitChance, 0.8);
// **Befund aus M9:** Die Basis stand auf 0, und ein Modifikator mit
// `percent` rechnet auf 0 immer 0 — die Signaturzahl des Diebs
// („+15 % kritische Chance") tat schlicht nichts. Gegner tragen seit M3
// eine Basis von 5; der Held hatte keine.
a.set(Attribute::CriticalChance, 5.0);
a.set(Attribute::CriticalFactor, 1.5);
a.set(Attribute::CarryCapacity, 50.0);
a
}
}
/// Rechnet ein Attribut um, solange er existiert — Ticket 01. Er wird nie
/// ausgefuehrt, sondern beim Auslesen aufsummiert.
#[derive(Debug, Clone, Deserialize)]
pub struct Modifier {
pub attribute: Attribute,
/// Nur bei `attribute = "resistance"`: Resistenz ist typ-indiziert
/// (Ticket 01), alle uebrigen Attribute sind Skalare.
#[serde(default)]
pub damage_type: Option<super::action::DamageType>,
#[serde(default)]
pub flat: f32,
#[serde(default)]
pub percent: f32,
// 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.
}
/// Stapelregel aus Ticket 01: `(Basis + Σ flach) × (1 + Σ prozentual)`,
/// intern `f32`, gerundet an genau einer Stelle — hier gar nicht, weil das
/// Runden dorthin gehoert, wo ein Wert angezeigt oder verrechnet wird.
pub fn stack<'a>(base: f32, modifiers: impl Iterator<Item = &'a Modifier>, attr: Attribute) -> f32 {
let (flat, percent) = modifiers
.filter(|m| m.attribute == attr)
.fold((0.0, 0.0), |(f, p), m| (f + m.flat, p + m.percent));
(base + flat) * (1.0 + percent / 100.0)
}
#[cfg(test)]
mod tests {
use super::*;
/// `ALL` wird von Hand neben dem Enum gepflegt. Wer eine Variante ergaenzt
/// und die Liste vergisst, faellt sonst nirgends auf — der Charakterbogen
/// zeigt das Attribut dann einfach nie.
#[test]
fn all_ist_vollstaendig_und_in_indexreihenfolge() {
assert_eq!(Attribute::ALL.len(), Attribute::COUNT);
for (i, a) in Attribute::ALL.iter().enumerate() {
assert_eq!(*a as usize, i, "{a:?} steht an der falschen Stelle in ALL");
}
}
#[test]
fn stapelregel_erst_flach_dann_prozentual() {
let m = |attribute, flat, percent| Modifier {
attribute,
damage_type: None,
flat,
percent,
};
let mods = [
m(Attribute::Strength, 2.0, 0.0),
m(Attribute::Strength, 3.0, 10.0),
// Fremdes Attribut darf nicht mitzaehlen:
m(Attribute::Damage, 100.0, 0.0),
];
// (10 + 5) * 1.10 == 16.5, nicht 10*1.1 + 5 == 16.0
assert_eq!(stack(10.0, mods.iter(), Attribute::Strength), 16.5);
}
}

View File

@@ -1,99 +0,0 @@
//! Kampfformeln — Ticket 14.
//!
//! Zahlen stehen hier keine: Die fuenf Stellknoepfe liegen in `[balance]` und
//! sind moddbar. Was hier steht, sind die **Formen** und die drei Klemmbereiche,
//! ohne die das Spiel Sackgassen erzeugt.
use crate::data::Balance;
/// `p = clamp(hit_chance − dexterity_ziel × AUSWEICH, 0.05, 0.95)`
///
/// Der Klemmbereich ist nicht Kosmetik: Ohne ihn wird ein hochgezuechteter
/// Verteidiger unangreifbar und ein hochgezuechteter Angreifer unfehlbar —
/// beides Sackgassen, die man erst auf Etage 5 bemerkt.
pub fn hit_chance(attacker_hit: f32, defender_dex: f32, b: &Balance) -> f32 {
(attacker_hit - defender_dex * b.ausweich).clamp(b.hit_floor, b.hit_ceiling)
}
/// `p = clamp(0.5 + (dex_held − dex_schnellster) × FLUCHT, 0.1, 0.9)`
pub fn flee_chance(own_dex: f32, fastest_enemy_dex: f32, b: &Balance) -> f32 {
(0.5 + (own_dex - fastest_enemy_dex) * b.flucht).clamp(0.1, 0.9)
}
/// Roher Schaden vor der Minderung: `amount + scale × damage-Attribut`.
pub fn raw_damage(amount: f32, scale: f32, attacker_damage: f32) -> f32 {
amount + scale * attacker_damage
}
/// Die Minderungskette. Ob `critical_factor` den Rohschaden oder den geminderten
/// multipliziert, ist **mathematisch belanglos** — solange beide multiplikativ
/// sind, ist `roh × kf × (1−r)` dasselbe wie `roh × (1−r) × kf`. Festzulegen war
/// nur, dass sie multiplikativ *bleiben*.
pub fn final_damage(raw: f32, critical: bool, crit_factor: f32, resistance: f32, b: &Balance) -> u32 {
let after_crit = if critical { raw * crit_factor } else { raw };
// Deckel bei 0.8: Bei 1.0 waere ein Wesen immun, und Immunitaet ist keine
// Zahl, sondern ein Spielabbruch. Nach unten bis −1.0 fuer Verwundbarkeit.
let r = resistance.clamp(-1.0, b.resistance_cap);
// `max(1)`: Ein Treffer macht nie null, sonst gibt es Kaempfe, die
// rechnerisch nie enden.
(after_crit * (1.0 - r)).round().max(1.0) as u32
}
#[cfg(test)]
mod tests {
use super::*;
fn b() -> Balance {
Balance::default()
}
#[test]
fn trefferchance_bleibt_entscheidbar() {
let b = b();
// Ein absurd flinker Verteidiger bleibt treffbar.
assert_eq!(hit_chance(0.8, 10_000.0, &b), b.hit_floor);
// Ein absurd treffsicherer Angreifer verfehlt trotzdem manchmal.
assert_eq!(hit_chance(99.0, 0.0, &b), b.hit_ceiling);
}
#[test]
fn resistenz_macht_nicht_immun() {
let b = b();
// 100 % Resistenz waere Immunitaet — der Deckel verhindert das.
assert!(final_damage(100.0, false, 1.5, 1.0, &b) > 0);
assert_eq!(final_damage(100.0, false, 1.5, 1.0, &b), 20, "80 % gedeckelt");
}
#[test]
fn verwundbarkeit_ist_ausdrueckbar() {
// Negative Resistenz = mehr Schaden. „Untote nehmen doppelten heiligen
// Schaden" braucht das.
assert_eq!(final_damage(10.0, false, 1.5, -1.0, &b()), 20);
}
#[test]
fn ein_treffer_macht_nie_null() {
assert_eq!(final_damage(0.4, false, 1.5, 0.8, &b()), 1);
}
#[test]
fn krit_reihenfolge_ist_belanglos() {
let b = b();
let (roh, kf, r) = (37.0f32, 1.7f32, 0.35f32);
// Beide Reihenfolgen, direkt ausgerechnet.
let erst_krit = (roh * kf) * (1.0 - r);
let erst_minderung = (roh * (1.0 - r)) * kf;
assert!((erst_krit - erst_minderung).abs() < 0.0001);
assert_eq!(final_damage(roh, true, kf, r, &b), erst_krit.round() as u32);
}
#[test]
fn skalierender_schaden() {
// Basisangriff: scale 1.0 auf 7 Schaden.
assert_eq!(raw_damage(0.0, 1.0, 7.0), 7.0);
// Feuerball: fester Betrag, unabhaengig von der Waffe.
assert_eq!(raw_damage(20.0, 0.0, 7.0), 20.0);
// Wuchtschlag: 150 % Waffenschaden.
assert_eq!(raw_damage(0.0, 1.5, 8.0), 12.0);
}
}

View File

@@ -1,84 +0,0 @@
//! Kommandos der Oberflaeche an das Spiel — Ticket 11.
//!
//! **Nicht** zu verwechseln mit `Action`, dem TOML-Effektvokabular aus Ticket
//! 01. „Geh nach Norden" ist keine der zehn Aktionen und wird nie eine.
#[derive(Debug, Clone, Copy)]
pub enum Direction {
North,
South,
West,
East,
}
/// Ticket 11 hat zwoelf Commands festgelegt; M1 braucht zwei davon.
///
#[derive(Debug, Clone)]
pub enum Command {
Move(Direction),
Wait,
Descend,
/// Hebt den Haufen auf, auf dem der Held steht. Ticket 16: Der Inhalt wird
/// **erst hier** gezogen — moeglich nur, weil alle Haufen dasselbe Symbol
/// zeigen, die Anzeige also nichts verraet.
PickUp,
UseItem(usize),
Equip(usize),
Unequip(String),
Drop(usize),
/// 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.
/// Dasselbe gilt fuer die Flucht.
UseAbility {
ability: String,
target: super::creature::CreatureRef,
},
/// Die gewaehlten Charakteroptionen, eine Liste von IDs. Wie viele je
/// Kategorie noetig sind, steht als `pick` in der Registry — der Screen
/// zaehlt nicht mit, `rejects` prueft.
CreateCharacter(Vec<String>),
/// **Alle** Punkte auf einmal und damit **ein** Zug (Ticket 15). Ein
/// Kommando je Punkt hiesse bei drei Stufen drei Zuege, in denen ein Gift
/// tickt, waehrend man ein Menue bedient.
AssignPoints(Vec<(super::attributes::Attribute, u32)>),
/// Werkbank. **Ein** Kommando mit einer Variantenliste statt fuenf
/// Kommandos, die sich durch `rejects` und `perform` ziehen — ein Cheat
/// wird nie abgewiesen, das ist sein ganzer Zweck.
Debug(Cheat),
}
/// Was die Werkbank kann. Bewusst klein: Was sich mit zwei vorhandenen
/// Kommandos nachstellen laesst, gehoert nicht hierher.
#[derive(Debug, Clone)]
pub enum Cheat {
/// Gibt den Gegenstand — zehn Stueck, wenn er stapelbar ist. Sonst muesste
/// man fuer hundert Gold hundertmal druecken.
Give(String),
/// Leben und Ausdauer voll.
Heal,
/// Eine Etage tiefer, ohne Treppe.
Descend,
/// Setzt alle Gegner auf null Leben. **Nicht** `enemies.clear()`: So laeuft
/// der normale Weg samt Todes-Triggern und „Der Raum ist geräumt".
ClearRoom,
}
/// Ergebnis eines Kommandos — der Befund aus dem Durchstich in Ticket 12:
/// „alle Commands kosten einen Zug" heisst **alle ausgefuehrten**. Gegen eine
/// Wand zu laufen darf keine Zeit verbrauchen, sonst tickt ein Gift-Debuff,
/// weil jemand danebengetippt hat.
#[derive(Debug, Clone)]
pub enum Outcome {
TurnPassed,
Rejected(&'static str),
}

View File

@@ -1,112 +0,0 @@
//! Bedingungen — Ticket 08.
//!
//! **Eine** Grammatik fuer Trigger *und* Modifikatoren, damit sie sich nicht
//! subtil auseinanderentwickeln. Eine Liste ist implizit UND, jeder Eintrag ist
//! ueber `not` negierbar; es gibt **kein ODER** und keine Verschachtelung.
//!
//! Zwei Moeglichkeiten werden zu zwei Eintraegen, oder der Modder fasst sie
//! ueber ein gemeinsames Tag zusammen — ein rekursiver Ausdrucksbaum braeuchte
//! eine Klammer-Syntax, die in TOML niemand freiwillig schreibt.
use serde::Deserialize;
use super::attributes::Attribute;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Who {
#[default]
#[serde(rename = "self")]
Zelf,
Target,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "check", rename_all = "snake_case")]
pub enum Check {
HasTag {
#[serde(default)]
who: Who,
value: String,
},
Attribute {
#[serde(default)]
who: Who,
attribute: Attribute,
#[serde(default)]
at_least: Option<f32>,
#[serde(default)]
below: Option<f32>,
},
HasItem {
#[serde(default)]
who: Who,
item: String,
#[serde(default = "one")]
count: u32,
},
/// Anteil am Maximum, 0.0–1.0. Fuer `ai`-Listen wie „unter 30 % Leben".
HealthBelow {
#[serde(default)]
who: Who,
value: f32,
},
}
fn one() -> u32 {
1
}
#[derive(Debug, Clone, Deserialize)]
pub struct Condition {
#[serde(flatten)]
pub check: Check,
/// Kein ODER, aber Negation je Eintrag.
#[serde(default)]
pub not: bool,
}
impl Condition {
pub fn who(&self) -> Who {
match &self.check {
Check::HasTag { who, .. }
| Check::Attribute { who, .. }
| Check::HasItem { who, .. }
| Check::HealthBelow { who, .. } => *who,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn grammatik_aus_ticket_08_liest_sich() {
let toml = r#"
condition = [
{ check = "has_tag", who = "target", value = "core:undead" },
{ check = "attribute", who = "self", attribute = "level", at_least = 3 },
{ check = "has_item", who = "self", item = "core:key", count = 1, not = true },
]
"#;
#[derive(Deserialize)]
struct Wrap {
condition: Vec<Condition>,
}
let w: Wrap = toml::from_str(toml).expect("Grammatik aus Ticket 08 muss lesbar sein");
assert_eq!(w.condition.len(), 3);
assert!(matches!(w.condition[0].check, Check::HasTag { .. }));
assert_eq!(w.condition[0].who(), Who::Target);
assert!(!w.condition[0].not);
assert!(w.condition[2].not, "`not` muss neben dem Check stehen duerfen");
}
#[test]
fn who_faellt_auf_self_zurueck() {
let c: Condition =
toml::from_str("check = \"has_tag\"\nvalue = \"core:rat\"").expect("minimal");
assert_eq!(c.who(), Who::Zelf);
}
}

View File

@@ -1,147 +0,0 @@
//! Wesen — Held und Gegner sind derselbe Typ (Ticket 07).
//!
//! `health`, `stamina`, `tags` und `CreatureRef::Enemy` liest in M1 noch
//! niemand: Sie brauchen den Kampf. Sie stehen trotzdem hier, weil sie die
//! Struktur aus Ticket 07 sind und nicht deren halbe Vorstufe.
#![allow(dead_code)]
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use super::action::ActiveModifier;
use super::attributes::{Attribute, Attributes};
/// Ein Exemplar eines Gegenstands. Traegt **nur IDs** — die Affixe sind
/// Registry-Eintraege, keine eingebetteten Modifikatoren (Ticket 07). Genau
/// deshalb bleibt der Speicherstand eine ID-Liste.
#[derive(Debug, Clone, Serialize, Deserialize)]
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(), 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
}
}
/// Adresse eines Wesens. Faellt direkt aus der Zwei-Felder-Entscheidung in
/// Ticket 07: Held und Gegner liegen getrennt, es gibt also keinen
/// gemeinsamen Index.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CreatureRef {
Player,
Enemy(usize),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Creature {
pub name: String,
/// Zustand, scharf getrennt von den Attributen (Ticket 01).
pub health: f32,
pub stamina: f32,
/// Einmal beim Betreten des Kampfes gewuerfelt (Ticket 14).
pub initiative: i32,
#[serde(default, skip_serializing_if = "Option::is_none")]
/// Aus welcher `creatures`-Definition dieses Wesen stammt — der Held hat
/// keine. Traegt `ai`-Liste, Faehigkeiten und Trigger.
pub def: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
/// **Die Ansage**: Was dieses Wesen im naechsten Zug tun wird — verbindlich
/// und deshalb stoerbar.
pub intent: Option<String>,
/// Grundwerte. `base.level` ist die einzige Quelle fuer Aufstiegspunkte
/// (Ticket 15) — das *gelesene* Attribut darf hoeher ausfallen.
pub base: Attributes,
/// Gewaehlte Charakteroptionen. Ohne dieses Feld haette der Held nach dem
/// Charakterbau keine Rasse mehr (Ticket 07).
pub options: Vec<String>,
pub tags: Vec<String>,
/// Faehigkeiten, die *nicht* aus Optionen oder Ausruestung kommen.
pub abilities: Vec<String>,
// Ab hier stehen TOML-**Tabellen**. Die Reihenfolge ist keine Kosmetik:
// TOML verlangt Werte vor Tabellen, und der Speicherstand ist der Baum
// selbst (Ticket 09).
pub inventory: Vec<ItemInstance>,
/// **Nur befristete** Modifikatoren. Rasse und Klasse kommen aus `options`,
/// Ausruestung aus `equipment` — hineinzukopieren machte das Lesen billiger
/// und das Schreiben gefaehrlich (Ticket 07).
pub modifiers: Vec<ActiveModifier>,
pub equipment: HashMap<String, ItemInstance>,
pub cooldowns: HashMap<String, u32>,
}
impl Creature {
pub fn new(name: impl Into<String>) -> Self {
let base = Attributes::default();
Self {
name: name.into(),
health: base.get(Attribute::MaxHealth),
stamina: base.get(Attribute::MaxStamina),
base,
options: Vec::new(),
inventory: Vec::new(),
equipment: HashMap::new(),
tags: Vec::new(),
modifiers: Vec::new(),
initiative: 0,
cooldowns: HashMap::new(),
intent: None,
def: None,
abilities: Vec::new(),
}
}
/// Baut ein Wesen aus seiner Registry-Definition (Ticket 14). Attribute,
/// die die Definition nicht nennt, bleiben beim Standard.
pub fn from_def(def: &crate::data::CreatureDef) -> Self {
let mut c = Creature::new(def.name.clone());
c.def = Some(def.id.clone());
c.tags = def.tags.clone();
c.abilities = def.abilities.clone();
for (name, value) in &def.base {
// **Ueber einen Wert, nicht ueber ein Dokument** (Befund M9):
// `toml::from_str` erwartet eine ganze TOML-Datei, und `"max_health"`
// allein ist keine — der Aufruf schlug also fuer *jeden*
// Attributnamen fehl, und jeder Gegner behielt seit M3 stumm die
// Standardwerte. Die Riesenratte hatte 100 Leben statt 14.
match toml::Value::String(name.clone()).try_into::<Attribute>() {
Ok(attr) => c.base.set(attr, *value),
// Unbekannter Attributname ist ein Datenfehler und wurde beim
// Laden gemeldet — hier schlicht ignorieren.
Err(_) => continue,
}
}
c.health = c.base.get(Attribute::MaxHealth);
c.stamina = c.base.get(Attribute::MaxStamina);
c
}
pub fn is_alive(&self) -> bool {
self.health > 0.0
}
}

View File

@@ -1,202 +0,0 @@
//! Events und Trigger — Ticket 08.
//!
//! Der **Trigger** ist die vierte Wirkungsform: Modifikator rechnet um, Aktion
//! wirkt einmal, Faehigkeit bietet an — der Trigger sagt *wann*. Er darf
//! ueberall stehen, wo `modifiers` und `actions` stehen.
use serde::{Deserialize, Serialize};
use super::action::{Action, DamageType};
use super::condition::Condition;
use super::creature::CreatureRef;
/// Zwoelf feste Events, Engine-Vokabular ohne Namensraum. Mod-Events braechten
/// nichts, solange niemand sie *ausloesen* kann — das kommt mit Stufe (c).
///
/// `attack_hit` und `damage_taken` bleiben getrennt: Ersteres feuert beim
/// Angreifer (dort haengt das flammende Schwert), Letzteres beim Getroffenen
/// (dort haengt der Dornenpanzer).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EventKind {
TurnStarted,
AttackHit,
DamageTaken,
CreatureDied,
ItemPickedUp,
ItemUsed,
FloorEntered,
RoomEntered,
NpcTalked,
QuestCompleted,
CombatStarted,
CombatEnded,
}
impl EventKind {
/// Ob das Ereignis **eine Seite hat**. Ticket 08: „`attack_hit` feuert beim
/// Angreifer, `damage_taken` beim Getroffenen — ein gemeinsames Event
/// zwaenge jeden Trigger, erst zu pruefen, auf welcher Seite er steht."
///
/// Genau das passierte trotzdem, weil die Traegerschleife *jedes* Ereignis
/// jedem Beteiligten vorlegte: Die Riesenratte vergiftete auch dann, wenn
/// der **Held** zuschlug (Befund M9). Bei diesen beiden feuert deshalb nur
/// der Traeger, der in `source` steht.
///
/// Alle uebrigen bleiben offen: `turn_started` muss bei jedem ticken, sonst
/// vergiftet sich nur der Spieler; `creature_died` will ein „wenn etwas
/// stirbt"-Trigger auch von der Seitenlinie sehen.
pub fn bound_to_source(self) -> bool {
matches!(self, EventKind::AttackHit | EventKind::DamageTaken)
}
}
#[derive(Debug, Clone)]
pub struct Event {
pub kind: EventKind,
pub source: CreatureRef,
pub target: Option<CreatureRef>,
/// Nutzlast. Nur was ein benanntes Beispiel braucht: `damage_type` fuer
/// „Dornenpanzer reflektiert nur Feuer", `prefab` fuer die Quest „betrete die
/// Bibliothek". `critical` und `amount` bleiben draussen, bis jemand sie
/// fordert (Nachtrag an Ticket 08).
pub damage_type: Option<DamageType>,
pub prefab: Option<String>,
}
impl Event {
pub fn new(kind: EventKind, source: CreatureRef) -> Self {
Self { kind, source, target: None, damage_type: None, prefab: None }
}
pub fn to(mut self, target: CreatureRef) -> Self {
self.target = Some(target);
self
}
pub fn with_damage(mut self, dt: DamageType) -> Self {
self.damage_type = Some(dt);
self
}
pub fn in_prefab(mut self, prefab: impl Into<String>) -> Self {
self.prefab = Some(prefab.into());
self
}
}
/// Filtert das **Ereignis**, nicht ein Wesen — deshalb ein eigenes Feld und
/// keine weitere Pruefung in `condition`: Dieselbe Bedingungsgrammatik laeuft
/// auch beim Auslesen eines Attributs, und dort gibt es kein Event. Eine
/// Nutzlast-Pruefung in `condition` waere im Trigger sinnvoll und am Modifikator
/// immer falsch, ohne dass der Modder es sieht.
///
/// Alle gesetzten Felder muessen zutreffen (implizit UND). **Ein Wert je Feld,
/// keine Negation** — wer Feuer *und* Eis reflektieren will, schreibt zwei
/// Trigger. Fehlt ein Feld, wird nicht gefiltert.
#[derive(Debug, Clone, Default, Deserialize)]
pub struct EventFilter {
#[serde(default)]
pub damage_type: Option<DamageType>,
#[serde(default)]
pub prefab: Option<String>,
}
impl EventFilter {
pub fn matches(&self, event: &Event) -> bool {
self.damage_type.is_none_or(|dt| event.damage_type == Some(dt))
&& self.prefab.as_ref().is_none_or(|p| event.prefab.as_ref() == Some(p))
}
}
/// `{ on = <event>, condition = [...], actions = [...] }` — die vierte
/// Wirkungsform. Im Trigger ist `self` immer sein **Traeger**, `target` die
/// andere Seite des Events.
#[derive(Debug, Clone, Deserialize)]
pub struct Trigger {
pub on: EventKind,
/// `condition` fragt **Wesen**, `when` filtert das **Ereignis**.
#[serde(default)]
pub when: EventFilter,
#[serde(default)]
pub condition: Vec<Condition>,
#[serde(default)]
pub actions: Vec<Action>,
/// Wahrscheinlichkeit, dass der Trigger ueberhaupt feuert — „20 % Chance,
/// Brennen auszuloesen" braucht sonst ein Script.
#[serde(default = "always")]
pub chance: f32,
}
fn always() -> f32 {
1.0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn trigger_liest_sich_aus_toml() {
let t: Trigger = toml::from_str(
r#"
on = "attack_hit"
chance = 0.2
condition = [ { check = "has_tag", who = "target", value = "core:undead" } ]
actions = [ { action = "damage", amount = 4, damage_type = "fire", target = "target" } ]
"#,
)
.expect("Trigger-Form aus Ticket 08");
assert_eq!(t.on, EventKind::AttackHit);
assert_eq!(t.condition.len(), 1);
assert_eq!(t.actions.len(), 1);
assert!((t.chance - 0.2).abs() < f32::EPSILON);
}
#[test]
fn chance_faellt_auf_immer_zurueck() {
let t: Trigger = toml::from_str("on = \"turn_started\"").expect("minimal");
assert_eq!(t.chance, 1.0);
assert!(t.actions.is_empty());
assert!(t.when.matches(&Event::new(EventKind::TurnStarted, CreatureRef::Player)));
}
/// `condition` fragt Wesen, `when` filtert das Ereignis — die Trennung aus
/// dem Nachtrag an Ticket 08.
#[test]
fn when_filtert_die_ereignis_nutzlast() {
let t: Trigger = toml::from_str(
r#"
on = "damage_taken"
when = { damage_type = "fire" }
"#,
)
.expect("Filter-Form aus dem Nachtrag");
let feuer = Event::new(EventKind::DamageTaken, CreatureRef::Player)
.with_damage(DamageType::Fire);
let eis =
Event::new(EventKind::DamageTaken, CreatureRef::Player).with_damage(DamageType::Ice);
let ohne = Event::new(EventKind::DamageTaken, CreatureRef::Player);
assert!(t.when.matches(&feuer));
assert!(!t.when.matches(&eis), "der Dornenpanzer reflektiert nur Feuer");
assert!(!t.when.matches(&ohne), "ohne Nutzlast passt der Filter nicht");
}
#[test]
fn mehrere_felder_sind_implizit_und() {
let f: EventFilter =
toml::from_str(r#"damage_type = "fire""#).expect("ein Feld reicht");
let mut passend = Event::new(EventKind::RoomEntered, CreatureRef::Player)
.with_damage(DamageType::Fire);
assert!(f.matches(&passend));
let beide: EventFilter =
toml::from_str("damage_type = \"fire\"\nprefab = \"core:vault\"").expect("zwei Felder");
assert!(!beide.matches(&passend), "prefab fehlt — alle Felder muessen zutreffen");
passend = passend.in_prefab("core:vault");
assert!(beide.matches(&passend));
}
}

View File

@@ -1,183 +0,0 @@
//! Etage — Innenbau aus Ticket 10.
//!
//! `Room.enemy_spawns`, `loot` und `cleared` liest in M2 noch niemand: Sie
//! brauchen Kampf (M3) und Beutetabellen (M4). Der Generator fuellt sie aber
//! bereits, weil die Marker im Prefab stehen — sie erst spaeter zu lesen
//! hiesse, die Prefabs zweimal zu parsen.
#![allow(dead_code)]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Tile {
Wall,
Floor,
Door,
Stairs,
Entrance,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Rect {
pub x: u16,
pub y: u16,
pub w: u16,
pub h: u16,
}
impl Rect {
/// Ueberlappt oder beruehrt — inklusive einem Feld Rand, damit zwei Raeume
/// nicht Wand an Wand kleben.
pub fn touches(&self, other: &Rect) -> bool {
self.x < other.x + other.w + 1
&& other.x < self.x + self.w + 1
&& self.y < other.y + other.h + 1
&& other.y < self.y + self.h + 1
}
pub fn contains(&self, x: u16, y: u16) -> bool {
x >= self.x && y >= self.y && x < self.x + self.w && y < self.y + self.h
}
}
/// 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, Serialize, Deserialize)]
pub struct NpcInstance {
pub pos: (u16, u16),
pub def: String,
/// Tabelle, also zuletzt (TOML).
pub stock: Vec<super::creature::ItemInstance>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Room {
/// Fuer `room_entered` und die Trigger am Prefab (Ticket 08/10).
pub prefab: String,
/// Aus welcher Tabelle die Haufen dieses Raums ziehen — Prefab-`loot` oder,
/// wenn es fehlt, die Etagentabelle (Ticket 16).
pub loot_table: String,
pub cleared: bool,
/// Die `e`-Plaetze aus dem Prefab — bleiben erhalten, damit die Anzeige
/// weiss, wo etwas stand.
pub enemy_spawns: Vec<(u16, u16)>,
/// Die `l`-Plaetze. Ticket 16: erst beim **Aufheben** aufgeloest, deshalb
/// stehen hier Positionen und keine Gegenstaende.
pub loot: Vec<(u16, u16)>,
// Tabellen zuletzt (TOML).
pub rect: Rect,
/// Die Gegner dieses Raums. Wandern beim Betreten nach `Game.enemies` —
/// **verschoben, nicht kopiert** (Ticket 10).
pub enemies: Vec<super::creature::Creature>,
/// 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>,
}
#[derive(Debug, Deserialize)]
pub struct Floor {
pub number: u8,
pub width: u16,
pub height: u16,
/// **Die eine handgeschriebene Ausnahme** (Ticket 09): Im Speicherstand
/// stehen ASCII-Zeilen wie in einem Prefab, nicht 3200 Enum-Eintraege. Rund
/// 3 KB, lesbar, im Notfall von Hand reparierbar.
#[serde(deserialize_with = "rows_to_tiles")]
pub tiles: Vec<Tile>,
/// `None` ist ein echter Zustand: Korridore gehoeren zu keinem Raum.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub player_room: Option<usize>,
pub rooms: Vec<Room>,
}
impl Tile {
fn symbol(self) -> char {
match self {
Tile::Wall => '#',
Tile::Floor => '.',
Tile::Door => '+',
Tile::Stairs => '>',
Tile::Entrance => '<',
}
}
/// Unbekannte Zeichen werden zur Wand. Ein handverbogener Speicherstand
/// soll den Helden einmauern, nicht das Spiel abbrechen.
fn from_symbol(c: char) -> Tile {
match c {
'.' => Tile::Floor,
'+' => Tile::Door,
'>' => Tile::Stairs,
'<' => Tile::Entrance,
_ => Tile::Wall,
}
}
}
fn rows_to_tiles<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<Tile>, D::Error> {
let rows = Vec::<String>::deserialize(d)?;
Ok(rows.iter().flat_map(|r| r.chars()).map(Tile::from_symbol).collect())
}
/// `Serialize` von Hand — und **nur** deshalb, weil der Zeilenumbruch die
/// Breite braucht, die ein feldweises `serde(with)` nicht sieht. Der Rest ist
/// eins zu eins der Baum; eigene Save-Structs hiessen, jede Struktur zweimal zu
/// pflegen (Ticket 09).
impl Serialize for Floor {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeStruct;
let mut st = s.serialize_struct("Floor", 6)?;
st.serialize_field("number", &self.number)?;
st.serialize_field("width", &self.width)?;
st.serialize_field("height", &self.height)?;
st.serialize_field("tiles", &self.rows())?;
st.serialize_field("player_room", &self.player_room)?;
// Tabellen zuletzt: TOML verlangt Werte vor Tabellen.
st.serialize_field("rooms", &self.rooms)?;
st.end()
}
}
impl Floor {
/// Das Kachelgitter als Textzeilen — dasselbe Format wie ein Prefab.
pub fn rows(&self) -> Vec<String> {
self.tiles
.chunks(self.width as usize)
.map(|z| z.iter().map(|t| t.symbol()).collect())
.collect()
}
pub fn tile(&self, x: u16, y: u16) -> Tile {
if x >= self.width || y >= self.height {
return Tile::Wall;
}
self.tiles[y as usize * self.width as usize + x as usize]
}
/// Startfeld: das `<` aus dem Eingangs-Prefab, sonst das erste begehbare.
pub fn entrance(&self) -> (u16, u16) {
for y in 0..self.height {
for x in 0..self.width {
if self.tile(x, y) == Tile::Entrance {
return (x, y);
}
}
}
for y in 0..self.height {
for x in 0..self.width {
if self.tile(x, y) != Tile::Wall {
return (x, y);
}
}
}
(1, 1)
}
/// In welchem Raum steht diese Position — `None` heisst Korridor.
pub fn room_at(&self, x: u16, y: u16) -> Option<usize> {
self.rooms.iter().position(|r| r.rect.contains(x, y))
}
}

View File

@@ -1,499 +0,0 @@
//! Etagengenerator — Ticket 10.
//!
//! Freie Platzierung mit L-Korridoren. Verbunden wird **Raum `i` mit Raum
//! `i−1`**, weil eine Kette Erreichbarkeit *strukturell* garantiert: Haengt
//! jeder Raum am vorigen, ist jeder von jedem erreichbar — ohne Flutfuellung
//! und ohne Reparaturschleife.
use crate::data::{FloorDef, Prefab, Registries};
use super::creature::Creature;
use super::floor::{Floor, Rect, Room, Tile};
use super::rng::Rng;
/// Ein Prefab-Layout in einer der acht Ausrichtungen.
struct Stamp {
w: u16,
h: u16,
cells: Vec<char>,
}
impl Stamp {
fn from(prefab: &Prefab) -> Self {
let rows = prefab.rows();
let w = rows[0].chars().count() as u16;
let h = rows.len() as u16;
Self { w, h, cells: rows.iter().flat_map(|r| r.chars()).collect() }
}
fn at(&self, x: u16, y: u16) -> char {
self.cells[y as usize * self.w as usize + x as usize]
}
/// 90° im Uhrzeigersinn: transponieren und Zeilen umkehren.
fn rotated(&self) -> Self {
let mut cells = Vec::with_capacity(self.cells.len());
for x in 0..self.w {
for y in (0..self.h).rev() {
cells.push(self.at(x, y));
}
}
Self { w: self.h, h: self.w, cells }
}
fn mirrored(&self) -> Self {
let mut cells = Vec::with_capacity(self.cells.len());
for y in 0..self.h {
for x in (0..self.w).rev() {
cells.push(self.at(x, y));
}
}
Self { w: self.w, h: self.h, cells }
}
/// Acht Varianten je Prefab — achtmal so viel Inhalt aus derselben
/// Handarbeit (Ticket 10).
fn oriented(prefab: &Prefab, rng: &mut Rng) -> Self {
let mut s = Stamp::from(prefab);
if rng.chance(0.5) {
s = s.mirrored();
}
for _ in 0..rng.below(4) {
s = s.rotated();
}
s
}
}
struct Placed {
prefab: String,
rect: Rect,
stamp: Stamp,
}
pub fn generate(def: &FloorDef, reg: &Registries, rng: &mut Rng) -> Floor {
let (width, height) = def.size;
let mut placed: Vec<Placed> = Vec::new();
// Pflichtraeume zuerst auf die leere Flaeche — **das ist die Garantie**.
for tag in &def.required {
let candidates = reg.prefabs_tagged(tag);
if candidates.is_empty() {
continue; // beim Laden bereits gewarnt (Ticket 10)
}
let prefab = candidates[rng.below(candidates.len())];
try_place(prefab, width, height, rng, &mut placed);
}
// Danach gewichtet auffuellen. Raeume, die nicht mehr passen, entfallen
// einfach — das ist der Normalfall, kein Fehler.
let weights: Vec<u32> = def.prefabs.iter().map(|p| p.weight).collect();
let mut attempts = 0;
while placed.len() < def.rooms && attempts < def.rooms * 20 {
attempts += 1;
let Some(i) = rng.weighted(&weights) else { break };
let candidates = reg.prefabs_tagged(&def.prefabs[i].tag);
if candidates.is_empty() {
continue;
}
let prefab = candidates[rng.below(candidates.len())];
try_place(prefab, width, height, rng, &mut placed);
}
// Stempeln
let mut tiles = vec![Tile::Wall; width as usize * height as usize];
let mut rooms = Vec::new();
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 {
let (gx, gy) = (p.rect.x + x, p.rect.y + y);
let c = p.stamp.at(x, y);
let tile = match c {
'#' => Tile::Wall,
'+' => Tile::Door,
'>' => Tile::Stairs,
'<' => Tile::Entrance,
_ => Tile::Floor,
};
tiles[gy as usize * width as usize + gx as usize] = tile;
match c {
'e' => enemy_spawns.push((gx, gy)),
'l' => loot.push((gx, gy)),
'n' => npc_plaetze.push((gx, gy)),
_ => {}
}
}
}
// Ein `e` sagt „hier steht ein Gegner", **nicht welcher** — das
// entscheidet die Etage (Ticket 10). Nur so laesst sich derselbe Raum
// auf Etage 1 und Etage 5 verwenden.
let gewichte: Vec<u32> = def.creatures.iter().map(|c| c.weight).collect();
let enemies = enemy_spawns
.iter()
.filter_map(|_| {
let i = rng.weighted(&gewichte)?;
let cdef = reg.creatures.get(&def.creatures[i].id)?;
Some(Creature::from_def(cdef))
})
.collect();
// Ein Prefab darf ueber sein optionales `loot`-Feld die Etagentabelle
// ersetzen — sonst ist ein Schatzkammer-Raum nur ein Raum mit mehr
// `l`-Zeichen (Ticket 16).
let loot_table = reg
.prefabs
.get(&p.prefab)
.and_then(|pf| pf.loot.clone())
.or_else(|| def.loot.clone())
.unwrap_or_default();
// 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, 0.0, reg, rng))
.unwrap_or_default();
Some(super::floor::NpcInstance { pos, def: id, stock })
})
.collect();
rooms.push(Room {
prefab: p.prefab.clone(),
rect: p.rect,
enemy_spawns,
enemies,
loot_table,
loot,
npcs,
cleared: false,
});
}
// Kette: Raum i an Raum i-1.
for i in 1..placed.len() {
connect(&placed, i - 1, i, width, height, &mut tiles);
}
Floor { number: def.number, width, height, tiles, rooms, player_room: None }
}
fn try_place(prefab: &Prefab, width: u16, height: u16, rng: &mut Rng, placed: &mut Vec<Placed>) {
for _ in 0..60 {
let stamp = Stamp::oriented(prefab, rng);
if stamp.w + 2 >= width || stamp.h + 2 >= height {
return; // passt grundsaetzlich nicht
}
let x = 1 + rng.below((width - stamp.w - 2) as usize) as u16;
let y = 1 + rng.below((height - stamp.h - 2) as usize) as u16;
let rect = Rect { x, y, w: stamp.w, h: stamp.h };
// Ein Feld Rand, damit zwei Raeume nicht Wand an Wand kleben.
if placed.iter().any(|p| p.rect.touches(&rect)) {
continue;
}
placed.push(Placed { prefab: prefab.id.clone(), rect, stamp });
return;
}
}
/// Tueren eines platzierten Raums, in Weltkoordinaten.
fn doors(p: &Placed) -> Vec<(u16, u16)> {
let mut out = Vec::new();
for y in 0..p.stamp.h {
for x in 0..p.stamp.w {
if p.stamp.at(x, y) == '+' {
out.push((p.rect.x + x, p.rect.y + y));
}
}
}
out
}
/// Das Feld **direkt ausserhalb** einer Tuer. Ohne diesen Schritt beginnt der
/// Korridor *auf* der Tuer — und weil eine Tuer auf der Raumwand liegt, graebt
/// der waagerechte Abschnitt die ganze Wandreihe auf. Der Test
/// `korridore_stanzen_keine_loecher_in_raumwaende` hat genau das gefunden.
fn door_exit(p: &Placed, (x, y): (u16, u16)) -> Option<(u16, u16)> {
let r = p.rect;
if y == r.y {
return y.checked_sub(1).map(|ny| (x, ny));
}
if y == r.y + r.h - 1 {
return Some((x, y + 1));
}
if x == r.x {
return x.checked_sub(1).map(|nx| (nx, y));
}
if x == r.x + r.w - 1 {
return Some((x + 1, y));
}
None // Tuer mitten im Raum ergibt keinen Ausgang
}
fn connect(all: &[Placed], ia: usize, ib: usize, width: u16, height: u16, tiles: &mut [Tile]) {
let exits = |i: usize| -> Vec<(u16, u16)> {
doors(&all[i]).into_iter().filter_map(|d| door_exit(&all[i], d)).collect()
};
let (ea, eb) = (exits(ia), exits(ib));
if ea.is_empty() || eb.is_empty() {
// Prefab ohne Tuer laesst sich nicht anschliessen. Mitte gegen Mitte
// waere die Alternative — und wuerde genau die Loecher stanzen, die
// Ticket 10 verhindern wollte.
return;
}
// Alle Tuerpaare, kuerzeste zuerst, jeweils beide L-Varianten.
let mut paare: Vec<((u16, u16), (u16, u16))> =
ea.iter().flat_map(|s| eb.iter().map(move |e| (*s, *e))).collect();
paare.sort_by_key(|(s, e)| s.0.abs_diff(e.0) as u32 + s.1.abs_diff(e.1) as u32);
let frei = |path: &[(u16, u16)]| {
path.iter().all(|&(x, y)| {
x < width && y < height && !all.iter().any(|p| p.rect.contains(x, y))
})
};
// Erst die L-Form: zwei Segmente sehen wie ein Korridor aus, nicht wie ein
// Irrgang. Wenn eine davon frei ist, gewinnt sie.
for (start, end) in &paare {
for horizontal_first in [true, false] {
let path = l_path(*start, *end, horizontal_first);
if frei(&path) {
carve(&path, width, tiles);
return;
}
}
}
// Sonst Wegsuche **ueber den freien Raum**. Sie kann eine Raumwand gar nicht
// beruehren, weil Raumfelder nicht im Suchraum liegen — damit haelt die
// Zusage aus Ticket 10 („keine Loecher in gezeichnete Waende") statt nur
// wahrscheinlich zu sein. Zwei L-Varianten senkten sie nur.
for (start, end) in &paare {
if let Some(path) = bfs(*start, *end, width, height, all) {
carve(&path, width, tiles);
return;
}
}
// Kein Weg: dann bleibt der Raum unverbunden, und
// `alles_begehbare_haengt_zusammen` schlaegt an. Laut statt still.
}
/// Kuerzester Weg durch Felder, die in **keinem** Raum liegen.
fn bfs(
start: (u16, u16),
end: (u16, u16),
width: u16,
height: u16,
rooms: &[Placed],
) -> Option<Vec<(u16, u16)>> {
let idx = |x: u16, y: u16| y as usize * width as usize + x as usize;
let blocked = |x: u16, y: u16| rooms.iter().any(|p| p.rect.contains(x, y));
if start.0 >= width || start.1 >= height || blocked(start.0, start.1) {
return None;
}
let mut prev: Vec<Option<(u16, u16)>> = vec![None; width as usize * height as usize];
let mut seen = vec![false; width as usize * height as usize];
let mut queue = std::collections::VecDeque::from([start]);
seen[idx(start.0, start.1)] = true;
while let Some((x, y)) = queue.pop_front() {
if (x, y) == end {
let mut path = vec![(x, y)];
let mut cur = (x, y);
while let Some(p) = prev[idx(cur.0, cur.1)] {
path.push(p);
cur = p;
}
return Some(path);
}
let nachbarn = [
(x.wrapping_sub(1), y),
(x + 1, y),
(x, y.wrapping_sub(1)),
(x, y + 1),
];
for (nx, ny) in nachbarn {
if nx >= width || ny >= height || seen[idx(nx, ny)] || blocked(nx, ny) {
continue;
}
seen[idx(nx, ny)] = true;
prev[idx(nx, ny)] = Some((x, y));
queue.push_back((nx, ny));
}
}
None
}
fn carve(path: &[(u16, u16)], width: u16, tiles: &mut [Tile]) {
for &(x, y) in path {
let idx = y as usize * width as usize + x as usize;
if idx < tiles.len() && tiles[idx] == Tile::Wall {
tiles[idx] = Tile::Floor;
}
}
}
fn l_path(from: (u16, u16), to: (u16, u16), horizontal_first: bool) -> Vec<(u16, u16)> {
let mut path = Vec::new();
let (x0, y0) = from;
let (x1, y1) = to;
let push_h = |y: u16, path: &mut Vec<(u16, u16)>| {
for x in x0.min(x1)..=x0.max(x1) {
path.push((x, y));
}
};
let push_v = |x: u16, path: &mut Vec<(u16, u16)>| {
for y in y0.min(y1)..=y0.max(y1) {
path.push((x, y));
}
};
if horizontal_first {
push_h(y0, &mut path);
push_v(x1, &mut path);
} else {
push_v(x0, &mut path);
push_h(y1, &mut path);
}
path
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data::load_mods;
fn erzeuge(seed: u64, nummer: u8) -> Floor {
let (reg, _) = load_mods().expect("core muss laden");
let def = reg.floor_number(nummer).expect("Etage existiert");
generate(def, &reg, &mut Rng::new(seed))
}
#[test]
fn derselbe_seed_erzeugt_dieselbe_etage() {
let a = erzeuge(1234, 1);
let b = erzeuge(1234, 1);
assert_eq!(a.tiles, b.tiles, "sonst waere der Seed im Save wertlos");
assert_eq!(a.rooms.len(), b.rooms.len());
}
#[test]
fn pflichtraeume_sind_da() {
// Pflichtraeume kommen zuerst auf die leere Flaeche — sie muessen
// ueber alle Seeds hinweg auftauchen.
for seed in 1..40u64 {
let f = erzeuge(seed, 1);
let hat = |tag: &str| f.rooms.iter().any(|r| r.prefab.contains(tag));
assert!(hat("entrance_hall"), "seed {seed}: kein Eingang");
assert!(hat("stair_room"), "seed {seed}: keine Treppe");
assert!(hat("market"), "seed {seed}: kein Händler");
}
}
#[test]
fn raeume_ueberlappen_nicht() {
for seed in 1..30u64 {
let f = erzeuge(seed, 3);
for (i, a) in f.rooms.iter().enumerate() {
for b in f.rooms.iter().skip(i + 1) {
assert!(!a.rect.touches(&b.rect), "seed {seed}: {} trifft {}", a.prefab, b.prefab);
}
}
}
}
/// Die Kette garantiert Erreichbarkeit strukturell — hier wird nachgezaehlt,
/// ob das auch nach dem Stempeln stimmt.
#[test]
fn alles_begehbare_haengt_zusammen() {
for seed in 1..30u64 {
let f = erzeuge(seed, 2);
let start = f.entrance();
let erreichbar = flood(&f, start);
let begehbar = f.tiles.iter().filter(|t| **t != Tile::Wall).count();
assert_eq!(
erreichbar, begehbar,
"seed {seed}: {} von {begehbar} Feldern erreichbar",
erreichbar
);
}
}
/// Ticket 10 hat Tueren ausdruecklich gemacht, damit der Generator **keine
/// Loecher in eine von Hand gezeichnete Wand stanzt**. Der Rand eines Raums
/// besteht im Prefab nur aus `#` und `+` — findet sich dort Boden, hat ein
/// Korridor durchgegraben.
#[test]
fn korridore_stanzen_keine_loecher_in_raumwaende() {
let mut durchgegraben = 0;
for seed in 1..60u64 {
let f = erzeuge(seed, 3);
for room in &f.rooms {
let r = room.rect;
let rand = (r.x..r.x + r.w)
.flat_map(|x| [(x, r.y), (x, r.y + r.h - 1)])
.chain((r.y..r.y + r.h).flat_map(|y| [(r.x, y), (r.x + r.w - 1, y)]));
for (x, y) in rand {
if f.tile(x, y) == Tile::Floor {
durchgegraben += 1;
}
}
}
}
assert_eq!(
durchgegraben, 0,
"{durchgegraben} Wandfelder wurden durchgegraben — der Rueckfallpfad in \
`connect` greift haeufiger als gedacht und braucht eine echte Wegsuche"
);
}
fn flood(f: &Floor, start: (u16, u16)) -> usize {
let mut seen = vec![false; f.tiles.len()];
let mut stack = vec![start];
let mut n = 0;
while let Some((x, y)) = stack.pop() {
let idx = y as usize * f.width as usize + x as usize;
if seen[idx] || f.tile(x, y) == Tile::Wall {
continue;
}
seen[idx] = true;
n += 1;
if x > 0 {
stack.push((x - 1, y));
}
if y > 0 {
stack.push((x, y - 1));
}
if x + 1 < f.width {
stack.push((x + 1, y));
}
if y + 1 < f.height {
stack.push((x, y + 1));
}
}
n
}
}

View File

@@ -1,235 +0,0 @@
//! Beutetabellen ziehen — Ticket 16.
//!
//! **Eine** Maschinerie fuer Gegenstaende *und* Affixe: dieselben Gewichte,
//! dieselbe Tabelle-in-Tabelle, dieselben Nieten.
//!
//! Gewichte statt Prozente ist keine Geschmacksfrage, sondern eine
//! Modding-Frage: Prozente muessen sich auf 100 summieren, und ein hinzugefuegter
//! Eintrag macht die Summe kaputt.
use crate::data::{Registries, parse_range};
use super::creature::ItemInstance;
use super::rng::Rng;
/// Zieht aus einer Tabelle. Rekursion ueber Tabelle-in-Tabelle ist mit einer
/// Tiefe gedeckelt — eine Tabelle, die sich selbst einbindet, wuerde sonst
/// endlos laufen, und das ist ein Datenfehler, kein Absturzgrund.
/// `luck` ist der **gerechnete** Wert des Ziehenden — 0.0, wenn niemand zieht
/// (ein Haendlerregal hat kein Glueck).
pub fn roll(table: &str, luck: f32, reg: &Registries, rng: &mut Rng) -> Vec<ItemInstance> {
let mut out = Vec::new();
draw(table, luck, reg, rng, 0, &mut out);
out
}
/// `luck` hat genau **eine** Regel (Ticket 16): eine Chance auf einen
/// zusaetzlichen Zug — auf jeder Tabelle, und deshalb auch auf jeder
/// Affixtabelle. Sie wirkt dreifach, ohne dreimal geschrieben zu werden: mehr
/// Gegenstaende, mehr Affixe, und haeufiger die staerkere Stufe, weil die als
/// eigener Eintrag mit kleinem Gewicht danebensteht.
fn extra_roll(luck: f32, reg: &Registries, rng: &mut Rng) -> u32 {
u32::from(rng.chance((luck * reg.balance.luck_factor).clamp(0.0, 0.9)))
}
fn draw(
table_id: &str,
luck: f32,
reg: &Registries,
rng: &mut Rng,
depth: u8,
out: &mut Vec<ItemInstance>,
) {
if depth > 8 {
return;
}
let Some(table) = reg.loot_tables.get(table_id) else { return };
// `guaranteed` nimmt an der Gewichtung nicht teil und kommt immer.
for entry in table.entries.iter().filter(|e| e.guaranteed) {
emit(entry, luck, reg, rng, depth, out);
}
let pool: Vec<&crate::data::LootEntry> =
table.entries.iter().filter(|e| !e.guaranteed).collect();
if pool.is_empty() {
return;
}
let weights: Vec<u32> = pool.iter().map(|e| e.weight).collect();
let (lo, hi) = parse_range(table.rolls.as_ref());
let rolls = rng.range(lo, hi) + extra_roll(luck, reg, rng);
for _ in 0..rolls {
if let Some(i) = rng.weighted(&weights) {
emit(pool[i], luck, reg, rng, depth, out);
}
}
}
fn emit(
entry: &crate::data::LootEntry,
luck: f32,
reg: &Registries,
rng: &mut Rng,
depth: u8,
out: &mut Vec<ItemInstance>,
) {
// Niete: ein gewoehnlicher Eintrag, kein eigenes `drop_chance`-Feld.
if entry.nothing {
return;
}
if let Some(sub) = &entry.table {
draw(sub, luck, reg, rng, depth + 1, out);
return;
}
// Ein Affix-Eintrag ausserhalb einer Affix-Tabelle ergibt keinen
// Gegenstand — er wird von `affixes_for` gelesen, nicht hier.
if entry.affix.is_some() {
return;
}
let Some(item) = &entry.item else { return };
if !reg.items.contains_key(item) {
return; // beim Laden gemeldet
}
let affixes = match &entry.affixes {
Some(table) => affixes_for(table, luck, reg, rng),
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());
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 });
}
}
}
/// Zieht Affixe aus derselben Tabellenmaschinerie. Abstufungen sind eigene
/// Eintraege mit kleinerem Gewicht — deshalb erhoeht ein zusaetzlicher Zug
/// (Glueck) auch die Chance auf die staerkere Stufe.
fn affixes_for(table_id: &str, luck: f32, reg: &Registries, rng: &mut Rng) -> Vec<String> {
let Some(table) = reg.loot_tables.get(table_id) else { return Vec::new() };
let weights: Vec<u32> = table.entries.iter().map(|e| e.weight).collect();
let (lo, hi) = parse_range(table.rolls.as_ref());
let mut out = Vec::new();
for _ in 0..rng.range(lo, hi) + extra_roll(luck, reg, rng) {
let Some(i) = rng.weighted(&weights) else { continue };
let entry = &table.entries[i];
if entry.nothing {
continue;
}
if let Some(affix) = &entry.affix
&& reg.affixes.contains_key(affix)
&& !out.contains(affix)
{
out.push(affix.clone());
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data::load_mods;
#[test]
fn niete_liefert_nichts_und_stuerzt_nicht_ab() {
let (reg, _) = load_mods().expect("core");
let mut rng = Rng::new(1);
// Eine Tabelle, die es nicht gibt, ist kein Absturzgrund.
assert!(roll("gibtsnicht", 0.0, &reg, &mut rng).is_empty());
}
#[test]
fn garantierter_eintrag_kommt_immer() {
let (reg, _) = load_mods().expect("core");
for seed in 1..30u64 {
let mut rng = Rng::new(seed);
let beute = roll("core:boss_table", 0.0, &reg, &mut rng);
assert!(
beute.iter().any(|i| i.def == "core:bone_blade"),
"seed {seed}: das Einzelstueck muss fallen"
);
}
}
#[test]
fn affixe_kommen_aus_derselben_maschinerie() {
let (reg, _) = load_mods().expect("core");
let mut mit_affix = 0;
for seed in 1..80u64 {
let mut rng = Rng::new(seed);
for inst in roll("core:floor_5_table", 0.0, &reg, &mut rng) {
if !inst.affixes.is_empty() {
mit_affix += 1;
// Jeder gezogene Affix muss existieren.
assert!(inst.affixes.iter().all(|a| reg.affixes.contains_key(a)));
}
}
}
assert!(mit_affix > 0, "auf Etage 5 muessen Affixe vorkommen");
}
#[test]
fn derselbe_seed_liefert_dieselbe_beute() {
let (reg, _) = load_mods().expect("core");
let a = roll("core:floor_3_table", 0.0, &reg, &mut Rng::new(77));
let b = roll("core:floor_3_table", 0.0, &reg, &mut Rng::new(77));
let ids = |v: &[ItemInstance]| {
v.iter().map(|i| (i.def.clone(), i.affixes.clone())).collect::<Vec<_>>()
};
assert_eq!(ids(&a), ids(&b), "sonst waere der Seed im Save wertlos");
}
/// Zwei Tabellen, die sich gegenseitig einbinden, sind ein Datenfehler —
/// aber kein Absturzgrund. Der Tiefendeckel ist die Stelle, die das traegt,
/// also braucht sie einen Test. Gebaut von Hand, damit `core` keine kaputten
/// Daten ausliefert.
#[test]
fn wechselseitiger_bezug_haengt_nicht() {
use crate::data::registries::{LootEntry, LootTable};
use crate::data::Registries;
let tabelle = |id: &str, other: &str| LootTable {
id: id.to_string(),
rolls: Some(String::from("1-1")),
entries: vec![LootEntry {
weight: 1,
guaranteed: false,
item: None,
table: Some(other.to_string()),
affix: None,
nothing: false,
count: None,
affixes: None,
}],
overrides: None,
};
let mut reg = Registries::default();
reg.loot_tables.insert(String::from("a"), tabelle("a", "b"));
reg.loot_tables.insert(String::from("b"), tabelle("b", "a"));
// Terminiert, statt den Stack zu sprengen.
assert!(roll("a", 0.0, &reg, &mut Rng::new(5)).is_empty());
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,122 +0,0 @@
//! Zufall — Ticket 14.
//!
//! Eigener xorshift64* statt der `rand`-Crate: acht Zeilen gegen eine
//! Dependency ist die eine Haelfte. Die andere ist, dass ein selbst gehaltener
//! Seed die Save-Scumming-Frage ueberhaupt erst beantwortbar macht — und die
//! Antwort ist **nein, es wird nicht neu gewuerfelt**.
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Rng(u64);
impl Rng {
pub fn new(seed: u64) -> Self {
// xorshift bleibt bei 0 fuer immer bei 0.
Self(if seed == 0 { 0x9E37_79B9_7F4A_7C15 } else { seed })
}
/// Entropie ohne Dependency. Nur fuer den *ersten* Seed eines Laufs — danach
/// steht er im Speicherstand und wird nie wieder frisch gezogen.
pub fn from_clock() -> Self {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0x5EED);
Self::new(nanos)
}
pub fn next_u64(&mut self) -> u64 {
let mut x = self.0;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.0 = x;
x.wrapping_mul(0x2545_F491_4F6C_DD1D)
}
/// Gleichverteilt in `0..n`. `n == 0` gibt 0 — ein leerer Bereich ist kein
/// Absturzgrund, sondern schlicht kein Zug.
pub fn below(&mut self, n: usize) -> usize {
if n == 0 { 0 } else { (self.next_u64() % n as u64) as usize }
}
/// Einschliesslich beider Grenzen.
pub fn range(&mut self, lo: u32, hi: u32) -> u32 {
if hi <= lo {
return lo;
}
lo + self.below((hi - lo + 1) as usize) as u32
}
pub fn chance(&mut self, p: f32) -> bool {
// Eine Chance von null ist keine Frage an den Wuerfel. Ohne das
// verbraucht ein `chance(0.0)` eine Zahl und verschiebt den ganzen
// Strom — ein Held ohne Glueck erzeugte eine andere Etage als einer mit.
if p <= 0.0 {
return false;
}
if p >= 1.0 {
return true;
}
(self.next_u64() % 10_000) < (p * 10_000.0) as u64
}
/// Zieht einen Index gewichtet. Gewichte statt Prozente — sie komponieren,
/// wenn ein Mod einen Eintrag ergaenzt (Ticket 16).
pub fn weighted(&mut self, weights: &[u32]) -> Option<usize> {
let total: u32 = weights.iter().sum();
if total == 0 {
return None;
}
let mut roll = (self.next_u64() % total as u64) as u32;
for (i, w) in weights.iter().enumerate() {
if roll < *w {
return Some(i);
}
roll -= w;
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn derselbe_seed_liefert_dieselbe_folge() {
let mut a = Rng::new(42);
let mut b = Rng::new(42);
let fa: Vec<u64> = (0..8).map(|_| a.next_u64()).collect();
let fb: Vec<u64> = (0..8).map(|_| b.next_u64()).collect();
assert_eq!(fa, fb, "Nachladen wuerde sonst neu wuerfeln (Ticket 14)");
}
#[test]
fn seed_null_bleibt_nicht_stehen() {
let mut r = Rng::new(0);
assert_ne!(r.next_u64(), 0);
assert_ne!(r.next_u64(), 0);
}
#[test]
fn below_bleibt_im_bereich() {
let mut r = Rng::new(7);
for _ in 0..500 {
assert!(r.below(5) < 5);
}
assert_eq!(r.below(0), 0);
}
#[test]
fn gewichtung_trifft_nur_eintraege_mit_gewicht() {
let mut r = Rng::new(1);
// Der mittlere Eintrag hat Gewicht 0 und darf nie kommen.
for _ in 0..500 {
let i = r.weighted(&[3, 0, 1]).expect("Gesamtgewicht > 0");
assert_ne!(i, 1);
}
assert_eq!(r.weighted(&[0, 0]), None, "nur Nullgewichte = kein Zug");
}
}

82
src/items.rs Normal file
View File

@@ -0,0 +1,82 @@
use serde::Deserialize;
use serde::de::DeserializeOwned;
pub mod armor;
pub mod weapon;
pub use armor::ItemArmor;
pub use weapon::ItemWeapon;
// === ENUMS ===
#[derive(Debug, Deserialize)]
pub enum ItemType {
Weapon,
OffHand,
Helmet,
Chestplate,
Gloves,
Boots,
Consumable,
Currency,
}
// === STRUCTS ===
#[derive(Deserialize)]
pub struct Item {
pub id: String,
pub name: String,
pub value: u32,
pub item_type: ItemType,
}
/// Rohform einer Item-Datei. Jede TOML nutzt `[[items]]` als Wurzel,
/// dadurch bedient dieser eine Wrapper alle Dateien.
#[derive(Deserialize)]
struct ItemFile<T> {
items: Vec<T>,
}
/// Laufzeit-Registry: einmal beim Start laden, danach als `&ItemCatalog` durchreichen.
// TODO: OffHand, Consumable und Currency haben noch keine Structs/TOMLs — hier ergaenzen.
pub struct ItemCatalog {
pub weapons: Vec<ItemWeapon>,
pub armors: Vec<ItemArmor>,
}
// === FUNCTIONS ===
pub fn load_items() -> ItemCatalog {
ItemCatalog {
weapons: parse(include_str!("items/weapons.toml"), "weapons.toml"),
armors: parse(include_str!("items/armors.toml"), "armors.toml"),
}
}
fn parse<T: DeserializeOwned>(raw: &str, file: &str) -> Vec<T> {
let parsed: ItemFile<T> =
toml::from_str(raw).unwrap_or_else(|e| panic!("{} ist kaputt:\n{}", file, e));
parsed.items
}
/// Rahmen um beliebig viele Zeilen. Wird von jedem `print_values` genutzt.
pub(crate) fn print_box(lines: &[String]) {
let longest: usize = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0);
let border: String = format!("+-{}-+", "-".repeat(longest));
println!("{}", border);
for line in lines {
println!("| {}{} |", line, " ".repeat(longest - line.chars().count()));
}
println!("{}", border);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn item_files_parse() {
let catalog: ItemCatalog = load_items();
assert!(!catalog.weapons.is_empty());
assert!(!catalog.armors.is_empty());
}
}

22
src/items/armor.rs Normal file
View File

@@ -0,0 +1,22 @@
use serde::Deserialize;
use super::{Item, print_box};
#[derive(Deserialize)]
pub struct ItemArmor {
pub armor: u32,
#[serde(flatten)]
pub item: Item,
}
impl ItemArmor {
pub fn print_values(&self) {
print_box(&[
format!("Stats for {} ", self.item.name),
format!("ID: {}", self.item.id),
format!("Slot: {:?}", self.item.item_type),
format!("Armor: {}", self.armor),
format!("Value: {}", self.item.value),
]);
}
}

13
src/items/armors.toml Normal file
View File

@@ -0,0 +1,13 @@
[[items]]
id = "iron_helmet"
name = "Iron Helmet"
armor = 4
value = 6
item_type = "Helmet"
[[items]]
id = "leather_boots"
name = "Leather Boots"
armor = 2
value = 3
item_type = "Boots"

26
src/items/weapon.rs Normal file
View File

@@ -0,0 +1,26 @@
use serde::Deserialize;
use super::{Item, print_box};
#[derive(Deserialize)]
pub struct ItemWeapon {
pub base_damage: u32,
pub critical_factor: f32,
#[serde(flatten)]
pub item: Item,
}
impl ItemWeapon {
pub fn print_values(&self) {
let crit_damage: u32 = (self.base_damage as f32 * self.critical_factor).ceil() as u32;
let crit_percent: i32 = ((self.critical_factor - 1.0) * 100.0).round() as i32;
print_box(&[
format!("Stats for {} ", self.item.name),
format!("ID: {}", self.item.id),
format!("Base Damage: {} ({})", self.base_damage, crit_damage),
format!("Critical Factor: {}%", crit_percent),
format!("Value: {}", self.item.value),
]);
}
}

15
src/items/weapons.toml Normal file
View File

@@ -0,0 +1,15 @@
[[items]]
id = "iron_sword"
name = "Iron Sword"
base_damage = 10
critical_factor = 1.1
value = 5
item_type = "Weapon"
[[items]]
id = "iron_dagger"
name = "Iron Dagger"
base_damage = 8
critical_factor = 1.5
value = 3
item_type = "Weapon"

View File

@@ -1,73 +1,20 @@
//! rustkeep — Meilenstein 1: vertikaler Durchstich. mod items;
//! mod player;
//! Ziel: Der Held laeuft in einem handgebauten Testraum herum, oeffnet den mod stats;
//! Charakterbogen und sieht dort einen Wert, der aus einem Modifikator stammt,
//! der aus einer TOML-Datei kam.
//!
//! Damit stehen die Zusagen auf dem Pruefstand, die bisher nur auf Papier
//! standen: `Registries` neben `Game` (07), zweistufige Deserialisierung (04),
//! `Game::attribute` ueber mehrere Quellen (07/08), `game/` bleibt ratatui-frei
//! (11), Press-Filter unter Windows (06/12).
mod data; use items::*;
mod game; use player::*;
mod ui;
use game::Creature;
fn main() { fn main() {
// Die `Result`-Kette endet **vor** `main` (Ticket 04): `main() -> Result<…>` let catalog: ItemCatalog = load_items();
// druckt ueber `Debug` und setzt dem Spieler `LoadError { .. }` vor.
let (registries, mut warnings) = match data::load_mods() { for weapon in &catalog.weapons {
Ok(v) => v, weapon.print_values();
Err(e) => {
eprintln!("{e}");
std::process::exit(1);
}
};
// Ein Speicherstand, kein Slot-Menue (Ticket 09). Liegt einer da, wird er
// fortgesetzt; sonst faengt ein Lauf an. Bei Tod loescht die Schleife ihn,
// also entscheidet allein sein Vorhandensein.
let (geladen, verworfen) = match data::save::load(&registries) {
Ok((game, aus_dem_save)) => (Some(game), aus_dem_save),
Err(data::save::SaveError::Missing) => (None, Vec::new()),
// Falsches Schema oder kaputte Datei: melden, **liegen lassen** und neu
// anfangen. Loeschen waere die schlechteste aller Reaktionen.
Err(e) => (None, vec![e.to_string()]),
};
// Getrennt gezaehlt: Mod-Ladewarnungen sind eine Zeile im Hauptmenue
// (Ticket 04), nur Verworfenes aus dem Save fragt zurueck (Ticket 09).
let rueckfragen = verworfen.len();
warnings.extend(verworfen);
let log_name = data::write_log(&warnings);
// Der Seed wird genau einmal frisch gezogen. Ab hier steht er im Spiel und
// spaeter im Speicherstand — Nachladen wuerfelt nicht neu (Ticket 14).
let neu = geladen.is_none();
let Some(game) =
geladen.or_else(|| game::Game::new(starting_hero(), &registries, game::Rng::from_clock()))
else {
eprintln!(
"Keine Etage mit number = 1 gefunden. Erwartet wird ein [[floors]]-Eintrag in mods/core."
);
std::process::exit(1);
};
if let Err(e) = ui::run(game, registries, warnings.len(), rueckfragen, log_name, neu) {
eprintln!("Terminal-Fehler: {e}");
std::process::exit(1);
} }
for armor in &catalog.armors {
armor.print_values();
} }
/// Ein **leerer** Held: Rasse, Klasse und Startausruestung kommen aus dem let player: Player = Player::new("Reya".to_string());
/// Charakterbau, nicht von hier. Ausruestung anzulegen war bis M8 der Job player.show_stats();
/// dieser Funktion — jetzt macht es `on_create` an der Charakteroption, und
/// damit kann ein Mod es aendern (Ticket 02).
///
// TODO(M9+): Der Name ist fest. Ihn eingeben zu lassen braucht ein
// Texteingabefeld, das es sonst nirgends im Spiel gibt.
fn starting_hero() -> Creature {
Creature::new("Reya")
} }

37
src/player.rs Normal file
View File

@@ -0,0 +1,37 @@
use crate::items::print_box;
use crate::stats::Stats;
pub struct Player {
pub name: String,
pub stats: Stats,
pub next_level_exp: usize,
pub current_exp: usize,
}
impl Player {
pub fn new(name: String) -> Self {
Self {
name,
stats: Stats::new(),
next_level_exp: 10,
current_exp: 0,
}
}
pub fn show_stats(&self) {
let stats: &Stats = &self.stats;
print_box(&[
format!("Name: {}", self.name),
format!(
"Level: {} ({}/{})",
stats.level, self.current_exp, self.next_level_exp
),
format!("Strength: {}", stats.strength),
format!("Dexterity: {}", stats.dexterity),
format!("Constitution: {}", stats.constitution),
format!("Charisma: {}", stats.charisma),
format!("Luck: {}", stats.luck),
]);
}
}

68
src/stats.rs Normal file
View File

@@ -0,0 +1,68 @@
pub struct Stats {
pub max_health: f32,
pub current_health: f32,
pub max_stamina: f32,
pub current_stamina: f32,
pub strength: usize, // Determines base_damage modifier
pub dexterity: usize, // Determines Critical Factor Modifier
pub constitution: usize, // Determines Max Health Bonuses
pub charisma: usize, // Determines Shop Prices
pub luck: usize, // Determines Loot Drop & Critical Chance
pub level: usize,
}
impl Stats {
pub fn new() -> Self {
let health: f32 = 100.0;
let stamina: f32 = 100.0;
Self {
max_health: health,
current_health: health,
max_stamina: stamina,
current_stamina: stamina,
strength: 1,
dexterity: 1,
constitution: 1,
charisma: 1,
luck: 1,
level: 1,
}
}
/// Delta auf die aktuelle Health: positiv heilt, negativ verletzt.
pub fn change_health(&mut self, delta: f32) {
self.current_health = (self.current_health + delta).clamp(0.0, self.max_health);
}
/// Delta auf die aktuelle Stamina: positiv regeneriert, negativ verbraucht.
// TODO: noch ungenutzt — wird vom Kampfsystem aufgerufen, sobald es existiert.
pub fn change_stamina(&mut self, delta: f32) {
self.current_stamina = (self.current_stamina + delta).clamp(0.0, self.max_stamina);
}
pub fn is_dead(&self) -> bool {
self.current_health <= 0.0
}
// TODO: noch ungenutzt — wird vom Exp-/Level-System aufgerufen, sobald es existiert.
pub fn level_up(&mut self) {
self.level = (self.level + 1).clamp(1, 50);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn health_clamps_at_both_ends() {
let mut stats: Stats = Stats::new();
stats.change_health(50.0);
assert_eq!(stats.current_health, stats.max_health);
stats.change_health(-500.0);
assert_eq!(stats.current_health, 0.0);
assert!(stats.is_dead());
}
}

View File

@@ -1,856 +0,0 @@
//! Die Schleife. Blockierendes `event::read()`, kein Tick (Ticket 06).
use ratatui::Frame;
use ratatui::crossterm::event::{self, Event, KeyEventKind};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Style, Stylize};
use ratatui::text::Line;
use ratatui::widgets::{Block, Paragraph, Row, Table};
use super::grid::GridWidget;
use super::screen::{Response, Screen};
use super::theme;
use crate::data::{Registries, save};
use crate::game::{Attribute, CreatureRef, DamageType, Game, Outcome, RunOutcome};
pub struct App {
/// `game` und `registries` sind **getrennte Felder**, damit die disjunkten
/// Borrows aus Ticket 07 funktionieren.
pub game: Game,
pub registries: Registries,
screens: Vec<Screen>,
warnings: usize,
log_name: Option<String>,
rejected: Option<&'static str>,
}
impl App {
/// Der Spielzustand fuehrt, der Stack folgt: `Game.enemies` und `Game.over`
/// sind die Wahrheit. Ohne das muesste jede Stelle, die einen Kampf startet
/// oder beendet, daran denken, den Screen mitzuschieben.
/// Beim Beenden und beim Betreten einer Etage. Ein gescheiterter Autosave
/// bricht das Spiel nicht ab — er landet in der Statuszeile.
fn autosave(&mut self) {
if self.game.over.is_some() {
return;
}
if let Err(e) = save::save(&self.game, &self.registries) {
self.game.last = format!("Speichern fehlgeschlagen: {e}");
}
}
fn sync_screens(&mut self) {
let in_combat = !self.game.enemies.is_empty();
let shows_combat = self.screens.iter().any(|s| matches!(s, Screen::Combat { .. }));
if in_combat && !shows_combat {
self.screens.push(Screen::Combat { pick: 0, target: 0, targeting: false });
} else if !in_combat {
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 });
}
// Der Charakterbau ist fertig, sobald der Held Optionen hat — dieselbe
// Regel wie beim Kampf: Der Spielzustand fuehrt, der Stack folgt.
let baut = self.screens.iter().any(|s| matches!(s, Screen::CharacterCreation { .. }));
if baut && !self.game.player.options.is_empty() {
self.screens.retain(|s| !matches!(s, Screen::CharacterCreation { .. }));
self.screens.push(Screen::Explore);
}
if self.game.over.is_some()
&& !self.screens.iter().any(|s| matches!(s, Screen::GameOver))
{
self.screens.push(Screen::GameOver);
}
}
}
pub fn run(
game: Game,
registries: Registries,
warnings: usize,
verworfen: usize,
log_name: Option<String>,
neu: bool,
) -> std::io::Result<()> {
let mut screens = vec![Screen::MainMenu { neu }];
// Die Rueckfrage steht **vor** dem Spiel, nicht als Zeile darin (Ticket
// 09): Wer hier „nein" sagt, will den fehlenden Mod nachinstallieren — und
// diese Wahl hat er nur jetzt, danach ist der verkrueppelte Held gespeichert.
if verworfen > 0 {
let wo = log_name.as_deref().unwrap_or("kein Log schreibbar");
screens.push(Screen::Confirm {
text: format!(
"{verworfen} Einträge aus dem Speicherstand fehlen (siehe logs/{wo}).
Fortfahren?"
),
on_yes: Box::new(Response::Pop),
on_no: Box::new(Response::Quit),
});
}
let mut app = App {
game,
registries,
screens,
warnings,
log_name,
rejected: None,
};
ratatui::run(|terminal| {
loop {
terminal.draw(|frame| draw(frame, &app))?;
let Event::Key(key) = event::read()? else { continue };
// Pflichtfilter unter Windows: Ticket 12 hat gemessen, dass jeder
// Tastendruck zwei Events erzeugt (Press und Release).
if key.kind != KeyEventKind::Press {
continue;
}
// Die Screens liegen in `app.screens`, gelesen wird aus `app.game`
// und `app.registries` — drei getrennte Felder, disjunkte Borrows.
let App { game, registries, screens, .. } = &mut app;
let Some(screen) = screens.last_mut() else { break Ok(()) };
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() {
app.autosave();
break Ok(());
}
}
Response::Push(s) => app.screens.push(s),
Response::Command(cmd) => {
let etage_vorher = app.game.floor.number;
// Hier steht die Zusage aus Ticket 07 auf dem Pruefstand:
// `&mut app.game` neben `&app.registries` in einem Aufruf.
app.rejected = match app.game.execute(cmd, &app.registries) {
Outcome::TurnPassed => None,
Outcome::Rejected(reason) => Some(reason),
};
while app.screens.last().is_some_and(Screen::is_overlay) {
app.screens.pop();
}
// Ticket 10/14: Der Lauf endet ueber `Game`, nicht ueber
// die UI — die reagiert nur darauf. Dasselbe gilt fuer den
// Kampf: `Game.enemies` ist die Wahrheit, der Screen folgt.
app.sync_screens();
// Autosave beim **Betreten einer Etage** (Ticket 09). Bei
// Tod stattdessen loeschen — Permadeath, und zwar erst hier
// beim `GameOver`-Screen: Stuerzt das Spiel in der Sekunde
// davor ab, ist der letzte Autosave noch da, und der stammt
// vom Betreten der Etage, verschenkt also nichts.
if app.game.over.is_some() {
save::delete();
} else if app.game.floor.number != etage_vorher {
app.autosave();
}
}
Response::Quit => {
app.autosave();
break Ok(());
}
}
}
})
}
fn draw(frame: &mut Frame, app: &App) {
let area = frame.area();
let first = app.screens.iter().rposition(|s| !s.is_overlay()).unwrap_or(0);
for screen in &app.screens[first..] {
match screen {
Screen::MainMenu { neu } => draw_main_menu(frame, area, app, *neu),
Screen::Explore => draw_explore(frame, area, app),
Screen::Combat { pick, target, targeting } => {
draw_combat(frame, area, app, *pick, *target, *targeting)
}
Screen::Inventory { pick } => draw_inventory(frame, area, app, *pick),
Screen::CharacterSheet { pick, pending } => {
draw_sheet(frame, area, app, *pick, pending)
}
Screen::Dialog { pick } => draw_dialog(frame, area, app, *pick),
Screen::Shop { pick, selling } => draw_shop(frame, area, app, *pick, *selling),
Screen::CharacterCreation { kategorie, pick, gewaehlt } => {
draw_creation(frame, area, app, *kategorie, *pick, gewaehlt)
}
Screen::QuestLog => draw_quests(frame, area, app),
Screen::Confirm { text, .. } => draw_confirm(frame, area, text),
Screen::Debug { pick } => draw_debug(frame, area, app, *pick),
Screen::GameOver => draw_game_over(frame, area, app),
}
}
}
fn draw_main_menu(frame: &mut Frame, area: Rect, app: &App, neu: bool) {
let mut text = vec![
Line::from("rustkeep".bold()),
Line::from(""),
Line::from(match neu {
true => "Enter neuen Helden erschaffen",
false => "Enter Lauf fortsetzen",
}),
Line::from("q beenden"),
];
if !neu {
text.insert(
2,
Line::from(format!(
"Etage {} · Zug {}",
app.game.floor.number, app.game.turn
))
.style(Style::new().fg(Color::DarkGray)),
);
}
// Eine Zeile, kein Screen — der Inhalt gehoert in einen Texteditor
// (Ticket 04).
if app.warnings > 0 {
let note = match &app.log_name {
Some(file) => format!("{} Ladewarnungen — logs/{file}", app.warnings),
None => format!("{} Ladewarnungen (Log nicht schreibbar)", app.warnings),
};
text.push(Line::from(""));
text.push(Line::from(note).style(Style::new().fg(Color::Yellow)));
}
frame.render_widget(
Paragraph::new(text).block(Block::bordered().title(" Hauptmenü ")),
area,
);
}
fn draw_explore(frame: &mut Frame, area: Rect, app: &App) {
let block = Block::bordered().title(format!(" Etage {} ", app.game.floor.number));
let inner = block.inner(area);
frame.render_widget(block, area);
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,
reg: &app.registries,
},
grid,
);
let (note, color) = match app.rejected {
Some(reason) => (reason.to_string(), Color::Red),
None => (app.game.last.clone(), Color::Cyan),
};
frame.render_widget(
Paragraph::new(format!(
"Zug {} │ {} │ .warten g aufheben >absteigen i Inventar c Bogen │ {}",
app.game.turn, app.game.player.name, note
))
.right_aligned()
.style(Style::new().fg(color)),
bar,
);
}
/// Links das Inventar mit Cursor, rechts die Ausrüstung als **Schleife über die
/// Slot-Registry** — Modder können Slots ergänzen, ohne dass hier etwas
/// geändert wird (Ticket 02).
fn draw_inventory(frame: &mut Frame, area: Rect, app: &App, pick: usize) {
let block = Block::bordered().title(" Inventar ");
let inner = block.inner(area);
frame.render_widget(block, area);
let [links, rechts] =
Layout::horizontal([Constraint::Percentage(60), Constraint::Min(0)]).areas(inner);
let mut zeilen = Vec::new();
for (i, inst) in app.game.player.inventory.iter().enumerate() {
let def = app.registries.items.get(&inst.def);
let name = app.game.item_label(inst, &app.registries);
let preis = app.game.item_price(inst, &app.registries);
let slot = def.and_then(|d| d.slot.as_deref()).unwrap_or("—");
let zeiger = if i == pick { "›" } else { " " };
let farbe = if i == pick { Color::Yellow } else { Color::Reset };
zeilen.push(
Line::from(format!("{zeiger} {name:<30} {slot:<12} {preis:>5} G"))
.style(Style::new().fg(farbe)),
);
}
if zeilen.is_empty() {
zeilen.push(Line::from("leer").style(Style::new().fg(Color::DarkGray)));
}
zeilen.push(Line::from(""));
zeilen.push(
Line::from("↑↓ wählen · u benutzen · e anlegen · d wegwerfen · 1-9 ablegen · Esc")
.style(Style::new().fg(Color::DarkGray)),
);
frame.render_widget(
Paragraph::new(zeilen).block(Block::bordered().title(" Getragen ")),
links,
);
let mut aus = Vec::new();
for (i, slot) in app.registries.slots_ordered().iter().enumerate() {
let getragen = app.game.player.equipment.get(&slot.id);
let text = match getragen {
Some(inst) => app.game.item_label(inst, &app.registries),
None => String::from("—"),
};
let farbe = if getragen.is_some() { Color::Reset } else { Color::DarkGray };
aus.push(
Line::from(format!("{} {:<10} {}", i + 1, slot.name, text))
.style(Style::new().fg(farbe)),
);
}
frame.render_widget(
Paragraph::new(aus).block(Block::bordered().title(" Ausrüstung ")),
rechts,
);
}
/// **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,
);
}
/// Links die Kategorien aus der Registry, rechts ihre Optionen — beides
/// geschleift, nichts gemauert (Ticket 02/11).
fn draw_creation(
frame: &mut Frame,
area: Rect,
app: &App,
kategorie: usize,
pick: usize,
gewaehlt: &[String],
) {
let block = Block::bordered().title(" Wer bist du? ");
let inner = block.inner(area);
frame.render_widget(block, area);
let [links, rechts] =
Layout::horizontal([Constraint::Percentage(35), Constraint::Min(0)]).areas(inner);
let kategorien = app.registries.categories_ordered();
let mut spalte = Vec::new();
for (i, k) in kategorien.iter().enumerate() {
let hier = app.registries.picked_in(&k.id, gewaehlt);
let aktiv = i == kategorie;
spalte.push(
Line::from(format!(
"{} {:<14} {hier}/{}",
if aktiv { "›" } else { " " },
k.name,
k.pick
))
.style(Style::new().fg(if aktiv { Color::Yellow } else { Color::Reset })),
);
}
frame.render_widget(
Paragraph::new(spalte).block(Block::bordered().title(" Wahl ")),
links,
);
let mut zeilen = Vec::new();
if let Some(k) = kategorien.get(kategorie) {
for (i, o) in app.registries.options_of(&k.id).iter().enumerate() {
let an = gewaehlt.contains(&o.id);
let zeiger = if i == pick { "›" } else { " " };
zeilen.push(
Line::from(format!("{zeiger} [{}] {}", if an { "x" } else { " " }, o.name)).style(
Style::new().fg(match (an, i == pick) {
(true, _) => Color::Green,
(false, true) => Color::Yellow,
_ => Color::Reset,
}),
),
);
if i == pick {
if !o.description.is_empty() {
zeilen.push(
Line::from(format!(" {}", o.description))
.style(Style::new().fg(Color::DarkGray)),
);
}
for m in &o.modifiers {
zeilen.push(
Line::from(format!(" {} {:+}", m.attribute.name(), m.flat as i32))
.style(Style::new().fg(Color::Cyan)),
);
}
for a in &o.abilities {
let name =
app.registries.abilities.get(a).map(|d| d.name.as_str()).unwrap_or(a);
zeilen.push(
Line::from(format!(" Fähigkeit: {name}"))
.style(Style::new().fg(Color::Magenta)),
);
}
}
}
}
zeilen.push(Line::from(""));
zeilen.push(
Line::from("↑↓ wählen · Enter nehmen · Tab Kategorie")
.style(Style::new().fg(Color::DarkGray)),
);
frame.render_widget(
Paragraph::new(zeilen).block(Block::bordered().title(" Optionen ")),
rechts,
);
}
/// Laufende oben, erledigte darunter — erledigte bleiben stehen, weil sie es
/// auch im Spielzustand tun (Ticket 08).
fn draw_quests(frame: &mut Frame, area: Rect, app: &App) {
let mut zeilen = Vec::new();
for q in &app.game.quests {
let Some(def) = app.registries.quests.get(&q.quest) else { continue };
let (marke, farbe) = match q.done {
true => ("✓", Color::DarkGray),
false => ("•", Color::Reset),
};
zeilen.push(
Line::from(format!("{marke} {:<28} {}/{}", def.name, q.count.min(def.count), def.count))
.style(Style::new().fg(farbe)),
);
if !def.description.is_empty() {
zeilen.push(
Line::from(format!(" {}", def.description))
.style(Style::new().fg(Color::DarkGray)),
);
}
}
if zeilen.is_empty() {
zeilen.push(
Line::from("Noch nichts angenommen. Sprich mit jemandem.")
.style(Style::new().fg(Color::DarkGray)),
);
}
zeilen.push(Line::from(""));
zeilen.push(Line::from("Esc / q zurück").style(Style::new().fg(Color::DarkGray)));
frame.render_widget(
Paragraph::new(zeilen).block(Block::bordered().title(" Aufträge ")),
area,
);
}
fn draw_confirm(frame: &mut Frame, area: Rect, text: &str) {
let mut zeilen: Vec<Line> = text.lines().map(Line::from).collect();
zeilen.push(Line::from(""));
zeilen.push(Line::from("j / Enter fortfahren").style(Style::new().fg(Color::Yellow)));
zeilen.push(Line::from("n / Esc beenden").style(Style::new().fg(Color::DarkGray)));
frame.render_widget(
Paragraph::new(zeilen).block(Block::bordered().title(" Rückfrage ")),
area,
);
}
/// Die Werkbank. Eine Liste, vier Tasten — was sich mit zwei vorhandenen
/// Kommandos nachstellen laesst, steht hier nicht.
fn draw_debug(frame: &mut Frame, area: Rect, app: &App, pick: usize) {
// ponytail: kein mitlaufender Ausschnitt wie im Grid — mehr Gegenstaende
// als Zeilen fallen stumm hinten raus. `core` hat sieben; der Ausschnitt
// kommt, wenn der erste Mod das sprengt.
let mut zeilen = Vec::new();
for (i, def) in app.registries.items_ordered().iter().enumerate() {
let zeiger = if i == pick { "›" } else { " " };
let menge = if def.stackable { "×10" } else { " " };
zeilen.push(
Line::from(format!("{zeiger} {:<26} {menge} {}", def.name, def.id))
.style(Style::new().fg(if i == pick { Color::Yellow } else { Color::Reset })),
);
}
zeilen.push(Line::from(""));
zeilen.push(
Line::from("↑↓ wählen · Enter geben · h heilen · > abstiegen · x Raum leeren · Esc")
.style(Style::new().fg(Color::DarkGray)),
);
frame.render_widget(
Paragraph::new(zeilen)
.block(Block::bordered().title(" Werkbank ").title_bottom(" F12 ")),
area,
);
}
/// 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).
fn draw_combat(
frame: &mut Frame,
area: Rect,
app: &App,
pick: usize,
target: usize,
targeting: bool,
) {
let block = Block::bordered().title(" Kampf ");
let inner = block.inner(area);
frame.render_widget(block, area);
let [links, rechts] = Layout::horizontal([Constraint::Percentage(45), Constraint::Min(0)])
.areas(inner);
let [gegner_area, log_area] =
Layout::vertical([Constraint::Percentage(45), Constraint::Min(0)]).areas(links);
// Gegner mit Zielziffer, Lebensbalken und **Ansage**.
let mut zeilen = Vec::new();
for (i, e) in app.game.enemies.iter().enumerate() {
let max = app
.game
.attribute(CreatureRef::Enemy(i), Attribute::MaxHealth, None, &app.registries);
let anteil = if max > 0.0 { (e.health / max).clamp(0.0, 1.0) } else { 0.0 };
let balken = "█".repeat((anteil * 10.0).round() as usize);
let gewaehlt = targeting && i == target;
let marken: String = e.modifiers.iter().map(|m| m.symbol).collect();
zeilen.push(
Line::from(format!(
"{} {} {:<12} {:<10} {:>4.0}/{:<4.0} {marken}",
if gewaehlt { "›" } else { " " },
i + 1,
e.name,
balken,
e.health.max(0.0),
max
))
.style(Style::new().fg(if gewaehlt { Color::Yellow } else { Color::Reset })),
);
// Die Ansage ist verbindlich — wer sie stoert, kostet dem Gegner den
// Zug. Deshalb steht sie so gross wie die Lebensanzeige.
let ansage = match app.game.intent_label(i, &app.registries) {
Some(name) => format!(" ↳ {name} → dich"),
None => String::from(" ↳ zögert"),
};
zeilen.push(Line::from(ansage).style(Style::new().fg(Color::Magenta)));
}
if zeilen.is_empty() {
zeilen.push(Line::from("—").style(Style::new().fg(Color::DarkGray)));
}
frame.render_widget(
Paragraph::new(zeilen).block(
Block::bordered().title(if targeting { " Gegner · aktiv " } else { " Gegner " }),
),
gegner_area,
);
// Kampflog, jüngste Zeile unten.
let log: Vec<Line> = app
.game
.journal
.iter()
.rev()
.take(log_area.height.saturating_sub(2) as usize)
.rev()
.map(|l| Line::from(l.as_str()))
.collect();
frame.render_widget(Paragraph::new(log).block(Block::bordered().title(" Verlauf ")), log_area);
// Faehigkeiten.
let mut zeilen = Vec::new();
for (i, id) in app.game.abilities_of(CreatureRef::Player, &app.registries).iter().enumerate() {
let Some(def) = app.registries.abilities.get(id) else { continue };
let block = app.game.ability_block(CreatureRef::Player, id, &app.registries);
let zeiger = if !targeting && i == pick { "›" } else { " " };
let text = format!(
"{zeiger} {:<16} {:>3} Ausd. {}",
def.name,
def.cost as u32,
block.unwrap_or("")
);
let farbe = match (block.is_some(), i == pick) {
(true, _) => Color::DarkGray,
(false, true) => Color::Yellow,
(false, false) => Color::Reset,
};
zeilen.push(Line::from(text).style(Style::new().fg(farbe)));
}
let p = &app.game.player;
let max_health =
app.game.attribute(CreatureRef::Player, Attribute::MaxHealth, None, &app.registries);
let max_stamina =
app.game.attribute(CreatureRef::Player, Attribute::MaxStamina, None, &app.registries);
zeilen.push(Line::from(""));
zeilen.push(Line::from(format!(
"{} {:.0}/{:.0} Leben {:.0}/{:.0} Ausdauer",
p.name, p.health.max(0.0), max_health, p.stamina, max_stamina
)));
if !p.modifiers.is_empty() {
let namen: Vec<&str> = p.modifiers.iter().map(|m| m.name.as_str()).collect();
zeilen.push(Line::from(namen.join(", ")).style(Style::new().fg(Color::Green)));
}
zeilen.push(
Line::from("Tab Seite · ↑↓ wählen · Enter angreifen · i Inventar · . warten · c Bogen")
.style(Style::new().fg(Color::DarkGray)),
);
frame.render_widget(
Paragraph::new(zeilen).block(
Block::bordered()
.title(if targeting { " Fähigkeiten " } else { " Fähigkeiten · aktiv " }),
),
rechts,
);
}
/// Der Lauf wird am Ende **erzaehlt** — das ist der Moment, fuer den man die
/// Etagen gespielt hat, und der Grund, warum Ticket 14 hier keinen Einzeiler im
/// Hauptmenue wollte.
fn draw_game_over(frame: &mut Frame, area: Rect, app: &App) {
let (titel, farbe, zeile) = match app.game.over {
Some(RunOutcome::Won) => (
" Gewonnen ",
Color::Green,
"Du steigst ans Licht — der Keep liegt hinter dir.",
),
_ => (" Gestorben ", Color::Red, "Der Keep behält dich."),
};
let text = vec![
Line::from(app.game.player.name.clone().bold()),
Line::from(""),
Line::from(zeile),
Line::from(""),
Line::from(format!("Etage {}", app.game.floor.number)),
Line::from(format!("Züge {}", app.game.turn)),
Line::from(""),
Line::from("Taste drücken").style(Style::new().fg(Color::DarkGray)),
];
frame.render_widget(
Paragraph::new(text)
.centered()
.block(Block::bordered().title(titel).style(Style::new().fg(farbe))),
area,
);
}
/// Der Charakterbogen ist eine **Schleife ueber Registries**, kein gemauertes
/// Layout — Modder koennen Slots und Wahlkategorien ergaenzen (Ticket 02).
fn draw_sheet(frame: &mut Frame, area: Rect, app: &App, pick: usize, pending: &[u32; 5]) {
let block = Block::bordered().title(" Charakterbogen ");
let inner = block.inner(area);
frame.render_widget(block, area);
let [left, right] = Layout::horizontal([Constraint::Percentage(50); 2]).areas(inner);
// Attribute: jeder Wert kommt aus Game::attribute, laeuft also durch alle
// Quellen — das ist der Punkt des ganzen Durchstichs.
// `resistance` faellt heraus: Ein einzelner Wert dafuer existiert nicht, es
// sind sieben (Ticket 01). Sie stehen rechts als eigene Zeile.
let rows: Vec<Row> = Attribute::ALL
.iter()
.filter(|a| **a != Attribute::Resistance)
.map(|a| {
let base = app.game.player.base.get(*a);
let total =
app.game
.attribute(CreatureRef::Player, *a, None, &app.registries);
let cell = if (total - base).abs() < f32::EPSILON {
format!("{total:.0}")
} else {
format!("{total:.0} ({base:.0} Basis)")
};
// Verteilbare Grundwerte tragen Cursor und Vormerkung — der Bogen
// ist zugleich der Verteil-Screen (Ticket 15).
let Some(i) = Attribute::ASSIGNABLE.iter().position(|x| x == a) else {
return Row::new(vec![format!(" {}", a.name()), cell]);
};
let zeiger = if i == pick { "›" } else { " " };
let vorgemerkt = if pending[i] > 0 { format!(" +{}", pending[i]) } else { String::new() };
Row::new(vec![format!("{zeiger} {} {vorgemerkt}", a.name()), cell])
.style(Style::new().fg(if i == pick { Color::Yellow } else { Color::Reset }))
})
.collect();
frame.render_widget(
Table::new(rows, [Constraint::Length(20), Constraint::Min(0)])
.block(Block::bordered().title(" Attribute ")),
left,
);
let mut lines = Vec::new();
// Erfahrung und Punkte stehen an `Game`, nicht am `Creature` (Ticket 15).
let offen = app.game.unspent_points.saturating_sub(pending.iter().sum::<u32>());
lines.push(Line::from(format!(
"Erfahrung {}/{}",
app.game.current_exp,
app.game.exp_needed(&app.registries)
)));
if app.game.unspent_points > 0 {
lines.push(
Line::from(format!("{offen} Punkte offen · ←→ verteilen · Enter bestätigen"))
.style(Style::new().fg(Color::Yellow)),
);
}
lines.push(Line::from(""));
lines.push(Line::from("Herkunft".bold()));
for id in &app.game.player.options {
let name = app.registries.options.get(id).map(|o| o.name.as_str()).unwrap_or(id);
lines.push(Line::from(format!(" {name}")));
}
lines.push(Line::from(""));
lines.push(Line::from("Ausrüstung".bold()));
for slot in app.registries.slots_ordered() {
let worn = app.game.player.equipment.get(&slot.id).and_then(|inst| {
app.registries.items.get(&inst.def).map(|i| (i.name.as_str(), i.color.as_deref()))
});
match worn {
Some((name, color)) => lines.push(Line::from(vec![
format!(" {:<10} ", slot.name).into(),
name.to_string().fg(theme::color(color)),
])),
None => lines.push(
Line::from(format!(" {:<10} —", slot.name)).style(Style::new().fg(Color::DarkGray)),
),
}
}
// Resistenz ist typ-indiziert — nur was von null abweicht, ist erwaehnenswert.
let widerstaende: Vec<String> = DamageType::ALL
.iter()
.filter_map(|dt| {
let r = app.game.resistance(CreatureRef::Player, *dt, &app.registries);
(r.abs() > f32::EPSILON).then(|| format!("{} {:+.0}%", dt.name(), r * 100.0))
})
.collect();
if !widerstaende.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from("Resistenz".bold()));
lines.push(Line::from(format!(" {}", widerstaende.join(", "))));
}
if !app.game.player.modifiers.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from("Zustände".bold()));
for m in &app.game.player.modifiers {
lines.push(Line::from(format!(" {} ({} Züge)", m.name, m.turns_left)));
}
}
lines.push(Line::from(""));
lines.push(Line::from("Esc zurück").style(Style::new().fg(Color::DarkGray)));
frame.render_widget(
Paragraph::new(lines).block(Block::bordered().title(" Herkunft & Ausrüstung ")),
right,
);
}

View File

@@ -1,118 +0,0 @@
//! Dungeon-Grid als eigenes Widget. Ticket 06 hat festgestellt, dass es weder
//! mitgeliefert noch als Drittanbieter-Crate existiert — also Eigenbau mit
//! direktem Buffer-Zugriff. Der Durchstich in Ticket 12 hat das bestaetigt.
use ratatui::buffer::Buffer;
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<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
// Ausschnitt, der dem Helden folgt. Etage 5 ist breiter als ein
// 80-Spalten-Terminal — ohne das laeuft er aus dem Bild.
let (ox, oy) = offset(self.floor.width, self.floor.height, self.player, area);
for vy in 0..area.height.min(self.floor.height) {
for vx in 0..area.width.min(self.floor.width) {
let (x, y) = (ox + vx, oy + vy);
let (px, py) = (area.x + vx, area.y + vy);
let (symbol, color) = match self.floor.tile(x, y) {
Tile::Wall => ("█", Color::DarkGray),
Tile::Floor => ("·", Color::Rgb(60, 60, 70)),
Tile::Door => ("+", Color::Yellow),
Tile::Stairs => (">", Color::Green),
Tile::Entrance => ("<", Color::Blue),
};
buf[(px, py)].set_symbol(symbol).set_style(Style::new().fg(color));
}
}
// Beutehaufen: **ein** generisches Symbol fuer alles. Nur deshalb darf
// der Inhalt erst beim Aufheben gezogen werden — die Anzeige verraet
// nichts, was noch nicht gewuerfelt ist (Ticket 16).
for room in &self.floor.rooms {
for (lx, ly) in &room.loot {
if *lx < ox || *ly < oy {
continue;
}
let (px, py) = (area.x + lx - ox, area.y + ly - oy);
if px < area.right() && py < area.bottom() {
buf[(px, py)]
.set_symbol("*")
.set_style(Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD));
}
}
}
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)]
.set_symbol("@")
.set_style(Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD));
}
}
}
/// Linke obere Ecke des Ausschnitts: mittig um den Helden, an den Kartenrand
/// geklemmt. Passt die Karte ganz hinein, ist der Ausschnitt (0, 0).
fn offset(map_w: u16, map_h: u16, player: (u16, u16), area: Rect) -> (u16, u16) {
let clamp = |pos: u16, view: u16, map: u16| {
if map <= view {
return 0;
}
pos.saturating_sub(view / 2).min(map - view)
};
(clamp(player.0, area.width, map_w), clamp(player.1, area.height, map_h))
}
#[cfg(test)]
mod tests {
use super::*;
fn view(w: u16, h: u16) -> Rect {
Rect { x: 0, y: 0, width: w, height: h }
}
#[test]
fn ausschnitt_bleibt_auf_der_karte() {
// Karte passt hinein: kein Verschieben.
assert_eq!(offset(20, 10, (5, 5), view(40, 20)), (0, 0));
// Held am linken Rand: nicht ins Negative.
assert_eq!(offset(80, 40, (2, 2), view(40, 20)), (0, 0));
// Held mittig: zentriert.
assert_eq!(offset(80, 40, (40, 20), view(40, 20)), (20, 10));
// Held am rechten Rand: an der Karte geklemmt, nicht darueber hinaus.
assert_eq!(offset(80, 40, (79, 39), view(40, 20)), (40, 20));
}
}

View File

@@ -1,8 +0,0 @@
//! Alles, was ratatui kennen darf.
mod app;
mod grid;
mod screen;
mod theme;
pub use app::run;

View File

@@ -1,492 +0,0 @@
//! Screen-Stack und Antwort — Ticket 11, im Durchstich (12) bestaetigt.
use ratatui::crossterm::event::KeyCode;
use crate::data::Registries;
use crate::game::{Attribute, Cheat, Command, CreatureRef, Direction, Game};
/// Popups sind gewoehnliche Screens, die sich beim Rendern als Overlay
/// ausweisen — ein gemeinsamer `Vec<Screen>` statt `current` plus
/// `Option<Popup>`, weil Popup ueber Popup real ist.
#[derive(Clone)]
pub enum Screen {
/// `neu` entscheidet, wohin Enter fuehrt — der Screen weiss es, weil `main`
/// es weiss. Es aus `player.options.is_empty()` zu folgern waere kuerzer und
/// falsch: Ein Speicherstand, dem der Rassen-Mod fehlt, hat auch keine
/// Optionen und wuerde neu gebaut statt fortgesetzt.
MainMenu { neu: bool },
Explore,
/// Menue-Kampf (Charter): Faehigkeitsliste links, Gegnerliste rechts.
Combat {
/// Cursor in der Faehigkeitsliste.
pick: usize,
/// Cursor in der Gegnerliste.
target: usize,
/// `true` = die Gegnerliste ist aktiv. Ein `bool` und Tab statt zweier
/// gleichzeitig sichtbarer Cursor — dieselbe Bedienung wie im Laden.
targeting: bool,
},
/// Zwei Bereiche: Inventar mit Cursor, Ausrüstung als Registry-Schleife.
Inventory {
pick: usize,
},
/// Zugleich der Verteil-Screen — **kein zwoelfter Screen** (Ticket 15).
CharacterSheet {
/// Cursor in `Attribute::ASSIGNABLE`.
pick: usize,
/// Vorgemerkte Punkte, noch nicht abgeschickt. Ein festes Feld statt
/// eines `Vec`, weil die fuenf verteilbaren Grundwerte eine feste
/// Liste sind.
pending: [u32; 5],
},
/// Vollbild, kein Overlay. Bewusst nicht die lazy Variante mit einer
/// Meldungszeile im Hauptmenue: Ein Abstiegs-Roguelike lebt davon, dass der
/// Lauf am Ende *erzaehlt* wird (Ticket 14).
GameOver,
/// Charakterbau. **Eine Schleife ueber die Registry**, kein gemauertes
/// Layout (Ticket 02/11): Ein Mod, der „Talent" als dritte Kategorie
/// ergaenzt, bekommt sie hier ohne eine Zeile Code.
CharacterCreation {
/// Welche Kategorie gerade dran ist.
kategorie: usize,
/// Cursor in deren Optionsliste.
pick: usize,
/// Bisher Gewaehltes, als IDs.
gewaehlt: Vec<String>,
},
/// Das Questlog. Reine Anzeige — es gibt nichts zu waehlen, weil eine
/// Quest keine Handlung kennt, die der Spieler hier ausloest.
QuestLog,
/// Rueckfrage. Ticket 09 wollte `on_yes: Box<Response>` und ein implizites
/// „Nein = wegklicken" — das reicht nicht: Beim Ladefall heisst Nein
/// *beenden und den Mod nachinstallieren*, denn diese Wahl hat der Spieler
/// nur **vor** dem Laden. Das symmetrische Paar ist ein Feld mehr und
/// spart die ungeschriebene Regel.
Confirm {
text: String,
on_yes: Box<Response>,
on_no: Box<Response>,
},
/// Werkbank. Immer mit gebaut statt hinter `cfg(debug_assertions)`: Das
/// waere ein `#[cfg]` an der Variante *und* an jedem `match` darueber, fuer
/// ein Spiel, das niemand ausliefert. Ein `#[cfg]` reicht spaeter nach.
Debug {
pick: usize,
},
/// **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
/// `&mut` auf genau die `Vec`, in der er liegt. Nebeneffekt — `Response::Command`
/// ist der einzige Weg nach `game/`.
#[derive(Clone)]
pub enum Response {
None,
Pop,
Push(Screen),
Command(Command),
Quit,
}
impl Screen {
pub fn sheet() -> Screen {
Screen::CharacterSheet { pick: 0, pending: [0; 5] }
}
pub fn is_overlay(&self) -> bool {
// TODO(M2): `Confirm` ist das erste echte Overlay. Bis dahin haelt die
// Regel „ein Command schliesst alle Overlays" nur die Luft.
false
}
/// **Befund aus M3:** Ticket 11 hatte `handle_key(&mut self, key)` ohne
/// Kontext festgelegt. Der Kampf-Screen muss aber wissen, wie viele
/// Faehigkeiten es gibt, um den Cursor zu klemmen, und welche der Cursor
/// meint. Lesender Zugriff genuegt — die Regel „ein Screen fasst `Game`
/// nicht an" bleibt, weil nur `Response::Command` etwas aendert.
pub fn handle_key(&mut self, key: KeyCode, game: &Game, reg: &Registries) -> Response {
match self {
Screen::MainMenu { neu } => match key {
KeyCode::Enter if *neu => Response::Push(Screen::CharacterCreation {
kategorie: 0,
pick: 0,
gewaehlt: Vec::new(),
}),
KeyCode::Enter => Response::Push(Screen::Explore),
KeyCode::Char('q') | KeyCode::Esc => Response::Quit,
_ => Response::None,
},
Screen::Explore => match key {
// Pfeiltasten *und* hjkl — drei zusaetzliche Arme sind die
// billige Hoeflichkeit gegenueber Vim-Fingern (Ticket 11).
KeyCode::Up | KeyCode::Char('k') => {
Response::Command(Command::Move(Direction::North))
}
KeyCode::Down | KeyCode::Char('j') => {
Response::Command(Command::Move(Direction::South))
}
KeyCode::Left | KeyCode::Char('h') => {
Response::Command(Command::Move(Direction::West))
}
KeyCode::Right | KeyCode::Char('l') => {
Response::Command(Command::Move(Direction::East))
}
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),
KeyCode::Char('q') => Response::Push(Screen::QuestLog),
KeyCode::F(12) => Response::Push(Screen::Debug { pick: 0 }),
// Screen-Navigation ist reine UI und fasst `Game` nie an.
KeyCode::Char('c') => Response::Push(Screen::sheet()),
KeyCode::Char('i') => Response::Push(Screen::Inventory { pick: 0 }),
KeyCode::Esc => Response::Pop,
_ => Response::None,
},
Screen::Combat { pick, target, targeting } => {
let abilities = game.abilities_of(CreatureRef::Player, reg);
if abilities.is_empty() {
return Response::None;
}
*pick = (*pick).min(abilities.len() - 1);
*target = (*target).min(game.enemies.len().saturating_sub(1));
// Der aktive Cursor und seine Laenge — sonst stuende dieselbe
// Klemmung viermal da.
let (cursor, len) = if *targeting {
(&mut *target, game.enemies.len())
} else {
(&mut *pick, abilities.len())
};
match key {
KeyCode::Tab | KeyCode::BackTab => {
*targeting = !*targeting;
Response::None
}
KeyCode::Up | KeyCode::Char('k') => {
*cursor = cursor.saturating_sub(1);
Response::None
}
KeyCode::Down | KeyCode::Char('j') => {
*cursor = (*cursor + 1).min(len.saturating_sub(1));
Response::None
}
// Die Ziffern springen aufs Ziel, sie feuern nicht — sonst
// haette dieselbe Taste je nach Seite zwei Bedeutungen.
KeyCode::Char(c @ '1'..='9') => {
let i = (c as u8 - b'1') as usize;
if i < game.enemies.len() {
*target = i;
}
Response::None
}
// Ohne Gegner (Flucht, Selbstheilung) zaehlt der Held
// selbst als Ziel.
KeyCode::Enter => {
let ziel = match game.enemies.is_empty() {
true => CreatureRef::Player,
false => CreatureRef::Enemy(*target),
};
Response::Command(Command::UseAbility {
ability: abilities[*pick].clone(),
target: ziel,
})
}
KeyCode::Char('.') => Response::Command(Command::Wait),
KeyCode::Char('c') => Response::Push(Screen::sheet()),
// Derselbe Screen wie draussen. `rejects` verbietet
// `UseItem` im Kampf nicht — es fehlte nur die Tuer.
KeyCode::Char('i') => Response::Push(Screen::Inventory { pick: 0 }),
KeyCode::F(12) => Response::Push(Screen::Debug { pick: 0 }),
_ => Response::None,
}
}
Screen::Inventory { pick } => {
let n = game.player.inventory.len();
if n > 0 {
*pick = (*pick).min(n - 1);
}
match key {
KeyCode::Up | KeyCode::Char('k') => {
*pick = pick.saturating_sub(1);
Response::None
}
KeyCode::Down | KeyCode::Char('j') => {
*pick = (*pick + 1).min(n.saturating_sub(1));
Response::None
}
KeyCode::Enter | KeyCode::Char('u') if n > 0 => {
Response::Command(Command::UseItem(*pick))
}
KeyCode::Char('e') if n > 0 => Response::Command(Command::Equip(*pick)),
KeyCode::Char('d') if n > 0 => Response::Command(Command::Drop(*pick)),
// Ablegen ueber die Slot-Reihenfolge aus der Registry —
// der Charakterbogen mauert kein Layout (Ticket 02).
KeyCode::Char(c @ '1'..='9') => {
let index = (c as u8 - b'1') as usize;
match reg.slots_ordered().get(index) {
Some(slot) => {
Response::Command(Command::Unequip(slot.id.clone()))
}
None => Response::None,
}
}
KeyCode::Esc | KeyCode::Char('i') | KeyCode::Char('q') => Response::Pop,
_ => Response::None,
}
}
Screen::CharacterSheet { pick, pending } => {
// `saturating_sub`, weil `unspent_points` sich unter dem
// offenen Screen aendern kann — ein Aufstieg im selben Zug.
let offen = game.unspent_points.saturating_sub(pending.iter().sum::<u32>());
match key {
KeyCode::Up | KeyCode::Char('k') => {
*pick = pick.saturating_sub(1);
Response::None
}
KeyCode::Down | KeyCode::Char('j') => {
*pick = (*pick + 1).min(Attribute::ASSIGNABLE.len() - 1);
Response::None
}
KeyCode::Right | KeyCode::Char('+' | 'l') if offen > 0 => {
pending[*pick] += 1;
Response::None
}
KeyCode::Left | KeyCode::Char('-' | 'h') if pending[*pick] > 0 => {
pending[*pick] -= 1;
Response::None
}
// **Alle** Punkte in einem Kommando und damit in einem Zug
// (Ticket 15).
KeyCode::Enter => {
let punkte = Attribute::ASSIGNABLE
.iter()
.zip(pending.iter())
.filter(|(_, n)| **n > 0)
.map(|(a, n)| (*a, *n))
.collect();
// **Vorgemerktes wird beim Abschicken verbraucht.** Ohne
// das behaelt der Screen Punkte, die das Spiel schon
// abgezogen hat — und rechnet `0 - 1` auf `u32`.
*pending = [0; 5];
Response::Command(Command::AssignPoints(punkte))
}
KeyCode::Esc | KeyCode::Char('c' | 'q') => Response::Pop,
_ => 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::Debug { pick } => {
let items = reg.items_ordered();
if !items.is_empty() {
*pick = (*pick).min(items.len() - 1);
}
match key {
KeyCode::Up | KeyCode::Char('k') => {
*pick = pick.saturating_sub(1);
Response::None
}
KeyCode::Down | KeyCode::Char('j') => {
*pick = (*pick + 1).min(items.len().saturating_sub(1));
Response::None
}
KeyCode::Enter if !items.is_empty() => {
Response::Command(Command::Debug(Cheat::Give(items[*pick].id.clone())))
}
KeyCode::Char('h') => Response::Command(Command::Debug(Cheat::Heal)),
KeyCode::Char('>') => Response::Command(Command::Debug(Cheat::Descend)),
KeyCode::Char('x') => Response::Command(Command::Debug(Cheat::ClearRoom)),
KeyCode::Esc | KeyCode::F(12) | KeyCode::Char('q') => Response::Pop,
_ => Response::None,
}
}
Screen::CharacterCreation { kategorie, pick, gewaehlt } => {
let kategorien = reg.categories_ordered();
let Some(k) = kategorien.get(*kategorie) else { return Response::None };
let optionen = reg.options_of(&k.id);
if optionen.is_empty() {
return Response::None;
}
*pick = (*pick).min(optionen.len() - 1);
match key {
KeyCode::Up | KeyCode::Char('k') => {
*pick = pick.saturating_sub(1);
Response::None
}
KeyCode::Down | KeyCode::Char('j') => {
*pick = (*pick + 1).min(optionen.len() - 1);
Response::None
}
// Auswaehlen schaltet um: nochmal druecken nimmt zurueck.
KeyCode::Char(' ') | KeyCode::Enter => {
let id = optionen[*pick].id.clone();
if let Some(i) = gewaehlt.iter().position(|g| *g == id) {
gewaehlt.remove(i);
} else if reg.picked_in(&k.id, gewaehlt) < k.pick as usize {
gewaehlt.push(id);
}
// Ist die Kategorie voll, weiter zur naechsten — und bei
// der letzten steht der Held.
if reg.picked_in(&k.id, gewaehlt) == k.pick as usize {
if *kategorie + 1 < kategorien.len() {
*kategorie += 1;
*pick = 0;
} else {
return Response::Command(Command::CreateCharacter(
gewaehlt.clone(),
));
}
}
Response::None
}
KeyCode::Tab | KeyCode::Right => {
*kategorie = (*kategorie + 1).min(kategorien.len() - 1);
*pick = 0;
Response::None
}
KeyCode::BackTab | KeyCode::Left => {
*kategorie = kategorie.saturating_sub(1);
*pick = 0;
Response::None
}
_ => Response::None,
}
}
Screen::QuestLog => match key {
KeyCode::Esc | KeyCode::Char('q') => Response::Pop,
_ => Response::None,
},
Screen::Confirm { on_yes, on_no, .. } => match key {
KeyCode::Char('j' | 'y') | KeyCode::Enter => (**on_yes).clone(),
KeyCode::Char('n' | 'q') | KeyCode::Esc => (**on_no).clone(),
_ => Response::None,
},
Screen::GameOver => Response::Quit,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data::load_mods;
use crate::game::{Creature, Rng};
fn spiel() -> (Game, Registries) {
let (reg, _) = load_mods().expect("core muss laden");
let game = Game::new(Creature::new("Test"), &reg, Rng::new(1)).expect("Etage 1");
(game, reg)
}
/// Gefunden im Spiel: Nach dem Abschicken stand `unspent_points` auf 0,
/// waehrend der Screen den Punkt noch vorgemerkt hielt — `0 - 1` auf `u32`.
/// Der erste Test, der **Screen-Zustand gegen Spielzustand** prueft; die
/// Trennung aus Ticket 11 macht genau diese Naht zur Fehlerquelle.
#[test]
fn vorgemerkte_punkte_werden_beim_abschicken_verbraucht() {
let (mut game, reg) = spiel();
game.unspent_points = 1;
let mut screen = Screen::sheet();
screen.handle_key(KeyCode::Char('+'), &game, &reg);
let Screen::CharacterSheet { pending, .. } = &screen else { panic!("falscher Screen") };
assert_eq!(pending[0], 1, "der Punkt ist vorgemerkt");
let antwort = screen.handle_key(KeyCode::Enter, &game, &reg);
let Response::Command(cmd) = antwort else { panic!("Enter muss abschicken") };
assert!(matches!(game.execute(cmd, &reg), crate::game::Outcome::TurnPassed));
assert_eq!(game.unspent_points, 0);
let Screen::CharacterSheet { pending, .. } = &screen else { panic!("falscher Screen") };
assert_eq!(pending[0], 0, "und im Screen nicht mehr vorgemerkt");
// Der Zustand, der abgestuerzt ist: kein offener Punkt, keine Vormerkung.
screen.handle_key(KeyCode::Char('+'), &game, &reg);
let Screen::CharacterSheet { pending, .. } = &screen else { panic!("falscher Screen") };
assert_eq!(pending[0], 0, "ohne offene Punkte laesst sich nichts vormerken");
}
}

View File

@@ -1,26 +0,0 @@
//! Die einzige Stelle, an der aus einem Farb*namen* eine ratatui-Farbe wird.
//!
//! Ticket 11 hat `symbol` als Schluessel verworfen, weil `ui/theme.rs` **Code**
//! ist: Ein Modder, der `meinmod:frostbite` anlegt, koennte dort nichts
//! eintragen. Deshalb tragen die TOML-Daten Zeichen und Farbnamen selbst, und
//! hier steht nur die Uebersetzung.
use ratatui::style::Color;
pub fn color(name: Option<&str>) -> Color {
match name.unwrap_or_default() {
"black" => Color::Black,
"red" => Color::Red,
"green" => Color::Green,
"yellow" => Color::Yellow,
"blue" => Color::Blue,
"magenta" => Color::Magenta,
"cyan" => Color::Cyan,
"white" => Color::White,
"gray" | "grey" => Color::Gray,
"dark_gray" | "dark_grey" => Color::DarkGray,
// Unbekannter Name ist kein Fehler, sondern Standardfarbe — ein Tippfehler
// im Mod soll das Spiel nicht anhalten (Ticket 04).
_ => Color::Reset,
}
}

View File

@@ -1,51 +0,0 @@
//! Ticket 11: `game/` kennt ratatui nicht — und zwar mechanisch geprueft.
//!
//! Ein Cargo-Workspace mit `game` als eigener Crate waere echte
//! Compiler-Durchsetzung und ist die Decke, falls es je ernst wird. Bis dahin
//! faengt dieser Test denselben Fehler und kostet keine Umstrukturierung.
use std::fs;
use std::path::Path;
fn rust_files(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
for entry in fs::read_dir(dir).expect("Verzeichnis lesbar").flatten() {
let path = entry.path();
if path.is_dir() {
rust_files(&path, out);
} else if path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
}
#[test]
fn game_bleibt_ratatui_frei() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
for dir in ["src/game", "src/data"] {
let mut files = Vec::new();
rust_files(&root.join(dir), &mut files);
assert!(!files.is_empty(), "{dir} sollte Dateien enthalten");
for file in files {
let source = fs::read_to_string(&file).expect("Datei lesbar");
// Kommentare ausnehmen. Ticket 11 hat „liest die Dateien und prueft
// auf ratatui" gesagt — die woertliche Fassung schlaegt an, sobald
// ein Doc-Kommentar erklaert, dass die Grenze existiert.
let offender = source
.lines()
.enumerate()
.find(|(_, l)| !l.trim_start().starts_with("//") && l.contains("ratatui"));
if let Some((n, line)) = offender {
panic!(
"{}:{}: „{}\" — die Grenze aus Ticket 11 ist gefallen",
file.display(),
n + 1,
line.trim()
);
}
}
}
}