Compare commits
33 Commits
prototyp/1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e145d5d2df | |||
| a8fea8b526 | |||
| 44cef14960 | |||
| 69efa72d68 | |||
| b721947a65 | |||
| 37b5820c5b | |||
| 090d9ae136 | |||
| d09fef7069 | |||
| 7fdd8bbb00 | |||
| 6f91b44e50 | |||
| b19492bb51 | |||
| b072e5ade3 | |||
| 26d9533a93 | |||
| 036f60d463 | |||
| 6014471c33 | |||
| 7963df5236 | |||
| 3a6af6affb | |||
| 7ed15afaf9 | |||
| 197f62ce33 | |||
| cdcabcce21 | |||
| b25dd1c49a | |||
| 217a7acbbc | |||
| 8bcd53efa2 | |||
| f998e55210 | |||
| 3714bad313 | |||
| b39491916f | |||
| 9c5efab331 | |||
| 5faa5d6165 | |||
| 380400611e | |||
| bed9ef5762 | |||
| 6442e104ab | |||
| d51ebb26a1 | |||
| 88195d3c9a |
@@ -116,6 +116,73 @@ 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
|
||||||
|
|||||||
@@ -93,6 +93,17 @@ 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,
|
||||||
@@ -105,3 +116,28 @@ Engine den Begriff „Startausrüstung" kennt.
|
|||||||
`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.
|
||||||
|
|||||||
@@ -125,6 +125,16 @@ 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
|
||||||
|
|||||||
@@ -85,6 +85,17 @@ 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
|
||||||
|
|||||||
@@ -52,3 +52,21 @@ 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.
|
||||||
|
|||||||
@@ -116,10 +116,20 @@ Mit frei zusammengesetzten Modifikatoren je Exemplar könnte ein zufällig erzeu
|
|||||||
alles, praktisch aber nichts, das ein Modder benennen oder ein Händler bepreisen könnte — und die
|
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`. Ein Vergleich zweier
|
**Stapelregel:** `stackable` **und** gleiche `def` **und** gleiche `affixes`.
|
||||||
`Vec<String>`, eine Zeile, verbietet nichts. Affixe an ausrüstbare Gegenstände zu binden wäre eine
|
|
||||||
Regel, die dem Modder erklärt, was er darf — Bevormundung, die 02 abgeräumt hat. Dass ein Trank mit
|
Ein Vergleich zweier `Vec<String>`, eine Zeile, verbietet nichts. Affixe an ausrüstbare Gegenstände zu
|
||||||
Zufallsaffix nicht mehr stapelt, ist die logische Folge und das Problem dessen, der ihn so baut.
|
binden wäre eine Regel, die dem Modder erklärt, was er darf — Bevormundung, die 02 abgeräumt hat. Dass
|
||||||
|
ein Trank mit Zufallsaffix nicht mehr stapelt, ist die logische Folge und das Problem dessen, der ihn
|
||||||
|
so baut.
|
||||||
|
|
||||||
|
**Befund aus M4: Die Regel war bis dahin nur beschrieben.** `ItemInstance` hatte kein `count`, und der
|
||||||
|
Beutezieher schob für `count = "50-120"` bis zu **120 einzelne Einträge** ins Inventar — 120 Zeilen im
|
||||||
|
Inventar-Screen, und ein Kauf hätte 45 davon einzeln entfernen müssen. `ItemInstance` trägt jetzt ein
|
||||||
|
`count`, und die Stapelregel wird beim Aufheben *ausgeführt* statt nur beschrieben.
|
||||||
|
|
||||||
|
Damit bedeutet auch `stackable` aus Ticket 02 zum ersten Mal etwas — das Feld stand seit M1 in den
|
||||||
|
Daten und hat nie jemand gelesen.
|
||||||
|
|
||||||
### Ein Eingang, ein Ausgang
|
### Ein Eingang, ein Ausgang
|
||||||
|
|
||||||
@@ -162,6 +172,34 @@ 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
|
||||||
@@ -179,3 +217,24 @@ weil synchrones Feuern über `self.quests` iterieren müsste, während `self.app
|
|||||||
`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.
|
||||||
|
|||||||
@@ -71,6 +71,58 @@ 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 |
|
||||||
@@ -102,6 +154,34 @@ 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
|
||||||
@@ -116,6 +196,12 @@ 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.
|
||||||
@@ -186,3 +272,25 @@ 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.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# 09 — Save-Schema
|
# 09 — Save-Schema
|
||||||
|
|
||||||
Type: grilling
|
Type: grilling
|
||||||
Status: open
|
Status: resolved
|
||||||
Blocked by: 07
|
Blocked by: 07
|
||||||
|
|
||||||
## Question
|
## Question
|
||||||
@@ -33,3 +33,150 @@ 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.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# 10 — Raum-Prefab-Format
|
# 10 — Raum-Prefab-Format
|
||||||
|
|
||||||
Type: grilling
|
Type: grilling
|
||||||
Status: open
|
Status: resolved
|
||||||
Blocked by: 03
|
Blocked by: 03
|
||||||
|
|
||||||
## Question
|
## Question
|
||||||
@@ -35,3 +35,203 @@ 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.
|
||||||
|
|||||||
@@ -59,13 +59,33 @@ 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.
|
||||||
|
|
||||||
Auch Handel kostet einen Zug. Ein `fn costs_turn(&self) -> bool` wäre eine Ausnahmeliste, die mit
|
**Korrektur aus dem Durchstich (Ticket 12): „alle Commands" heißt „alle *ausgeführten*".** Gegen eine
|
||||||
jeder neuen Variante gepflegt werden muss und bei der irgendwann jemand falsch rät. Zehn Züge beim
|
Wand zu laufen darf keinen Zug kosten — sonst tickt ein Gift-Debuff, weil jemand danebengetippt hat.
|
||||||
|
|
||||||
|
```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.
|
||||||
|
|
||||||
@@ -94,8 +114,16 @@ 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: Command }` wird
|
keine zusätzliche `Response`-Variante und keine Antwortliste. `Confirm { text, on_yes: Box<Response> }` wird
|
||||||
dadurch für jede Rückfrage wiederverwendbar, ohne eigene Variante je Anlass. Dass `Inventory` als
|
dadurch für jede Rückfrage wiederverwendbar, ohne eigene Variante je Anlass — **`Command` muss dafür
|
||||||
|
`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.
|
||||||
|
|
||||||
@@ -104,10 +132,22 @@ 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
|
||||||
@@ -155,6 +195,20 @@ 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
|
||||||
@@ -179,3 +233,60 @@ 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.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# 12 — Prototyp: ratatui-Durchstich
|
# 12 — Prototyp: ratatui-Durchstich
|
||||||
|
|
||||||
Type: prototype
|
Type: prototype
|
||||||
Status: open
|
Status: resolved
|
||||||
Blocked by: 11
|
Blocked by: 11
|
||||||
|
|
||||||
## Question
|
## Question
|
||||||
@@ -21,3 +21,56 @@ 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.
|
||||||
|
|||||||
@@ -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: open
|
Status: resolved
|
||||||
Blocked by: 01
|
Blocked by: 01
|
||||||
|
|
||||||
## Question
|
## Question
|
||||||
@@ -27,3 +27,146 @@ 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).
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# 14 — Kampfformeln und Rundenablauf
|
# 14 — Kampfformeln und Rundenablauf
|
||||||
|
|
||||||
Type: grilling
|
Type: grilling
|
||||||
Status: open
|
Status: resolved
|
||||||
Blocked by: 01
|
Blocked by: 01
|
||||||
|
|
||||||
## Question
|
## Question
|
||||||
@@ -41,3 +41,295 @@ 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.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# 15 — Progression und Level-up
|
# 15 — Progression und Level-up
|
||||||
|
|
||||||
Type: grilling
|
Type: grilling
|
||||||
Status: open
|
Status: resolved
|
||||||
Blocked by: 02
|
Blocked by: 02
|
||||||
|
|
||||||
## Question
|
## Question
|
||||||
@@ -23,3 +23,157 @@ 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`.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# 16 — Loot-Tabellen
|
# 16 — Loot-Tabellen
|
||||||
|
|
||||||
Type: grilling
|
Type: grilling
|
||||||
Status: open
|
Status: resolved
|
||||||
Blocked by: 02
|
Blocked by: 02
|
||||||
|
|
||||||
## Question
|
## Question
|
||||||
@@ -35,3 +35,147 @@ 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]` |
|
||||||
|
|||||||
@@ -17,11 +17,245 @@ 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
|
## 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.
|
||||||
|
|
||||||
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 drei Stufen.
|
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, ®)` und `game.attribute(…, ®)` 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
|
||||||
|
|
||||||
@@ -44,7 +278,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 |
|
| 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 |
|
| 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 |
|
||||||
@@ -67,16 +301,79 @@ 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** — ob Bosse nur Gegner mit besseren Werten sind oder eigene Mechanik brauchen (Phasen, Skripte). Berührt Stufe (c) des Mod-Supports.
|
- **Boss-Design** — ein Boss ist derzeit ein Wesen mit `ai`-Liste (14) in einem Prefab mit `core:boss`-Tag (10). Was fehlt, sind **Phasen**: „ab 50 % Leben ein anderes Verhalten" wäre mit der `ai`-Bedingungsliste sogar ausdrückbar, „ab 50 % ändern sich die Werte" nicht. Berührt Stufe (c) des Mod-Supports.
|
||||||
- **NPC- und Handelsmodell** — Warenbestand, Dialogdaten. Wartet auf das Event-System (08). Die Preisformel steht bereits in 07; weil 07 Backtracking ausgeschlossen hat, muss ein Händler auf **jeder** Etage erreichbar sein.
|
- ~~**NPC- und Handelsmodell**~~ — **entschieden während M4**, siehe unten.
|
||||||
- **Inventar-UX** — Gewicht oder Kapazität (`carry_capacity` ist seit 01 ein Attribut ohne Regel), Sortierung, Vergleichsansicht. 11 hat nur den Screen und die Commands festgelegt, nicht was darin steht.
|
- **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.
|
||||||
|
|||||||
151
CONTEXT.md
151
CONTEXT.md
@@ -11,6 +11,22 @@ 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
|
||||||
@@ -30,6 +46,16 @@ 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,
|
||||||
@@ -40,6 +66,14 @@ 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".
|
||||||
@@ -157,9 +191,126 @@ 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
|
||||||
|
|||||||
1648
Cargo.lock
generated
1648
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -6,3 +6,6 @@ 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)
|
||||||
|
ratatui = "0.30.2"
|
||||||
|
directories = "6.0.0"
|
||||||
|
|||||||
77
mods/core/abilities.toml
Normal file
77
mods/core/abilities.toml
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# 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" } ]
|
||||||
46
mods/core/affixes.toml
Normal file
46
mods/core/affixes.toml
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# 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 } ]
|
||||||
20
mods/core/balance.toml
Normal file
20
mods/core/balance.toml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# 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
|
||||||
61
mods/core/character_options.toml
Normal file
61
mods/core/character_options.toml
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# 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 } ]
|
||||||
72
mods/core/creatures.toml
Normal file
72
mods/core/creatures.toml
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# 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
|
||||||
63
mods/core/floors.toml
Normal file
63
mods/core/floors.toml
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# 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"
|
||||||
76
mods/core/items.toml
Normal file
76
mods/core/items.toml
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# 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 } ]
|
||||||
103
mods/core/loot.toml
Normal file
103
mods/core/loot.toml
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# 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" },
|
||||||
|
]
|
||||||
4
mods/core/manifest.toml
Normal file
4
mods/core/manifest.toml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
id = "core"
|
||||||
|
name = "rustkeep"
|
||||||
|
version = "0.1.0"
|
||||||
|
depends = []
|
||||||
43
mods/core/modifiers.toml
Normal file
43
mods/core/modifiers.toml
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# 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 } ]
|
||||||
29
mods/core/npcs.toml
Normal file
29
mods/core/npcs.toml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# 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." },
|
||||||
|
]
|
||||||
98
mods/core/prefabs.toml
Normal file
98
mods/core/prefabs.toml
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# 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..#
|
||||||
|
#.....#
|
||||||
|
#######
|
||||||
|
"""
|
||||||
34
mods/core/quests.toml
Normal file
34
mods/core/quests.toml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# 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 } ]
|
||||||
17
mods/core/slots.toml
Normal file
17
mods/core/slots.toml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# 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
|
||||||
673
src/data/loader.rs
Normal file
673
src/data/loader.rs
Normal file
@@ -0,0 +1,673 @@
|
|||||||
|
//! 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 = ®.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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/data/mod.rs
Normal file
11
src/data/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
//! 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,
|
||||||
|
};
|
||||||
578
src/data/registries.rs
Normal file
578
src/data/registries.rs
Normal file
@@ -0,0 +1,578 @@
|
|||||||
|
//! 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
406
src/data/save.rs
Normal file
406
src/data/save.rs
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
//! 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, ®, 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), ®);
|
||||||
|
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, ®);
|
||||||
|
|
||||||
|
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, ®),
|
||||||
|
game.attribute(CreatureRef::Player, Attribute::Level, None, ®)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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, ®, &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, ®);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
236
src/game/action.rs
Normal file
236
src/game/action.rs
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
//! 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, .. }));
|
||||||
|
}
|
||||||
|
}
|
||||||
191
src/game/attributes.rs
Normal file
191
src/game/attributes.rs
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
//! 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
99
src/game/combat.rs
Normal file
99
src/game/combat.rs
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
//! 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
84
src/game/command.rs
Normal file
84
src/game/command.rs
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
//! 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),
|
||||||
|
}
|
||||||
112
src/game/condition.rs
Normal file
112
src/game/condition.rs
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
//! 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
147
src/game/creature.rs
Normal file
147
src/game/creature.rs
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
//! 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
|
||||||
|
}
|
||||||
|
}
|
||||||
202
src/game/event.rs
Normal file
202
src/game/event.rs
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
//! 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
183
src/game/floor.rs
Normal file
183
src/game/floor.rs
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
//! 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
499
src/game/generate.rs
Normal file
499
src/game/generate.rs
Normal file
@@ -0,0 +1,499 @@
|
|||||||
|
//! 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, ®, &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
|
||||||
|
}
|
||||||
|
}
|
||||||
235
src/game/loot.rs
Normal file
235
src/game/loot.rs
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
//! 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, ®, &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, ®, &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, ®, &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, ®, &mut Rng::new(77));
|
||||||
|
let b = roll("core:floor_3_table", 0.0, ®, &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, ®, &mut Rng::new(5)).is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
2357
src/game/mod.rs
Normal file
2357
src/game/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
122
src/game/rng.rs
Normal file
122
src/game/rng.rs
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
//! 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
82
src/items.rs
@@ -1,82 +0,0 @@
|
|||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
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),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
[[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"
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
[[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"
|
|
||||||
81
src/main.rs
81
src/main.rs
@@ -1,20 +1,73 @@
|
|||||||
mod items;
|
//! rustkeep — Meilenstein 1: vertikaler Durchstich.
|
||||||
mod player;
|
//!
|
||||||
mod stats;
|
//! Ziel: Der Held laeuft in einem handgebauten Testraum herum, oeffnet den
|
||||||
|
//! 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).
|
||||||
|
|
||||||
use items::*;
|
mod data;
|
||||||
use player::*;
|
mod game;
|
||||||
|
mod ui;
|
||||||
|
|
||||||
|
use game::Creature;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let catalog: ItemCatalog = load_items();
|
// Die `Result`-Kette endet **vor** `main` (Ticket 04): `main() -> Result<…>`
|
||||||
|
// druckt ueber `Debug` und setzt dem Spieler `LoadError { .. }` vor.
|
||||||
|
let (registries, mut warnings) = match data::load_mods() {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("{e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
for weapon in &catalog.weapons {
|
// Ein Speicherstand, kein Slot-Menue (Ticket 09). Liegt einer da, wird er
|
||||||
weapon.print_values();
|
// fortgesetzt; sonst faengt ein Lauf an. Bei Tod loescht die Schleife ihn,
|
||||||
}
|
// also entscheidet allein sein Vorhandensein.
|
||||||
for armor in &catalog.armors {
|
let (geladen, verworfen) = match data::save::load(®istries) {
|
||||||
armor.print_values();
|
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 player: Player = Player::new("Reya".to_string());
|
let log_name = data::write_log(&warnings);
|
||||||
player.show_stats();
|
|
||||||
|
// 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(), ®istries, 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein **leerer** Held: Rasse, Klasse und Startausruestung kommen aus dem
|
||||||
|
/// Charakterbau, nicht von hier. Ausruestung anzulegen war bis M8 der Job
|
||||||
|
/// 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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
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
68
src/stats.rs
@@ -1,68 +0,0 @@
|
|||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
856
src/ui/app.rs
Normal file
856
src/ui/app.rs
Normal file
@@ -0,0 +1,856 @@
|
|||||||
|
//! 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
118
src/ui/grid.rs
Normal file
118
src/ui/grid.rs
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
//! 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/ui/mod.rs
Normal file
8
src/ui/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
//! Alles, was ratatui kennen darf.
|
||||||
|
|
||||||
|
mod app;
|
||||||
|
mod grid;
|
||||||
|
mod screen;
|
||||||
|
mod theme;
|
||||||
|
|
||||||
|
pub use app::run;
|
||||||
492
src/ui/screen.rs
Normal file
492
src/ui/screen.rs
Normal file
@@ -0,0 +1,492 @@
|
|||||||
|
//! 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"), ®, 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, ®);
|
||||||
|
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, ®);
|
||||||
|
let Response::Command(cmd) = antwort else { panic!("Enter muss abschicken") };
|
||||||
|
assert!(matches!(game.execute(cmd, ®), 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, ®);
|
||||||
|
let Screen::CharacterSheet { pending, .. } = &screen else { panic!("falscher Screen") };
|
||||||
|
assert_eq!(pending[0], 0, "ohne offene Punkte laesst sich nichts vormerken");
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/ui/theme.rs
Normal file
26
src/ui/theme.rs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
//! 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
51
tests/boundaries.rs
Normal file
51
tests/boundaries.rs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
//! 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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user