Files
rustkeep/.scratch/rustkeep-1.0/map.md
MDietermann 6f91b44e50 fix(bogen): vorgemerkte Punkte beim Abschicken verbrauchen
Nach Enter stand unspent_points auf 0, waehrend der Screen den Punkt noch
vorgemerkt hielt — die Anzeige rechnete 0 - 1 auf u32 und stuerzte ab.

Der Screen leert sein Feld, waehrend er das Kommando uebergibt, dazu
saturating_sub an den zwei Lesestellen: unspent_points kann sich auch
unter dem offenen Screen aendern.

Erster UI-Test des Projekts. handle_key ist ohne Terminal aufrufbar, und
sobald ein Screen Zustand haelt, ist die Naht zum Spielzustand eine
Fehlerquelle wie jede andere.
2026-08-12 21:20:02 +02:00

293 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Map: rustkeep 1.0
Gitea-Spiegel: [Issue #1](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/1).
Diese Datei ist die kanonische Quelle — bei Abweichung gilt `.scratch/`.
Gitea hat keine nativen Parent-Child- oder Blocking-Beziehungen, deshalb steht beides
dort als Konvention im Body. Zuordnung Ticket → Gitea-Issue:
| Ticket | Gitea | Ticket | Gitea |
|---|---|---|---|
| 01 Effekt-Vokabular | [#2](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/2) | 06 Research ratatui | [#7](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/7) |
| 02 Typ-Registry | [#3](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/3) | 07 Game-State-Baum | [#8](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/8) |
| 03 Mod-Ladepipeline | [#4](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/4) | 08 Event-System | [#9](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/9) |
| 04 Fehlerstrategie | [#5](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/5) | 09 Save-Schema | [#10](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/10) |
| 05 Research Script-Engine | [#6](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/6) | 10 Raum-Prefab-Format | [#11](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/11) |
| 11 UI-Gerüst | [#12](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/12) | 12 Prototyp ratatui | [#13](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/13) |
| 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) |
## Destination — **erreicht am 2026-08-12**
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.
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
(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.
**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
Domain: Rust-Terminal-Roguelike, moddbar. Repo: `MDietermann/rustkeep`.
Das Glossar liegt in [`CONTEXT.md`](../../CONTEXT.md) im Repo-Wurzelverzeichnis — kanonische Begriffe,
keine Implementierung. Jede Session, die einen Begriff schärft, schreibt ihn dort fest.
Jede Session konsultiert: `ponytail` (dauerhaft aktiv), `/grilling` und `/domain-modeling` für
Grilling-Tickets, `/research` für Research-Tickets, `/prototype` für Prototype-Tickets.
Feststehende Rahmenentscheidungen aus der Charter-Session:
| Thema | Entscheidung |
|---|---|
| Motivation | Rust lernen **und** Spiel bauen — Lern-Entscheidungen bekommen eigene Tickets, wo sie echte Architekturfragen sind |
| Kernschleife | Grid-Bewegung zum Erkunden, Kampf im Menü (Dungeon Master / Etrian Odyssey) |
| Held | Solo, kein Party-System |
| Rendering | ratatui + crossterm; `print_box` fliegt raus, sobald ratatui steht |
| Etagen | Hybrid — Raum-Prefabs in TOML, prozedural zusammengesetzt |
| 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-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* |
| 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 |
| Weltzustand | `Game`-Ownership-Baum, Verweise über `String`-IDs (keine Indizes, 07). `Registries` liegt daneben, nicht darin. Kein ECS |
| Fehler | `Result` statt `panic!` — kaputte Mod-TOMLs sind der Normalfall, nicht der Ausnahmefall |
| Save | TOML, Mod-Liste im Header, fehlender Mod = Warnung und Verwerfen, kein harter Abbruch |
**Leitplanke aus Ticket 05**: Scripts reden ausschließlich über das Effekt-Vokabular, nie direkt mit dem
Game-State. Solange das gilt, ist die Script-Engine austauschbar — Ticket 01 entscheidet damit indirekt
auch über Ticket 13.
## Decisions so far
<!-- eine Zeile pro geschlossenem Ticket -->
- [01 — Effekt-Vokabular](issues/01-effekt-vokabular.md) ([#2](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/2)) — **Zwei Begriffe statt einem**: Modifikator (rechnet ein Attribut um, solange er existiert) und Aktion (ändert Zustand einmalig), getrennte Maschinen und getrennte TOML-Arrays. Fester Attributkatalog ohne Namensraum; `max_health` ist Attribut, der Lebensstand ist Zustand. Sechs Aktionen, `damage` eigenständig wegen der Minderungskette. Stapeln `(Basis + Σ flach) × (1 + Σ prozentual)`. Eine Uhr: der Zug. Sieben feste Schadenstypen, ein Minderungsmechanismus (`resistance`), `armor` nur Kurzschreibweise. Auren wohnen beim Träger. Bedingungsvokabular bewusst an Ticket 08 delegiert. Begriffe im [Glossar](../../CONTEXT.md).
- [02 — Typ-Registry statt Enums](issues/02-typ-registry.md) ([#3](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/3)) — **Es gibt keinen Item-Typ mehr**: `enum ItemType` vermischte Slot, Verhalten und Kategorie und entfällt ersatzlos zugunsten der Felder `slot`, `stackable`, `consumed_on_use`. `ItemWeapon` und `ItemArmor` fallen weg, ein Gegenstand ist einheitlich. **Fähigkeit** kommt als dritter Begriff neben Modifikator und Aktion dazu (Angebot statt Wirkung). Fünf moddbare Registries; Slots und sogar Charakteroption-Kategorien sind Inhalt, mit `pick = N`. Startausrüstung ist `on_create` als gewöhnliche Aktionsliste. `value` → `base_price`, `shop_price` → `price_modifier`.
- [03 — Mod-Ladepipeline und Manifest](issues/03-mod-ladepipeline.md) ([#4](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/4)) — Zwei Suchpfade: `mods/` neben der Binary (enthält `core/`) und `data_dir()/rustkeep/mods` über die Crate `directories`; bei gleicher ID gewinnt das Profil, mit Warnzeile. **`core` liegt als echter Mod-Ordner auf der Platte, `include_str!` entfällt.** `manifest.toml` mit vier Feldern (`id`, `name`, `version`, `depends`) — TOML statt YAML, weil `serde_yaml` und der Fork `serde_yml` beide archiviert sind. Ladereihenfolge aus einer selbstheilenden Spielerliste `load_order.toml`; `depends` sortiert nicht, sondern prüft, und eine Verletzung ist harter Startfehler. Jede `*.toml` im Mod wird eingelesen, jede darf jede Registry-Tabelle enthalten. Präfix wird automatisch vorangestellt, Überschreiben über `overrides = "core:iron_sword"` am Eintrag — der landet **nur** unter der überschriebenen ID. Drei Phasen (Manifeste → Einlesen → Auflösen), dadurch keine Vorwärtsreferenzen; Fehlereinheit ist der Eintrag. Kein Nachladen zur Laufzeit.
- [04 — Fehlerstrategie für kaputte Mods](issues/04-fehlerstrategie.md) ([#5](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/5)) — **Syntax ist ein Datei-Fehler, alles Semantische ein Eintrags-Fehler**; damit 03s Versprechen hält, wird zweistufig deserialisiert (`Vec<Spanned<toml::Value>>`, dann Eintrag für Eintrag), sonst reißt ein Tippfehler die ganze `items.toml` mit. Genau drei harte Fehler: `depends` fehlend, `depends` verletzt, `core` nicht gefunden — stderr und `exit(1)`. `core` ist sonst kein Sonderfall; abgesichert wird es durch einen `#[test]`, der `mods/core/` mit null Warnungen lädt. Warnungen sammeln sich in `Vec<String>` und landen in `logs/load-<zeitstempel>.log` (letzte zehn), plus eine Zeile auf dem Startbildschirm — kein Report-Screen. Meldung `meinmod/items.toml:42: core:iron_sword — <message>`, Mod-ID vorne, keine Spalte. Rust: handgeschriebenes `enum LoadError` mit `impl Display` statt `thiserror`/`anyhow`, `load_mods() -> Result<(Registries, Vec<String>), LoadError>`, Kette endet vor `main` mit `eprintln!` und `exit(1)`. **Panic-Grenze ist die Datenherkunft**: TOML ist `Result`, eigener Code darf panicken.
- [05 — Research: Script-Engine](issues/05-research-script-engine.md) ([#6](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/6)) — Empfehlung Rhai wegen Fehlermeldungen mit Zeile und Spalte, billiger Typ-Brücke, AST-Hot-Reload und Build ohne `cc`. Gemessen: mlua liest unter `StdLib::ALL_SAFE` per `io.open` echte Dateien — „safe" heißt dort speichersicher, nicht fähigkeitsbeschränkt. Lua gewinnt bei der Modder-Vertrautheit; WASM scheidet für 1.0 aus. **Kernfolge: reden Scripts nur über das Effekt-Vokabular, ist die Engine-Wahl umkehrbar.** Entscheidung offen in 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).
- [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.
- [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.
- [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
- **Balancing** — konkrete Zahlen für Attribute, Schaden, Preise und Erfahrung über fünf Etagen. Wartet auf Kampfformeln (14), Progression (15) und Loot (16); vorher gibt es nichts zu balancieren.
- **Boss-Design** — ein Boss ist derzeit ein Wesen mit `ai`-Liste (14) in einem Prefab mit `core:boss`-Tag (10). Was fehlt, sind **Phasen**: „ab 50 % Leben ein anderes Verhalten" wäre mit der `ai`-Bedingungsliste sogar ausdrückbar, „ab 50 % ändern sich die Werte" nicht. Berührt Stufe (c) des Mod-Supports.
- ~~**NPC- und Handelsmodell**~~ — **entschieden während M4**, siehe unten.
- **Inventar-UX** — Gewicht oder Kapazität (`carry_capacity` ist seit 01 ein Attribut ohne Regel), Sortierung, Vergleichsansicht. 11 hat nur den Screen und die Commands festgelegt, nicht was darin steht.
- **Mod-Verteilung** — durch 03 auf „Ordner ins Profil entpacken" geschrumpft, ohne Versionsprüfung. Offen ist nur noch, ob 1.0 mehr braucht.
- **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
- **Story-Modul** — kommt später als eigenes Modul, linear statt prozedural, ebenfalls moddbar. Eigene Map, wenn 1.0 steht.
- **Party-System** — bewusst gegen Solo entschieden; ein Wechsel wäre ein Umbau der Kampfarchitektur, keine Erweiterung.
- **Multiplayer**
- **Grafik jenseits des Terminals**