use std::collections::{BTreeMap, BTreeSet};
use crate::diagnostic::{Diagnostic, codes};
use crate::envelope::Campaign;
use crate::gate::DatumSet;
use crate::stages::{CompareOp, QuestEffect, StateCompare, StateWrite};
struct EffectList<'a> {
path: String,
stage: &'static str,
context: Vec<StateCompare>,
context_path: Option<String>,
effects: &'a [QuestEffect],
}
fn effect_lists(c: &Campaign) -> Vec<EffectList<'_>> {
let mut out: Vec<EffectList<'_>> = Vec::new();
crate::effects::for_each_effect_root(c, &mut |site, list| {
let (context, context_path): (Vec<StateCompare>, Option<String>) = match site.owner {
crate::effects::EffectRootOwner::Trigger(t) => (
t.gate().requires_state.to_vec(),
Some(trim_suffix(&site.path, "/effects")),
),
crate::effects::EffectRootOwner::TrapPayload(t) => (
t.gate().requires_state.to_vec(),
Some(trim_suffix(&site.path, "/payload")),
),
crate::effects::EffectRootOwner::ShopOffer(shop) => {
let offer = offer_index(&site.path).and_then(|i| shop.offers.get(i));
(
offer
.map(|o| o.gate_view().requires_state.to_vec())
.unwrap_or_default(),
Some(trim_suffix(&site.path, "/effects")),
)
}
_ => (Vec::new(), None),
};
out.push(EffectList {
path: site.path.clone(),
stage: site.stage,
context,
context_path,
effects: list,
});
for (i, eff) in list.iter().enumerate() {
nested_lists(eff, &format!("{}/{i}", site.path), site.stage, &mut out);
}
});
out
}
fn nested_lists<'a>(
eff: &'a QuestEffect,
path: &str,
stage: &'static str,
out: &mut Vec<EffectList<'a>>,
) {
for (pseg, _kseg, list) in eff.nested_effect_lists_labeled() {
let lpath = format!("{path}/{pseg}");
out.push(EffectList {
path: lpath.clone(),
stage,
context: eff.requires_state().to_vec(),
context_path: Some(format!("{path}/when")),
effects: list,
});
for (i, inner) in list.iter().enumerate() {
nested_lists(inner, &format!("{lpath}/{i}"), stage, out);
}
}
}
fn offer_index(path: &str) -> Option<usize> {
path.split('/').nth(5).and_then(|n| n.parse().ok())
}
fn trim_suffix(path: &str, suffix: &str) -> String {
path.strip_suffix(suffix).unwrap_or(path).to_string()
}
fn floor_of(terms: &[&StateCompare], state: &str) -> Option<i32> {
let mut set = DatumSet::all();
let mut bounded = false;
for t in terms {
if t.state.as_str() != state {
continue;
}
if matches!(t.op, CompareOp::AtLeast | CompareOp::Equals) {
bounded = true;
}
set.require(t.op, t.value);
}
bounded.then(|| set.min()).flatten()
}
fn ceiling_of(terms: &[&StateCompare], state: &str) -> Option<i32> {
let mut set = DatumSet::all();
let mut bounded = false;
for t in terms {
if t.state.as_str() != state {
continue;
}
if matches!(t.op, CompareOp::AtMost | CompareOp::Equals) {
bounded = true;
}
set.require(t.op, t.value);
}
bounded.then(|| set.max()).flatten()
}
fn in_scope<'a>(list: &'a EffectList<'_>, eff: &'a QuestEffect) -> Vec<&'a StateCompare> {
list.context
.iter()
.chain(eff.requires_state().iter())
.collect()
}
pub fn purchase_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
for list in effect_lists(c) {
for state in charged_states(&list) {
judge(&list, &state, d);
}
}
}
fn charged_states(list: &EffectList<'_>) -> BTreeSet<String> {
let mut out = BTreeSet::new();
for eff in list.effects {
if let Some((state, StateWrite::Add(n))) = eff.writes_state()
&& n < 0
{
out.insert(state.as_str().to_string());
}
}
out
}
fn judge(list: &EffectList<'_>, state: &str, d: &mut Vec<Diagnostic>) {
let mut sell_floor: Option<i32> = None;
for (i, eff) in list.effects.iter().enumerate() {
let Some((s, StateWrite::Add(amount))) = eff.writes_state() else {
continue;
};
if s.as_str() != state || amount >= 0 {
continue;
}
let charge = -(amount as i64);
let terms = in_scope(list, eff);
let floor = floor_of(&terms, state);
match floor {
Some(f) if (f as i64) >= charge => {}
Some(f) => d.push(Diagnostic::error(
codes::PURCHASE_ARITHMETIC,
list.stage,
format!("{}/{i}", list.path),
format!(
"this effect charges {charge} of `{state}` behind a gate that opens at \
{f}: at a balance of {f} the charge leaves `{state}` at {}, below the \
floor the gate states. The two numbers are one price written twice — \
raise the gate to `at-least {charge}`, or charge {f}{}",
(f as i64) - charge,
gate_note(list),
),
)),
None if priced_elsewhere(list, state, i) => d.push(Diagnostic::error(
codes::PURCHASE_ARITHMETIC,
list.stage,
format!("{}/{i}", list.path),
format!(
"this effect charges {charge} of `{state}` and nothing gates it on what \
`{state}` holds, in a list that prices the same datum elsewhere. It fires \
at any balance, including one that cannot pay — gate it `at-least \
{charge}` in its own `when`{}",
gate_note(list),
),
)),
None => {}
}
if let Some(f) = floor {
sell_floor = Some(sell_floor.map_or(f, |m: i32| m.min(f)));
}
}
let Some(m) = sell_floor else { return };
for (i, eff) in list.effects.iter().enumerate() {
let terms = in_scope(list, eff);
if floor_of(&terms, state).is_some() {
continue;
}
let Some(k) = ceiling_of(&terms, state) else {
continue;
};
if k == m - 1 {
continue;
}
let (what, balances) = if k < m - 1 {
(
"leaves a gap",
format!(
"at a balance of {}..{} this list neither charges nor answers",
k + 1,
m - 1
),
)
} else {
(
"overlaps the sale",
format!(
"at a balance of {}..{k} this list both charges and answers as if it could \
not",
m
),
)
};
d.push(Diagnostic::error(
codes::PURCHASE_ARITHMETIC,
list.stage,
format!("{}/{i}", list.path),
format!(
"this effect answers below `{state} at-most {k}` while the charge in the same \
list fires from {m}, which {what}: {balances}. The two literals are one price \
— the answering arm's ceiling is `at-most {}`",
m - 1
),
));
}
}
fn priced_elsewhere(list: &EffectList<'_>, state: &str, skip: usize) -> bool {
list.context.iter().any(|t| t.state.as_str() == state)
|| list
.effects
.iter()
.enumerate()
.any(|(j, e)| j != skip && e.requires_state().iter().any(|t| t.state.as_str() == state))
}
fn gate_note(list: &EffectList<'_>) -> String {
match &list.context_path {
Some(p) if !list.context.is_empty() => {
format!(" (the gate at `{p}` is read together with this effect's own `when`)")
}
_ => String::new(),
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct PurchaseBinding {
pub lists: usize,
pub gated_lists: usize,
pub charges: usize,
pub pairs: usize,
pub datums: usize,
pub refused: usize,
}
impl PurchaseBinding {
pub fn of(c: &Campaign) -> Self {
let mut b = PurchaseBinding::default();
let mut datums: BTreeSet<String> = BTreeSet::new();
let mut per_list: BTreeMap<String, usize> = BTreeMap::new();
for list in effect_lists(c) {
b.lists += 1;
if !list.context.is_empty() {
b.gated_lists += 1;
}
for eff in list.effects {
if let Some((state, StateWrite::Add(n))) = eff.writes_state()
&& n < 0
{
b.charges += 1;
datums.insert(state.as_str().to_string());
*per_list
.entry(format!("{}|{}", list.path, state.as_str()))
.or_default() += 1;
}
}
}
b.pairs = per_list.len();
b.datums = datums.len();
let mut d = Vec::new();
purchase_checks(c, &mut d);
b.refused = d.len();
b
}
pub fn line(&self) -> String {
format!(
"purchase binding: {} charge(s) over {} datum(s) bind {} (list, datum) pair(s) of {} \
effect list(s) walked, {} of them behind a numeric gate, {} refused (DW0901).",
self.charges, self.datums, self.pairs, self.lists, self.gated_lists, self.refused
)
}
}