use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::{AnchorId, FlagId, QuestEffect, ShopId, StakeId, StateCompare, StateId};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Shop {
pub id: ShopId,
pub anchor: AnchorId,
pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub marker_item: Option<String>,
pub offers: Vec<ShopOffer>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ShopOffer {
pub label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tooltip: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub requires_flags: Vec<FlagId>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub forbids_flags: Vec<FlagId>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub requires_state: Vec<StateCompare>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub effects: Vec<QuestEffect>,
}
impl ShopOffer {
pub fn gate(&self) -> crate::gate::Gate<'_> {
crate::gate::Gate::of(
&self.requires_flags,
&self.forbids_flags,
&self.requires_state,
)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
pub enum Forfeit {
All,
Proportion {
percent: u32,
},
Fixed {
amount: i32,
},
None,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum OnFull {
Replace,
Keep,
}
impl OnFull {
pub fn token(self) -> &'static str {
match self {
OnFull::Replace => "replace",
OnFull::Keep => "keep",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum CollectBy {
Owner,
Anyone,
}
impl CollectBy {
pub fn token(self) -> &'static str {
match self {
CollectBy::Owner => "owner",
CollectBy::Anyone => "anyone",
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Stake {
pub id: StakeId,
pub state: StateId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub forfeit: Option<Forfeit>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_live: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_full: Option<OnFull>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub collect_by: Option<CollectBy>,
pub collected_message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub marker_item: Option<String>,
}
impl Stake {
pub fn forfeit(&self) -> Forfeit {
self.forfeit.unwrap_or(Forfeit::All)
}
pub fn max_live(&self) -> u32 {
self.max_live.unwrap_or(1)
}
pub fn on_full(&self) -> OnFull {
self.on_full.unwrap_or(OnFull::Replace)
}
pub fn collect_by(&self) -> CollectBy {
self.collect_by.unwrap_or(CollectBy::Owner)
}
pub fn marker_item(&self) -> &str {
self.marker_item
.as_deref()
.unwrap_or("minecraft:soul_lantern")
}
}
use std::collections::{BTreeMap, BTreeSet};
use crate::diagnostic::{Diagnostic, DwCode, ExitTier, codes};
use crate::envelope::Campaign;
use crate::gate::DatumSet;
use crate::registry::AnchorRegistry;
use crate::validate::{AnchorProviders, station_kind_diag};
use crate::{CompareOp, StateWrite};
crate::dw_code! {
pub const STAKE_STATE_SCOPE: DwCode = DwCode::new("DW0520", ExitTier::Build);
}
crate::dw_code! {
pub const STAKE_UNDECLARED: DwCode = DwCode::new("DW0521", ExitTier::Build);
}
crate::dw_code! {
pub const STAKE_NEVER_DROPPED: DwCode = DwCode::new("DW0522", ExitTier::Build);
}
crate::dw_code! {
pub const SHOP_OFFER_INERT: DwCode = DwCode::new("DW0523", ExitTier::Build);
}
crate::dw_code! {
pub const STAKE_FORFEIT_RANGE: DwCode = DwCode::new("DW0524", ExitTier::Build);
}
crate::dw_code! {
pub const PURCHASE_ARITHMETIC: DwCode = DwCode::new("DW0901", ExitTier::Build);
}
pub(crate) fn shop_anchor_checks(
c: &Campaign,
anchors: &dyn AnchorRegistry,
d: &mut Vec<Diagnostic>,
) {
if c.quests.content.shops.is_empty() {
return;
}
let providers = AnchorProviders::build(c, anchors);
for (i, sh) in c.quests.content.shops.iter().enumerate() {
if let Some(f) = station_kind_diag(
&providers,
sh.anchor.as_str(),
crate::layout::StationKind::Point,
"a shop counter",
"quests",
format!("/content/shops/{i}/anchor"),
) {
d.push(f);
}
if providers.resolvable(sh.anchor.as_str()) {
continue;
}
d.push(Diagnostic::error(
codes::ANCHOR_UNRESOLVED,
"quests",
format!("/content/shops/{i}/anchor"),
format!(
"shop anchor `{}` is not provided by any prefab bound in this campaign — {}",
sh.anchor,
providers.anchor_remedy(
"use an anchor the prefab exposes (anchor names come from prefab metadata; do \
NOT invent one)"
),
),
));
}
}
pub(crate) fn economy_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
let stakes = &c.quests.content.stakes;
let shops = &c.quests.content.shops;
if stakes.is_empty() && shops.is_empty() {
return;
}
let mut seen: BTreeSet<&str> = BTreeSet::new();
for (i, s) in stakes.iter().enumerate() {
if !s.id.is_valid_syntax() {
d.push(Diagnostic::error(
codes::ID_SYNTAX,
"quests",
format!("/content/stakes/{i}/id"),
format!(
"malformed stake id `{}` — stake ids are `stake/<kebab-case>`",
s.id
),
));
}
if !seen.insert(s.id.as_str()) {
d.push(Diagnostic::error(
codes::ID_DUPLICATE,
"quests",
format!("/content/stakes/{i}/id"),
format!("duplicate stake id `{}`", s.id),
));
}
match c.quests.content.state_decl(s.state.as_str()) {
None => d.push(Diagnostic::error(
STAKE_STATE_SCOPE,
"quests",
format!("/content/stakes/{i}/state"),
format!(
"stake `{}` forfeits `{}`, which the campaign never declares. Add it to the \
stage-5 `state` list with `\"scope\": \"player\"` — a stake is one player's \
wager, and a datum's scope is a fact no use site can supply",
s.id,
s.state.as_str()
),
)),
Some(decl) if decl.scope != crate::StateScope::Player => {
d.push(Diagnostic::error(
STAKE_STATE_SCOPE,
"quests",
format!("/content/stakes/{i}/state"),
format!(
"stake `{}` forfeits `{}`, which is declared `party`-scoped. A stake is a \
PERSONAL wager: one shared purse turns a teammate's death into a penalty \
on everyone, and nothing in the JSON would say so. Declare the datum \
`player`-scoped, or point the stake at one that is.",
s.id,
s.state.as_str()
),
));
}
Some(_) => {}
}
if let Some(crate::Forfeit::Proportion { percent }) = s.forfeit
&& percent > 100
{
d.push(Diagnostic::error(
STAKE_FORFEIT_RANGE,
"quests",
format!("/content/stakes/{i}/forfeit/percent"),
format!(
"stake `{}` forfeits {percent}% of `{}` — more than the whole purse. Use \
0–100, or `{{\"kind\": \"all\"}}`.",
s.id,
s.state.as_str()
),
));
}
}
let mut dropped: BTreeSet<String> = BTreeSet::new();
crate::for_each_campaign_effect(c, &mut |path, _site, eff| {
let crate::Verb::DropStake { stake, .. } = &eff.verb else {
return;
};
if c.quests.content.stake_decl(stake.as_str()).is_none() {
d.push(Diagnostic::error(
STAKE_UNDECLARED,
"quests",
path.to_string(),
format!(
"`drop-stake` leaves `{}`, which the campaign never declares. Add it to the \
stage-5 `stakes` list, or fix the id",
stake.as_str()
),
));
return;
}
dropped.insert(stake.as_str().to_string());
});
for (i, s) in stakes.iter().enumerate() {
if dropped.contains(s.id.as_str()) {
continue;
}
d.push(Diagnostic::error(
STAKE_NEVER_DROPPED,
"quests",
format!("/content/stakes/{i}/id"),
format!(
"stake `{}` is declared and no `drop-stake` effect anywhere in the campaign ever \
leaves one. Its forfeit rule, its retention policy and its whole compile-time \
placement table describe a mechanism no beat can fire. Drop it from a beat — \
`on_death` is the usual one — or delete the declaration.",
s.id
),
));
}
let mut seen_shop: BTreeSet<&str> = BTreeSet::new();
for (i, sh) in shops.iter().enumerate() {
if !sh.id.is_valid_syntax() {
d.push(Diagnostic::error(
codes::ID_SYNTAX,
"quests",
format!("/content/shops/{i}/id"),
format!(
"malformed shop id `{}` — shop ids are `shop/<kebab-case>`",
sh.id
),
));
}
if !seen_shop.insert(sh.id.as_str()) {
d.push(Diagnostic::error(
codes::ID_DUPLICATE,
"quests",
format!("/content/shops/{i}/id"),
format!("duplicate shop id `{}`", sh.id),
));
}
if sh.offers.is_empty() {
d.push(Diagnostic::error(
SHOP_OFFER_INERT,
"quests",
format!("/content/shops/{i}/offers"),
format!(
"shop `{}` declares no offers. Vanilla's dialog codec rejects an empty action \
list outright, so this is not merely an empty shop — it is a dialog that \
fails to load. Give it at least one offer, or delete the shop.",
sh.id
),
));
}
for (j, off) in sh.offers.iter().enumerate() {
if off.effects.is_empty() {
d.push(Diagnostic::error(
SHOP_OFFER_INERT,
"quests",
format!("/content/shops/{i}/offers/{j}/effects"),
format!(
"offer `{}` in shop `{}` declares no effects: the button is drawn, is \
pressable, and does nothing. A control the player can operate must have \
an answer — a refusal counts, so a single `narrate` gated on \
`requires_state` (`at-most <price − 1>`) is enough.",
off.label, sh.id
),
));
}
}
}
}
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(
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(
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(
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
)
}
}