feat(quests): Quests als Trigger mit Zaehler

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.
This commit is contained in:
2026-08-13 07:43:46 +02:00
parent 090d9ae136
commit 37b5820c5b
12 changed files with 376 additions and 22 deletions

View File

@@ -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

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
/// 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 {

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
/// 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), &reg);
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);
@@ -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, &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.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**.

View File

@@ -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,
}
}
}

View File

@@ -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), &reg);
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::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(""));

View File

@@ -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(),