Skip to main content

delvewright_dsl/quest/
mod.rs

1//! Stage 5 — quests: each quest's trigger, objectives and effects, and the
2//! stage document that holds every stage-5 collection.
3
4pub mod check;
5mod effect;
6mod objective;
7mod verb;
8
9pub use effect::*;
10pub use objective::*;
11pub use verb::*;
12
13use std::collections::BTreeMap;
14
15use schemars::JsonSchema;
16use serde::{Deserialize, Serialize};
17
18use crate::{
19    Actor, Ambush, Assembly, CastEntry, EnvTrigger, Happening, LethalVolume, Loop, Loot, NpcId,
20    ObjectiveId, Pulse, QuestId, Shop, Shortcut, Stake, StateDecl, TimedGate, Trap, TriggerOn,
21    Wave,
22};
23
24/// Stage 5 payload: quest expansions.
25#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
26#[serde(deny_unknown_fields)]
27pub struct QuestsContent {
28    /// The expanded quests (1:1 with stage 4).
29    pub quests: Vec<Quest>,
30    /// **How loudly the campaign guides** (spec-0093): the default for every
31    /// objective's `marker` and `announcement`. Absent = both `shown`, which is
32    /// what every campaign written before the block existed gets, byte for byte.
33    #[serde(default, skip_serializing_if = "Guidance::is_default")]
34    pub guidance: Guidance,
35    /// Combat waves (DSL v0.3). Each wave is spawned by a `spawn-wave` effect and
36    /// slain to complete a `kill` objective. Empty/absent in v0.2 campaigns.
37    #[serde(default, skip_serializing_if = "Vec::is_empty")]
38    pub waves: Vec<Wave>,
39    /// Environment triggers (DSL v0.4, spec-0008 §7): "the world answers". Each
40    /// watches an anchor for a strike / use / approach event and fires a bundle
41    /// of [`QuestEffect`]s. Empty/absent in v0.2/v0.3 campaigns.
42    #[serde(default, skip_serializing_if = "Vec::is_empty")]
43    pub triggers: Vec<EnvTrigger>,
44    /// Scripted actors (DSL v0.6, spec-0014): NoAI/Silent/no-loot puppets moved by
45    /// compiler-emitted per-tick teleport. Distinct from stage-2 NPCs
46    /// (no dialogue, any mob type). Summoned/removed/moved/unleashed by the actor
47    /// staging effects. Empty/absent before v0.6.
48    #[serde(default, skip_serializing_if = "Vec::is_empty")]
49    pub actors: Vec<Actor>,
50    /// Traps (DSL v0.6, spec-0011; command payloads spec-0022): environmental
51    /// hazards, each at one point anchor an area's prefab provides. Each says
52    /// what springs the trap, what it then does (a command `payload`, a legacy
53    /// dispenser `effect`, or both), how dangerous it is, how it is disarmed and
54    /// whether it re-arms. Empty/absent in pre-0.6 campaigns, so a v0.5-or-earlier campaign that declares none stays
55    /// byte-identical.
56    #[serde(default, skip_serializing_if = "Vec::is_empty")]
57    pub traps: Vec<Trap>,
58    /// Shortcut doors (spec-0016 §2): a gate that is sealed from world-load and
59    /// is opened — permanently — from the FAR side. Empty/absent in pre-0.6
60    /// campaigns, so a campaign that declares none stays
61    /// byte-identical.
62    #[serde(default, skip_serializing_if = "Vec::is_empty")]
63    pub shortcuts: Vec<Shortcut>,
64    /// Ambushes (spec-0016 §3): sugar over "deferred actors + a trigger that
65    /// springs them". Empty/absent in pre-0.6 campaigns.
66    ///
67    /// **Never serialized.** [`parse_campaign`](crate::parse_campaign) expands
68    /// each ambush into `triggers`, so the canonical form of a campaign is its
69    /// **desugared** form. That is what keeps the canonical round-trip idempotent
70    /// (re-parsing canonical output finds no `ambushes` and so cannot expand a
71    /// second time and duplicate the trigger ids), and it means the sugar exists
72    /// at exactly one layer boundary — the authored `.json` — with nothing
73    /// downstream needing to know it was ever there. The list itself is kept in
74    /// memory so diagnostics can name the ambush the author wrote.
75    /// Timed gates (spec-0016 §4): gates on a deterministic open/close clock.
76    /// Empty/absent in pre-0.6 campaigns.
77    #[serde(default, skip_serializing_if = "Vec::is_empty")]
78    pub timed_gates: Vec<TimedGate>,
79    /// Container fills (spec-0021): pre-placed chests/barrels in the prefabs
80    /// given contents at world init. Empty/absent in pre-0.6 campaigns
81    ///.
82    #[serde(default, skip_serializing_if = "Vec::is_empty")]
83    pub loot: Vec<Loot>,
84    /// Runtime state data (DSL v0.10, spec-0031): named, scoped, integer-valued
85    /// counters the campaign sets, adds to and clears at runtime, and compares
86    /// against in any gate. A campaign that declares none emits none of it.
87    #[serde(default, skip_serializing_if = "Vec::is_empty")]
88    pub state: Vec<StateDecl>,
89    /// **The campaign's death beat** (DSL v0.10, spec-0031): effects run at the
90    /// moment a player dies, for that player. Effect root **R7**
91    /// ([`crate::EffectRootKind::OnDeath`]); a campaign that declares none emits
92    /// none of it.
93    ///
94    /// **Why this is campaign-wide and not a field on a checkpoint.** The engine
95    /// already has `on_respawn`, and it hangs off a `set-checkpoint` because
96    /// *where you come back* is a property of the checkpoint. *That you died* is
97    /// not: it is true at every point of the delve, under every checkpoint, and a
98    /// bundle repeated on each checkpoint would be the same content written N
99    /// times with N chances to forget one. So death is a moment in the campaign,
100    /// and this is the one place it is named. Anything that should only happen in
101    /// some phase of the delve is expressed by the ordinary per-effect
102    /// `requires_flags` / `forbids_flags` gate every other root already carries —
103    /// no second gating surface.
104    ///
105    /// **Audience is the dying player** (`Audience::Solo`, the audience
106    /// `on_respawn` and `on_caught` already use): a death is one player's, and
107    /// re-broadcasting it to the party would duplicate their narration and their
108    /// kit. A beat the whole party should see is a `narrate` addressed by the
109    /// author to the party through the effect's own vocabulary, not a different
110    /// default here.
111    ///
112    /// **Timing.** It fires on the death edge while the player is still a corpse
113    /// (`Health: 0.0f`, on the death screen) — see
114    /// `emit::emit_checkpoint_functions`. That is the difference between this and
115    /// `on_respawn`, which deliberately waits for the player to come back.
116    #[serde(default, skip_serializing_if = "Vec::is_empty")]
117    pub on_death: Vec<QuestEffect>,
118    /// Lethal volumes (DSL v0.10, spec-0031): declared boxes that kill whatever
119    /// enters them. Empty/absent in pre-0.10 campaigns, so a
120    /// campaign that declares none stays byte-identical.
121    #[serde(default, skip_serializing_if = "Vec::is_empty")]
122    pub lethal_volumes: Vec<LethalVolume>,
123    /// Shops (DSL v0.10, spec-0032): interaction points that open a list of
124    /// gated offers. Empty/absent in pre-0.10 campaigns, so a
125    /// campaign that declares none stays byte-identical.
126    #[serde(default, skip_serializing_if = "Vec::is_empty")]
127    pub shops: Vec<Shop>,
128    /// Recovery stakes (DSL v0.10, spec-0032): what a death forfeits, where the
129    /// marker lands, and how it comes back. Empty/absent in pre-0.10 campaigns
130    ///, so a campaign that declares none stays byte-identical.
131    #[serde(default, skip_serializing_if = "Vec::is_empty")]
132    pub stakes: Vec<Stake>,
133    /// Assemblies (spec-0082): fixed things built of display entities that
134    /// play clips from a library rig, can be struck in melee, and strike back
135    /// at a player who stands where they reach. Appear on `spawn-assembly`,
136    /// leave on `despawn-assembly`. Empty/absent = nothing emitted.
137    #[serde(default, skip_serializing_if = "Vec::is_empty")]
138    pub assemblies: Vec<Assembly>,
139    /// Loops (spec-0086): slabs whose crossing returns a body by a whole-block
140    /// offset to an identical earlier section, held while a party gate is open.
141    /// Empty/absent for every campaign that declares none, so such a campaign
142    /// stays byte-identical.
143    #[serde(default, skip_serializing_if = "Vec::is_empty")]
144    pub loops: Vec<Loop>,
145    /// Pulses (spec-0102): sounds that beat from a mark on a fixed interval to
146    /// every player standing in a place, while a party gate holds.
147    /// Empty/absent for every campaign that declares none, so such a campaign
148    /// stays byte-identical.
149    #[serde(default, skip_serializing_if = "Vec::is_empty")]
150    pub pulses: Vec<Pulse>,
151    #[serde(default, skip_serializing)]
152    pub ambushes: Vec<Ambush>,
153    /// Whether [`Self::expand_ambushes`] has already run (never serialized). The
154    /// authored `ambushes` are deliberately KEPT after expansion so validation
155    /// and the counterplay proof can attribute diagnostics to the ambush the
156    /// author actually wrote; this flag is what makes a second expansion a no-op.
157    #[serde(skip)]
158    pub ambushes_expanded: bool,
159}
160
161/// Whether a piece of guidance is put in front of the player (spec-0093).
162#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
163#[serde(rename_all = "kebab-case")]
164pub enum Visibility {
165    /// Shown — the marker is summoned, the announcement is printed.
166    #[default]
167    Shown,
168    /// Hidden — nothing is summoned or printed; the objective still adjudicates.
169    Hidden,
170}
171
172impl Visibility {
173    /// `true` for [`Visibility::Shown`]. Takes a reference so it doubles as the
174    /// serde skip predicate on [`Guidance`]'s two fields.
175    pub fn is_shown(&self) -> bool {
176        *self == Visibility::Shown
177    }
178}
179
180/// The campaign's guidance defaults (spec-0093): what an objective gets when it
181/// states no `marker` or `announcement` of its own.
182///
183/// Two values, both defaulting to `shown`, so a document that omits the block
184/// is the document every campaign already was. The objective's own field wins
185/// over the campaign's; there is no third level, because a quest is where a
186/// beat is booked and not the object a lantern hangs over.
187#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
188#[serde(deny_unknown_fields)]
189pub struct Guidance {
190    /// The default for every `interact` (without a `prop`) and `reach-anchor`
191    /// objective's glowing marker.
192    #[serde(default, skip_serializing_if = "Visibility::is_shown")]
193    pub markers: Visibility,
194    /// The default for every objective's announcement — the `New objective`
195    /// line, the hint's line, the cue sound and the `Objective complete` line.
196    #[serde(default, skip_serializing_if = "Visibility::is_shown")]
197    pub announcements: Visibility,
198}
199
200impl Guidance {
201    /// Serde skip predicate: both `shown` needs no block on the wire.
202    pub fn is_default(&self) -> bool {
203        *self == Guidance::default()
204    }
205}
206
207impl QuestsContent {
208    /// Every environment trigger this stage produces: the authored `triggers`
209    /// followed by the ones each `ambush` desugars to (spec-0016 §3), in
210    /// declared order.
211    ///
212    /// **This is the single trigger authority.** Validation, the l10n
213    /// inventory, the flag/wave producer scans, the nav proofs and emission all
214    /// read triggers through it, so an ambush behaves exactly like the trigger
215    /// an author would otherwise hand-write — there is no second code path for
216    /// the sugar to drift down. Deterministic (declaration order, no hashing).
217    pub fn all_triggers(&self) -> Vec<EnvTrigger> {
218        let mut out = self.triggers.clone();
219        out.extend(self.ambushes.iter().map(Ambush::to_trigger));
220        out
221    }
222
223    /// **Does the campaign itself answer a right-click at `anchor`?**
224    ///
225    /// One predicate, read by both consumers of the press-answer rule, so they can
226    /// never disagree about what "the campaign answered it" means: the compiler's
227    /// synthesis (`plan::collect_press_answers`, which stands down where this is
228    /// true) and the obligation on a shortcut door (`DW0429`, which fires where it
229    /// is false). Split across the two crates they would drift, and the drift
230    /// would read as "the compiler refused a door I answered".
231    ///
232    /// Deliberately the widest reading — *any* `use` trigger anchored there.
233    /// Pressing it already does something the author chose, and the engine does
234    /// not adjudicate whether what they chose counts as an answer.
235    pub fn answers_press_at(&self, anchor: &str) -> bool {
236        self.all_triggers()
237            .iter()
238            .any(|t| matches!(t.on, TriggerOn::Use) && t.at_anchor() == Some(anchor))
239    }
240
241    /// Desugar every `ambush` into a real environment trigger and clear the
242    /// ambush list (spec-0016 §3). Called once, by
243    /// [`parse_campaign`](crate::parse_campaign); idempotent by construction
244    /// (a second call sees no ambushes left to expand).
245    pub fn expand_ambushes(&mut self) {
246        if self.ambushes_expanded || self.ambushes.is_empty() {
247            return;
248        }
249        self.triggers = self.all_triggers();
250        self.ambushes_expanded = true;
251    }
252
253    /// The declared datum with this id, if any (DSL v0.10).
254    pub fn state_decl(&self, id: &str) -> Option<&StateDecl> {
255        self.state.iter().find(|s| s.id.as_str() == id)
256    }
257
258    /// The declared stake with this id, if any (DSL v0.10, spec-0032).
259    pub fn stake_decl(&self, id: &str) -> Option<&Stake> {
260        self.stakes.iter().find(|s| s.id.as_str() == id)
261    }
262
263    /// The declared assembly with this id, if any (spec-0082).
264    pub fn assembly_decl(&self, id: &str) -> Option<&Assembly> {
265        self.assemblies.iter().find(|a| a.id.as_str() == id)
266    }
267}
268
269/// One expanded quest.
270#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
271#[serde(deny_unknown_fields)]
272pub struct Quest {
273    /// Quest id (matches a stage-4 planned quest).
274    pub id: QuestId,
275    /// What starts the quest.
276    pub trigger: Trigger,
277    /// Ordered objectives (intra-quest DAG via `after`).
278    pub objectives: Vec<Objective>,
279    /// Effects fired when a given objective completes.
280    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
281    pub on_objective_complete: BTreeMap<ObjectiveId, Vec<QuestEffect>>,
282    /// Effects fired when the whole quest completes.
283    pub on_complete: Vec<QuestEffect>,
284    /// The **cast ledger** (DSL v0.7, spec-0020): for every stage-2 NPC that is
285    /// live during this quest — spawned and not explicitly removed — where it
286    /// stands, what it is doing, and what its right-click offers *for this
287    /// quest's duration*.
288    ///
289    /// The ledger exists because an NPC's dialogue used to be one tree for the
290    /// whole campaign: after the climactic escape a crew member still offered
291    /// "Tell me what he is." — a premise question absurd once the story moved on.
292    /// Declaring the scene per quest makes the compiler able to check it
293    /// (`DW0460`–`DW0467`) and makes the declaration itself the gate: the
294    /// emitted right-click shows the root this quest declares, so a stale root
295    /// retires *because the ledger says so*, not because an author remembered a
296    /// flag.
297    ///
298    /// Every NPC live during the quest owes an entry (`DW0460`).
299    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
300    pub cast: BTreeMap<NpcId, CastEntry>,
301    /// What this quest does to the story (DSL v0.8, spec-0025; required at 0.8.0,
302    /// `DW0481`).
303    #[serde(default, skip_serializing_if = "Option::is_none")]
304    pub happening: Option<Happening>,
305}
306
307/// What triggers a quest.
308#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
309#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)]
310pub enum Trigger {
311    /// Fires when the campaign starts.
312    CampaignStart,
313    /// Fires when another quest completes.
314    QuestComplete {
315        /// The prerequisite quest.
316        quest: QuestId,
317    },
318}