delvewright_dsl/gate.rs
1//! **The one gate.**
2//!
3//! A *gate* is the campaign's answer to "may this happen yet?". Eight object
4//! classes ask it — an objective, an effect, an environment trigger, a trap, a
5//! dialogue option, a cast placement, a shop offer and a lethal volume. The
6//! first six once each carried its own copy of the same two fields,
7//! `requires_flags` and `forbids_flags`, with nothing in the type system saying
8//! they were one thing.
9//!
10//! That arrangement is the defect CLAUDE.md names first. When spec-0031 needed a
11//! numeric comparison ("this door opens at 500", "this line is withheld below
12//! 200", "this lever does nothing while the car is moving"), the shape of the
13//! code offered exactly one obvious place to put it: the verb that asked. The
14//! second consumer would then have had no surface, and the fix would have looked
15//! like a second bespoke field. **Generality is decided at the FIRST site.**
16//!
17//! So `requires_state` was added to all twenty-five declaration sites at once,
18//! and this module is what makes that a property rather than a coincidence:
19//!
20//! * [`Gate`] is the gate as one value — the three fields together, borrowed.
21//! Every consumer answers `gate()`, and every proof that reasons about gating
22//! is written against it rather than against two of three fields.
23//! * [`GateConsumer`] is the **closed set** of object classes that carry a gate.
24//! `ALL` is the enumeration; adding a variant is a rustc error at every match.
25//! * [`for_each_gate`] visits every gate in a campaign, in one fixed order, and
26//! returns a [`GateBinding`] ledger stating how many gates it found per
27//! consumer — because a proof over "every gate" that bound to nothing is
28//! vacuous, not a pass (CLAUDE.md).
29//!
30//! What this module cannot do is stop a twenty-sixth *declaration site* being
31//! added tomorrow with only the flag pair on it. Nothing in Rust's type system
32//! can: the fields are ordinary fields on ordinary structs, and serde's
33//! `flatten` — the one construct that would have made a shared struct
34//! literal — is a compile error in combination with `deny_unknown_fields`, which
35//! every stage struct carries and which is what turns an author's typo into
36//! `DW0100` instead of silence. That half of the obligation is
37//! `crates/dsl/tests/gate_consumers.rs`, which enumerates the consumers **from
38//! the generated JSON Schema** — i.e. from the types — and fails when any schema
39//! object declares `requires_flags` without `requires_state`.
40//!
41//! Determinism (ADR-0006): iteration is over slices and `BTreeMap` keys, in a
42//! fixed order that is part of this module's contract.
43
44use crate::StateCompare;
45use crate::envelope::Campaign;
46use crate::ids::FlagId;
47
48/// A gate, as one value: everything that decides whether the thing carrying it
49/// may happen.
50///
51/// Borrowed rather than owned, so `gate()` is free on every consumer and no
52/// consumer has to store a second copy of its own fields.
53#[derive(Clone, Copy, Debug, PartialEq, Eq)]
54pub struct Gate<'a> {
55 /// Flags that must all be set (DSL v0.3/v0.4).
56 pub requires_flags: &'a [FlagId],
57 /// Flags whose being set suppresses this (DSL v0.6).
58 pub forbids_flags: &'a [FlagId],
59 /// Numeric comparisons that must all hold (DSL v0.10, spec-0031).
60 pub requires_state: &'a [StateCompare],
61}
62
63impl<'a> Gate<'a> {
64 /// Build a gate from its three fields. The one constructor, so a consumer
65 /// that forgets a field is a rustc error rather than a silently narrower
66 /// gate.
67 pub fn of(
68 requires_flags: &'a [FlagId],
69 forbids_flags: &'a [FlagId],
70 requires_state: &'a [StateCompare],
71 ) -> Self {
72 Gate {
73 requires_flags,
74 forbids_flags,
75 requires_state,
76 }
77 }
78
79 /// The always-open gate: no flags, no comparison.
80 pub const OPEN: Gate<'static> = Gate {
81 requires_flags: &[],
82 forbids_flags: &[],
83 requires_state: &[],
84 };
85
86 /// True if this gate constrains nothing — the thing carrying it is
87 /// unconditional, and emission writes it verbatim with no `execute` wrapper.
88 pub fn is_empty(&self) -> bool {
89 self.requires_flags.is_empty()
90 && self.forbids_flags.is_empty()
91 && self.requires_state.is_empty()
92 }
93
94 /// How many terms this gate has, across all three axes. The number a binding
95 /// ledger reports.
96 pub fn terms(&self) -> usize {
97 self.requires_flags.len() + self.forbids_flags.len() + self.requires_state.len()
98 }
99}
100
101// ---------------------------------------------------------------------------
102// The six consumers, in one place
103// ---------------------------------------------------------------------------
104//
105// Every object class that carries a gate answers `gate()`, and all six answers
106// are written HERE rather than beside their own type. That is deliberate: the
107// list of gate consumers is one fact, and a fact spread over six files is a fact
108// nobody can read. A seventh consumer whose author forgets to add its `gate()`
109// here is a consumer no proof written against `Gate` can see — which is exactly
110// the shape `crates/dsl/tests/gate_consumers.rs` fails on.
111
112impl crate::Objective {
113 /// This objective's whole gate, as one value (DSL v0.10).
114 pub fn gate(&self) -> Gate<'_> {
115 Gate::of(
116 self.requires_flags(),
117 self.forbids_flags(),
118 self.requires_state(),
119 )
120 }
121}
122
123impl crate::QuestEffect {
124 /// This effect's whole gate, as one value (DSL v0.10).
125 pub fn gate(&self) -> Gate<'_> {
126 Gate::of(
127 self.requires_flags(),
128 self.forbids_flags(),
129 self.requires_state(),
130 )
131 }
132}
133
134impl crate::EnvTrigger {
135 /// This trigger's whole gate, as one value (DSL v0.10).
136 pub fn gate(&self) -> Gate<'_> {
137 Gate::of(
138 &self.requires_flags,
139 &self.forbids_flags,
140 &self.requires_state,
141 )
142 }
143}
144
145impl crate::Trap {
146 /// This trap's whole gate, as one value (DSL v0.10).
147 pub fn gate(&self) -> Gate<'_> {
148 Gate::of(
149 &self.requires_flags,
150 &self.forbids_flags,
151 &self.requires_state,
152 )
153 }
154}
155
156impl crate::DialogueOption {
157 /// This option's whole gate, as one value (DSL v0.10).
158 pub fn gate(&self) -> Gate<'_> {
159 Gate::of(
160 &self.requires_flags,
161 &self.forbids_flags,
162 &self.requires_state,
163 )
164 }
165}
166
167impl crate::CastPlacement {
168 /// This placement's whole gate, as one value (DSL v0.10).
169 pub fn gate(&self) -> Gate<'_> {
170 Gate::of(
171 &self.requires_flags,
172 &self.forbids_flags,
173 &self.requires_state,
174 )
175 }
176}
177
178impl crate::ShopOffer {
179 /// This offer's whole gate, as one value (DSL v0.10, spec-0032) — a **price
180 /// is a gate**, so a shop declares no comparison surface of its own.
181 ///
182 /// Defined beside the other six rather than on the type, for the reason the
183 /// section header gives: the list of gate consumers is one fact.
184 pub fn gate_view(&self) -> Gate<'_> {
185 Gate::of(
186 &self.requires_flags,
187 &self.forbids_flags,
188 &self.requires_state,
189 )
190 }
191}
192
193impl crate::Loop {
194 /// This loop's whole gate, as one value (spec-0086): the loop **holds** while
195 /// it is open and stands down while it is shut.
196 pub fn gate(&self) -> Gate<'_> {
197 Gate::of(
198 &self.requires_flags,
199 &self.forbids_flags,
200 &self.requires_state,
201 )
202 }
203}
204
205impl crate::LethalVolume {
206 /// This volume's whole gate, as one value (spec-0088) — the [`Guard`]
207 /// under `when`, or the always-open gate when it declares none.
208 ///
209 /// [`Guard`]: crate::Guard
210 pub fn gate(&self) -> Gate<'_> {
211 match &self.when {
212 Some(g) => Gate::of(&g.requires_flags, &g.forbids_flags, &g.requires_state),
213 None => Gate::OPEN,
214 }
215 }
216}
217
218impl crate::Pulse {
219 /// This pulse's whole gate, as one value (spec-0102) — the [`Guard`] under
220 /// `when`, or the always-open gate when it declares none.
221 ///
222 /// [`Guard`]: crate::Guard
223 pub fn gate(&self) -> Gate<'_> {
224 match &self.when {
225 Some(g) => Gate::of(&g.requires_flags, &g.forbids_flags, &g.requires_state),
226 None => Gate::OPEN,
227 }
228 }
229}
230
231/// The object classes that carry a gate. **A closed set.**
232///
233/// `ALL` is the enumeration; [`GateConsumer::label`] and every consumer that
234/// matches on one is exhaustive, so an eighth class is a compile error at every
235/// site where the answer would have to change.
236#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)]
237pub enum GateConsumer {
238 /// A stage-5 `quests[].objectives[]` — the gate decides whether the
239 /// objective activates.
240 Objective,
241 /// Any `QuestEffect`, at any of the five effect roots, top-level or nested —
242 /// the gate decides whether the effect's commands run.
243 Effect,
244 /// A stage-5 `triggers[]` — the gate decides whether the trigger can fire.
245 Trigger,
246 /// A stage-5 `traps[]` — the gate decides whether the trap is armed.
247 Trap,
248 /// A stage-6 `dialogues[].nodes[].options[]` — the gate decides whether the
249 /// option is shown, and whether a direct `/trigger` on it does anything.
250 DialogueOption,
251 /// A stage-5 `quests[].cast[]` placement — the gate decides which branch's
252 /// scene describes the world.
253 CastPlacement,
254 /// A stage-5 `shops[].offers[]` (DSL v0.10, spec-0032) — the gate decides
255 /// whether the button is shown, and whether a direct `/trigger` on it does
256 /// anything. **This is where a price lives**: a shop declares no comparison
257 /// surface of its own, because "may this happen yet?" already has an owner.
258 ShopOffer,
259 /// A stage-5 `loops[]` (spec-0086) — the gate decides whether the loop
260 /// holds. Read against the party on the tick, before any body is selected.
261 Loop,
262 /// A stage-5 `lethal_volumes[]` entry (spec-0088) — the gate decides
263 /// whether the volume kills. A volume's liveness is a fact about the place,
264 /// so its gate is a party predicate: the tick reads it on `#party`.
265 LethalVolume,
266 /// A stage-5 `pulses[]` entry (spec-0102) — the gate decides whether the
267 /// pulse beats. A pulse addresses every player in its place, so its gate is
268 /// a party predicate read on `#party`.
269 Pulse,
270}
271
272impl GateConsumer {
273 /// Every consumer class, in enumeration order (= visit order in
274 /// [`for_each_gate`]).
275 pub const ALL: [GateConsumer; 10] = [
276 GateConsumer::Objective,
277 GateConsumer::Effect,
278 GateConsumer::Trigger,
279 GateConsumer::Trap,
280 GateConsumer::DialogueOption,
281 GateConsumer::CastPlacement,
282 GateConsumer::ShopOffer,
283 GateConsumer::Loop,
284 GateConsumer::LethalVolume,
285 GateConsumer::Pulse,
286 ];
287
288 /// How many consumer classes there are.
289 pub const COUNT: usize = Self::ALL.len();
290
291 /// A short, stable label for a binding ledger or a diagnostic.
292 pub fn label(self) -> &'static str {
293 match self {
294 GateConsumer::Objective => "objective",
295 GateConsumer::Effect => "effect",
296 GateConsumer::Trigger => "trigger",
297 GateConsumer::Trap => "trap",
298 GateConsumer::DialogueOption => "dialogue option",
299 GateConsumer::CastPlacement => "cast placement",
300 GateConsumer::ShopOffer => "shop offer",
301 GateConsumer::Loop => "loop",
302 GateConsumer::LethalVolume => "lethal volume",
303 GateConsumer::Pulse => "pulse",
304 }
305 }
306
307 /// Whether emission evaluates this consumer's gate **against an acting
308 /// player** (`@s`) rather than against the party holder (`#party`) — or
309 /// `None` when the class alone cannot say.
310 ///
311 /// This is a statement about the emitter, not a preference: a dialogue
312 /// option's availability is computed per player into `dw.dmask` and its
313 /// `/trigger` handler runs `as @s`; a cast placement selects a scene into a
314 /// per-player `dw.cast`. Three of the others are party predicates by
315 /// construction — an objective's activation guard is read on the tick
316 /// ("whoever finishes the last objective completes the quest for everyone"),
317 /// and a trigger's and a trap's *arming* gates flip one global sentinel.
318 ///
319 /// **`Effect` answers `None`, and that is the whole point of the return
320 /// type.** An effect's gate is evaluated wherever its bundle is run, and
321 /// which that is belongs to the **root**, not to the effect: four roots have
322 /// an acting player and three do not
323 /// ([`EffectRootKind::runs_with_acting_player`](crate::EffectRootKind::runs_with_acting_player)),
324 /// and on top of that the `sequence` / `on_arrive` seams inside a bundle drop
325 /// the actor mid-walk. An earlier version of this method answered `true` for
326 /// `Effect`, which is right for `on_objective_complete` and wrong for a
327 /// trigger's effects, a trap's payload and a shortcut's `on_unlock` — three
328 /// of the seven roots, silently. `Option` makes that wrong answer
329 /// unrepresentable: a caller must handle the deferral.
330 ///
331 /// It is what makes a `player`-scoped datum's readability decidable
332 /// (`DW0503`) from the closed consumer set rather than from a list somebody
333 /// maintains — an eighth consumer class must answer this to compile.
334 pub fn evaluates_per_player(self) -> Option<bool> {
335 match self {
336 // A shop offer's gate is computed into `dw.dmask` per player and its
337 // `/trigger` handler runs `as @s`, exactly as a dialogue option's does
338 // — which is what makes a `player`-scoped purse a legal price.
339 GateConsumer::DialogueOption
340 | GateConsumer::CastPlacement
341 | GateConsumer::ShopOffer => Some(true),
342 GateConsumer::Objective
343 | GateConsumer::Trigger
344 | GateConsumer::Trap
345 | GateConsumer::Loop
346 | GateConsumer::LethalVolume
347 | GateConsumer::Pulse => Some(false),
348 // Ask the root (and then the seams inside the bundle).
349 GateConsumer::Effect => None,
350 }
351 }
352
353 /// The stage document this consumer lives in.
354 pub fn stage(self) -> &'static str {
355 match self {
356 GateConsumer::Objective
357 | GateConsumer::Trigger
358 | GateConsumer::Trap
359 | GateConsumer::CastPlacement
360 | GateConsumer::ShopOffer
361 | GateConsumer::Loop
362 | GateConsumer::LethalVolume
363 | GateConsumer::Pulse => "quests",
364 // An effect root hangs off the quests stage four times out of five and
365 // off dialogue once; the site's own path says which.
366 GateConsumer::Effect => "quests",
367 GateConsumer::DialogueOption => "dialogue",
368 }
369 }
370}
371
372/// One gate the walk found: which consumer class it belongs to, and where.
373pub struct GateSite {
374 /// The object class carrying the gate.
375 pub consumer: GateConsumer,
376 /// JSON pointer, within its stage document, to the object that declares the
377 /// gate's fields — so `<path>/requires_flags/0` is where an author would look.
378 /// For every consumer but the effect that is the consumer object itself; an
379 /// effect declares its gate in one `when`, and the pointer names it.
380 pub path: String,
381}
382
383/// What a walk over the campaign's gates actually examined.
384///
385/// CLAUDE.md: *a green gate that binds to nothing is vacuous, not a pass.* A
386/// proof over "every gate" is only as good as the consumer classes it reached
387/// and the gates it found there; neither number is visible from the proof's own
388/// output, so it is reported here.
389#[derive(Clone, Debug, PartialEq, Eq)]
390pub struct GateBinding {
391 /// How many of [`GateConsumer::COUNT`] classes the walk enumerated. Always
392 /// `COUNT` for a walk that ran; asserted by [`for_each_gate`].
393 pub consumers_enumerated: usize,
394 /// Per class: how many gate-carrying objects this campaign actually has.
395 pub sites: [(GateConsumer, usize); GateConsumer::COUNT],
396 /// How many of those objects carry a **non-empty** gate.
397 pub gated: usize,
398 /// Total gate terms across every axis and every site.
399 pub terms: usize,
400}
401
402impl GateBinding {
403 /// A one-line, deterministic rendering for a report or a `--json` field.
404 pub fn summary(&self) -> String {
405 let per: Vec<String> = self
406 .sites
407 .iter()
408 .map(|(k, n)| format!("{}={n}", k.label()))
409 .collect();
410 format!(
411 "consumers {}/{}, sites {}, gated {}, terms {} [{}]",
412 self.consumers_enumerated,
413 GateConsumer::COUNT,
414 self.sites.iter().map(|(_, n)| n).sum::<usize>(),
415 self.gated,
416 self.terms,
417 per.join(", ")
418 )
419 }
420}
421
422/// Visit **every gate in the campaign**, in one fixed deterministic order, as
423/// `f(&site, gate)`.
424///
425/// Order: every objective (quest order, objective order); every effect (via
426/// [`crate::for_each_campaign_effect`], which inherits the single effect-root
427/// enumeration and descends nesting); every trigger; every trap; every dialogue
428/// option; every cast placement; every shop offer; every loop; every lethal
429/// volume; every pulse.
430///
431/// Returns the [`GateBinding`] ledger.
432///
433/// # Panics
434///
435/// If the walk failed to enumerate all [`GateConsumer::COUNT`] classes.
436/// Unreachable by construction and asserted anyway, for the same reason
437/// [`crate::effects::for_each_effect_root`] asserts its own: a walk that quietly
438/// stops visiting a class just answers a narrower question and stays green over
439/// every campaign that does not use it.
440pub fn for_each_gate(c: &Campaign, f: &mut dyn FnMut(&GateSite, Gate<'_>)) -> GateBinding {
441 let mut sites = [
442 (GateConsumer::Objective, 0usize),
443 (GateConsumer::Effect, 0usize),
444 (GateConsumer::Trigger, 0usize),
445 (GateConsumer::Trap, 0usize),
446 (GateConsumer::DialogueOption, 0usize),
447 (GateConsumer::CastPlacement, 0usize),
448 (GateConsumer::ShopOffer, 0usize),
449 (GateConsumer::Loop, 0usize),
450 (GateConsumer::LethalVolume, 0usize),
451 (GateConsumer::Pulse, 0usize),
452 ];
453 debug_assert_eq!(
454 sites.map(|(k, _)| k),
455 GateConsumer::ALL,
456 "the binding ledger's slots are GateConsumer::ALL, in order"
457 );
458 let mut enumerated = [false; GateConsumer::COUNT];
459 let mut gated = 0usize;
460 let mut terms = 0usize;
461 fn slot_of(k: GateConsumer) -> usize {
462 GateConsumer::ALL
463 .iter()
464 .position(|x| *x == k)
465 .expect("every consumer is a member of GateConsumer::ALL")
466 }
467
468 let mut visit = |consumer: GateConsumer,
469 path: String,
470 gate: Gate<'_>,
471 sites: &mut [(GateConsumer, usize); GateConsumer::COUNT],
472 gated: &mut usize,
473 terms: &mut usize| {
474 sites[slot_of(consumer)].1 += 1;
475 if !gate.is_empty() {
476 *gated += 1;
477 }
478 *terms += gate.terms();
479 f(&GateSite { consumer, path }, gate);
480 };
481
482 // C1 objectives.
483 enumerated[slot_of(GateConsumer::Objective)] = true;
484 for (qi, q) in c.quests.content.quests.iter().enumerate() {
485 for (oi, o) in q.objectives.iter().enumerate() {
486 visit(
487 GateConsumer::Objective,
488 format!("/content/quests/{qi}/objectives/{oi}"),
489 o.gate(),
490 &mut sites,
491 &mut gated,
492 &mut terms,
493 );
494 }
495 }
496 // C2 effects — every root, top-level and nested, from the single enumeration.
497 enumerated[slot_of(GateConsumer::Effect)] = true;
498 crate::for_each_campaign_effect(c, &mut |path, _site, eff| {
499 visit(
500 GateConsumer::Effect,
501 format!("{path}/when"),
502 eff.gate(),
503 &mut sites,
504 &mut gated,
505 &mut terms,
506 );
507 });
508 // C3 triggers.
509 enumerated[slot_of(GateConsumer::Trigger)] = true;
510 for (ti, t) in c.quests.content.triggers.iter().enumerate() {
511 visit(
512 GateConsumer::Trigger,
513 format!("/content/triggers/{ti}"),
514 t.gate(),
515 &mut sites,
516 &mut gated,
517 &mut terms,
518 );
519 }
520 // C4 traps.
521 enumerated[slot_of(GateConsumer::Trap)] = true;
522 for (pi, p) in c.quests.content.traps.iter().enumerate() {
523 visit(
524 GateConsumer::Trap,
525 format!("/content/traps/{pi}"),
526 p.gate(),
527 &mut sites,
528 &mut gated,
529 &mut terms,
530 );
531 }
532 // C5 dialogue options.
533 enumerated[slot_of(GateConsumer::DialogueOption)] = true;
534 for (di, tree) in c.dialogue.content.dialogues.iter().enumerate() {
535 for (ni, node) in tree.nodes.iter().enumerate() {
536 for (oi, opt) in node.options.iter().enumerate() {
537 visit(
538 GateConsumer::DialogueOption,
539 format!("/content/dialogues/{di}/nodes/{ni}/options/{oi}"),
540 opt.gate(),
541 &mut sites,
542 &mut gated,
543 &mut terms,
544 );
545 }
546 }
547 }
548 // C6 cast placements.
549 enumerated[slot_of(GateConsumer::CastPlacement)] = true;
550 for (qi, q) in c.quests.content.quests.iter().enumerate() {
551 for (npc, entry) in &q.cast {
552 for (pi, p) in entry.placements().iter().enumerate() {
553 visit(
554 GateConsumer::CastPlacement,
555 format!("/content/quests/{qi}/cast/{}/{pi}", npc.as_str()),
556 p.gate(),
557 &mut sites,
558 &mut gated,
559 &mut terms,
560 );
561 }
562 }
563 }
564
565 // C7 shop offers (DSL v0.10, spec-0032). A price is a gate term, so every
566 // offer is visited here and nowhere else.
567 enumerated[slot_of(GateConsumer::ShopOffer)] = true;
568 for (si, shop) in c.quests.content.shops.iter().enumerate() {
569 for (oi, off) in shop.offers.iter().enumerate() {
570 visit(
571 GateConsumer::ShopOffer,
572 format!("/content/shops/{si}/offers/{oi}"),
573 off.gate_view(),
574 &mut sites,
575 &mut gated,
576 &mut terms,
577 );
578 }
579 }
580
581 // C8 loops (spec-0086). The gate is the release.
582 enumerated[slot_of(GateConsumer::Loop)] = true;
583 for (li, l) in c.quests.content.loops.iter().enumerate() {
584 visit(
585 GateConsumer::Loop,
586 format!("/content/loops/{li}"),
587 l.gate(),
588 &mut sites,
589 &mut gated,
590 &mut terms,
591 );
592 }
593
594 // C9 lethal volumes (spec-0088), after every shop offer, in declaration
595 // order. The pointer names the `when` object, as an effect's does.
596 enumerated[slot_of(GateConsumer::LethalVolume)] = true;
597 for (vi, v) in c.quests.content.lethal_volumes.iter().enumerate() {
598 visit(
599 GateConsumer::LethalVolume,
600 format!("/content/lethal_volumes/{vi}/when"),
601 v.gate(),
602 &mut sites,
603 &mut gated,
604 &mut terms,
605 );
606 }
607
608 // C10 pulses (spec-0102), in declaration order. The pointer names the
609 // `when` object, as a lethal volume's does.
610 enumerated[slot_of(GateConsumer::Pulse)] = true;
611 for (pi, p) in c.quests.content.pulses.iter().enumerate() {
612 visit(
613 GateConsumer::Pulse,
614 format!("/content/pulses/{pi}/when"),
615 p.gate(),
616 &mut sites,
617 &mut gated,
618 &mut terms,
619 );
620 }
621
622 let missed: Vec<&str> = GateConsumer::ALL
623 .iter()
624 .zip(enumerated)
625 .filter(|(_, seen)| !*seen)
626 .map(|(k, _)| k.label())
627 .collect();
628 assert!(
629 missed.is_empty(),
630 "for_each_gate enumerated {} of {} gate consumers — missing: {}. A consumer that stops \
631 being enumerated has no other symptom.",
632 GateConsumer::COUNT - missed.len(),
633 GateConsumer::COUNT,
634 missed.join(", ")
635 );
636
637 GateBinding {
638 consumers_enumerated: GateConsumer::COUNT,
639 sites,
640 gated,
641 terms,
642 }
643}
644
645// ---------------------------------------------------------------------------
646// Satisfiability: the set of values that opens a gate, as a value
647// ---------------------------------------------------------------------------
648
649/// The set of integer values of ONE datum that a conjunction of comparison
650/// terms leaves open — an interval with holes, or a pinned point.
651///
652/// This is the arithmetic shared by every question of the form "can this gate
653/// ever open, and at what value?": [`Gate::contradiction`] asks it per gate,
654/// and the compiler's cast-ladder solver asks it per clause while also
655/// *refusing* sibling terms ([`DatumSet::forbid`]). It lives here, on the gate,
656/// because a gate is the object class the question is about — a copy beside
657/// each asking verb is how two answers to one question start disagreeing.
658///
659/// Determinism (ADR-0006): [`DatumSet::pick`] returns one canonical member, a
660/// pure function of the constraint set.
661#[derive(Clone, Debug, PartialEq, Eq)]
662pub struct DatumSet {
663 /// Lower bound (inclusive), `None` = unbounded below.
664 lo: Option<i32>,
665 /// Upper bound (inclusive), `None` = unbounded above.
666 hi: Option<i32>,
667 /// Excluded points.
668 holes: std::collections::BTreeSet<i32>,
669 /// `Some(v)`: the set is at most `{v}` (an `equals` term).
670 pin: Option<i32>,
671 /// Two constraints that no integer can satisfy at once.
672 contra: bool,
673}
674
675impl Default for DatumSet {
676 fn default() -> Self {
677 Self::all()
678 }
679}
680
681impl DatumSet {
682 /// Every integer: the set before any term constrains it.
683 pub fn all() -> Self {
684 DatumSet {
685 lo: None,
686 hi: None,
687 holes: std::collections::BTreeSet::new(),
688 pin: None,
689 contra: false,
690 }
691 }
692
693 /// Intersect with the values that SATISFY `op value`.
694 pub fn require(&mut self, op: crate::CompareOp, value: i32) {
695 use crate::CompareOp::*;
696 match op {
697 Equals => match self.pin {
698 Some(p) if p != value => self.contra = true,
699 _ => self.pin = Some(value),
700 },
701 NotEquals => {
702 self.holes.insert(value);
703 }
704 AtLeast => self.lo = Some(self.lo.map_or(value, |l| l.max(value))),
705 AtMost => self.hi = Some(self.hi.map_or(value, |h| h.min(value))),
706 }
707 }
708
709 /// Intersect with the values that VIOLATE `op value` — the negation of
710 /// [`DatumSet::require`], spelled once so the two can never disagree about
711 /// what a term means.
712 pub fn forbid(&mut self, op: crate::CompareOp, value: i32) {
713 use crate::CompareOp::*;
714 match op {
715 Equals => self.require(NotEquals, value),
716 NotEquals => self.require(Equals, value),
717 // ¬(x ≥ v) ⇔ x ≤ v−1; at i32::MIN nothing violates it.
718 AtLeast => match value.checked_sub(1) {
719 Some(v) => self.require(AtMost, v),
720 None => self.contra = true,
721 },
722 AtMost => match value.checked_add(1) {
723 Some(v) => self.require(AtLeast, v),
724 None => self.contra = true,
725 },
726 }
727 }
728
729 /// **The smallest value in the set**, or `None` when it is unbounded below
730 /// or empty.
731 ///
732 /// The question a price asks: *what is the least balance at which this gate
733 /// opens?* It is the same arithmetic [`Self::pick`] does — an interval with
734 /// holes, stepped past — asked from the bottom, so the purchase rule
735 /// (`DW0901`) and the satisfiability verdict cannot disagree about what a
736 /// term means. `equals` pins, and a pin is its own floor.
737 pub fn min(&self) -> Option<i32> {
738 let lo = self.pin.or(self.lo)?;
739 let steps = self.holes.len() as i64 + 1;
740 (lo as i64..lo as i64 + steps)
741 .filter_map(|v| i32::try_from(v).ok())
742 .find(|v| self.contains(*v))
743 }
744
745 /// **The largest value in the set**, or `None` when it is unbounded above or
746 /// empty — the mirror of [`Self::min`], which is what a refusal arm's
747 /// ceiling is read from.
748 pub fn max(&self) -> Option<i32> {
749 let hi = self.pin.or(self.hi)?;
750 let steps = self.holes.len() as i64 + 1;
751 ((hi as i64 - steps + 1)..=hi as i64)
752 .rev()
753 .filter_map(|v| i32::try_from(v).ok())
754 .find(|v| self.contains(*v))
755 }
756
757 /// Whether `v` satisfies every term this set carries.
758 fn contains(&self, v: i32) -> bool {
759 !self.contra
760 && self.pin.is_none_or(|p| p == v)
761 && self.lo.is_none_or(|l| v >= l)
762 && self.hi.is_none_or(|h| v <= h)
763 && !self.holes.contains(&v)
764 }
765
766 /// A deterministic member of the set, or `None` when the set is empty —
767 /// which is the satisfiability verdict.
768 ///
769 /// Complete without enumeration games: an interval-with-holes is nonempty
770 /// iff a member exists within `holes.len() + 1` steps of a bound (or of 0
771 /// when unbounded both ways), because each step is only ever excluded by a
772 /// distinct hole.
773 pub fn pick(&self) -> Option<i32> {
774 if self.contra {
775 return None;
776 }
777 let ok = |v: i32| {
778 self.lo.is_none_or(|l| v >= l)
779 && self.hi.is_none_or(|h| v <= h)
780 && !self.holes.contains(&v)
781 };
782 if let Some(p) = self.pin {
783 return ok(p).then_some(p);
784 }
785 let steps = self.holes.len() as i64 + 1;
786 let from = match (self.lo, self.hi) {
787 (Some(l), _) => l as i64,
788 (None, Some(h)) => (h as i64) - steps + 1,
789 (None, None) => 0,
790 };
791 (from..from + steps)
792 .filter_map(|v| i32::try_from(v).ok())
793 .find(|v| ok(*v))
794 }
795}
796
797/// Why a gate can never open (see [`Gate::contradiction`]).
798#[derive(Clone, Debug, PartialEq, Eq)]
799pub enum GateContradiction {
800 /// The same flag is both required and forbidden.
801 Flag(String),
802 /// No integer value of this datum satisfies every `requires_state` term
803 /// that reads it.
804 Datum(String),
805}
806
807impl Gate<'_> {
808 /// The first reason this gate can NEVER open, or `None` for a satisfiable
809 /// gate.
810 ///
811 /// A gate is a conjunction, so it is unsatisfiable exactly when one flag is
812 /// on both lists, or one datum's terms intersect to the empty set. Terms on
813 /// distinct flags/datums are independent and cannot contradict each other.
814 pub fn contradiction(&self) -> Option<GateContradiction> {
815 conjunction_contradictions(&[*self]).into_iter().next()
816 }
817
818 /// **Every reason this gate and `other` can never both hold** against one
819 /// reading of the campaign's flags and data — empty when some state
820 /// satisfies both.
821 ///
822 /// Two gates are mutually exclusive exactly when their conjunction is a
823 /// gate that can never open, so this is [`Gate::contradiction`]'s
824 /// arithmetic asked of the two together: a flag one requires and the other
825 /// forbids, or a datum whose terms across both intersect to the empty set.
826 /// Nothing else proves exclusivity — two gates on distinct flags or data
827 /// can both hold, however unlikely the author meant that to be. The answer
828 /// is about ONE reading: a caller whose two gates are tested at different
829 /// moments must also show that no write to the named flag or datum falls
830 /// between them. Every reason is returned, not the first, so that caller
831 /// can find one nothing writes.
832 pub fn exclusions(&self, other: &Gate<'_>) -> Vec<GateContradiction> {
833 conjunction_contradictions(&[*self, *other])
834 }
835}
836
837/// Every flag and every datum on which the conjunction of `gates` is empty, in
838/// a fixed order: flags in first-required order, then data by id (ADR-0006).
839/// The one arithmetic behind [`Gate::contradiction`] and [`Gate::exclusions`].
840fn conjunction_contradictions(gates: &[Gate<'_>]) -> Vec<GateContradiction> {
841 let mut out = Vec::new();
842 let mut seen = std::collections::BTreeSet::new();
843 for g in gates {
844 for f in g.requires_flags {
845 let forbidden = gates.iter().any(|h| h.forbids_flags.contains(f));
846 if forbidden && seen.insert(f.as_str()) {
847 out.push(GateContradiction::Flag(f.as_str().to_string()));
848 }
849 }
850 }
851 let mut per: std::collections::BTreeMap<&str, DatumSet> = std::collections::BTreeMap::new();
852 for g in gates {
853 for t in g.requires_state {
854 per.entry(t.state.as_str())
855 .or_default()
856 .require(t.op, t.value);
857 }
858 }
859 for (state, set) in per {
860 if set.pick().is_none() {
861 out.push(GateContradiction::Datum(state.to_string()));
862 }
863 }
864 out
865}
866
867#[cfg(test)]
868mod tests {
869 use super::*;
870
871 #[test]
872 fn open_gate_is_empty() {
873 assert!(Gate::OPEN.is_empty());
874 assert_eq!(Gate::OPEN.terms(), 0);
875 }
876
877 /// Every consumer names a stage and a label, and the stages are exactly the
878 /// two stage documents gates live in.
879 #[test]
880 fn every_consumer_names_its_stage() {
881 for k in GateConsumer::ALL {
882 assert!(matches!(k.stage(), "quests" | "dialogue"), "{k:?}");
883 assert!(!k.label().is_empty(), "{k:?}");
884 }
885 }
886
887 use crate::CompareOp::*;
888
889 #[test]
890 fn datum_set_picks_within_bounds_and_around_holes() {
891 let mut s = DatumSet::all();
892 assert_eq!(s.pick(), Some(0), "the unconstrained canonical member is 0");
893 s.require(AtLeast, 3);
894 s.require(AtMost, 5);
895 assert_eq!(s.pick(), Some(3), "the lower boundary is canonical");
896 s.require(NotEquals, 3);
897 assert_eq!(s.pick(), Some(4), "a hole at the boundary steps past it");
898 s.require(NotEquals, 4);
899 s.require(NotEquals, 5);
900 assert_eq!(s.pick(), None, "holes covering the interval empty it");
901 }
902
903 #[test]
904 fn datum_set_pins_and_pin_conflicts() {
905 let mut s = DatumSet::all();
906 s.require(Equals, 7);
907 assert_eq!(s.pick(), Some(7));
908 s.require(NotEquals, 7);
909 assert_eq!(s.pick(), None, "a hole at the pin empties the set");
910 let mut t = DatumSet::all();
911 t.require(Equals, 7);
912 t.require(Equals, 8);
913 assert_eq!(t.pick(), None, "two different pins contradict");
914 }
915
916 #[test]
917 fn forbid_is_the_exact_negation() {
918 // Violating `at-least 5` means at most 4; violating that too is empty.
919 let mut s = DatumSet::all();
920 s.forbid(AtLeast, 5);
921 assert_eq!(s.pick(), Some(4), "the violating boundary is canonical");
922 s.require(AtLeast, 5);
923 assert_eq!(s.pick(), None);
924 // Nothing violates `at-least i32::MIN` / `at-most i32::MAX`.
925 let mut lo = DatumSet::all();
926 lo.forbid(AtLeast, i32::MIN);
927 assert_eq!(lo.pick(), None);
928 let mut hi = DatumSet::all();
929 hi.forbid(AtMost, i32::MAX);
930 assert_eq!(hi.pick(), None);
931 // Violating `not-equals v` pins v.
932 let mut ne = DatumSet::all();
933 ne.forbid(NotEquals, 9);
934 assert_eq!(ne.pick(), Some(9));
935 }
936
937 /// The floor and the ceiling of a set — what a price reads. A pin is its own
938 /// floor and its own ceiling; a hole at a bound steps past it; an unbounded
939 /// side answers `None` rather than a number nothing stated.
940 #[test]
941 fn a_datum_set_states_its_floor_and_its_ceiling() {
942 let mut s = DatumSet::all();
943 assert_eq!((s.min(), s.max()), (None, None), "nothing bounds it");
944 s.require(AtLeast, 15);
945 assert_eq!((s.min(), s.max()), (Some(15), None));
946 s.require(AtMost, 20);
947 assert_eq!((s.min(), s.max()), (Some(15), Some(20)));
948 s.require(NotEquals, 15);
949 s.require(NotEquals, 20);
950 assert_eq!((s.min(), s.max()), (Some(16), Some(19)));
951 let mut pinned = DatumSet::all();
952 pinned.require(Equals, 7);
953 assert_eq!((pinned.min(), pinned.max()), (Some(7), Some(7)));
954 let mut empty = DatumSet::all();
955 empty.require(AtLeast, 5);
956 empty.require(AtMost, 4);
957 assert_eq!(
958 (empty.min(), empty.max()),
959 (None, None),
960 "an empty set has neither"
961 );
962 }
963
964 #[test]
965 fn an_upper_bounded_set_picks_below_its_holes() {
966 let mut s = DatumSet::all();
967 s.require(AtMost, 10);
968 s.require(NotEquals, 10);
969 s.require(NotEquals, 9);
970 assert_eq!(s.pick(), Some(8), "walks down from the upper bound");
971 }
972
973 #[test]
974 fn gate_contradiction_answers_per_axis() {
975 use crate::StateCompare;
976 use crate::ids::FlagId;
977 let f: Vec<FlagId> = vec![FlagId("flag/paid".to_string())];
978 let g = Gate::of(&f, &f, &[]);
979 assert_eq!(
980 g.contradiction(),
981 Some(GateContradiction::Flag("flag/paid".to_string()))
982 );
983 let terms = [
984 StateCompare {
985 state: crate::ids::StateId("state/toll".to_string()),
986 op: AtLeast,
987 value: 5,
988 },
989 StateCompare {
990 state: crate::ids::StateId("state/toll".to_string()),
991 op: AtMost,
992 value: 3,
993 },
994 ];
995 let g = Gate::of(&[], &[], &terms);
996 assert_eq!(
997 g.contradiction(),
998 Some(GateContradiction::Datum("state/toll".to_string()))
999 );
1000 assert_eq!(Gate::OPEN.contradiction(), None);
1001 }
1002
1003 /// Two gates exclude each other exactly when their conjunction cannot open:
1004 /// a flag one requires and the other forbids, or one datum's terms across
1005 /// both meeting in the empty set. Distinct flags, and overlapping ranges,
1006 /// still both hold.
1007 #[test]
1008 fn exclusions_are_the_conjunctions_contradictions() {
1009 use crate::StateCompare;
1010 use crate::ids::{FlagId, StateId};
1011 let x = vec![FlagId("flag/x".to_string())];
1012 let y = vec![FlagId("flag/y".to_string())];
1013 let requires_x = Gate::of(&x, &[], &[]);
1014 let forbids_x = Gate::of(&[], &x, &[]);
1015 let forbids_y = Gate::of(&[], &y, &[]);
1016 assert_eq!(
1017 forbids_x.exclusions(&requires_x),
1018 vec![GateContradiction::Flag("flag/x".to_string())]
1019 );
1020 assert_eq!(
1021 requires_x.exclusions(&forbids_x),
1022 forbids_x.exclusions(&requires_x),
1023 "exclusion is symmetric"
1024 );
1025 assert!(
1026 requires_x.exclusions(&forbids_y).is_empty(),
1027 "distinct flags can both hold"
1028 );
1029 assert!(requires_x.exclusions(&requires_x).is_empty());
1030 assert!(requires_x.exclusions(&Gate::OPEN).is_empty());
1031 let cmp = |op, value| StateCompare {
1032 state: StateId("state/tide".to_string()),
1033 op,
1034 value,
1035 };
1036 let high = [cmp(AtLeast, 5)];
1037 let low = [cmp(AtMost, 4)];
1038 let mid = [cmp(AtMost, 5)];
1039 assert_eq!(
1040 Gate::of(&[], &[], &high).exclusions(&Gate::of(&[], &[], &low)),
1041 vec![GateContradiction::Datum("state/tide".to_string())]
1042 );
1043 assert!(
1044 Gate::of(&[], &[], &high)
1045 .exclusions(&Gate::of(&[], &[], &mid))
1046 .is_empty(),
1047 "ranges meeting at 5 both hold at 5"
1048 );
1049 // Every reason is named, so a caller can pick one nothing writes.
1050 let both = Gate::of(&x, &[], &high);
1051 assert_eq!(
1052 both.exclusions(&Gate::of(&[], &x, &low)),
1053 vec![
1054 GateContradiction::Flag("flag/x".to_string()),
1055 GateContradiction::Datum("state/tide".to_string()),
1056 ]
1057 );
1058 }
1059}