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