Skip to main content

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}