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::envelope::Campaign;
45use crate::ids::FlagId;
46use crate::stages::StateCompare;
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::stages::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::stages::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::stages::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::stages::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::stages::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::stages::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::stages::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::stages::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::stages::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::stages::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::stages::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::stages::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::stages::CompareOp, value: i32) {
658        use crate::stages::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::stages::CompareOp, value: i32) {
676        use crate::stages::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        for f in self.requires_flags {
779            if self.forbids_flags.iter().any(|g| g == f) {
780                return Some(GateContradiction::Flag(f.as_str().to_string()));
781            }
782        }
783        let mut per: std::collections::BTreeMap<&str, DatumSet> = std::collections::BTreeMap::new();
784        for t in self.requires_state {
785            per.entry(t.state.as_str())
786                .or_default()
787                .require(t.op, t.value);
788        }
789        for (state, set) in per {
790            if set.pick().is_none() {
791                return Some(GateContradiction::Datum(state.to_string()));
792            }
793        }
794        None
795    }
796}
797
798#[cfg(test)]
799mod tests {
800    use super::*;
801
802    #[test]
803    fn open_gate_is_empty() {
804        assert!(Gate::OPEN.is_empty());
805        assert_eq!(Gate::OPEN.terms(), 0);
806    }
807
808    /// Every consumer names a stage and a label, and the stages are exactly the
809    /// two stage documents gates live in.
810    #[test]
811    fn every_consumer_names_its_stage() {
812        for k in GateConsumer::ALL {
813            assert!(matches!(k.stage(), "quests" | "dialogue"), "{k:?}");
814            assert!(!k.label().is_empty(), "{k:?}");
815        }
816    }
817
818    use crate::stages::CompareOp::*;
819
820    #[test]
821    fn datum_set_picks_within_bounds_and_around_holes() {
822        let mut s = DatumSet::all();
823        assert_eq!(s.pick(), Some(0), "the unconstrained canonical member is 0");
824        s.require(AtLeast, 3);
825        s.require(AtMost, 5);
826        assert_eq!(s.pick(), Some(3), "the lower boundary is canonical");
827        s.require(NotEquals, 3);
828        assert_eq!(s.pick(), Some(4), "a hole at the boundary steps past it");
829        s.require(NotEquals, 4);
830        s.require(NotEquals, 5);
831        assert_eq!(s.pick(), None, "holes covering the interval empty it");
832    }
833
834    #[test]
835    fn datum_set_pins_and_pin_conflicts() {
836        let mut s = DatumSet::all();
837        s.require(Equals, 7);
838        assert_eq!(s.pick(), Some(7));
839        s.require(NotEquals, 7);
840        assert_eq!(s.pick(), None, "a hole at the pin empties the set");
841        let mut t = DatumSet::all();
842        t.require(Equals, 7);
843        t.require(Equals, 8);
844        assert_eq!(t.pick(), None, "two different pins contradict");
845    }
846
847    #[test]
848    fn forbid_is_the_exact_negation() {
849        // Violating `at-least 5` means at most 4; violating that too is empty.
850        let mut s = DatumSet::all();
851        s.forbid(AtLeast, 5);
852        assert_eq!(s.pick(), Some(4), "the violating boundary is canonical");
853        s.require(AtLeast, 5);
854        assert_eq!(s.pick(), None);
855        // Nothing violates `at-least i32::MIN` / `at-most i32::MAX`.
856        let mut lo = DatumSet::all();
857        lo.forbid(AtLeast, i32::MIN);
858        assert_eq!(lo.pick(), None);
859        let mut hi = DatumSet::all();
860        hi.forbid(AtMost, i32::MAX);
861        assert_eq!(hi.pick(), None);
862        // Violating `not-equals v` pins v.
863        let mut ne = DatumSet::all();
864        ne.forbid(NotEquals, 9);
865        assert_eq!(ne.pick(), Some(9));
866    }
867
868    /// The floor and the ceiling of a set — what a price reads. A pin is its own
869    /// floor and its own ceiling; a hole at a bound steps past it; an unbounded
870    /// side answers `None` rather than a number nothing stated.
871    #[test]
872    fn a_datum_set_states_its_floor_and_its_ceiling() {
873        let mut s = DatumSet::all();
874        assert_eq!((s.min(), s.max()), (None, None), "nothing bounds it");
875        s.require(AtLeast, 15);
876        assert_eq!((s.min(), s.max()), (Some(15), None));
877        s.require(AtMost, 20);
878        assert_eq!((s.min(), s.max()), (Some(15), Some(20)));
879        s.require(NotEquals, 15);
880        s.require(NotEquals, 20);
881        assert_eq!((s.min(), s.max()), (Some(16), Some(19)));
882        let mut pinned = DatumSet::all();
883        pinned.require(Equals, 7);
884        assert_eq!((pinned.min(), pinned.max()), (Some(7), Some(7)));
885        let mut empty = DatumSet::all();
886        empty.require(AtLeast, 5);
887        empty.require(AtMost, 4);
888        assert_eq!(
889            (empty.min(), empty.max()),
890            (None, None),
891            "an empty set has neither"
892        );
893    }
894
895    #[test]
896    fn an_upper_bounded_set_picks_below_its_holes() {
897        let mut s = DatumSet::all();
898        s.require(AtMost, 10);
899        s.require(NotEquals, 10);
900        s.require(NotEquals, 9);
901        assert_eq!(s.pick(), Some(8), "walks down from the upper bound");
902    }
903
904    #[test]
905    fn gate_contradiction_answers_per_axis() {
906        use crate::ids::FlagId;
907        use crate::stages::StateCompare;
908        let f: Vec<FlagId> = vec![FlagId("flag/paid".to_string())];
909        let g = Gate::of(&f, &f, &[]);
910        assert_eq!(
911            g.contradiction(),
912            Some(GateContradiction::Flag("flag/paid".to_string()))
913        );
914        let terms = [
915            StateCompare {
916                state: crate::ids::StateId("state/toll".to_string()),
917                op: AtLeast,
918                value: 5,
919            },
920            StateCompare {
921                state: crate::ids::StateId("state/toll".to_string()),
922                op: AtMost,
923                value: 3,
924            },
925        ];
926        let g = Gate::of(&[], &[], &terms);
927        assert_eq!(
928            g.contradiction(),
929            Some(GateContradiction::Datum("state/toll".to_string()))
930        );
931        assert_eq!(Gate::OPEN.contradiction(), None);
932    }
933}