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