Skip to main content

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