Skip to main content

delvewright_dsl/
gate.rs

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