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, within its stage document, to the object that declares the
313    /// gate's fields — so `<path>/requires_flags/0` is where an author would look.
314    /// For every consumer but the effect that is the consumer object itself; an
315    /// effect declares its gate in one `when`, and the pointer names it.
316    pub path: String,
317}
318
319/// What a walk over the campaign's gates actually examined.
320///
321/// CLAUDE.md: *a green gate that binds to nothing is vacuous, not a pass.* A
322/// proof over "every gate" is only as good as the consumer classes it reached
323/// and the gates it found there; neither number is visible from the proof's own
324/// output, so it is reported here.
325#[derive(Clone, Debug, PartialEq, Eq)]
326pub struct GateBinding {
327    /// How many of [`GateConsumer::COUNT`] classes the walk enumerated. Always
328    /// `COUNT` for a walk that ran; asserted by [`for_each_gate`].
329    pub consumers_enumerated: usize,
330    /// Per class: how many gate-carrying objects this campaign actually has.
331    pub sites: [(GateConsumer, usize); GateConsumer::COUNT],
332    /// How many of those objects carry a **non-empty** gate.
333    pub gated: usize,
334    /// Total gate terms across every axis and every site.
335    pub terms: usize,
336}
337
338impl GateBinding {
339    /// A one-line, deterministic rendering for a report or a `--json` field.
340    pub fn summary(&self) -> String {
341        let per: Vec<String> = self
342            .sites
343            .iter()
344            .map(|(k, n)| format!("{}={n}", k.label()))
345            .collect();
346        format!(
347            "consumers {}/{}, sites {}, gated {}, terms {} [{}]",
348            self.consumers_enumerated,
349            GateConsumer::COUNT,
350            self.sites.iter().map(|(_, n)| n).sum::<usize>(),
351            self.gated,
352            self.terms,
353            per.join(", ")
354        )
355    }
356}
357
358/// Visit **every gate in the campaign**, in one fixed deterministic order, as
359/// `f(&site, gate)`.
360///
361/// Order: every objective (quest order, objective order); every effect (via
362/// [`crate::stages::for_each_campaign_effect`], which inherits the single effect-root
363/// enumeration and descends nesting); every trigger; every trap; every dialogue
364/// option; every cast placement.
365///
366/// Returns the [`GateBinding`] ledger.
367///
368/// # Panics
369///
370/// If the walk failed to enumerate all [`GateConsumer::COUNT`] classes.
371/// Unreachable by construction and asserted anyway, for the same reason
372/// [`crate::effects::for_each_effect_root`] asserts its own: a walk that quietly
373/// stops visiting a class just answers a narrower question and stays green over
374/// every campaign that does not use it.
375pub fn for_each_gate(c: &Campaign, f: &mut dyn FnMut(&GateSite, Gate<'_>)) -> GateBinding {
376    let mut sites = [
377        (GateConsumer::Objective, 0usize),
378        (GateConsumer::Effect, 0usize),
379        (GateConsumer::Trigger, 0usize),
380        (GateConsumer::Trap, 0usize),
381        (GateConsumer::DialogueOption, 0usize),
382        (GateConsumer::CastPlacement, 0usize),
383        (GateConsumer::ShopOffer, 0usize),
384    ];
385    debug_assert_eq!(
386        sites.map(|(k, _)| k),
387        GateConsumer::ALL,
388        "the binding ledger's slots are GateConsumer::ALL, in order"
389    );
390    let mut enumerated = [false; GateConsumer::COUNT];
391    let mut gated = 0usize;
392    let mut terms = 0usize;
393    fn slot_of(k: GateConsumer) -> usize {
394        GateConsumer::ALL
395            .iter()
396            .position(|x| *x == k)
397            .expect("every consumer is a member of GateConsumer::ALL")
398    }
399
400    let mut visit = |consumer: GateConsumer,
401                     path: String,
402                     gate: Gate<'_>,
403                     sites: &mut [(GateConsumer, usize); GateConsumer::COUNT],
404                     gated: &mut usize,
405                     terms: &mut usize| {
406        sites[slot_of(consumer)].1 += 1;
407        if !gate.is_empty() {
408            *gated += 1;
409        }
410        *terms += gate.terms();
411        f(&GateSite { consumer, path }, gate);
412    };
413
414    // C1 objectives.
415    enumerated[slot_of(GateConsumer::Objective)] = true;
416    for (qi, q) in c.quests.content.quests.iter().enumerate() {
417        for (oi, o) in q.objectives.iter().enumerate() {
418            visit(
419                GateConsumer::Objective,
420                format!("/content/quests/{qi}/objectives/{oi}"),
421                o.gate(),
422                &mut sites,
423                &mut gated,
424                &mut terms,
425            );
426        }
427    }
428    // C2 effects — every root, top-level and nested, from the single enumeration.
429    enumerated[slot_of(GateConsumer::Effect)] = true;
430    crate::stages::for_each_campaign_effect(c, &mut |path, _site, eff| {
431        visit(
432            GateConsumer::Effect,
433            format!("{path}/when"),
434            eff.gate(),
435            &mut sites,
436            &mut gated,
437            &mut terms,
438        );
439    });
440    // C3 triggers.
441    enumerated[slot_of(GateConsumer::Trigger)] = true;
442    for (ti, t) in c.quests.content.triggers.iter().enumerate() {
443        visit(
444            GateConsumer::Trigger,
445            format!("/content/triggers/{ti}"),
446            t.gate(),
447            &mut sites,
448            &mut gated,
449            &mut terms,
450        );
451    }
452    // C4 traps.
453    enumerated[slot_of(GateConsumer::Trap)] = true;
454    for (pi, p) in c.quests.content.traps.iter().enumerate() {
455        visit(
456            GateConsumer::Trap,
457            format!("/content/traps/{pi}"),
458            p.gate(),
459            &mut sites,
460            &mut gated,
461            &mut terms,
462        );
463    }
464    // C5 dialogue options.
465    enumerated[slot_of(GateConsumer::DialogueOption)] = true;
466    for (di, tree) in c.dialogue.content.dialogues.iter().enumerate() {
467        for (ni, node) in tree.nodes.iter().enumerate() {
468            for (oi, opt) in node.options.iter().enumerate() {
469                visit(
470                    GateConsumer::DialogueOption,
471                    format!("/content/dialogues/{di}/nodes/{ni}/options/{oi}"),
472                    opt.gate(),
473                    &mut sites,
474                    &mut gated,
475                    &mut terms,
476                );
477            }
478        }
479    }
480    // C6 cast placements.
481    enumerated[slot_of(GateConsumer::CastPlacement)] = true;
482    for (qi, q) in c.quests.content.quests.iter().enumerate() {
483        for (npc, entry) in &q.cast {
484            for (pi, p) in entry.placements().iter().enumerate() {
485                visit(
486                    GateConsumer::CastPlacement,
487                    format!("/content/quests/{qi}/cast/{}/{pi}", npc.as_str()),
488                    p.gate(),
489                    &mut sites,
490                    &mut gated,
491                    &mut terms,
492                );
493            }
494        }
495    }
496
497    // C7 shop offers (DSL v0.10, spec-0032). A price is a gate term, so every
498    // offer is visited here and nowhere else.
499    enumerated[slot_of(GateConsumer::ShopOffer)] = true;
500    for (si, shop) in c.quests.content.shops.iter().enumerate() {
501        for (oi, off) in shop.offers.iter().enumerate() {
502            visit(
503                GateConsumer::ShopOffer,
504                format!("/content/shops/{si}/offers/{oi}"),
505                off.gate_view(),
506                &mut sites,
507                &mut gated,
508                &mut terms,
509            );
510        }
511    }
512
513    let missed: Vec<&str> = GateConsumer::ALL
514        .iter()
515        .zip(enumerated)
516        .filter(|(_, seen)| !*seen)
517        .map(|(k, _)| k.label())
518        .collect();
519    assert!(
520        missed.is_empty(),
521        "for_each_gate enumerated {} of {} gate consumers — missing: {}. A consumer that stops \
522         being enumerated has no other symptom.",
523        GateConsumer::COUNT - missed.len(),
524        GateConsumer::COUNT,
525        missed.join(", ")
526    );
527
528    GateBinding {
529        consumers_enumerated: GateConsumer::COUNT,
530        sites,
531        gated,
532        terms,
533    }
534}
535
536// ---------------------------------------------------------------------------
537// Satisfiability: the set of values that opens a gate, as a value
538// ---------------------------------------------------------------------------
539
540/// The set of integer values of ONE datum that a conjunction of comparison
541/// terms leaves open — an interval with holes, or a pinned point.
542///
543/// This is the arithmetic shared by every question of the form "can this gate
544/// ever open, and at what value?": [`Gate::contradiction`] asks it per gate,
545/// and the compiler's cast-ladder solver asks it per clause while also
546/// *refusing* sibling terms ([`DatumSet::forbid`]). It lives here, on the gate,
547/// because a gate is the object class the question is about — a copy beside
548/// each asking verb is how two answers to one question start disagreeing.
549///
550/// Determinism (ADR-0006): [`DatumSet::pick`] returns one canonical member, a
551/// pure function of the constraint set.
552#[derive(Clone, Debug, PartialEq, Eq)]
553pub struct DatumSet {
554    /// Lower bound (inclusive), `None` = unbounded below.
555    lo: Option<i32>,
556    /// Upper bound (inclusive), `None` = unbounded above.
557    hi: Option<i32>,
558    /// Excluded points.
559    holes: std::collections::BTreeSet<i32>,
560    /// `Some(v)`: the set is at most `{v}` (an `equals` term).
561    pin: Option<i32>,
562    /// Two constraints that no integer can satisfy at once.
563    contra: bool,
564}
565
566impl Default for DatumSet {
567    fn default() -> Self {
568        Self::all()
569    }
570}
571
572impl DatumSet {
573    /// Every integer: the set before any term constrains it.
574    pub fn all() -> Self {
575        DatumSet {
576            lo: None,
577            hi: None,
578            holes: std::collections::BTreeSet::new(),
579            pin: None,
580            contra: false,
581        }
582    }
583
584    /// Intersect with the values that SATISFY `op value`.
585    pub fn require(&mut self, op: crate::stages::CompareOp, value: i32) {
586        use crate::stages::CompareOp::*;
587        match op {
588            Equals => match self.pin {
589                Some(p) if p != value => self.contra = true,
590                _ => self.pin = Some(value),
591            },
592            NotEquals => {
593                self.holes.insert(value);
594            }
595            AtLeast => self.lo = Some(self.lo.map_or(value, |l| l.max(value))),
596            AtMost => self.hi = Some(self.hi.map_or(value, |h| h.min(value))),
597        }
598    }
599
600    /// Intersect with the values that VIOLATE `op value` — the negation of
601    /// [`DatumSet::require`], spelled once so the two can never disagree about
602    /// what a term means.
603    pub fn forbid(&mut self, op: crate::stages::CompareOp, value: i32) {
604        use crate::stages::CompareOp::*;
605        match op {
606            Equals => self.require(NotEquals, value),
607            NotEquals => self.require(Equals, value),
608            // ¬(x ≥ v) ⇔ x ≤ v−1; at i32::MIN nothing violates it.
609            AtLeast => match value.checked_sub(1) {
610                Some(v) => self.require(AtMost, v),
611                None => self.contra = true,
612            },
613            AtMost => match value.checked_add(1) {
614                Some(v) => self.require(AtLeast, v),
615                None => self.contra = true,
616            },
617        }
618    }
619
620    /// A deterministic member of the set, or `None` when the set is empty —
621    /// which is the satisfiability verdict.
622    ///
623    /// Complete without enumeration games: an interval-with-holes is nonempty
624    /// iff a member exists within `holes.len() + 1` steps of a bound (or of 0
625    /// when unbounded both ways), because each step is only ever excluded by a
626    /// distinct hole.
627    pub fn pick(&self) -> Option<i32> {
628        if self.contra {
629            return None;
630        }
631        let ok = |v: i32| {
632            self.lo.is_none_or(|l| v >= l)
633                && self.hi.is_none_or(|h| v <= h)
634                && !self.holes.contains(&v)
635        };
636        if let Some(p) = self.pin {
637            return ok(p).then_some(p);
638        }
639        let steps = self.holes.len() as i64 + 1;
640        let from = match (self.lo, self.hi) {
641            (Some(l), _) => l as i64,
642            (None, Some(h)) => (h as i64) - steps + 1,
643            (None, None) => 0,
644        };
645        (from..from + steps)
646            .filter_map(|v| i32::try_from(v).ok())
647            .find(|v| ok(*v))
648    }
649}
650
651/// Why a gate can never open (see [`Gate::contradiction`]).
652#[derive(Clone, Debug, PartialEq, Eq)]
653pub enum GateContradiction {
654    /// The same flag is both required and forbidden.
655    Flag(String),
656    /// No integer value of this datum satisfies every `requires_state` term
657    /// that reads it.
658    Datum(String),
659}
660
661impl Gate<'_> {
662    /// The first reason this gate can NEVER open, or `None` for a satisfiable
663    /// gate.
664    ///
665    /// A gate is a conjunction, so it is unsatisfiable exactly when one flag is
666    /// on both lists, or one datum's terms intersect to the empty set. Terms on
667    /// distinct flags/datums are independent and cannot contradict each other.
668    pub fn contradiction(&self) -> Option<GateContradiction> {
669        for f in self.requires_flags {
670            if self.forbids_flags.iter().any(|g| g == f) {
671                return Some(GateContradiction::Flag(f.as_str().to_string()));
672            }
673        }
674        let mut per: std::collections::BTreeMap<&str, DatumSet> = std::collections::BTreeMap::new();
675        for t in self.requires_state {
676            per.entry(t.state.as_str())
677                .or_default()
678                .require(t.op, t.value);
679        }
680        for (state, set) in per {
681            if set.pick().is_none() {
682                return Some(GateContradiction::Datum(state.to_string()));
683            }
684        }
685        None
686    }
687}
688
689#[cfg(test)]
690mod tests {
691    use super::*;
692
693    #[test]
694    fn open_gate_is_empty() {
695        assert!(Gate::OPEN.is_empty());
696        assert_eq!(Gate::OPEN.terms(), 0);
697    }
698
699    /// Every consumer names a stage and a label, and the stages are exactly the
700    /// two stage documents gates live in.
701    #[test]
702    fn every_consumer_names_its_stage() {
703        for k in GateConsumer::ALL {
704            assert!(matches!(k.stage(), "quests" | "dialogue"), "{k:?}");
705            assert!(!k.label().is_empty(), "{k:?}");
706        }
707    }
708
709    use crate::stages::CompareOp::*;
710
711    #[test]
712    fn datum_set_picks_within_bounds_and_around_holes() {
713        let mut s = DatumSet::all();
714        assert_eq!(s.pick(), Some(0), "the unconstrained canonical member is 0");
715        s.require(AtLeast, 3);
716        s.require(AtMost, 5);
717        assert_eq!(s.pick(), Some(3), "the lower boundary is canonical");
718        s.require(NotEquals, 3);
719        assert_eq!(s.pick(), Some(4), "a hole at the boundary steps past it");
720        s.require(NotEquals, 4);
721        s.require(NotEquals, 5);
722        assert_eq!(s.pick(), None, "holes covering the interval empty it");
723    }
724
725    #[test]
726    fn datum_set_pins_and_pin_conflicts() {
727        let mut s = DatumSet::all();
728        s.require(Equals, 7);
729        assert_eq!(s.pick(), Some(7));
730        s.require(NotEquals, 7);
731        assert_eq!(s.pick(), None, "a hole at the pin empties the set");
732        let mut t = DatumSet::all();
733        t.require(Equals, 7);
734        t.require(Equals, 8);
735        assert_eq!(t.pick(), None, "two different pins contradict");
736    }
737
738    #[test]
739    fn forbid_is_the_exact_negation() {
740        // Violating `at-least 5` means at most 4; violating that too is empty.
741        let mut s = DatumSet::all();
742        s.forbid(AtLeast, 5);
743        assert_eq!(s.pick(), Some(4), "the violating boundary is canonical");
744        s.require(AtLeast, 5);
745        assert_eq!(s.pick(), None);
746        // Nothing violates `at-least i32::MIN` / `at-most i32::MAX`.
747        let mut lo = DatumSet::all();
748        lo.forbid(AtLeast, i32::MIN);
749        assert_eq!(lo.pick(), None);
750        let mut hi = DatumSet::all();
751        hi.forbid(AtMost, i32::MAX);
752        assert_eq!(hi.pick(), None);
753        // Violating `not-equals v` pins v.
754        let mut ne = DatumSet::all();
755        ne.forbid(NotEquals, 9);
756        assert_eq!(ne.pick(), Some(9));
757    }
758
759    #[test]
760    fn an_upper_bounded_set_picks_below_its_holes() {
761        let mut s = DatumSet::all();
762        s.require(AtMost, 10);
763        s.require(NotEquals, 10);
764        s.require(NotEquals, 9);
765        assert_eq!(s.pick(), Some(8), "walks down from the upper bound");
766    }
767
768    #[test]
769    fn gate_contradiction_answers_per_axis() {
770        use crate::ids::FlagId;
771        use crate::stages::StateCompare;
772        let f: Vec<FlagId> = vec![FlagId("flag/paid".to_string())];
773        let g = Gate::of(&f, &f, &[]);
774        assert_eq!(
775            g.contradiction(),
776            Some(GateContradiction::Flag("flag/paid".to_string()))
777        );
778        let terms = [
779            StateCompare {
780                state: crate::ids::StateId("state/toll".to_string()),
781                op: AtLeast,
782                value: 5,
783            },
784            StateCompare {
785                state: crate::ids::StateId("state/toll".to_string()),
786                op: AtMost,
787                value: 3,
788            },
789        ];
790        let g = Gate::of(&[], &[], &terms);
791        assert_eq!(
792            g.contradiction(),
793            Some(GateContradiction::Datum("state/toll".to_string()))
794        );
795        assert_eq!(Gate::OPEN.contradiction(), None);
796    }
797}