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