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}