use crate::envelope::Campaign;
use crate::ids::FlagId;
use crate::stages::StateCompare;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Gate<'a> {
pub requires_flags: &'a [FlagId],
pub forbids_flags: &'a [FlagId],
pub requires_state: &'a [StateCompare],
}
impl<'a> Gate<'a> {
pub fn of(
requires_flags: &'a [FlagId],
forbids_flags: &'a [FlagId],
requires_state: &'a [StateCompare],
) -> Self {
Gate {
requires_flags,
forbids_flags,
requires_state,
}
}
pub const OPEN: Gate<'static> = Gate {
requires_flags: &[],
forbids_flags: &[],
requires_state: &[],
};
pub fn is_empty(&self) -> bool {
self.requires_flags.is_empty()
&& self.forbids_flags.is_empty()
&& self.requires_state.is_empty()
}
pub fn terms(&self) -> usize {
self.requires_flags.len() + self.forbids_flags.len() + self.requires_state.len()
}
}
impl crate::stages::Objective {
pub fn gate(&self) -> Gate<'_> {
Gate::of(
self.requires_flags(),
self.forbids_flags(),
self.requires_state(),
)
}
}
impl crate::stages::QuestEffect {
pub fn gate(&self) -> Gate<'_> {
Gate::of(
self.requires_flags(),
self.forbids_flags(),
self.requires_state(),
)
}
}
impl crate::stages::EnvTrigger {
pub fn gate(&self) -> Gate<'_> {
Gate::of(
&self.requires_flags,
&self.forbids_flags,
&self.requires_state,
)
}
}
impl crate::stages::Trap {
pub fn gate(&self) -> Gate<'_> {
Gate::of(
&self.requires_flags,
&self.forbids_flags,
&self.requires_state,
)
}
}
impl crate::stages::DialogueOption {
pub fn gate(&self) -> Gate<'_> {
Gate::of(
&self.requires_flags,
&self.forbids_flags,
&self.requires_state,
)
}
}
impl crate::stages::CastPlacement {
pub fn gate(&self) -> Gate<'_> {
Gate::of(
&self.requires_flags,
&self.forbids_flags,
&self.requires_state,
)
}
}
impl crate::stages::ShopOffer {
pub fn gate_view(&self) -> Gate<'_> {
Gate::of(
&self.requires_flags,
&self.forbids_flags,
&self.requires_state,
)
}
}
impl crate::stages::Loop {
pub fn gate(&self) -> Gate<'_> {
Gate::of(
&self.requires_flags,
&self.forbids_flags,
&self.requires_state,
)
}
}
impl crate::stages::LethalVolume {
pub fn gate(&self) -> Gate<'_> {
match &self.when {
Some(g) => Gate::of(&g.requires_flags, &g.forbids_flags, &g.requires_state),
None => Gate::OPEN,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)]
pub enum GateConsumer {
Objective,
Effect,
Trigger,
Trap,
DialogueOption,
CastPlacement,
ShopOffer,
Loop,
LethalVolume,
}
impl GateConsumer {
pub const ALL: [GateConsumer; 9] = [
GateConsumer::Objective,
GateConsumer::Effect,
GateConsumer::Trigger,
GateConsumer::Trap,
GateConsumer::DialogueOption,
GateConsumer::CastPlacement,
GateConsumer::ShopOffer,
GateConsumer::Loop,
GateConsumer::LethalVolume,
];
pub const COUNT: usize = Self::ALL.len();
pub fn label(self) -> &'static str {
match self {
GateConsumer::Objective => "objective",
GateConsumer::Effect => "effect",
GateConsumer::Trigger => "trigger",
GateConsumer::Trap => "trap",
GateConsumer::DialogueOption => "dialogue option",
GateConsumer::CastPlacement => "cast placement",
GateConsumer::ShopOffer => "shop offer",
GateConsumer::Loop => "loop",
GateConsumer::LethalVolume => "lethal volume",
}
}
pub fn evaluates_per_player(self) -> Option<bool> {
match self {
GateConsumer::DialogueOption
| GateConsumer::CastPlacement
| GateConsumer::ShopOffer => Some(true),
GateConsumer::Objective
| GateConsumer::Trigger
| GateConsumer::Trap
| GateConsumer::Loop
| GateConsumer::LethalVolume => Some(false),
GateConsumer::Effect => None,
}
}
pub fn stage(self) -> &'static str {
match self {
GateConsumer::Objective
| GateConsumer::Trigger
| GateConsumer::Trap
| GateConsumer::CastPlacement
| GateConsumer::ShopOffer
| GateConsumer::Loop
| GateConsumer::LethalVolume => "quests",
GateConsumer::Effect => "quests",
GateConsumer::DialogueOption => "dialogue",
}
}
}
pub struct GateSite {
pub consumer: GateConsumer,
pub path: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GateBinding {
pub consumers_enumerated: usize,
pub sites: [(GateConsumer, usize); GateConsumer::COUNT],
pub gated: usize,
pub terms: usize,
}
impl GateBinding {
pub fn summary(&self) -> String {
let per: Vec<String> = self
.sites
.iter()
.map(|(k, n)| format!("{}={n}", k.label()))
.collect();
format!(
"consumers {}/{}, sites {}, gated {}, terms {} [{}]",
self.consumers_enumerated,
GateConsumer::COUNT,
self.sites.iter().map(|(_, n)| n).sum::<usize>(),
self.gated,
self.terms,
per.join(", ")
)
}
}
pub fn for_each_gate(c: &Campaign, f: &mut dyn FnMut(&GateSite, Gate<'_>)) -> GateBinding {
let mut sites = [
(GateConsumer::Objective, 0usize),
(GateConsumer::Effect, 0usize),
(GateConsumer::Trigger, 0usize),
(GateConsumer::Trap, 0usize),
(GateConsumer::DialogueOption, 0usize),
(GateConsumer::CastPlacement, 0usize),
(GateConsumer::ShopOffer, 0usize),
(GateConsumer::Loop, 0usize),
(GateConsumer::LethalVolume, 0usize),
];
debug_assert_eq!(
sites.map(|(k, _)| k),
GateConsumer::ALL,
"the binding ledger's slots are GateConsumer::ALL, in order"
);
let mut enumerated = [false; GateConsumer::COUNT];
let mut gated = 0usize;
let mut terms = 0usize;
fn slot_of(k: GateConsumer) -> usize {
GateConsumer::ALL
.iter()
.position(|x| *x == k)
.expect("every consumer is a member of GateConsumer::ALL")
}
let mut visit = |consumer: GateConsumer,
path: String,
gate: Gate<'_>,
sites: &mut [(GateConsumer, usize); GateConsumer::COUNT],
gated: &mut usize,
terms: &mut usize| {
sites[slot_of(consumer)].1 += 1;
if !gate.is_empty() {
*gated += 1;
}
*terms += gate.terms();
f(&GateSite { consumer, path }, gate);
};
enumerated[slot_of(GateConsumer::Objective)] = true;
for (qi, q) in c.quests.content.quests.iter().enumerate() {
for (oi, o) in q.objectives.iter().enumerate() {
visit(
GateConsumer::Objective,
format!("/content/quests/{qi}/objectives/{oi}"),
o.gate(),
&mut sites,
&mut gated,
&mut terms,
);
}
}
enumerated[slot_of(GateConsumer::Effect)] = true;
crate::stages::for_each_campaign_effect(c, &mut |path, _site, eff| {
visit(
GateConsumer::Effect,
format!("{path}/when"),
eff.gate(),
&mut sites,
&mut gated,
&mut terms,
);
});
enumerated[slot_of(GateConsumer::Trigger)] = true;
for (ti, t) in c.quests.content.triggers.iter().enumerate() {
visit(
GateConsumer::Trigger,
format!("/content/triggers/{ti}"),
t.gate(),
&mut sites,
&mut gated,
&mut terms,
);
}
enumerated[slot_of(GateConsumer::Trap)] = true;
for (pi, p) in c.quests.content.traps.iter().enumerate() {
visit(
GateConsumer::Trap,
format!("/content/traps/{pi}"),
p.gate(),
&mut sites,
&mut gated,
&mut terms,
);
}
enumerated[slot_of(GateConsumer::DialogueOption)] = true;
for (di, tree) in c.dialogue.content.dialogues.iter().enumerate() {
for (ni, node) in tree.nodes.iter().enumerate() {
for (oi, opt) in node.options.iter().enumerate() {
visit(
GateConsumer::DialogueOption,
format!("/content/dialogues/{di}/nodes/{ni}/options/{oi}"),
opt.gate(),
&mut sites,
&mut gated,
&mut terms,
);
}
}
}
enumerated[slot_of(GateConsumer::CastPlacement)] = true;
for (qi, q) in c.quests.content.quests.iter().enumerate() {
for (npc, entry) in &q.cast {
for (pi, p) in entry.placements().iter().enumerate() {
visit(
GateConsumer::CastPlacement,
format!("/content/quests/{qi}/cast/{}/{pi}", npc.as_str()),
p.gate(),
&mut sites,
&mut gated,
&mut terms,
);
}
}
}
enumerated[slot_of(GateConsumer::ShopOffer)] = true;
for (si, shop) in c.quests.content.shops.iter().enumerate() {
for (oi, off) in shop.offers.iter().enumerate() {
visit(
GateConsumer::ShopOffer,
format!("/content/shops/{si}/offers/{oi}"),
off.gate_view(),
&mut sites,
&mut gated,
&mut terms,
);
}
}
enumerated[slot_of(GateConsumer::Loop)] = true;
for (li, l) in c.quests.content.loops.iter().enumerate() {
visit(
GateConsumer::Loop,
format!("/content/loops/{li}"),
l.gate(),
&mut sites,
&mut gated,
&mut terms,
);
}
enumerated[slot_of(GateConsumer::LethalVolume)] = true;
for (vi, v) in c.quests.content.lethal_volumes.iter().enumerate() {
visit(
GateConsumer::LethalVolume,
format!("/content/lethal_volumes/{vi}/when"),
v.gate(),
&mut sites,
&mut gated,
&mut terms,
);
}
let missed: Vec<&str> = GateConsumer::ALL
.iter()
.zip(enumerated)
.filter(|(_, seen)| !*seen)
.map(|(k, _)| k.label())
.collect();
assert!(
missed.is_empty(),
"for_each_gate enumerated {} of {} gate consumers — missing: {}. A consumer that stops \
being enumerated has no other symptom.",
GateConsumer::COUNT - missed.len(),
GateConsumer::COUNT,
missed.join(", ")
);
GateBinding {
consumers_enumerated: GateConsumer::COUNT,
sites,
gated,
terms,
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DatumSet {
lo: Option<i32>,
hi: Option<i32>,
holes: std::collections::BTreeSet<i32>,
pin: Option<i32>,
contra: bool,
}
impl Default for DatumSet {
fn default() -> Self {
Self::all()
}
}
impl DatumSet {
pub fn all() -> Self {
DatumSet {
lo: None,
hi: None,
holes: std::collections::BTreeSet::new(),
pin: None,
contra: false,
}
}
pub fn require(&mut self, op: crate::stages::CompareOp, value: i32) {
use crate::stages::CompareOp::*;
match op {
Equals => match self.pin {
Some(p) if p != value => self.contra = true,
_ => self.pin = Some(value),
},
NotEquals => {
self.holes.insert(value);
}
AtLeast => self.lo = Some(self.lo.map_or(value, |l| l.max(value))),
AtMost => self.hi = Some(self.hi.map_or(value, |h| h.min(value))),
}
}
pub fn forbid(&mut self, op: crate::stages::CompareOp, value: i32) {
use crate::stages::CompareOp::*;
match op {
Equals => self.require(NotEquals, value),
NotEquals => self.require(Equals, value),
AtLeast => match value.checked_sub(1) {
Some(v) => self.require(AtMost, v),
None => self.contra = true,
},
AtMost => match value.checked_add(1) {
Some(v) => self.require(AtLeast, v),
None => self.contra = true,
},
}
}
pub fn min(&self) -> Option<i32> {
let lo = self.pin.or(self.lo)?;
let steps = self.holes.len() as i64 + 1;
(lo as i64..lo as i64 + steps)
.filter_map(|v| i32::try_from(v).ok())
.find(|v| self.contains(*v))
}
pub fn max(&self) -> Option<i32> {
let hi = self.pin.or(self.hi)?;
let steps = self.holes.len() as i64 + 1;
((hi as i64 - steps + 1)..=hi as i64)
.rev()
.filter_map(|v| i32::try_from(v).ok())
.find(|v| self.contains(*v))
}
fn contains(&self, v: i32) -> bool {
!self.contra
&& self.pin.is_none_or(|p| p == v)
&& self.lo.is_none_or(|l| v >= l)
&& self.hi.is_none_or(|h| v <= h)
&& !self.holes.contains(&v)
}
pub fn pick(&self) -> Option<i32> {
if self.contra {
return None;
}
let ok = |v: i32| {
self.lo.is_none_or(|l| v >= l)
&& self.hi.is_none_or(|h| v <= h)
&& !self.holes.contains(&v)
};
if let Some(p) = self.pin {
return ok(p).then_some(p);
}
let steps = self.holes.len() as i64 + 1;
let from = match (self.lo, self.hi) {
(Some(l), _) => l as i64,
(None, Some(h)) => (h as i64) - steps + 1,
(None, None) => 0,
};
(from..from + steps)
.filter_map(|v| i32::try_from(v).ok())
.find(|v| ok(*v))
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum GateContradiction {
Flag(String),
Datum(String),
}
impl Gate<'_> {
pub fn contradiction(&self) -> Option<GateContradiction> {
conjunction_contradictions(&[*self]).into_iter().next()
}
pub fn exclusions(&self, other: &Gate<'_>) -> Vec<GateContradiction> {
conjunction_contradictions(&[*self, *other])
}
}
fn conjunction_contradictions(gates: &[Gate<'_>]) -> Vec<GateContradiction> {
let mut out = Vec::new();
let mut seen = std::collections::BTreeSet::new();
for g in gates {
for f in g.requires_flags {
let forbidden = gates.iter().any(|h| h.forbids_flags.contains(f));
if forbidden && seen.insert(f.as_str()) {
out.push(GateContradiction::Flag(f.as_str().to_string()));
}
}
}
let mut per: std::collections::BTreeMap<&str, DatumSet> = std::collections::BTreeMap::new();
for g in gates {
for t in g.requires_state {
per.entry(t.state.as_str())
.or_default()
.require(t.op, t.value);
}
}
for (state, set) in per {
if set.pick().is_none() {
out.push(GateContradiction::Datum(state.to_string()));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn open_gate_is_empty() {
assert!(Gate::OPEN.is_empty());
assert_eq!(Gate::OPEN.terms(), 0);
}
#[test]
fn every_consumer_names_its_stage() {
for k in GateConsumer::ALL {
assert!(matches!(k.stage(), "quests" | "dialogue"), "{k:?}");
assert!(!k.label().is_empty(), "{k:?}");
}
}
use crate::stages::CompareOp::*;
#[test]
fn datum_set_picks_within_bounds_and_around_holes() {
let mut s = DatumSet::all();
assert_eq!(s.pick(), Some(0), "the unconstrained canonical member is 0");
s.require(AtLeast, 3);
s.require(AtMost, 5);
assert_eq!(s.pick(), Some(3), "the lower boundary is canonical");
s.require(NotEquals, 3);
assert_eq!(s.pick(), Some(4), "a hole at the boundary steps past it");
s.require(NotEquals, 4);
s.require(NotEquals, 5);
assert_eq!(s.pick(), None, "holes covering the interval empty it");
}
#[test]
fn datum_set_pins_and_pin_conflicts() {
let mut s = DatumSet::all();
s.require(Equals, 7);
assert_eq!(s.pick(), Some(7));
s.require(NotEquals, 7);
assert_eq!(s.pick(), None, "a hole at the pin empties the set");
let mut t = DatumSet::all();
t.require(Equals, 7);
t.require(Equals, 8);
assert_eq!(t.pick(), None, "two different pins contradict");
}
#[test]
fn forbid_is_the_exact_negation() {
let mut s = DatumSet::all();
s.forbid(AtLeast, 5);
assert_eq!(s.pick(), Some(4), "the violating boundary is canonical");
s.require(AtLeast, 5);
assert_eq!(s.pick(), None);
let mut lo = DatumSet::all();
lo.forbid(AtLeast, i32::MIN);
assert_eq!(lo.pick(), None);
let mut hi = DatumSet::all();
hi.forbid(AtMost, i32::MAX);
assert_eq!(hi.pick(), None);
let mut ne = DatumSet::all();
ne.forbid(NotEquals, 9);
assert_eq!(ne.pick(), Some(9));
}
#[test]
fn a_datum_set_states_its_floor_and_its_ceiling() {
let mut s = DatumSet::all();
assert_eq!((s.min(), s.max()), (None, None), "nothing bounds it");
s.require(AtLeast, 15);
assert_eq!((s.min(), s.max()), (Some(15), None));
s.require(AtMost, 20);
assert_eq!((s.min(), s.max()), (Some(15), Some(20)));
s.require(NotEquals, 15);
s.require(NotEquals, 20);
assert_eq!((s.min(), s.max()), (Some(16), Some(19)));
let mut pinned = DatumSet::all();
pinned.require(Equals, 7);
assert_eq!((pinned.min(), pinned.max()), (Some(7), Some(7)));
let mut empty = DatumSet::all();
empty.require(AtLeast, 5);
empty.require(AtMost, 4);
assert_eq!(
(empty.min(), empty.max()),
(None, None),
"an empty set has neither"
);
}
#[test]
fn an_upper_bounded_set_picks_below_its_holes() {
let mut s = DatumSet::all();
s.require(AtMost, 10);
s.require(NotEquals, 10);
s.require(NotEquals, 9);
assert_eq!(s.pick(), Some(8), "walks down from the upper bound");
}
#[test]
fn gate_contradiction_answers_per_axis() {
use crate::ids::FlagId;
use crate::stages::StateCompare;
let f: Vec<FlagId> = vec![FlagId("flag/paid".to_string())];
let g = Gate::of(&f, &f, &[]);
assert_eq!(
g.contradiction(),
Some(GateContradiction::Flag("flag/paid".to_string()))
);
let terms = [
StateCompare {
state: crate::ids::StateId("state/toll".to_string()),
op: AtLeast,
value: 5,
},
StateCompare {
state: crate::ids::StateId("state/toll".to_string()),
op: AtMost,
value: 3,
},
];
let g = Gate::of(&[], &[], &terms);
assert_eq!(
g.contradiction(),
Some(GateContradiction::Datum("state/toll".to_string()))
);
assert_eq!(Gate::OPEN.contradiction(), None);
}
#[test]
fn exclusions_are_the_conjunctions_contradictions() {
use crate::ids::{FlagId, StateId};
use crate::stages::StateCompare;
let x = vec![FlagId("flag/x".to_string())];
let y = vec![FlagId("flag/y".to_string())];
let requires_x = Gate::of(&x, &[], &[]);
let forbids_x = Gate::of(&[], &x, &[]);
let forbids_y = Gate::of(&[], &y, &[]);
assert_eq!(
forbids_x.exclusions(&requires_x),
vec![GateContradiction::Flag("flag/x".to_string())]
);
assert_eq!(
requires_x.exclusions(&forbids_x),
forbids_x.exclusions(&requires_x),
"exclusion is symmetric"
);
assert!(
requires_x.exclusions(&forbids_y).is_empty(),
"distinct flags can both hold"
);
assert!(requires_x.exclusions(&requires_x).is_empty());
assert!(requires_x.exclusions(&Gate::OPEN).is_empty());
let cmp = |op, value| StateCompare {
state: StateId("state/tide".to_string()),
op,
value,
};
let high = [cmp(AtLeast, 5)];
let low = [cmp(AtMost, 4)];
let mid = [cmp(AtMost, 5)];
assert_eq!(
Gate::of(&[], &[], &high).exclusions(&Gate::of(&[], &[], &low)),
vec![GateContradiction::Datum("state/tide".to_string())]
);
assert!(
Gate::of(&[], &[], &high)
.exclusions(&Gate::of(&[], &[], &mid))
.is_empty(),
"ranges meeting at 5 both hold at 5"
);
let both = Gate::of(&x, &[], &high);
assert_eq!(
both.exclusions(&Gate::of(&[], &x, &low)),
vec![
GateContradiction::Flag("flag/x".to_string()),
GateContradiction::Datum("state/tide".to_string()),
]
);
}
}