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.evaluates_per_player() == Some(false)
476                        && site.consumer != crate::gate::GateConsumer::Loop
477                    {
478                        d.push(Diagnostic::error(
479                            STATE_SCOPE_UNREACHABLE,
480                            stage,
481                            path,
482                            format!(
483                                "`{}` is `player`-scoped, but emission evaluates a {}'s gate \
484                                 against the party holder — there is no acting player to read it \
485                                 from. Declare the datum `party`-scoped, or move the comparison \
486                                 onto a site a player drives (a dialogue option, a cast \
487                                 placement, or an effect on a beat a player completes)",
488                                cmp.state.as_str(),
489                                site.consumer.label()
490                            ),
491                        ));
492                    }
493                }
494            }
495        }
496    });
497
498    // --- the writes: every state verb, at every effect root, nesting included -
499    let mut written: BTreeSet<String> = BTreeSet::new();
500    crate::for_each_campaign_effect(c, &mut |path, _site, eff| {
501        let Some((id, _)) = eff.writes_state() else {
502            return;
503        };
504        match declared.get(id.as_str()) {
505            None => d.push(Diagnostic::error(
506                codes::STATE_UNDECLARED,
507                "quests",
508                format!("{path}/state"),
509                format!(
510                    "`{}` writes `{}`, which the campaign never declares. Add it to the stage-5 \
511                     `state` list, or fix the id",
512                    eff.verb.tag(),
513                    id.as_str()
514                ),
515            )),
516            Some(_) => {
517                written.insert(id.as_str().to_string());
518            }
519        }
520    });
521    // A loop's `counts` is a write: every move raises it by one (spec-0086 §3.4).
522    for l in &c.quests.content.loops {
523        if let Some(counts) = &l.counts
524            && declared.contains_key(counts.as_str())
525        {
526            written.insert(counts.as_str().to_string());
527        }
528    }
529    // A `player`-scoped datum read or written where there is no acting player
530    // has no subject, exactly as a `carrier: "one"` give does (`DW0357`).
531    //
532    // **The latch starts from the ROOT**, not from `false`. Four of the seven
533    // roots run with an acting player and three do not
534    // (`EffectRootKind::runs_with_acting_player` — a trigger's effects, a trap's
535    // payload and a shortcut's `on_unlock` are all polled on the tick with no
536    // executor), and inside a bundle the `sequence` / `on_arrive` seams drop the
537    // actor the same way. Seeding it `false` — as this walk first did — read
538    // every root as player-bearing and let three of the seven through.
539    crate::effects::for_each_effect_root(c, &mut |site, effs| {
540        // Per SITE, not per kind (DSL v0.11): a trigger declaring
541        // `audience: presser` is dispatched by the interaction advancement and so
542        // DOES have an acting player, while every other trigger is polled with
543        // none. Asking the kind would refuse a `player`-scoped read that the
544        // emitter can serve — the mirror of the bug this seed was added to fix.
545        let scheduled = !site.runs_with_acting_player();
546        check_player_state_not_scheduled(effs, &declared, site.stage, &site.path, scheduled, d);
547    });
548
549    // --- the two halves of the vacuity ledger ---------------------------------
550    for (i, s) in decls.iter().enumerate() {
551        let id = s.id.as_str();
552        // A malformed or duplicate id has already been reported; reporting it a
553        // third time as "unread" would be noise about a datum that does not exist.
554        if declared.get(id).is_none_or(|kept| !std::ptr::eq(*kept, s)) {
555            continue;
556        }
557        if read.contains(id) && !written.contains(id) {
558            d.push(Diagnostic::error(
559                STATE_NEVER_WRITTEN,
560                "quests",
561                format!("/content/state/{i}"),
562                format!(
563                    "`{id}` is read by a gate but no `set-state`/`add-state`/`clear-state` \
564                     anywhere in the campaign ever writes it — it can only ever hold its declared \
565                     initial ({}), so every comparison against it was decided when the campaign \
566                     was written. Write it somewhere, or drop the comparison and say what you \
567                     meant unconditionally",
568                    s.initial
569                ),
570            ));
571        }
572        if !read.contains(id) {
573            let tail = if written.contains(id) {
574                "some verb writes it and nothing ever asks"
575            } else {
576                "nothing touches it at all"
577            };
578            d.push(Diagnostic::error(
579                STATE_NEVER_READ,
580                "quests",
581                format!("/content/state/{i}"),
582                format!(
583                    "`{id}` is declared but no gate's `requires_state` anywhere in the campaign \
584                     ever reads it — {tail}. Runtime state exists to be compared against; gate \
585                     something on it, or delete the declaration and its writes"
586                ),
587            ));
588        }
589    }
590}
591
592/// Reject a `player`-scoped datum **read or written** where emission has no
593/// acting player (`DW0503`).
594///
595/// `scheduled` arrives already seeded from the ROOT
596/// ([`EffectRootKind::runs_with_acting_player`](crate::EffectRootKind::runs_with_acting_player)),
597/// and from there the seams and the latch semantics are
598/// [`check_carrier_one_not_scheduled`]'s, deliberately: a `sequence` step and a
599/// `move-npc`/`move-actor` `on_arrive` are re-invoked with the server command
600/// source, while a `set-checkpoint`'s `on_respawn` and a `begin-stealth`'s
601/// `on_caught` are dispatched per player and so reset the latch.
602///
603/// Reads and writes are checked together because they fail the same way: a
604/// per-player score named from a sourceless function is `@s` with nothing to
605/// resolve it to, whether the command is a `scoreboard players set` or an
606/// `execute if score`.
607fn check_player_state_not_scheduled(
608    effs: &[QuestEffect],
609    declared: &BTreeMap<&str, &crate::StateDecl>,
610    stage: &str,
611    path: &str,
612    scheduled: bool,
613    d: &mut Vec<Diagnostic>,
614) {
615    let is_player = |id: &str| {
616        declared
617            .get(id)
618            .is_some_and(|s| s.scope == crate::StateScope::Player)
619    };
620    for e in effs {
621        if scheduled {
622            for cmp in e.requires_state() {
623                if is_player(cmp.state.as_str()) {
624                    d.push(Diagnostic::error(
625                        STATE_SCOPE_UNREACHABLE,
626                        stage,
627                        path.to_string(),
628                        format!(
629                            "a `{}` effect's `requires_state` compares `{}`, which is \
630                             `player`-scoped, in a bundle that runs with no acting player (a \
631                             trigger's effects, a trap's payload and a shortcut's `on_unlock` \
632                             are polled on the tick from the server command source; so are a \
633                             `sequence` step and a `move-npc`/`move-actor` `on_arrive`). There \
634                             is no player to read the datum from. Declare it `party`-scoped, or \
635                             move the comparison onto a beat a player completes",
636                            e.verb.tag(),
637                            cmp.state.as_str()
638                        ),
639                    ));
640                }
641            }
642        }
643        if scheduled
644            && let Some((id, _)) = e.writes_state()
645            && is_player(id.as_str())
646        {
647            d.push(Diagnostic::error(
648                STATE_SCOPE_UNREACHABLE,
649                stage,
650                path.to_string(),
651                format!(
652                    "`{}` writes `{}`, which is `player`-scoped, from a bundle that runs with no \
653                     acting player (a trigger's effects, a trap's payload and a shortcut's \
654                     `on_unlock` are polled on the tick from the server command source; so are a \
655                     `sequence` step and a `move-npc`/`move-actor` `on_arrive`). There is no \
656                     acting player whose datum this would be, so the write would silently reach \
657                     nobody. Declare the datum `party`-scoped, or move the write onto a beat a \
658                     player completes",
659                    e.verb.tag(),
660                    id.as_str()
661                ),
662            ));
663        }
664        // spec-0085 §3.3: the fourth shape — an actor-addressed effect where
665        // emission has no acting player. One rule, *no `@s` where emission has
666        // none*, and one remedy.
667        if scheduled && e.audience == Some(crate::EffectAudience::Actor) {
668            d.push(Diagnostic::error(
669                STATE_SCOPE_UNREACHABLE,
670                stage,
671                path.to_string(),
672                format!(
673                    "a `{}` effect declares `audience: actor` in a bundle that runs with no \
674                     acting player (a polled trigger's effects, a trap's payload and a shortcut's \
675                     `on_unlock` run from the server command source; so do a `move-npc`/\
676                     `move-actor` `on_arrive`, a `bonfire`'s `on_rest`, and every step of a \
677                     timeline started there). There is no actor to address. Move the beat onto a \
678                     site a player drives (an objective's completion, a `presser` trigger, a \
679                     respawn), or drop `audience` to address the party",
680                    e.verb.tag()
681                ),
682            ));
683        }
684        // The seams are the DSL's one statement of them
685        // (`QuestEffect::nested_effect_dispatch`): a `sequence` step keeps the
686        // actor its timeline was started with (spec-0085 §3.2).
687        for (list, how) in e.nested_effect_dispatch() {
688            check_player_state_not_scheduled(
689                list,
690                declared,
691                stage,
692                path,
693                !how.has_actor(!scheduled),
694                d,
695            );
696        }
697    }
698}
699
700/// `DW0527` — a gate that reads a datum an earlier effect in the same bundle has
701/// already written.
702///
703/// Sibling effects in one bundle are consecutive commands in one generated
704/// function, and vanilla evaluates each `execute` condition when it reaches it. So
705/// a comparison placed after a write is a comparison against the post-write value —
706/// which is exactly what the shop pattern spec-0032 recommends walks into if the
707/// refusal is written after the purchase instead of before it.
708///
709/// Scope is deliberately one flat sibling list: a `sequence` step runs on a later
710/// tick and a nested lifecycle bundle runs at a different moment entirely, so
711/// "earlier in the same breath" is precisely a list index. Warning tier — an author
712/// who means to write then compare is doing something legitimate, and the
713/// diagnostic's job is to make sure they meant it.
714pub(crate) fn read_after_write_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
715    read_after_write_walk(c, d);
716}
717
718/// What the read-after-write rule (`DW0527`) examined, zeroes included.
719#[derive(Clone, Debug, Default, PartialEq, Eq)]
720pub struct ReadAfterWriteBinding {
721    /// Effect bundles walked — the denominator.
722    pub bundles: usize,
723    /// Effects walked across those bundles.
724    pub effects: usize,
725    /// Conditional writes (gated on the datum they write) the walk recorded.
726    pub gated_writes: usize,
727    /// Diagnostics raised (`DW0527`).
728    pub refused: usize,
729}
730
731impl ReadAfterWriteBinding {
732    /// Count what [`read_after_write_checks`] examines on `c`.
733    pub fn of(c: &Campaign) -> Self {
734        read_after_write_walk(c, &mut Vec::new())
735    }
736
737    /// The one line this rule owes its reader.
738    pub fn line(&self) -> String {
739        format!(
740            "read-after-write binding: {} effect(s) over {} bundle(s) walked, {} gated write(s) \
741             recorded, {} refused (DW0527).",
742            self.effects, self.bundles, self.gated_writes, self.refused
743        )
744    }
745}
746
747fn read_after_write_walk(c: &Campaign, d: &mut Vec<Diagnostic>) -> ReadAfterWriteBinding {
748    let before = d.len();
749    let mut b = ReadAfterWriteBinding::default();
750    crate::effects::for_each_effect_root(c, &mut |site, list| {
751        b.bundles += 1;
752        b.effects += list.len();
753        // Only a **conditional** write counts, and that narrowing is the whole
754        // precision of this rule. An UNCONDITIONAL write followed by a comparison
755        // is the ordinary sequenced idiom — *pay the toll, then the door opens
756        // because the toll is now zero* — where the author plainly means the value
757        // the bundle just produced. A write that is itself gated on the SAME datum
758        // is the other thing: the bundle asks about the datum, changes it across
759        // the boundary it just asked about, and then asks again — which is the
760        // shape that pays for something and apologises for it in the same breath.
761        let mut written: BTreeMap<&str, usize> = BTreeMap::new();
762        for (i, eff) in list.iter().enumerate() {
763            for cmp in eff.requires_state() {
764                let Some(at) = written.get(cmp.state.as_str()) else {
765                    continue;
766                };
767                d.push(Diagnostic::warning(
768                    STATE_READ_AFTER_WRITE,
769                    site.stage,
770                    format!("{}/{i}/when/requires_state", site.path),
771                    format!(
772                        "this `{}` compares `{}`, and effect {at} of the same bundle already \
773                         changes `{}` behind a gate on `{}` itself — so this comparison is made \
774                         against the value THIS bundle just produced, on the far side of the \
775                         boundary it just tested. The shape that bites is a purchase followed by \
776                         its own refusal: buying the LAST coin debits it, and the `at-most` \
777                         apology written after the debit then holds as well, so the player is \
778                         charged AND told they cannot afford it. Move every reading effect ahead \
779                         of the write. (An UNCONDITIONAL write followed by a comparison is not \
780                         this: `set-state toll 0` and then a door gated on `toll at-most 0` \
781                         plainly means the value the bundle just produced, and is not \
782                         diagnosed.)",
783                        eff.verb.tag(),
784                        cmp.state.as_str(),
785                        cmp.state.as_str(),
786                        cmp.state.as_str()
787                    ),
788                ));
789            }
790            if let Some((id, _)) = eff.writes_state()
791                && eff
792                    .requires_state()
793                    .iter()
794                    .any(|c| c.state.as_str() == id.as_str())
795            {
796                b.gated_writes += 1;
797                written.entry(id.as_str()).or_insert(i);
798            }
799        }
800    });
801    b.refused = d.len() - before;
802    b
803}
804
805/// `DW0847`: a gate whose own terms contradict each other can never open, so
806/// the thing carrying it is authored content that provably never happens — an
807/// objective that never activates, an effect that never fires, a dialogue
808/// option that never shows, a cast clause that never governs.
809///
810/// One rule over [`for_each_gate`](crate::gate::for_each_gate)'s closed
811/// consumer set, because satisfiability is a property of the **gate** and a
812/// check written beside the first verb that needed it would leave the other
813/// six classes with no surface (CLAUDE.md: a capability belongs to the object
814/// class it acts on). The arithmetic is [`crate::gate::Gate::contradiction`],
815/// the same [`crate::gate::DatumSet`] the compiler's cast-ladder solver picks
816/// drive values from — one authority, so "can this open" and "at what value"
817/// can never disagree.
818pub(crate) fn gate_contradiction_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
819    crate::gate::for_each_gate(c, &mut |site, gate| {
820        let Some(contra) = gate.contradiction() else {
821            return;
822        };
823        let what = match contra {
824            crate::gate::GateContradiction::Flag(f) => {
825                format!("flag `{f}` is both required and forbidden, so the gate is never satisfied")
826            }
827            crate::gate::GateContradiction::Datum(s) => format!(
828                "no value of `{s}` satisfies every `requires_state` term that reads it, so the \
829                 gate is never satisfied"
830            ),
831        };
832        d.push(Diagnostic::error(
833            GATE_NEVER_OPENS,
834            site.consumer.stage(),
835            site.path.clone(),
836            format!(
837                "this {}'s gate contradicts itself: {what}. Whatever it guards can never happen — \
838                 fix the gate, or delete the thing it makes unreachable",
839                site.consumer.label()
840            ),
841        ));
842    });
843}
844
845/// The id of the lethal volume a `/content/lethal_volumes/<i>/…` pointer names,
846/// for a diagnostic's wording; the pointer itself when it names none.
847fn volume_id_at(c: &Campaign, path: &str) -> String {
848    path.strip_prefix("/content/lethal_volumes/")
849        .and_then(|rest| rest.split('/').next())
850        .and_then(|i| i.parse::<usize>().ok())
851        .and_then(|i| c.quests.content.lethal_volumes.get(i))
852        .map_or_else(|| path.to_string(), |v| v.id.as_str().to_string())
853}