use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::StateId;
#[cfg(doc)]
use crate::FlagId;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum StateScope {
Player,
Party,
}
impl StateScope {
pub fn token(self) -> &'static str {
match self {
StateScope::Player => "player",
StateScope::Party => "party",
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct StateDecl {
pub id: StateId,
pub scope: StateScope,
#[serde(default, skip_serializing_if = "is_zero_i32")]
pub initial: i32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display: Option<StateDisplay>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum StateDisplay {
Sidebar,
}
impl StateDisplay {
pub fn slot(self) -> &'static str {
match self {
StateDisplay::Sidebar => "sidebar",
}
}
}
fn is_zero_i32(v: &i32) -> bool {
*v == 0
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum CompareOp {
Equals,
NotEquals,
AtLeast,
AtMost,
}
impl CompareOp {
pub fn token(self) -> &'static str {
match self {
CompareOp::Equals => "equals",
CompareOp::NotEquals => "not-equals",
CompareOp::AtLeast => "at-least",
CompareOp::AtMost => "at-most",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StateWrite {
Set(i32),
Add(i32),
Clear,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct StateCompare {
pub state: StateId,
pub op: CompareOp,
pub value: i32,
}
impl StateCompare {
pub fn holds(&self, v: i32) -> bool {
match self.op {
CompareOp::Equals => v == self.value,
CompareOp::NotEquals => v != self.value,
CompareOp::AtLeast => v >= self.value,
CompareOp::AtMost => v <= self.value,
}
}
}
use crate::QuestEffect;
use crate::diagnostic::{Diagnostic, DwCode, ExitTier, codes};
use crate::envelope::Campaign;
use std::collections::{BTreeMap, BTreeSet};
crate::dw_code! {
pub const STATE_DISPLAY_UNDRAWABLE: DwCode = DwCode::new("DW0919", ExitTier::Build);
}
crate::dw_code! {
pub const STATE_NEVER_WRITTEN: DwCode = DwCode::new("DW0501", ExitTier::Build);
}
crate::dw_code! {
pub const STATE_NEVER_READ: DwCode = DwCode::new("DW0502", ExitTier::Build);
}
crate::dw_code! {
pub const STATE_SCOPE_UNREACHABLE: DwCode = DwCode::new("DW0503", ExitTier::Build);
}
crate::dw_code! {
pub const STATE_READ_AFTER_WRITE: DwCode = DwCode::new("DW0527", ExitTier::Build);
}
crate::dw_code! {
pub const GATE_NEVER_OPENS: DwCode = DwCode::new("DW0847", ExitTier::Build);
}
pub(crate) fn state_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
let decls = &c.quests.content.state;
let mut declared: BTreeMap<&str, &crate::StateDecl> = BTreeMap::new();
for (i, s) in decls.iter().enumerate() {
if !s.id.is_valid_syntax() {
d.push(Diagnostic::error(
codes::ID_SYNTAX,
"quests",
format!("/content/state/{i}/id"),
format!(
"runtime-state id `{}` is malformed — ids are `state/<kebab-case>`",
s.id.as_str()
),
));
continue;
}
if declared.insert(s.id.as_str(), s).is_some() {
d.push(Diagnostic::error(
codes::ID_DUPLICATE,
"quests",
format!("/content/state/{i}/id"),
format!(
"runtime-state id `{}` is declared more than once — one datum, one \
declaration (its scope and its initial value have to be a single fact)",
s.id.as_str()
),
));
}
}
let mut standing: Option<&crate::StateDecl> = None;
for (i, s) in decls.iter().enumerate() {
let Some(display) = s.display else {
continue;
};
let slot = display.slot();
let path = format!("/content/state/{i}/display");
if s.name.is_none() {
d.push(Diagnostic::error(
STATE_DISPLAY_UNDRAWABLE,
"quests",
path.clone(),
format!(
"`{}` asks to stand on the {slot} but has no `name`, and the {slot}'s \
heading is the display name — without one the objective's internal id \
would stand on every player's screen. Give the datum a `name`, or take \
`display` off it",
s.id.as_str()
),
));
}
if s.scope == crate::StateScope::Party {
d.push(Diagnostic::error(
STATE_DISPLAY_UNDRAWABLE,
"quests",
path.clone(),
format!(
"`{}` is `party`-scoped and asks to stand on the {slot}, but a party \
datum's value lives on the `#party` holder and the {slot} hides every \
holder whose name starts with `#` — the display would be an empty \
heading. Declare it `player`-scoped if each player holds their own, or \
take `display` off it and keep the announcement",
s.id.as_str()
),
));
}
match standing {
None => standing = Some(s),
Some(first) => d.push(Diagnostic::error(
STATE_DISPLAY_UNDRAWABLE,
"quests",
path,
format!(
"`{}` and `{}` both ask to stand on the {slot}, which holds one \
objective. Keep `display` on the one datum the party reads between \
changes and take it off the other — the engine does not pick for you",
first.id.as_str(),
s.id.as_str()
),
)),
}
}
let mut read: BTreeSet<String> = BTreeSet::new();
crate::gate::for_each_gate(c, &mut |site, gate| {
for (k, cmp) in gate.requires_state.iter().enumerate() {
let path = format!("{}/requires_state/{k}", site.path);
let stage = site.consumer.stage();
match declared.get(cmp.state.as_str()) {
None => d.push(Diagnostic::error(
codes::STATE_UNDECLARED,
stage,
path,
format!(
"`requires_state` on this {} compares `{}`, which the campaign never \
declares. Add it to the stage-5 `state` list (a datum's scope and its \
initial value are facts no use site can supply), or fix the id",
site.consumer.label(),
cmp.state.as_str()
),
)),
Some(decl) => {
read.insert(cmp.state.as_str().to_string());
if decl.scope == crate::StateScope::Player
&& site.consumer == crate::gate::GateConsumer::LethalVolume
{
d.push(Diagnostic::error(
codes::LETHAL_STAGE_GATE,
stage,
path,
format!(
"lethal volume `{}` is staged on `{}`, which is `player`-scoped — a \
volume's liveness is a fact about the place, so a term one player \
satisfies and another does not would be a pit that kills one body \
and spares the one beside it, and the sweep's entity half has no \
player to read a per-player score from. Name a flag or a \
`party`-scoped datum in `when`, or leave `when` out to make the \
volume live from world-load",
volume_id_at(c, &site.path),
cmp.state.as_str()
),
));
} else if decl.scope == crate::StateScope::Player
&& site.consumer.evaluates_per_player() == Some(false)
&& site.consumer != crate::gate::GateConsumer::Loop
{
d.push(Diagnostic::error(
STATE_SCOPE_UNREACHABLE,
stage,
path,
format!(
"`{}` is `player`-scoped, but emission evaluates a {}'s gate \
against the party holder — there is no acting player to read it \
from. Declare the datum `party`-scoped, or move the comparison \
onto a site a player drives (a dialogue option, a cast \
placement, or an effect on a beat a player completes)",
cmp.state.as_str(),
site.consumer.label()
),
));
}
}
}
}
});
let mut written: BTreeSet<String> = BTreeSet::new();
crate::for_each_campaign_effect(c, &mut |path, _site, eff| {
let Some((id, _)) = eff.writes_state() else {
return;
};
match declared.get(id.as_str()) {
None => d.push(Diagnostic::error(
codes::STATE_UNDECLARED,
"quests",
format!("{path}/state"),
format!(
"`{}` writes `{}`, which the campaign never declares. Add it to the stage-5 \
`state` list, or fix the id",
eff.verb.tag(),
id.as_str()
),
)),
Some(_) => {
written.insert(id.as_str().to_string());
}
}
});
for l in &c.quests.content.loops {
if let Some(counts) = &l.counts
&& declared.contains_key(counts.as_str())
{
written.insert(counts.as_str().to_string());
}
}
crate::effects::for_each_effect_root(c, &mut |site, effs| {
let scheduled = !site.runs_with_acting_player();
check_player_state_not_scheduled(effs, &declared, site.stage, &site.path, scheduled, d);
});
for (i, s) in decls.iter().enumerate() {
let id = s.id.as_str();
if declared.get(id).is_none_or(|kept| !std::ptr::eq(*kept, s)) {
continue;
}
if read.contains(id) && !written.contains(id) {
d.push(Diagnostic::error(
STATE_NEVER_WRITTEN,
"quests",
format!("/content/state/{i}"),
format!(
"`{id}` is read by a gate but no `set-state`/`add-state`/`clear-state` \
anywhere in the campaign ever writes it — it can only ever hold its declared \
initial ({}), so every comparison against it was decided when the campaign \
was written. Write it somewhere, or drop the comparison and say what you \
meant unconditionally",
s.initial
),
));
}
if !read.contains(id) {
let tail = if written.contains(id) {
"some verb writes it and nothing ever asks"
} else {
"nothing touches it at all"
};
d.push(Diagnostic::error(
STATE_NEVER_READ,
"quests",
format!("/content/state/{i}"),
format!(
"`{id}` is declared but no gate's `requires_state` anywhere in the campaign \
ever reads it — {tail}. Runtime state exists to be compared against; gate \
something on it, or delete the declaration and its writes"
),
));
}
}
}
fn check_player_state_not_scheduled(
effs: &[QuestEffect],
declared: &BTreeMap<&str, &crate::StateDecl>,
stage: &str,
path: &str,
scheduled: bool,
d: &mut Vec<Diagnostic>,
) {
let is_player = |id: &str| {
declared
.get(id)
.is_some_and(|s| s.scope == crate::StateScope::Player)
};
for e in effs {
if scheduled {
for cmp in e.requires_state() {
if is_player(cmp.state.as_str()) {
d.push(Diagnostic::error(
STATE_SCOPE_UNREACHABLE,
stage,
path.to_string(),
format!(
"a `{}` effect's `requires_state` compares `{}`, which is \
`player`-scoped, in a bundle that runs with no acting player (a \
trigger's effects, a trap's payload and a shortcut's `on_unlock` \
are polled on the tick from the server command source; so are a \
`sequence` step and a `move-npc`/`move-actor` `on_arrive`). There \
is no player to read the datum from. Declare it `party`-scoped, or \
move the comparison onto a beat a player completes",
e.verb.tag(),
cmp.state.as_str()
),
));
}
}
}
if scheduled
&& let Some((id, _)) = e.writes_state()
&& is_player(id.as_str())
{
d.push(Diagnostic::error(
STATE_SCOPE_UNREACHABLE,
stage,
path.to_string(),
format!(
"`{}` writes `{}`, which is `player`-scoped, from a bundle that runs with no \
acting player (a trigger's effects, a trap's payload and a shortcut's \
`on_unlock` are polled on the tick from the server command source; so are a \
`sequence` step and a `move-npc`/`move-actor` `on_arrive`). There is no \
acting player whose datum this would be, so the write would silently reach \
nobody. Declare the datum `party`-scoped, or move the write onto a beat a \
player completes",
e.verb.tag(),
id.as_str()
),
));
}
if scheduled && e.audience == Some(crate::EffectAudience::Actor) {
d.push(Diagnostic::error(
STATE_SCOPE_UNREACHABLE,
stage,
path.to_string(),
format!(
"a `{}` effect declares `audience: actor` in a bundle that runs with no \
acting player (a polled trigger's effects, a trap's payload and a shortcut's \
`on_unlock` run from the server command source; so do a `move-npc`/\
`move-actor` `on_arrive`, a `bonfire`'s `on_rest`, and every step of a \
timeline started there). There is no actor to address. Move the beat onto a \
site a player drives (an objective's completion, a `presser` trigger, a \
respawn), or drop `audience` to address the party",
e.verb.tag()
),
));
}
for (list, how) in e.nested_effect_dispatch() {
check_player_state_not_scheduled(
list,
declared,
stage,
path,
!how.has_actor(!scheduled),
d,
);
}
}
}
pub(crate) fn read_after_write_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
read_after_write_walk(c, d);
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ReadAfterWriteBinding {
pub bundles: usize,
pub effects: usize,
pub gated_writes: usize,
pub refused: usize,
}
impl ReadAfterWriteBinding {
pub fn of(c: &Campaign) -> Self {
read_after_write_walk(c, &mut Vec::new())
}
pub fn line(&self) -> String {
format!(
"read-after-write binding: {} effect(s) over {} bundle(s) walked, {} gated write(s) \
recorded, {} refused (DW0527).",
self.effects, self.bundles, self.gated_writes, self.refused
)
}
}
fn read_after_write_walk(c: &Campaign, d: &mut Vec<Diagnostic>) -> ReadAfterWriteBinding {
let before = d.len();
let mut b = ReadAfterWriteBinding::default();
crate::effects::for_each_effect_root(c, &mut |site, list| {
b.bundles += 1;
b.effects += list.len();
let mut written: BTreeMap<&str, usize> = BTreeMap::new();
for (i, eff) in list.iter().enumerate() {
for cmp in eff.requires_state() {
let Some(at) = written.get(cmp.state.as_str()) else {
continue;
};
d.push(Diagnostic::warning(
STATE_READ_AFTER_WRITE,
site.stage,
format!("{}/{i}/when/requires_state", site.path),
format!(
"this `{}` compares `{}`, and effect {at} of the same bundle already \
changes `{}` behind a gate on `{}` itself — so this comparison is made \
against the value THIS bundle just produced, on the far side of the \
boundary it just tested. The shape that bites is a purchase followed by \
its own refusal: buying the LAST coin debits it, and the `at-most` \
apology written after the debit then holds as well, so the player is \
charged AND told they cannot afford it. Move every reading effect ahead \
of the write. (An UNCONDITIONAL write followed by a comparison is not \
this: `set-state toll 0` and then a door gated on `toll at-most 0` \
plainly means the value the bundle just produced, and is not \
diagnosed.)",
eff.verb.tag(),
cmp.state.as_str(),
cmp.state.as_str(),
cmp.state.as_str()
),
));
}
if let Some((id, _)) = eff.writes_state()
&& eff
.requires_state()
.iter()
.any(|c| c.state.as_str() == id.as_str())
{
b.gated_writes += 1;
written.entry(id.as_str()).or_insert(i);
}
}
});
b.refused = d.len() - before;
b
}
pub(crate) fn gate_contradiction_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
crate::gate::for_each_gate(c, &mut |site, gate| {
let Some(contra) = gate.contradiction() else {
return;
};
let what = match contra {
crate::gate::GateContradiction::Flag(f) => {
format!("flag `{f}` is both required and forbidden, so the gate is never satisfied")
}
crate::gate::GateContradiction::Datum(s) => format!(
"no value of `{s}` satisfies every `requires_state` term that reads it, so the \
gate is never satisfied"
),
};
d.push(Diagnostic::error(
GATE_NEVER_OPENS,
site.consumer.stage(),
site.path.clone(),
format!(
"this {}'s gate contradicts itself: {what}. Whatever it guards can never happen — \
fix the gate, or delete the thing it makes unreachable",
site.consumer.label()
),
));
});
}
fn volume_id_at(c: &Campaign, path: &str) -> String {
path.strip_prefix("/content/lethal_volumes/")
.and_then(|rest| rest.split('/').next())
.and_then(|i| i.parse::<usize>().ok())
.and_then(|i| c.quests.content.lethal_volumes.get(i))
.map_or_else(|| path.to_string(), |v| v.id.as_str().to_string())
}