Merge branch 'feat/m7-quests'

This commit is contained in:
2026-08-13 07:49:39 +02:00
12 changed files with 376 additions and 22 deletions

View File

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

View File

@@ -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 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). 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: **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. - **11**: Der Grenztest in seiner wörtlichen Fassung schlägt beim eigenen Doc-Kommentar an. Muss Kommentarzeilen ausnehmen.

View File

@@ -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 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.
**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 ## 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".
@@ -268,7 +275,7 @@ Es gibt **keinen Seltenheitsbegriff**: Abstufungen sind eigene Einträge mit kle
## Registry ## 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 und das hier ist die kanonische Liste — sie ist über sieben Tickets und vier Meilensteine gewachsen und
stand sonst nirgends vollständig: stand sonst nirgends vollständig:

View File

@@ -19,6 +19,8 @@ shop = "core:blacksmith_stock"
options = [ options = [
{ text = "Zeig mir deine Waren.", actions = [ { action = "open_shop" } ] }, { text = "Zeig mir deine Waren.", actions = [ { action = "open_shop" } ] },
{ text = "Was gibt es hier unten?" }, { 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 # Bedingte Option statt eines zweiten Dialogknotens — genau der Fall, fuer
# den der Knotengraph angeblich noetig waere. # den der Knotengraph angeblich noetig waere.
{ text = "Diese Knochenklinge — kennst du sie?", { text = "Diese Knochenklinge — kennst du sie?",

34
mods/core/quests.toml Normal file
View 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 } ]

View File

@@ -16,7 +16,7 @@ use toml::Spanned;
use super::registries::{ use super::registries::{
Ability, Affix, Category, CharacterOption, CreatureDef, FloorDef, Item, LootTable, ModifierDef, 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 /// Genau drei harte Fehler (Ticket 04). Alles Uebrige ist Warnung plus
@@ -265,6 +265,8 @@ struct ModFile {
loot_tables: Vec<Spanned<toml::Value>>, loot_tables: Vec<Spanned<toml::Value>>,
#[serde(default)] #[serde(default)]
npcs: Vec<Spanned<toml::Value>>, npcs: Vec<Spanned<toml::Value>>,
#[serde(default)]
quests: Vec<Spanned<toml::Value>>,
/// **Keine** Tabelle mit `[[...]]`, sondern ein einzelner Block, der /// **Keine** Tabelle mit `[[...]]`, sondern ein einzelner Block, der
/// feldweise verschmilzt — die ausdrueckliche Ausnahme zu Ticket 03. /// feldweise verschmilzt — die ausdrueckliche Ausnahme zu Ticket 03.
#[serde(default)] #[serde(default)]
@@ -305,6 +307,7 @@ impl_entry!(ModifierDef);
impl_entry!(Affix); impl_entry!(Affix);
impl_entry!(LootTable); impl_entry!(LootTable);
impl_entry!(Npc); impl_entry!(Npc);
impl_entry!(Quest);
struct FileCtx<'a> { struct FileCtx<'a> {
module: &'a str, module: &'a str,
@@ -516,6 +519,7 @@ pub fn load_mods() -> Result<(Registries, Vec<String>), LoadError> {
parse_entries(parsed.affixes, &ctx, &mut reg.affixes, &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.loot_tables, &ctx, &mut reg.loot_tables, &mut warnings);
parse_entries(parsed.npcs, &ctx, &mut reg.npcs, &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 // `[balance]` verschmilzt **feldweise** (Ticket 14): Ein Mod, der
// einen Knopf dreht, schreibt eine Zeile — und ein neuer Knopf in // einen Knopf dreht, schreibt eine Zeile — und ein neuer Knopf in

View File

@@ -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<String>,
}
/// Eine Wahlmoeglichkeit im Gespraech. **Flach, kein Knotengraph** — was erst /// Eine Wahlmoeglichkeit im Gespraech. **Flach, kein Knotengraph** — was erst
/// ein Graph koennte, ist Erzaehlung, und das Story-Modul steht unter /// ein Graph koennte, ist Erzaehlung, und das Story-Modul steht unter
/// *Out of scope*. /// *Out of scope*.
@@ -475,6 +497,7 @@ pub struct Registries {
pub affixes: HashMap<String, Affix>, pub affixes: HashMap<String, Affix>,
pub loot_tables: HashMap<String, LootTable>, pub loot_tables: HashMap<String, LootTable>,
pub npcs: HashMap<String, Npc>, pub npcs: HashMap<String, Npc>,
pub quests: HashMap<String, Quest>,
/// Keine Registry — ein Block mit festem Schema, feldweise verschmolzen. /// Keine Registry — ein Block mit festem Schema, feldweise verschmolzen.
pub balance: Balance, pub balance: Balance,
/// Wer geladen wurde, in Ladereihenfolge. **Einziger Leser ist der /// 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 /// spaeterer Ladelauf „`meinmod` 1.0.0 → 1.2.0 seit dem Speichern" melden
/// kann. Das ist die ganze Bilanz des `version`-Felds. /// kann. Das ist die ganze Bilanz des `version`-Felds.
pub mods: Vec<crate::data::save::ModStamp>, pub mods: Vec<crate::data::save::ModStamp>,
// TODO(M5+): `quests` (Ticket 08) und `scripts` (Ticket 13). // TODO(M8): `scripts` (Ticket 13).
} }
impl Registries { impl Registries {

View File

@@ -169,6 +169,16 @@ fn version_drift(damals: &[ModStamp], reg: &Registries) -> Vec<String> {
/// **Verwerfen, nie ablehnen** (Ticket 09): Ein Spieler soll nicht seinen /// **Verwerfen, nie ablehnen** (Ticket 09): Ein Spieler soll nicht seinen
/// ganzen Lauf verlieren, weil er einen Mod deinstalliert hat. /// ganzen Lauf verlieren, weil er einen Mod deinstalliert hat.
fn prune(game: &mut Game, reg: &Registries, warnings: &mut Vec<String>) { 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); prune_creature(&mut game.player, reg, warnings);
for e in &mut game.enemies { for e in &mut game.enemies {
prune_creature(e, reg, warnings); prune_creature(e, reg, warnings);
@@ -271,6 +281,11 @@ mod tests {
game.unspent_points = 2; game.unspent_points = 2;
game.give(ItemInstance::stack("core:gold", 130), &reg); game.give(ItemInstance::stack("core:gold", 130), &reg);
game.player.health = 33.0; game.player.health = 33.0;
game.quests.push(crate::game::QuestProgress {
quest: String::from("core:rattenplage"),
count: 3,
done: false,
});
let (text, zurueck) = durch_toml(&game, &reg); let (text, zurueck) = durch_toml(&game, &reg);
@@ -291,6 +306,8 @@ mod tests {
// Die Etage kommt Kachel fuer Kachel zurueck — und steht als lesbare // Die Etage kommt Kachel fuer Kachel zurueck — und steht als lesbare
// Zeilen in der Datei, nicht als 3200 Enum-Eintraege (Ticket 09). // 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.rows(), game.floor.rows());
assert_eq!(zurueck.floor.rooms.len(), game.floor.rooms.len()); assert_eq!(zurueck.floor.rooms.len(), game.floor.rooms.len());
assert!(text.contains("####"), "die Kacheln muessen als Zeilen dastehen"); assert!(text.contains("####"), "die Kacheln muessen als Zeilen dastehen");
@@ -310,9 +327,15 @@ mod tests {
game.player game.player
.equipment .equipment
.insert(String::from("core:head"), ItemInstance::new("weg:helm")); .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(); let mut warnungen = Vec::new();
prune(&mut game, &reg, &mut warnungen); prune(&mut game, &reg, &mut warnungen);
assert!(game.quests.is_empty(), "ein Fortschritt ohne Definition ist eine Zahl ohne Sinn");
assert!(!game.player.inventory.iter().any(|i| i.def == "weg:phantom")); assert!(!game.player.inventory.iter().any(|i| i.def == "weg:phantom"));
assert!(!game.player.options.iter().any(|o| o == "weg:klasse")); assert!(!game.player.options.iter().any(|o| o == "weg:klasse"));
@@ -326,7 +349,7 @@ mod tests {
.expect("der Gegenstand bleibt, nur das Affix faellt"); .expect("der Gegenstand bleibt, nur das Affix faellt");
assert_eq!(schwert.affixes, vec![String::from("core:sharp")]); 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**. /// Falsches Schema: ablehnen mit Klartext, **Datei bleibt liegen**.

View File

@@ -1,5 +1,10 @@
//! Aktionen und Schadenstypen — Ticket 01. //! 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 //! Eine **Aktion** ist eine einmalige Zustandsaenderung. Sie feuert, wirkt, ist
//! vorbei. Getrennte Maschine vom Modifikator, der nie ausgefuehrt, sondern beim //! vorbei. Getrennte Maschine vom Modifikator, der nie ausgefuehrt, sondern beim
//! Auslesen aufsummiert wird. //! Auslesen aufsummiert wird.
@@ -66,7 +71,7 @@ pub enum Target {
/// Der Aktionskatalog. Zehn Eintraege sind entschieden (01, 08, 13, 14, 15); /// Der Aktionskatalog. Zehn Eintraege sind entschieden (01, 08, 13, 14, 15);
/// M3 braucht fuenf davon. /// 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)] #[derive(Debug, Clone, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case")] #[serde(tag = "action", rename_all = "snake_case")]
pub enum Action { pub enum Action {
@@ -105,18 +110,37 @@ pub enum Action {
#[serde(default)] #[serde(default)]
target: Target, target: Target,
}, },
/// Ticket 14: achte Aktion, weil Ticket 11 Flucht zu einer Faehigkeit /// Aus Ticket 14: Ticket 11 hat Flucht zu einer Faehigkeit gemacht, und
/// gemacht hat und die etwas braucht, das sie ausfuehrt. /// die braucht etwas, das sie ausfuehrt.
Flee, Flee,
/// Elfte Aktion (M4): Eine Dialogoption oeffnet den Laden. Als Aktion steht /// Aus M4: Eine Dialogoption oeffnet den Laden. Als Aktion steht sie in
/// sie in derselben Liste wie `start_quest`, statt als Sonderfeld daneben. /// derselben Liste wie `start_quest`, statt als Sonderfeld daneben.
OpenShop, OpenShop,
/// Zwoelfte Aktion (Ticket 15): Erfahrung fuer Questbelohnungen. Der /// Aus Ticket 15: Erfahrung fuer Questbelohnungen. Der zweite Weg neben
/// zweite Weg neben `exp_reward` — das ist die Kurzform fuer einen /// `exp_reward` — das ist die Kurzform fuer einen Todes-Trigger, den sonst
/// Todes-Trigger, den sonst jede Gegnerdefinition mitschleppen muesste. /// jede Gegnerdefinition mitschleppen muesste.
GiveExp { GiveExp {
amount: u32, 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** /// Die beiden Zustandsgroessen, die eine Aktion aendern darf. Bewusst **kein**
@@ -135,7 +159,11 @@ impl Action {
| Action::ChangeAttribute { target, .. } | Action::ChangeAttribute { target, .. }
| Action::ApplyModifier { target, .. } | Action::ApplyModifier { target, .. }
| Action::RemoveModifier { target, .. } => *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,
} }
} }
} }

View File

@@ -74,11 +74,25 @@ pub struct Game {
pub events: std::collections::VecDeque<Event>, pub events: std::collections::VecDeque<Event>,
// Ab hier Tabellen. // 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<QuestProgress>,
pub player: Creature, pub player: Creature,
/// Der **laufende Kampf**, nicht die Etage (Ticket 07/14). Leer = kein Kampf. /// Der **laufende Kampf**, nicht die Etage (Ticket 07/14). Leer = kein Kampf.
pub enemies: Vec<Creature>, pub enemies: Vec<Creature>,
pub floor: Floor, 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 { impl Game {
@@ -106,6 +120,7 @@ impl Game {
open_shop: false, open_shop: false,
current_exp: 0, current_exp: 0,
unspent_points: 0, unspent_points: 0,
quests: Vec::new(),
}) })
} }
@@ -413,6 +428,15 @@ impl Game {
// den Helden: Ein Gegner hat keine Erfahrung, die ihm nuetzt. // den Helden: Ein Gegner hat keine Erfahrung, die ihm nuetzt.
Action::GiveExp { amount } => self.current_exp += amount, 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 => { Action::Flee => {
let own = self.attribute(source, Attribute::Dexterity, None, reg); let own = self.attribute(source, Attribute::Dexterity, None, reg);
// Gerechnete Werte, nicht Basiswerte — sonst ignoriert die // Gerechnete Werte, nicht Basiswerte — sonst ignoriert die
@@ -491,22 +515,86 @@ impl Game {
let triggers = self.triggers_for(carrier, event.kind, reg); let triggers = self.triggers_for(carrier, event.kind, reg);
for t in triggers { for t in triggers {
if !t.when.matches(&event) { if !self.trigger_fires(t, &event, carrier, reg) {
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) {
continue; continue;
} }
let other = self.other_side(&event, carrier);
for action in &t.actions { for action in &t.actions {
let target = other.unwrap_or(carrier); let target = other.unwrap_or(carrier);
self.apply(action, carrier, target, reg); 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<CreatureRef> {
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), &reg); game.execute(Command::Debug(Cheat::ClearRoom), &reg);
assert_eq!(game.current_exp, ratte.exp_reward); 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), &reg);
};
// 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,
&reg,
);
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,
&reg,
);
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);
}
} }

View File

@@ -191,6 +191,7 @@ fn draw(frame: &mut Frame, app: &App) {
} }
Screen::Dialog { pick } => draw_dialog(frame, area, app, *pick), Screen::Dialog { pick } => draw_dialog(frame, area, app, *pick),
Screen::Shop { pick, selling } => draw_shop(frame, area, app, *pick, *selling), 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::Confirm { text, .. } => draw_confirm(frame, area, text),
Screen::Debug { pick } => draw_debug(frame, area, app, *pick), Screen::Debug { pick } => draw_debug(frame, area, app, *pick),
Screen::GameOver => draw_game_over(frame, area, app), 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) { fn draw_confirm(frame: &mut Frame, area: Rect, text: &str) {
let mut zeilen: Vec<Line> = text.lines().map(Line::from).collect(); let mut zeilen: Vec<Line> = text.lines().map(Line::from).collect();
zeilen.push(Line::from("")); zeilen.push(Line::from(""));

View File

@@ -39,6 +39,9 @@ pub enum Screen {
/// Meldungszeile im Hauptmenue: Ein Abstiegs-Roguelike lebt davon, dass der /// Meldungszeile im Hauptmenue: Ein Abstiegs-Roguelike lebt davon, dass der
/// Lauf am Ende *erzaehlt* wird (Ticket 14). /// Lauf am Ende *erzaehlt* wird (Ticket 14).
GameOver, 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<Response>` und ein implizites /// Rueckfrage. Ticket 09 wollte `on_yes: Box<Response>` und ein implizites
/// „Nein = wegklicken" — das reicht nicht: Beim Ladefall heisst Nein /// „Nein = wegklicken" — das reicht nicht: Beim Ladefall heisst Nein
/// *beenden und den Mod nachinstallieren*, denn diese Wahl hat der Spieler /// *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('>') => Response::Command(Command::Descend),
KeyCode::Char('g') => Response::Command(Command::PickUp), KeyCode::Char('g') => Response::Command(Command::PickUp),
KeyCode::Char('t') => Response::Command(Command::Talk), KeyCode::Char('t') => Response::Command(Command::Talk),
KeyCode::Char('q') => Response::Push(Screen::QuestLog),
KeyCode::F(12) => Response::Push(Screen::Debug { pick: 0 }), KeyCode::F(12) => Response::Push(Screen::Debug { pick: 0 }),
// Screen-Navigation ist reine UI und fasst `Game` nie an. // Screen-Navigation ist reine UI und fasst `Game` nie an.
KeyCode::Char('c') => Response::Push(Screen::sheet()), 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 { Screen::Confirm { on_yes, on_no, .. } => match key {
KeyCode::Char('j' | 'y') | KeyCode::Enter => (**on_yes).clone(), KeyCode::Char('j' | 'y') | KeyCode::Enter => (**on_yes).clone(),
KeyCode::Char('n' | 'q') | KeyCode::Esc => (**on_no).clone(), KeyCode::Char('n' | 'q') | KeyCode::Esc => (**on_no).clone(),