From 37b5820c5b49a0a6e6217f6985eef0f6b8d2f96e Mon Sep 17 00:00:00 2001 From: MDietermann Date: Thu, 13 Aug 2026 07:43:46 +0200 Subject: [PATCH] feat(quests): Quests als Trigger mit Zaehler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eine Quest IST ein Trigger mit count = N — kein zweiter Mechanismus. Gezaehlt wird erst ab start_quest, erledigte bleiben mit done = true stehen, und die Belohnung darf die naechste starten: das ist die Kette. - fuenfzehnte Registry, Trigger per serde(flatten) eingebettet - give_item als Aktion, damit eine Belohnung mehr als Erfahrung zahlt - QuestLog auf q, Fortschritt im Speicherstand, unbekannte IDs verworfen - eine Funktion entscheidet fuer alle Traeger, wann ein Trigger feuert Befund: Die Ordnungszahlen im Aktionskatalog widersprechen sich — vier Tickets zaehlten je vom eigenen Stand. Sie sind raus, CONTEXT.md fuehrt die Liste. --- .../issues/08-event-trigger-system.md | 22 +++ .scratch/rustkeep-1.0/map.md | 17 ++ CONTEXT.md | 9 +- mods/core/npcs.toml | 2 + mods/core/quests.toml | 34 ++++ src/data/loader.rs | 6 +- src/data/registries.rs | 25 ++- src/data/save.rs | 25 ++- src/game/action.rs | 46 ++++- src/game/mod.rs | 166 +++++++++++++++++- src/ui/app.rs | 37 ++++ src/ui/screen.rs | 9 + 12 files changed, 376 insertions(+), 22 deletions(-) create mode 100644 mods/core/quests.toml diff --git a/.scratch/rustkeep-1.0/issues/08-event-trigger-system.md b/.scratch/rustkeep-1.0/issues/08-event-trigger-system.md index 1d98ab3..5a6251e 100644 --- a/.scratch/rustkeep-1.0/issues/08-event-trigger-system.md +++ b/.scratch/rustkeep-1.0/issues/08-event-trigger-system.md @@ -272,3 +272,25 @@ Dazu `Game.events: Vec` als neues Feld im Baum. nicht den einzelnen Raum. - **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. + +### 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. diff --git a/.scratch/rustkeep-1.0/map.md b/.scratch/rustkeep-1.0/map.md index 663510e..5767c07 100644 --- a/.scratch/rustkeep-1.0/map.md +++ b/.scratch/rustkeep-1.0/map.md @@ -181,6 +181,23 @@ vor Tabellen, „der Save ist der Baum" macht die Reihenfolge damit zur Bedingun 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. + **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. diff --git a/CONTEXT.md b/CONTEXT.md index 58af895..6f95c26 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -61,6 +61,13 @@ 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 Begriffe zusammen, ohne dasselbe zu sein. +**Neun** sind gebaut: `damage`, `change_attribute`, `apply_modifier`, `remove_modifier`, `flee`, +`open_shop`, `give_exp`, `start_quest`, `give_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 Ein Kommando der Oberfläche an das Spiel — „geh nach Norden", „leg das an", „kauf drei Tränke". @@ -268,7 +275,7 @@ Es gibt **keinen Seltenheitsbegriff**: Abstufungen sind eigene Einträge mit kle ## Registry -Eine benannte Sammlung moddbarer Definitionen, geschlüsselt nach namespaced ID. Es gibt **vierzehn**, +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: diff --git a/mods/core/npcs.toml b/mods/core/npcs.toml index cdc693a..d2ef8b7 100644 --- a/mods/core/npcs.toml +++ b/mods/core/npcs.toml @@ -19,6 +19,8 @@ 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?", diff --git a/mods/core/quests.toml b/mods/core/quests.toml new file mode 100644 index 0000000..7021645 --- /dev/null +++ b/mods/core/quests.toml @@ -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 } ] diff --git a/src/data/loader.rs b/src/data/loader.rs index 9e45048..8d880d3 100644 --- a/src/data/loader.rs +++ b/src/data/loader.rs @@ -16,7 +16,7 @@ use toml::Spanned; use super::registries::{ Ability, Affix, Category, CharacterOption, CreatureDef, FloorDef, Item, LootTable, ModifierDef, - Npc, Prefab, Registries, Slot, + Npc, Prefab, Quest, Registries, Slot, }; /// Genau drei harte Fehler (Ticket 04). Alles Uebrige ist Warnung plus @@ -265,6 +265,8 @@ struct ModFile { loot_tables: Vec>, #[serde(default)] npcs: Vec>, + #[serde(default)] + quests: Vec>, /// **Keine** Tabelle mit `[[...]]`, sondern ein einzelner Block, der /// feldweise verschmilzt — die ausdrueckliche Ausnahme zu Ticket 03. #[serde(default)] @@ -305,6 +307,7 @@ impl_entry!(ModifierDef); impl_entry!(Affix); impl_entry!(LootTable); impl_entry!(Npc); +impl_entry!(Quest); struct FileCtx<'a> { module: &'a str, @@ -516,6 +519,7 @@ pub fn load_mods() -> Result<(Registries, Vec), LoadError> { 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 diff --git a/src/data/registries.rs b/src/data/registries.rs index b65b4a7..2280808 100644 --- a/src/data/registries.rs +++ b/src/data/registries.rs @@ -259,6 +259,28 @@ impl Default for Balance { } } +/// 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, +} + /// Eine Wahlmoeglichkeit im Gespraech. **Flach, kein Knotengraph** — was erst /// ein Graph koennte, ist Erzaehlung, und das Story-Modul steht unter /// *Out of scope*. @@ -475,6 +497,7 @@ pub struct Registries { pub affixes: HashMap, pub loot_tables: HashMap, pub npcs: HashMap, + pub quests: HashMap, /// Keine Registry — ein Block mit festem Schema, feldweise verschmolzen. pub balance: Balance, /// Wer geladen wurde, in Ladereihenfolge. **Einziger Leser ist der @@ -482,7 +505,7 @@ pub struct Registries { /// 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, - // TODO(M5+): `quests` (Ticket 08) und `scripts` (Ticket 13). + // TODO(M8): `scripts` (Ticket 13). } impl Registries { diff --git a/src/data/save.rs b/src/data/save.rs index 84763b4..909d8e2 100644 --- a/src/data/save.rs +++ b/src/data/save.rs @@ -169,6 +169,16 @@ fn version_drift(damals: &[ModStamp], reg: &Registries) -> Vec { /// **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) { + // 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); @@ -271,6 +281,11 @@ mod tests { 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, ®); @@ -291,6 +306,8 @@ mod tests { // 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"); @@ -310,9 +327,15 @@ mod tests { 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")); @@ -326,7 +349,7 @@ mod tests { .expect("der Gegenstand bleibt, nur das Affix faellt"); assert_eq!(schwert.affixes, vec![String::from("core:sharp")]); - assert_eq!(warnungen.len(), 4, "jeder Verlust bekommt eine Zeile: {warnungen:?}"); + assert_eq!(warnungen.len(), 5, "jeder Verlust bekommt eine Zeile: {warnungen:?}"); } /// Falsches Schema: ablehnen mit Klartext, **Datei bleibt liegen**. diff --git a/src/game/action.rs b/src/game/action.rs index c7514cf..feac42d 100644 --- a/src/game/action.rs +++ b/src/game/action.rs @@ -1,5 +1,10 @@ //! 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. @@ -66,7 +71,7 @@ pub enum Target { /// Der Aktionskatalog. Zehn Eintraege sind entschieden (01, 08, 13, 14, 15); /// M3 braucht fuenf davon. /// -// TODO(M6+): `give_item`, `remove_item`, `start_quest` (08), `run_script` (13). +// TODO(M8): `remove_item` und `run_script` (13). #[derive(Debug, Clone, Deserialize)] #[serde(tag = "action", rename_all = "snake_case")] pub enum Action { @@ -105,18 +110,37 @@ pub enum Action { #[serde(default)] target: Target, }, - /// Ticket 14: achte Aktion, weil Ticket 11 Flucht zu einer Faehigkeit - /// gemacht hat und die etwas braucht, das sie ausfuehrt. + /// Aus Ticket 14: Ticket 11 hat Flucht zu einer Faehigkeit gemacht, und + /// die braucht etwas, das sie ausfuehrt. Flee, - /// Elfte Aktion (M4): Eine Dialogoption oeffnet den Laden. Als Aktion steht - /// sie in derselben Liste wie `start_quest`, statt als Sonderfeld daneben. + /// Aus M4: Eine Dialogoption oeffnet den Laden. Als Aktion steht sie in + /// derselben Liste wie `start_quest`, statt als Sonderfeld daneben. OpenShop, - /// Zwoelfte Aktion (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. + /// 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, + }, +} + +fn one() -> u32 { + 1 } /// Die beiden Zustandsgroessen, die eine Aktion aendern darf. Bewusst **kein** @@ -135,7 +159,11 @@ impl Action { | Action::ChangeAttribute { target, .. } | Action::ApplyModifier { target, .. } | Action::RemoveModifier { target, .. } => *target, - Action::Flee | Action::OpenShop | Action::GiveExp { .. } => Target::Zelf, + Action::Flee + | Action::OpenShop + | Action::GiveExp { .. } + | Action::StartQuest { .. } + | Action::GiveItem { .. } => Target::Zelf, } } } diff --git a/src/game/mod.rs b/src/game/mod.rs index 92766d7..a60271c 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -74,11 +74,25 @@ pub struct Game { pub events: std::collections::VecDeque, // Ab hier Tabellen. + /// Erledigte bleiben mit `done = true` stehen, statt entfernt zu werden: + /// Sonst kann eine Kette, die auf `quest_completed` hoert, nicht + /// unterscheiden, ob eine Quest nie lief oder gerade fertig wurde — und ein + /// wiederauflebender Zaehler schuettete die Belohnung erneut aus (Ticket 08). + #[serde(default)] + pub quests: Vec, pub player: Creature, /// Der **laufende Kampf**, nicht die Etage (Ticket 07/14). Leer = kein Kampf. pub enemies: Vec, pub floor: Floor, - // TODO(M7): `quests` (Ticket 08). +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct QuestProgress { + pub quest: String, + #[serde(default)] + pub count: u32, + #[serde(default)] + pub done: bool, } impl Game { @@ -106,6 +120,7 @@ impl Game { open_shop: false, current_exp: 0, unspent_points: 0, + quests: Vec::new(), }) } @@ -413,6 +428,15 @@ impl Game { // den Helden: Ein Gegner hat keine Erfahrung, die ihm nuetzt. Action::GiveExp { amount } => self.current_exp += amount, + Action::StartQuest { quest } => self.start_quest(quest, reg), + + Action::GiveItem { item, count } => { + let inst = ItemInstance::stack(item.clone(), *count); + let name = self.item_label(&inst, reg); + self.give(inst, reg); + self.log(format!("Erhalten: {name}")); + } + Action::Flee => { let own = self.attribute(source, Attribute::Dexterity, None, reg); // Gerechnete Werte, nicht Basiswerte — sonst ignoriert die @@ -491,22 +515,86 @@ impl Game { let triggers = self.triggers_for(carrier, event.kind, reg); for t in triggers { - if !t.when.matches(&event) { - continue; - } - let other = if carrier == event.source { event.target } else { Some(event.source) }; - if !self.conditions_hold(&t.condition, carrier, other, reg) { - continue; - } - if t.chance < 1.0 && !self.rng.chance(t.chance) { + if !self.trigger_fires(t, &event, carrier, reg) { continue; } + let other = self.other_side(&event, carrier); for action in &t.actions { let target = other.unwrap_or(carrier); self.apply(action, carrier, target, reg); } } } + + // Eine Quest ist ein Trigger mit **festem Traeger**: dem Helden. + // Deshalb steht sie neben der Traegerschleife und nicht darin. + self.advance_quests(&event, reg); + } + } + + /// Die andere Seite des Ereignisses aus Sicht des Traegers (Ticket 08): + /// `self` ist immer der Traeger, `target` der jeweils andere. + fn other_side(&self, event: &Event, carrier: CreatureRef) -> Option { + if carrier == event.source { event.target } else { Some(event.source) } + } + + /// Ob ein Trigger auf dieses Ereignis anspricht. **Eine** Stelle fuer + /// Gegenstand, Modifikator, Gegner *und* Quest — eine Quest ist ein + /// Trigger, also entscheidet dieselbe Regel. + fn trigger_fires( + &mut self, + t: &Trigger, + event: &Event, + carrier: CreatureRef, + reg: &Registries, + ) -> bool { + t.on == event.kind + && t.when.matches(event) + && self.conditions_hold(&t.condition, carrier, self.other_side(event, carrier), reg) + && (t.chance >= 1.0 || self.rng.chance(t.chance)) + } + + /// Startet eine Quest, wenn sie noch nicht laeuft. Ein zweiter Start ist + /// kein Fehler, sondern ein Nichts — sonst muesste jeder Dialog pruefen, + /// ob der Spieler schon einmal dort war. + fn start_quest(&mut self, id: &str, reg: &Registries) { + if self.quests.iter().any(|q| q.quest == id) { + return; + } + let Some(def) = reg.quests.get(id) else { return }; + self.quests.push(QuestProgress { quest: id.to_string(), count: 0, done: false }); + self.log(format!("Quest angenommen: {}", def.name)); + } + + /// Zaehlt jede laufende Quest hoch, deren Trigger auf dieses Ereignis passt, + /// und schuettet bei `count` die Belohnung aus. + fn advance_quests(&mut self, event: &Event, reg: &Registries) { + let p = CreatureRef::Player; + + // Ueber den Index, weil eine Belohnung `self.quests` verlaengern darf — + // eine Folgequest zu starten *ist* die Kette. Die Laenge steht vor der + // Schleife fest, neue Eintraege werden also erst beim naechsten + // Ereignis gezaehlt. Genau richtig: Sie waren beim Ausloeser nicht da. + for i in 0..self.quests.len() { + if self.quests[i].done { + continue; + } + let Some(def) = reg.quests.get(&self.quests[i].quest) else { continue }; + if !self.trigger_fires(&def.trigger, event, p, reg) { + continue; + } + + self.quests[i].count += 1; + if self.quests[i].count < def.count { + continue; + } + self.quests[i].done = true; + self.log(format!("Quest erledigt: {}", def.name)); + for action in &def.trigger.actions { + self.apply(action, p, p, reg); + } + // Das Ereignis, an dem eine Kette haengt (Ticket 08). + self.events.push_back(Event::new(EventKind::QuestCompleted, p)); } } @@ -1981,4 +2069,64 @@ mod tests { game.execute(Command::Debug(Cheat::ClearRoom), ®); assert_eq!(game.current_exp, ratte.exp_reward); } + + /// Der ganze Bogen aus Ticket 08: Eine Quest ist ein Trigger mit `count`, + /// sie zaehlt **erst ab dem Start**, und ihre Belohnung darf die naechste + /// starten — das *ist* die Kette, ohne einen zweiten Begriff. + #[test] + fn eine_quest_zaehlt_erst_ab_dem_start_und_kettet() { + let (mut game, reg) = zwergenkrieger(); + let ratte = reg.creatures.get("core:rat").expect("Riesenratte"); + let toete = |game: &mut Game| { + game.enemies = vec![Creature::from_def(ratte)]; + game.execute(Command::Debug(Cheat::ClearRoom), ®); + }; + + // Ohne Annahme laeuft gar nichts mit — sonst waere eine Kette schon + // erfuellt, bevor sie jemand erwaehnt hat. + toete(&mut game); + assert!(game.quests.is_empty(), "eine nicht angenommene Quest zaehlt nicht"); + + game.apply( + &Action::StartQuest { quest: String::from("core:rattenplage") }, + CreatureRef::Player, + CreatureRef::Player, + ®, + ); + assert_eq!(game.quests.len(), 1); + assert_eq!(game.quests[0].count, 0, "der Kill davor zaehlt nicht nach"); + + // Ein zweiter Start ist ein Nichts, kein Fehler. + game.apply( + &Action::StartQuest { quest: String::from("core:rattenplage") }, + CreatureRef::Player, + CreatureRef::Player, + ®, + ); + assert_eq!(game.quests.len(), 1); + + let gold_vorher = game.gold(); + for _ in 0..4 { + toete(&mut game); + } + assert_eq!(game.quests[0].count, 4); + assert!(!game.quests[0].done, "vier von fuenf sind nicht fuenf"); + assert_eq!(game.gold(), gold_vorher, "und zahlen noch nichts"); + + toete(&mut game); + assert!(game.quests[0].done); + assert_eq!(game.gold(), gold_vorher + 60, "die Belohnung kommt genau einmal"); + + // Die Belohnung hat die Folgequest gestartet. + assert!( + game.quests.iter().any(|q| q.quest == "core:tiefer_hinab" && !q.done), + "{:?}", + game.quests + ); + + // Erledigte bleiben stehen und zahlen nicht nach. + toete(&mut game); + assert_eq!(game.quests[0].count, 5, "der Zaehler laeuft nicht weiter"); + assert_eq!(game.gold(), gold_vorher + 60); + } } diff --git a/src/ui/app.rs b/src/ui/app.rs index fe1e6b7..0c79280 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -191,6 +191,7 @@ fn draw(frame: &mut Frame, app: &App) { } Screen::Dialog { pick } => draw_dialog(frame, area, app, *pick), Screen::Shop { pick, selling } => draw_shop(frame, area, app, *pick, *selling), + 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), @@ -398,6 +399,42 @@ fn draw_shop(frame: &mut Frame, area: Rect, app: &App, pick: usize, selling: boo ); } +/// 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 = text.lines().map(Line::from).collect(); zeilen.push(Line::from("")); diff --git a/src/ui/screen.rs b/src/ui/screen.rs index 24a8edc..daa07b5 100644 --- a/src/ui/screen.rs +++ b/src/ui/screen.rs @@ -39,6 +39,9 @@ pub enum Screen { /// Meldungszeile im Hauptmenue: Ein Abstiegs-Roguelike lebt davon, dass der /// Lauf am Ende *erzaehlt* wird (Ticket 14). GameOver, + /// 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` und ein implizites /// „Nein = wegklicken" — das reicht nicht: Beim Ladefall heisst Nein /// *beenden und den Mod nachinstallieren*, denn diese Wahl hat der Spieler @@ -126,6 +129,7 @@ impl Screen { 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()), @@ -356,6 +360,11 @@ impl Screen { } } + 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(),