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