Merge branch 'feat/m7-quests'
This commit is contained in:
@@ -272,3 +272,25 @@ Dazu `Game.events: Vec<Event>` 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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?",
|
||||
|
||||
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 } ]
|
||||
@@ -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<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)]
|
||||
@@ -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<String>), 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
|
||||
|
||||
@@ -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
|
||||
/// ein Graph koennte, ist Erzaehlung, und das Story-Modul steht unter
|
||||
/// *Out of scope*.
|
||||
@@ -475,6 +497,7 @@ pub struct Registries {
|
||||
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
|
||||
@@ -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<crate::data::save::ModStamp>,
|
||||
// TODO(M5+): `quests` (Ticket 08) und `scripts` (Ticket 13).
|
||||
// TODO(M8): `scripts` (Ticket 13).
|
||||
}
|
||||
|
||||
impl Registries {
|
||||
|
||||
@@ -169,6 +169,16 @@ fn version_drift(damals: &[ModStamp], reg: &Registries) -> Vec<String> {
|
||||
/// **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);
|
||||
@@ -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**.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
166
src/game/mod.rs
166
src/game/mod.rs
@@ -74,11 +74,25 @@ pub struct Game {
|
||||
pub events: std::collections::VecDeque<Event>,
|
||||
|
||||
// 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,
|
||||
/// Der **laufende Kampf**, nicht die Etage (Ticket 07/14). Leer = kein Kampf.
|
||||
pub enemies: Vec<Creature>,
|
||||
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<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), ®);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Line> = text.lines().map(Line::from).collect();
|
||||
zeilen.push(Line::from(""));
|
||||
|
||||
@@ -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<Response>` 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(),
|
||||
|
||||
Reference in New Issue
Block a user