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}