delvewright_dsl/state.rs
1//! Stage 5 — runtime state (DSL v0.10, spec-0031): declared data, how it is
2//! displayed, written and compared.
3
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7use crate::StateId;
8
9#[cfg(doc)]
10use crate::FlagId;
11
12/// Who holds a datum's value (DSL v0.10, spec-0031).
13///
14/// **Declared, never inferred.** A datum's multiplayer semantics is the one
15/// thing about it that cannot be recovered from its uses: a purse read on a
16/// `talk-to` looks identical whether every player has their own or the party
17/// shares one, and the difference decides the whole design. spec-0031 states it
18/// as a rule, and the type makes it un-omittable — there is no default.
19#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
20#[serde(rename_all = "kebab-case")]
21pub enum StateScope {
22 /// Each player holds their own value. Read and written against the acting
23 /// player, so a bundle with no acting player (the scheduler) cannot touch one
24 /// (`DW0503`).
25 Player,
26 /// One value the whole party shares — the same holder story flags use
27 /// (spec-0018). Readable and writable from every audience, including the
28 /// scheduler.
29 Party,
30}
31
32impl StateScope {
33 /// The wire token (`player` / `party`).
34 pub fn token(self) -> &'static str {
35 match self {
36 StateScope::Player => "player",
37 StateScope::Party => "party",
38 }
39 }
40}
41
42/// One declared runtime datum (DSL v0.10, spec-0031): a named, scoped,
43/// integer-valued counter.
44///
45/// This is what [`FlagId`] is not. A flag is boolean, party-wide and
46/// **monotonic** — no verb clears one — which is exactly right for "this has
47/// happened" and useless for a balance, a floor number, or "a ride is in
48/// progress". A datum clears, counts down as well as up, and states its scope.
49#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
50#[serde(deny_unknown_fields)]
51pub struct StateDecl {
52 /// Unique datum id (`state/<kebab>`).
53 pub id: StateId,
54 /// Who holds the value. Required — see [`StateScope`].
55 pub scope: StateScope,
56 /// The value the datum starts at, and the value `clear-state` returns it to.
57 /// Defaults to `0`.
58 ///
59 /// One field rather than a separate `initial` and `cleared`: "the value this
60 /// datum has when nothing has happened to it yet" is one fact, and two fields
61 /// would let a campaign declare a datum that can never be returned to its own
62 /// starting state.
63 #[serde(default, skip_serializing_if = "is_zero_i32")]
64 pub initial: i32,
65 /// Free prose: what this datum means. Never machine-checked, never shown to a
66 /// player — the forcing function that makes an author say what the number is,
67 /// the same role `cast[].doing` plays.
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub note: Option<String>,
70 /// The player-visible name of this datum (DSL v0.10, spec-0032).
71 ///
72 /// **A named datum is a currency.** There is no separate `currencies` section
73 /// and there deliberately is not: a purse is a runtime datum that the player
74 /// can see, and "the player can see it" is a property of the datum, not a
75 /// different object class. A second struct carrying `id` + `scope` +
76 /// `initial` + `name` would be a private copy of this one, which is the defect
77 /// CLAUDE.md names second.
78 ///
79 /// Present ⇒ every `set-state` / `add-state` / `clear-state` on this datum
80 /// also states the new balance to whoever holds it, on the action bar, as
81 /// `<name>: <value>` with the value carried by vanilla's own `score`
82 /// component. Absent ⇒ the datum is silent bookkeeping and emission is exactly
83 /// what it was.
84 ///
85 /// Player-visible, so it is inventoried under `state.<id>.name` and
86 /// translated like any other authored line.
87 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub name: Option<String>,
89 /// Where this datum **stands** on screen between changes (spec-0076).
90 ///
91 /// The announcement `name` buys fades with the action bar; a datum that
92 /// declares `display` also occupies a vanilla display slot, so its balance is
93 /// on screen at every moment for every player. Declared, never automatic: a
94 /// creator may want a named tally that is spoken only when it moves, and the
95 /// engine does not decide which of two named datums is the purse.
96 ///
97 /// Present ⇒ `setup` heads the datum's objective with its translated `name`,
98 /// paints the value gold, and puts the objective in the slot. Requires `name`
99 /// (the slot's heading is the display name, and without one it would show the
100 /// objective's id) and a `player` scope (the sidebar hides `#`-prefixed
101 /// holders, which is what a `party` datum's value lives on); one datum per
102 /// campaign may stand, because the slot holds one objective — all three are
103 /// `DW0919`. Absent ⇒ the datum announces itself and stands nowhere.
104 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub display: Option<StateDisplay>,
106}
107
108/// The vanilla display slot a named datum stands in (spec-0076).
109///
110/// One value, because vanilla has one slot that stands for the viewer: `list`
111/// shows only while the tab key is held and `below_name` draws under *other*
112/// players' name tags, never over the viewer's own body. A second variant is
113/// added when the pinned game offers a second standing surface, not before.
114#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
115#[serde(rename_all = "kebab-case")]
116pub enum StateDisplay {
117 /// The right-hand sidebar: a heading (the datum's `name`) and one line per
118 /// player, each showing that player's own balance.
119 Sidebar,
120}
121
122impl StateDisplay {
123 /// The `minecraft:scoreboard_slot` token the slot is addressed by.
124 pub fn slot(self) -> &'static str {
125 match self {
126 StateDisplay::Sidebar => "sidebar",
127 }
128 }
129}
130
131/// serde `skip_serializing_if` helper: skip a zero `i32` (`StateDecl.initial`).
132fn is_zero_i32(v: &i32) -> bool {
133 *v == 0
134}
135
136/// How a [`StateCompare`] relates a datum to its operand (DSL v0.10).
137///
138/// Four operators, not six: over integers `less-than n` is `at-most n-1` and
139/// `greater-than n` is `at-least n+1`, so the extra spellings would add a second
140/// way to say one thing and a second emission path to keep honest.
141#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
142#[serde(rename_all = "kebab-case")]
143pub enum CompareOp {
144 /// The datum is exactly `value`.
145 Equals,
146 /// The datum is anything but `value`.
147 NotEquals,
148 /// The datum is `value` or more.
149 AtLeast,
150 /// The datum is `value` or less.
151 AtMost,
152}
153
154impl CompareOp {
155 /// The wire token (`equals` / `not-equals` / `at-least` / `at-most`).
156 pub fn token(self) -> &'static str {
157 match self {
158 CompareOp::Equals => "equals",
159 CompareOp::NotEquals => "not-equals",
160 CompareOp::AtLeast => "at-least",
161 CompareOp::AtMost => "at-most",
162 }
163 }
164}
165
166/// What one of the DSL v0.10 state verbs does to a datum — the value half of
167/// [`QuestEffect::writes_state`].
168#[derive(Clone, Copy, Debug, PartialEq, Eq)]
169pub enum StateWrite {
170 /// `set-state`: the datum becomes this value.
171 Set(i32),
172 /// `add-state`: the datum moves by this signed amount.
173 Add(i32),
174 /// `clear-state`: the datum returns to its declared `initial`.
175 Clear,
176}
177
178/// One numeric term of a gate (DSL v0.10, spec-0031): *this datum compares thus
179/// to this value*.
180///
181/// It rides [`Gate`](crate::gate::Gate) — the shared gate, carried by every
182/// consumer of `requires_flags`/`forbids_flags` — and not any one verb. The
183/// comparison's consumers are exactly the gate's consumers ("this door opens at
184/// 500", "this line is withheld below 200", "this lever does nothing while the
185/// car is moving"), so hanging it off the first verb that asked would leave the
186/// second with no surface and make a second bespoke field look like the fix.
187#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
188#[serde(deny_unknown_fields)]
189pub struct StateCompare {
190 /// The datum to read. Must be declared in the stage-5 `state` list
191 /// (`DW0502`).
192 pub state: StateId,
193 /// How to compare it.
194 pub op: CompareOp,
195 /// What to compare it against.
196 pub value: i32,
197}
198
199impl StateCompare {
200 /// Whether a datum holding `v` satisfies this comparison.
201 pub fn holds(&self, v: i32) -> bool {
202 match self.op {
203 CompareOp::Equals => v == self.value,
204 CompareOp::NotEquals => v != self.value,
205 CompareOp::AtLeast => v >= self.value,
206 CompareOp::AtMost => v <= self.value,
207 }
208 }
209}
210
211// ---------------------------------------------------------------------------
212// Validation — the checks `dsl::validate` runs over this object (ADR-0031)
213// ---------------------------------------------------------------------------
214
215use crate::QuestEffect;
216use crate::diagnostic::{Diagnostic, DwCode, ExitTier, codes};
217use crate::envelope::Campaign;
218use std::collections::{BTreeMap, BTreeSet};
219
220crate::dw_code! {
221 /// (spec-0076 §7) **A standing display the sidebar cannot draw as declared.**
222 /// A `state[]` datum declares `display: sidebar` and the slot cannot show
223 /// what it is handed: a second datum already asks for the one slot (the
224 /// sidebar holds one objective, and "first wins" would hide a decision the
225 /// creator has to make); the datum has no `name` (the slot's heading is the
226 /// display name, and without one the objective's id would stand on screen);
227 /// or the datum is `party`-scoped (its value lives on `#party`, and the
228 /// sidebar hides every `#`-prefixed holder, so the display would be an empty
229 /// heading). One rule about what the slot can draw, three ways to ask for
230 /// what it cannot — the `DW0520` shape. Validation-tier (exit 1).
231 /// Prescription: keep one `display`, give the datum a `name`, or declare it
232 /// `player`-scoped; a `party` purse keeps its announcement and stands nowhere.
233 pub const STATE_DISPLAY_UNDRAWABLE: DwCode = DwCode::new("DW0919", ExitTier::Build);
234}
235
236crate::dw_code! {
237 /// (v0.10, spec-0031) A gate's `requires_state` reads a declared datum that
238 /// **no verb anywhere in the campaign ever writes**. The datum can only ever
239 /// hold its declared `initial`, so the comparison's answer was decided at
240 /// authoring time and the gate is a constant wearing a condition's clothes.
241 ///
242 /// This is the vacuity rule at the level of one datum (CLAUDE.md: *a green
243 /// gate that binds to nothing is vacuous, not a pass*) — the numeric
244 /// equivalent of the bot's combat floor examining zero enemies for nineteen
245 /// rounds. Validation-tier (exit 1). Prescription: write the datum somewhere
246 /// (`set-state`/`add-state`/`clear-state`), or drop the comparison and say
247 /// what you meant unconditionally.
248 pub const STATE_NEVER_WRITTEN: DwCode = DwCode::new("DW0501", ExitTier::Build);
249}
250
251crate::dw_code! {
252 /// (v0.10, spec-0031) A declared datum that **no gate anywhere in the
253 /// campaign ever reads**. Either some verb writes it and nothing ever asks
254 /// (the write is inert — a counter nobody consults), or nothing touches it at
255 /// all (a dead declaration). Runtime state exists to be compared against; a
256 /// datum with no reader is bookkeeping no player can ever observe.
257 /// Validation-tier (exit 1). Prescription: gate something on it with
258 /// `requires_state`, or delete the declaration and its writes.
259 pub const STATE_NEVER_READ: DwCode = DwCode::new("DW0502", ExitTier::Build);
260}
261
262crate::dw_code! {
263 /// (v0.10, spec-0031) A `player`-scoped datum is referenced where emission
264 /// has no acting player to read or write it against.
265 ///
266 /// Two such places exist, and both are properties of the SITE, not of the
267 /// verb: a scheduler-only bundle (a `sequence` step, a `move-npc` /
268 /// `move-actor` `on_arrive`) runs with the server command source — the same
269 /// seam `DW0357` polices for `carrier: "one"` — and the gates emission
270 /// evaluates against the party holder rather than against a player (an
271 /// objective's activation guard, a trigger's arming gate, a trap's arming
272 /// gate) have no `@s` either. Validation-tier (exit 1). Prescription: declare
273 /// the datum `party`-scoped if the whole party shares it, or move the
274 /// read/write onto a site a player drives (a dialogue option, a cast
275 /// placement, an effect on a beat a player completes).
276 pub const STATE_SCOPE_UNREACHABLE: DwCode = DwCode::new("DW0503", ExitTier::Build);
277}
278
279crate::dw_code! {
280 /// (v0.10, spec-0032) **A comparison read after the bundle has already changed
281 /// what it compares.** An effect's `requires_state` names a datum that an
282 /// EARLIER effect in the same bundle writes, so the gate is evaluated against
283 /// the post-write value, not the value the beat started with.
284 ///
285 /// Found in the emitted output of spec-0032's own first shop. The authored
286 /// shape a shop wants is "the purchase behind `at-least 1`, the apology behind
287 /// `at-most 0`" — and written in that order, buying your LAST ember prints both:
288 /// the debit runs, the balance falls to 0, and the apology's gate — evaluated
289 /// after it — now holds. Vanilla evaluates each `execute` when it reaches it,
290 /// which is the whole reason a per-effect gate is useful, so this is not a bug
291 /// to fix in emission: it is an ordering hazard that only reading the generated
292 /// function reveals. The fix is always the same and always local — **put the
293 /// reading effect before the writing one** — which is why this is a warning
294 /// naming the earlier write rather than a refusal.
295 ///
296 /// Warning-tier (exit 0). Prescription: move the gated effect ahead of the
297 /// write, or gate it on something the bundle does not itself change.
298 pub const STATE_READ_AFTER_WRITE: DwCode = DwCode::new("DW0527", ExitTier::Build);
299}
300
301crate::dw_code! {
302 /// A gate contradicts itself, so it can NEVER open: a flag on both its
303 /// `requires_flags` and `forbids_flags`, or `requires_state` terms on one
304 /// datum that no integer satisfies (`at-least 5` with `at-most 3`, two
305 /// different `equals`). The thing carrying it — objective, effect, trigger,
306 /// trap, dialogue option, cast placement, shop offer — is authored content
307 /// that provably never happens, which is a defect in what the document
308 /// SAYS, not a stylistic lint. One rule over the whole closed consumer set
309 /// ([`crate::gate::for_each_gate`]), because satisfiability is a property
310 /// of the gate, never of the verb that first needed the question answered.
311 ///
312 /// Error tier, validation (exit 1).
313 pub const GATE_NEVER_OPENS: DwCode = DwCode::new("DW0847", ExitTier::Build);
314}
315
316/// DSL v0.10 runtime-state checks (spec-0031): every reference resolves, every
317/// read has a writer, every datum has a reader, and a `player`-scoped datum is
318/// only touched where emission has a player to touch it against.
319///
320/// Both halves of the read/write obligation are here on purpose. A datum a gate
321/// reads and no verb writes is a comparison whose answer was fixed when the
322/// campaign was written; a datum a verb writes and no gate reads is bookkeeping
323/// no player can observe. Each is a **vacuous binding** in the CLAUDE.md sense,
324/// and each is silent — the campaign compiles, the datapack loads, and the delve
325/// plays as though the mechanism were live.
326///
327/// The reads come from [`for_each_gate`](crate::gate::for_each_gate) and the
328/// writes from
329/// [`for_each_campaign_effect`](crate::for_each_campaign_effect) — the
330/// two closed enumerations — so neither side of the ledger can drift narrower
331/// than the surface it polices.
332pub(crate) fn state_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
333 let decls = &c.quests.content.state;
334 // --- the declarations themselves ------------------------------------------
335 let mut declared: BTreeMap<&str, &crate::StateDecl> = BTreeMap::new();
336 for (i, s) in decls.iter().enumerate() {
337 if !s.id.is_valid_syntax() {
338 d.push(Diagnostic::error(
339 codes::ID_SYNTAX,
340 "quests",
341 format!("/content/state/{i}/id"),
342 format!(
343 "runtime-state id `{}` is malformed — ids are `state/<kebab-case>`",
344 s.id.as_str()
345 ),
346 ));
347 continue;
348 }
349 if declared.insert(s.id.as_str(), s).is_some() {
350 d.push(Diagnostic::error(
351 codes::ID_DUPLICATE,
352 "quests",
353 format!("/content/state/{i}/id"),
354 format!(
355 "runtime-state id `{}` is declared more than once — one datum, one \
356 declaration (its scope and its initial value have to be a single fact)",
357 s.id.as_str()
358 ),
359 ));
360 }
361 }
362
363 // --- the standing display (spec-0076): what the sidebar can draw -----------
364 //
365 // One slot, one objective, drawn holder by holder under the objective's
366 // display name. A declaration the slot cannot draw as written is refused
367 // where it is written (`DW0919`), never resolved by order: a silent "first
368 // wins" would hide the one decision this field exists to make explicit.
369 let mut standing: Option<&crate::StateDecl> = None;
370 for (i, s) in decls.iter().enumerate() {
371 let Some(display) = s.display else {
372 continue;
373 };
374 let slot = display.slot();
375 let path = format!("/content/state/{i}/display");
376 if s.name.is_none() {
377 d.push(Diagnostic::error(
378 STATE_DISPLAY_UNDRAWABLE,
379 "quests",
380 path.clone(),
381 format!(
382 "`{}` asks to stand on the {slot} but has no `name`, and the {slot}'s \
383 heading is the display name — without one the objective's internal id \
384 would stand on every player's screen. Give the datum a `name`, or take \
385 `display` off it",
386 s.id.as_str()
387 ),
388 ));
389 }
390 if s.scope == crate::StateScope::Party {
391 d.push(Diagnostic::error(
392 STATE_DISPLAY_UNDRAWABLE,
393 "quests",
394 path.clone(),
395 format!(
396 "`{}` is `party`-scoped and asks to stand on the {slot}, but a party \
397 datum's value lives on the `#party` holder and the {slot} hides every \
398 holder whose name starts with `#` — the display would be an empty \
399 heading. Declare it `player`-scoped if each player holds their own, or \
400 take `display` off it and keep the announcement",
401 s.id.as_str()
402 ),
403 ));
404 }
405 match standing {
406 None => standing = Some(s),
407 Some(first) => d.push(Diagnostic::error(
408 STATE_DISPLAY_UNDRAWABLE,
409 "quests",
410 path,
411 format!(
412 "`{}` and `{}` both ask to stand on the {slot}, which holds one \
413 objective. Keep `display` on the one datum the party reads between \
414 changes and take it off the other — the engine does not pick for you",
415 first.id.as_str(),
416 s.id.as_str()
417 ),
418 )),
419 }
420 }
421
422 // --- the reads: every gate's `requires_state`, from the closed set --------
423 let mut read: BTreeSet<String> = BTreeSet::new();
424 crate::gate::for_each_gate(c, &mut |site, gate| {
425 for (k, cmp) in gate.requires_state.iter().enumerate() {
426 let path = format!("{}/requires_state/{k}", site.path);
427 let stage = site.consumer.stage();
428 match declared.get(cmp.state.as_str()) {
429 None => d.push(Diagnostic::error(
430 codes::STATE_UNDECLARED,
431 stage,
432 path,
433 format!(
434 "`requires_state` on this {} compares `{}`, which the campaign never \
435 declares. Add it to the stage-5 `state` list (a datum's scope and its \
436 initial value are facts no use site can supply), or fix the id",
437 site.consumer.label(),
438 cmp.state.as_str()
439 ),
440 )),
441 Some(decl) => {
442 read.insert(cmp.state.as_str().to_string());
443 // An EFFECT's gate is evaluated wherever its bundle runs, and
444 // that is the root's fact, not the effect's — so
445 // `evaluates_per_player` answers `None` here and the scope
446 // check for effects happens in the root walk below, which
447 // knows both the root's audience and the seams inside it.
448 // A loop's gate is refused for a `player` datum by `DW0949`,
449 // which names the release rather than the audience; one
450 // fault, one code.
451 if decl.scope == crate::StateScope::Player
452 && site.consumer == crate::gate::GateConsumer::LethalVolume
453 {
454 // The same fault as `DW0503` on any other party-read
455 // gate, with the volume's own code and remedy
456 // (spec-0088 §3.2): one check site, the code chosen by
457 // the consumer.
458 d.push(Diagnostic::error(
459 codes::LETHAL_STAGE_GATE,
460 stage,
461 path,
462 format!(
463 "lethal volume `{}` is staged on `{}`, which is `player`-scoped — a \
464 volume's liveness is a fact about the place, so a term one player \
465 satisfies and another does not would be a pit that kills one body \
466 and spares the one beside it, and the sweep's entity half has no \
467 player to read a per-player score from. Name a flag or a \
468 `party`-scoped datum in `when`, or leave `when` out to make the \
469 volume live from world-load",
470 volume_id_at(c, &site.path),
471 cmp.state.as_str()
472 ),
473 ));
474 } else if decl.scope == crate::StateScope::Player
475 && site.consumer == crate::gate::GateConsumer::Pulse
476 {
477 // The same fault, with the pulse's own code (spec-0102
478 // §6.1): one check site, the code chosen by the consumer.
479 d.push(Diagnostic::error(
480 crate::pulse::PULSE_DECL,
481 stage,
482 path,
483 format!(
484 "pulse `{}` is staged on `{}`, which is `player`-scoped — a pulse \
485 addresses every player standing in its place, so a term one \
486 player satisfies and another does not would be a beat one body \
487 hears and the one beside it does not, and the tick that opens it \
488 reads the party, not a player. Name a flag or a `party`-scoped \
489 datum in `when`, or leave `when` out to make it beat from world \
490 load",
491 pulse_id_at(c, &site.path),
492 cmp.state.as_str()
493 ),
494 ));
495 } else if decl.scope == crate::StateScope::Player
496 && site.consumer.evaluates_per_player() == Some(false)
497 && site.consumer != crate::gate::GateConsumer::Loop
498 {
499 d.push(Diagnostic::error(
500 STATE_SCOPE_UNREACHABLE,
501 stage,
502 path,
503 format!(
504 "`{}` is `player`-scoped, but emission evaluates a {}'s gate \
505 against the party holder — there is no acting player to read it \
506 from. Declare the datum `party`-scoped, or move the comparison \
507 onto a site a player drives (a dialogue option, a cast \
508 placement, or an effect on a beat a player completes)",
509 cmp.state.as_str(),
510 site.consumer.label()
511 ),
512 ));
513 }
514 }
515 }
516 }
517 });
518
519 // --- the writes: every state verb, at every effect root, nesting included -
520 let mut written: BTreeSet<String> = BTreeSet::new();
521 crate::for_each_campaign_effect(c, &mut |path, _site, eff| {
522 let Some((id, _)) = eff.writes_state() else {
523 return;
524 };
525 match declared.get(id.as_str()) {
526 None => d.push(Diagnostic::error(
527 codes::STATE_UNDECLARED,
528 "quests",
529 format!("{path}/state"),
530 format!(
531 "`{}` writes `{}`, which the campaign never declares. Add it to the stage-5 \
532 `state` list, or fix the id",
533 eff.verb.tag(),
534 id.as_str()
535 ),
536 )),
537 Some(_) => {
538 written.insert(id.as_str().to_string());
539 }
540 }
541 });
542 // A loop's `counts` is a write: every move raises it by one (spec-0086 §3.4).
543 for l in &c.quests.content.loops {
544 if let Some(counts) = &l.counts
545 && declared.contains_key(counts.as_str())
546 {
547 written.insert(counts.as_str().to_string());
548 }
549 }
550 // A `player`-scoped datum read or written where there is no acting player
551 // has no subject, exactly as a `carrier: "one"` give does (`DW0357`).
552 //
553 // **The latch starts from the ROOT**, not from `false`. Four of the seven
554 // roots run with an acting player and three do not
555 // (`EffectRootKind::runs_with_acting_player` — a trigger's effects, a trap's
556 // payload and a shortcut's `on_unlock` are all polled on the tick with no
557 // executor), and inside a bundle the `sequence` / `on_arrive` seams drop the
558 // actor the same way. Seeding it `false` — as this walk first did — read
559 // every root as player-bearing and let three of the seven through.
560 crate::effects::for_each_effect_root(c, &mut |site, effs| {
561 // Per SITE, not per kind (DSL v0.11): a trigger declaring
562 // `audience: presser` is dispatched by the interaction advancement and so
563 // DOES have an acting player, while every other trigger is polled with
564 // none. Asking the kind would refuse a `player`-scoped read that the
565 // emitter can serve — the mirror of the bug this seed was added to fix.
566 let scheduled = !site.runs_with_acting_player();
567 check_player_state_not_scheduled(effs, &declared, site.stage, &site.path, scheduled, d);
568 });
569
570 // --- the two halves of the vacuity ledger ---------------------------------
571 for (i, s) in decls.iter().enumerate() {
572 let id = s.id.as_str();
573 // A malformed or duplicate id has already been reported; reporting it a
574 // third time as "unread" would be noise about a datum that does not exist.
575 if declared.get(id).is_none_or(|kept| !std::ptr::eq(*kept, s)) {
576 continue;
577 }
578 if read.contains(id) && !written.contains(id) {
579 d.push(Diagnostic::error(
580 STATE_NEVER_WRITTEN,
581 "quests",
582 format!("/content/state/{i}"),
583 format!(
584 "`{id}` is read by a gate but no `set-state`/`add-state`/`clear-state` \
585 anywhere in the campaign ever writes it — it can only ever hold its declared \
586 initial ({}), so every comparison against it was decided when the campaign \
587 was written. Write it somewhere, or drop the comparison and say what you \
588 meant unconditionally",
589 s.initial
590 ),
591 ));
592 }
593 if !read.contains(id) {
594 let tail = if written.contains(id) {
595 "some verb writes it and nothing ever asks"
596 } else {
597 "nothing touches it at all"
598 };
599 d.push(Diagnostic::error(
600 STATE_NEVER_READ,
601 "quests",
602 format!("/content/state/{i}"),
603 format!(
604 "`{id}` is declared but no gate's `requires_state` anywhere in the campaign \
605 ever reads it — {tail}. Runtime state exists to be compared against; gate \
606 something on it, or delete the declaration and its writes"
607 ),
608 ));
609 }
610 }
611}
612
613/// Reject a `player`-scoped datum **read or written** where emission has no
614/// acting player (`DW0503`).
615///
616/// `scheduled` arrives already seeded from the ROOT
617/// ([`EffectRootKind::runs_with_acting_player`](crate::EffectRootKind::runs_with_acting_player)),
618/// and from there the seams and the latch semantics are
619/// [`check_carrier_one_not_scheduled`]'s, deliberately: a `sequence` step and a
620/// `move-npc`/`move-actor` `on_arrive` are re-invoked with the server command
621/// source, while a `set-checkpoint`'s `on_respawn` and a `begin-stealth`'s
622/// `on_caught` are dispatched per player and so reset the latch.
623///
624/// Reads and writes are checked together because they fail the same way: a
625/// per-player score named from a sourceless function is `@s` with nothing to
626/// resolve it to, whether the command is a `scoreboard players set` or an
627/// `execute if score`.
628fn check_player_state_not_scheduled(
629 effs: &[QuestEffect],
630 declared: &BTreeMap<&str, &crate::StateDecl>,
631 stage: &str,
632 path: &str,
633 scheduled: bool,
634 d: &mut Vec<Diagnostic>,
635) {
636 let is_player = |id: &str| {
637 declared
638 .get(id)
639 .is_some_and(|s| s.scope == crate::StateScope::Player)
640 };
641 for e in effs {
642 if scheduled {
643 for cmp in e.requires_state() {
644 if is_player(cmp.state.as_str()) {
645 d.push(Diagnostic::error(
646 STATE_SCOPE_UNREACHABLE,
647 stage,
648 path.to_string(),
649 format!(
650 "a `{}` effect's `requires_state` compares `{}`, which is \
651 `player`-scoped, in a bundle that runs with no acting player (a \
652 trigger's effects, a trap's payload and a shortcut's `on_unlock` \
653 are polled on the tick from the server command source; so are a \
654 `sequence` step and a `move-npc`/`move-actor` `on_arrive`). There \
655 is no player to read the datum from. Declare it `party`-scoped, or \
656 move the comparison onto a beat a player completes",
657 e.verb.tag(),
658 cmp.state.as_str()
659 ),
660 ));
661 }
662 }
663 }
664 if scheduled
665 && let Some((id, _)) = e.writes_state()
666 && is_player(id.as_str())
667 {
668 d.push(Diagnostic::error(
669 STATE_SCOPE_UNREACHABLE,
670 stage,
671 path.to_string(),
672 format!(
673 "`{}` writes `{}`, which is `player`-scoped, from a bundle that runs with no \
674 acting player (a trigger's effects, a trap's payload and a shortcut's \
675 `on_unlock` are polled on the tick from the server command source; so are a \
676 `sequence` step and a `move-npc`/`move-actor` `on_arrive`). There is no \
677 acting player whose datum this would be, so the write would silently reach \
678 nobody. Declare the datum `party`-scoped, or move the write onto a beat a \
679 player completes",
680 e.verb.tag(),
681 id.as_str()
682 ),
683 ));
684 }
685 // spec-0085 §3.3: the fourth shape — an actor-addressed effect where
686 // emission has no acting player. One rule, *no `@s` where emission has
687 // none*, and one remedy.
688 if scheduled && e.audience == Some(crate::EffectAudience::Actor) {
689 d.push(Diagnostic::error(
690 STATE_SCOPE_UNREACHABLE,
691 stage,
692 path.to_string(),
693 format!(
694 "a `{}` effect declares `audience: actor` in a bundle that runs with no \
695 acting player (a polled trigger's effects, a trap's payload and a shortcut's \
696 `on_unlock` run from the server command source; so do a `move-npc`/\
697 `move-actor` `on_arrive`, a `bonfire`'s `on_rest`, and every step of a \
698 timeline started there). There is no actor to address. Move the beat onto a \
699 site a player drives (an objective's completion, a `presser` trigger, a \
700 respawn), or drop `audience` to address the party",
701 e.verb.tag()
702 ),
703 ));
704 }
705 // The seams are the DSL's one statement of them
706 // (`QuestEffect::nested_effect_dispatch`): a `sequence` step keeps the
707 // actor its timeline was started with (spec-0085 §3.2).
708 for (list, how) in e.nested_effect_dispatch() {
709 check_player_state_not_scheduled(
710 list,
711 declared,
712 stage,
713 path,
714 !how.has_actor(!scheduled),
715 d,
716 );
717 }
718 }
719}
720
721/// `DW0527` — a gate that reads a datum an earlier effect in the same bundle has
722/// already written.
723///
724/// Sibling effects in one bundle are consecutive commands in one generated
725/// function, and vanilla evaluates each `execute` condition when it reaches it. So
726/// a comparison placed after a write is a comparison against the post-write value —
727/// which is exactly what the shop pattern spec-0032 recommends walks into if the
728/// refusal is written after the purchase instead of before it.
729///
730/// Scope is deliberately one flat sibling list: a `sequence` step runs on a later
731/// tick and a nested lifecycle bundle runs at a different moment entirely, so
732/// "earlier in the same breath" is precisely a list index. Warning tier — an author
733/// who means to write then compare is doing something legitimate, and the
734/// diagnostic's job is to make sure they meant it.
735pub(crate) fn read_after_write_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
736 read_after_write_walk(c, d);
737}
738
739/// What the read-after-write rule (`DW0527`) examined, zeroes included.
740#[derive(Clone, Debug, Default, PartialEq, Eq)]
741pub struct ReadAfterWriteBinding {
742 /// Effect bundles walked — the denominator.
743 pub bundles: usize,
744 /// Effects walked across those bundles.
745 pub effects: usize,
746 /// Conditional writes (gated on the datum they write) the walk recorded.
747 pub gated_writes: usize,
748 /// Diagnostics raised (`DW0527`).
749 pub refused: usize,
750}
751
752impl ReadAfterWriteBinding {
753 /// Count what [`read_after_write_checks`] examines on `c`.
754 pub fn of(c: &Campaign) -> Self {
755 read_after_write_walk(c, &mut Vec::new())
756 }
757
758 /// The one line this rule owes its reader.
759 pub fn line(&self) -> String {
760 format!(
761 "read-after-write binding: {} effect(s) over {} bundle(s) walked, {} gated write(s) \
762 recorded, {} refused (DW0527).",
763 self.effects, self.bundles, self.gated_writes, self.refused
764 )
765 }
766}
767
768fn read_after_write_walk(c: &Campaign, d: &mut Vec<Diagnostic>) -> ReadAfterWriteBinding {
769 let before = d.len();
770 let mut b = ReadAfterWriteBinding::default();
771 crate::effects::for_each_effect_root(c, &mut |site, list| {
772 b.bundles += 1;
773 b.effects += list.len();
774 // Only a **conditional** write counts, and that narrowing is the whole
775 // precision of this rule. An UNCONDITIONAL write followed by a comparison
776 // is the ordinary sequenced idiom — *pay the toll, then the door opens
777 // because the toll is now zero* — where the author plainly means the value
778 // the bundle just produced. A write that is itself gated on the SAME datum
779 // is the other thing: the bundle asks about the datum, changes it across
780 // the boundary it just asked about, and then asks again — which is the
781 // shape that pays for something and apologises for it in the same breath.
782 let mut written: BTreeMap<&str, usize> = BTreeMap::new();
783 for (i, eff) in list.iter().enumerate() {
784 for cmp in eff.requires_state() {
785 let Some(at) = written.get(cmp.state.as_str()) else {
786 continue;
787 };
788 d.push(Diagnostic::warning(
789 STATE_READ_AFTER_WRITE,
790 site.stage,
791 format!("{}/{i}/when/requires_state", site.path),
792 format!(
793 "this `{}` compares `{}`, and effect {at} of the same bundle already \
794 changes `{}` behind a gate on `{}` itself — so this comparison is made \
795 against the value THIS bundle just produced, on the far side of the \
796 boundary it just tested. The shape that bites is a purchase followed by \
797 its own refusal: buying the LAST coin debits it, and the `at-most` \
798 apology written after the debit then holds as well, so the player is \
799 charged AND told they cannot afford it. Move every reading effect ahead \
800 of the write. (An UNCONDITIONAL write followed by a comparison is not \
801 this: `set-state toll 0` and then a door gated on `toll at-most 0` \
802 plainly means the value the bundle just produced, and is not \
803 diagnosed.)",
804 eff.verb.tag(),
805 cmp.state.as_str(),
806 cmp.state.as_str(),
807 cmp.state.as_str()
808 ),
809 ));
810 }
811 if let Some((id, _)) = eff.writes_state()
812 && eff
813 .requires_state()
814 .iter()
815 .any(|c| c.state.as_str() == id.as_str())
816 {
817 b.gated_writes += 1;
818 written.entry(id.as_str()).or_insert(i);
819 }
820 }
821 });
822 b.refused = d.len() - before;
823 b
824}
825
826/// `DW0847`: a gate whose own terms contradict each other can never open, so
827/// the thing carrying it is authored content that provably never happens — an
828/// objective that never activates, an effect that never fires, a dialogue
829/// option that never shows, a cast clause that never governs.
830///
831/// One rule over [`for_each_gate`](crate::gate::for_each_gate)'s closed
832/// consumer set, because satisfiability is a property of the **gate** and a
833/// check written beside the first verb that needed it would leave the other
834/// six classes with no surface (CLAUDE.md: a capability belongs to the object
835/// class it acts on). The arithmetic is [`crate::gate::Gate::contradiction`],
836/// the same [`crate::gate::DatumSet`] the compiler's cast-ladder solver picks
837/// drive values from — one authority, so "can this open" and "at what value"
838/// can never disagree.
839pub(crate) fn gate_contradiction_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
840 crate::gate::for_each_gate(c, &mut |site, gate| {
841 let Some(contra) = gate.contradiction() else {
842 return;
843 };
844 let what = match contra {
845 crate::gate::GateContradiction::Flag(f) => {
846 format!("flag `{f}` is both required and forbidden, so the gate is never satisfied")
847 }
848 crate::gate::GateContradiction::Datum(s) => format!(
849 "no value of `{s}` satisfies every `requires_state` term that reads it, so the \
850 gate is never satisfied"
851 ),
852 };
853 d.push(Diagnostic::error(
854 GATE_NEVER_OPENS,
855 site.consumer.stage(),
856 site.path.clone(),
857 format!(
858 "this {}'s gate contradicts itself: {what}. Whatever it guards can never happen — \
859 fix the gate, or delete the thing it makes unreachable",
860 site.consumer.label()
861 ),
862 ));
863 });
864}
865
866/// The id of the pulse a `/content/pulses/<i>/…` pointer names, for a
867/// diagnostic's wording; the pointer itself when it names none.
868fn pulse_id_at(c: &Campaign, path: &str) -> String {
869 path.strip_prefix("/content/pulses/")
870 .and_then(|rest| rest.split('/').next())
871 .and_then(|i| i.parse::<usize>().ok())
872 .and_then(|i| c.quests.content.pulses.get(i))
873 .map_or_else(|| path.to_string(), |p| p.id.as_str().to_string())
874}
875
876/// The id of the lethal volume a `/content/lethal_volumes/<i>/…` pointer names,
877/// for a diagnostic's wording; the pointer itself when it names none.
878fn volume_id_at(c: &Campaign, path: &str) -> String {
879 path.strip_prefix("/content/lethal_volumes/")
880 .and_then(|rest| rest.split('/').next())
881 .and_then(|i| i.parse::<usize>().ok())
882 .and_then(|i| c.quests.content.lethal_volumes.get(i))
883 .map_or_else(|| path.to_string(), |v| v.id.as_str().to_string())
884}