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