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**.