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}