use std::collections::{BTreeMap, BTreeSet};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::Objective;
use crate::diagnostic::{Diagnostic, DwCode, ExitTier};
use crate::envelope::Campaign;
use crate::ids::{AnchorId, EdgeId, FactId, FlagId, NodeId, ObjectiveId, QuestId};
use crate::metrics::{MetricKind, Metrics, Reads};
crate::dw_code! {
pub const DW_GRAPH_MALFORMED: DwCode = DwCode::new("DW0814", ExitTier::Build);
}
crate::dw_code! {
pub const DW_NODE_UNREACHED: DwCode = DwCode::new("DW0816", ExitTier::Build);
}
crate::dw_code! {
pub const DW_CRITICAL_PATH: DwCode = DwCode::new("DW0817", ExitTier::Build);
}
crate::dw_code! {
pub const DW_GRAPH_MISSION: DwCode = DwCode::new("DW0818", ExitTier::Build);
}
crate::dw_code! {
pub const DW_ONE_WAY_STRANDS: DwCode = DwCode::new("DW0819", ExitTier::Build);
}
crate::dw_code! {
pub const DW_SHORTCUT_NO_LOOP: DwCode = DwCode::new("DW0820", ExitTier::Build);
}
crate::dw_code! {
pub const DW_PACING: DwCode = DwCode::new("DW0822", ExitTier::Build);
}
crate::dw_code! {
pub const DW_STATION_RESERVED: DwCode = DwCode::new("DW0869", ExitTier::Build);
}
crate::dw_code! {
pub const DW_STATION_DUPLICATE: DwCode = DwCode::new("DW0870", ExitTier::Build);
}
crate::dw_code! {
pub const DW_STATION_KIND: DwCode = DwCode::new("DW0871", ExitTier::Build);
}
crate::dw_code! {
pub const DW_PLACE_CLASS: DwCode = DwCode::new("DW0875", ExitTier::Build);
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct GeometryBriefContent {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub facts: Vec<BriefFact>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct BriefFact {
pub id: FactId,
pub value: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unit: Option<String>,
pub note: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct LayoutGraphContent {
pub nodes: Vec<Node>,
pub edges: Vec<Edge>,
pub entry: NodeId,
pub goal: NodeId,
pub critical_path: Vec<NodeId>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub beats: Vec<Beat>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Node {
pub id: NodeId,
pub intent: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size_class: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub way_class: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub stations: Vec<Station>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Station {
pub anchor: AnchorId,
pub kind: StationKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum StationKind {
Point,
Gate,
}
impl StationKind {
#[must_use]
pub fn word(self) -> &'static str {
match self {
Self::Point => "point",
Self::Gate => "gate",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum Direction {
AToB,
BToA,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum OpensFrom {
A,
B,
#[default]
Either,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct EdgeGating {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub flags: Vec<FlagId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub quest: Option<QuestId>,
}
impl EdgeGating {
#[must_use]
pub fn is_empty(&self) -> bool {
self.flags.is_empty() && self.quest.is_none()
}
#[must_use]
pub fn terms(&self) -> usize {
self.flags.len() + usize::from(self.quest.is_some())
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "class", rename_all = "kebab-case", deny_unknown_fields)]
pub enum Edge {
Walk {
id: EdgeId,
a: NodeId,
b: NodeId,
#[serde(default, skip_serializing_if = "Option::is_none")]
one_way: Option<Direction>,
#[serde(default, skip_serializing_if = "is_false")]
shortcut: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
gating: Option<EdgeGating>,
},
Stair {
id: EdgeId,
a: NodeId,
b: NodeId,
#[serde(default, skip_serializing_if = "Option::is_none")]
one_way: Option<Direction>,
#[serde(default, skip_serializing_if = "is_false")]
shortcut: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
gating: Option<EdgeGating>,
},
Drop {
id: EdgeId,
a: NodeId,
b: NodeId,
falls: Direction,
#[serde(default, skip_serializing_if = "is_false")]
shortcut: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
gating: Option<EdgeGating>,
},
Barred {
id: EdgeId,
a: NodeId,
b: NodeId,
#[serde(default)]
opens_from: OpensFrom,
#[serde(default, skip_serializing_if = "Option::is_none")]
one_way: Option<Direction>,
#[serde(default, skip_serializing_if = "is_false")]
shortcut: bool,
gating: EdgeGating,
},
Carry {
id: EdgeId,
a: NodeId,
b: NodeId,
#[serde(default, skip_serializing_if = "Option::is_none")]
one_way: Option<Direction>,
gating: EdgeGating,
},
Vision {
id: EdgeId,
a: NodeId,
b: NodeId,
},
}
fn is_false(b: &bool) -> bool {
!*b
}
impl Edge {
#[must_use]
pub fn id(&self) -> &EdgeId {
match self {
Edge::Walk { id, .. }
| Edge::Stair { id, .. }
| Edge::Drop { id, .. }
| Edge::Barred { id, .. }
| Edge::Carry { id, .. }
| Edge::Vision { id, .. } => id,
}
}
#[must_use]
pub fn a(&self) -> &NodeId {
match self {
Edge::Walk { a, .. }
| Edge::Stair { a, .. }
| Edge::Drop { a, .. }
| Edge::Barred { a, .. }
| Edge::Carry { a, .. }
| Edge::Vision { a, .. } => a,
}
}
#[must_use]
pub fn b(&self) -> &NodeId {
match self {
Edge::Walk { b, .. }
| Edge::Stair { b, .. }
| Edge::Drop { b, .. }
| Edge::Barred { b, .. }
| Edge::Carry { b, .. }
| Edge::Vision { b, .. } => b,
}
}
#[must_use]
pub fn class(&self) -> &'static str {
match self {
Edge::Walk { .. } => "walk",
Edge::Stair { .. } => "stair",
Edge::Drop { .. } => "drop",
Edge::Barred { .. } => "barred",
Edge::Carry { .. } => "carry",
Edge::Vision { .. } => "vision",
}
}
#[must_use]
pub fn is_traversal(&self) -> bool {
!matches!(self, Edge::Vision { .. })
}
#[must_use]
pub fn has_seam(&self) -> bool {
!matches!(self, Edge::Vision { .. } | Edge::Carry { .. })
}
#[must_use]
pub fn direction(&self) -> Option<Direction> {
match self {
Edge::Walk { one_way, .. }
| Edge::Stair { one_way, .. }
| Edge::Barred { one_way, .. }
| Edge::Carry { one_way, .. } => *one_way,
Edge::Drop { falls, .. } => Some(*falls),
Edge::Vision { .. } => None,
}
}
#[must_use]
pub fn shortcut(&self) -> bool {
match self {
Edge::Walk { shortcut, .. }
| Edge::Stair { shortcut, .. }
| Edge::Drop { shortcut, .. }
| Edge::Barred { shortcut, .. } => *shortcut,
Edge::Carry { .. } | Edge::Vision { .. } => false,
}
}
#[must_use]
pub fn gating(&self) -> Option<&EdgeGating> {
match self {
Edge::Walk { gating, .. } | Edge::Stair { gating, .. } | Edge::Drop { gating, .. } => {
gating.as_ref()
}
Edge::Barred { gating, .. } | Edge::Carry { gating, .. } => Some(gating),
Edge::Vision { .. } => None,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Beat {
pub quest: QuestId,
pub objective: ObjectiveId,
pub node: NodeId,
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Grant {
Flag(String),
Quest(String),
}
#[derive(Debug, Clone, Default)]
pub struct Closure {
pub reached: BTreeSet<String>,
pub obtained: BTreeSet<Grant>,
pub obtained_when: BTreeMap<String, BTreeSet<Grant>>,
}
impl Closure {
#[must_use]
pub fn satisfied(gating: Option<&EdgeGating>, held: &BTreeSet<Grant>) -> bool {
let Some(g) = gating else { return true };
g.flags
.iter()
.all(|f| held.contains(&Grant::Flag(f.0.clone())))
&& g.quest
.as_ref()
.is_none_or(|q| held.contains(&Grant::Quest(q.0.clone())))
}
#[must_use]
pub fn run(graph: &LayoutGraphContent, grants: &Grants) -> Closure {
let mut c = Closure::default();
c.reached.insert(graph.entry.0.clone());
c.obtained_when
.insert(graph.entry.0.clone(), BTreeSet::new());
loop {
let before = (c.reached.len(), c.obtained.len());
for e in &graph.edges {
if !e.is_traversal() || !Closure::satisfied(e.gating(), &c.obtained) {
continue;
}
let (a, b) = (e.a().0.as_str(), e.b().0.as_str());
let forward = e.direction() != Some(Direction::BToA);
let backward = e.direction() != Some(Direction::AToB);
if forward && c.reached.contains(a) && !c.reached.contains(b) {
c.reached.insert(b.to_string());
c.obtained_when.insert(b.to_string(), c.obtained.clone());
}
if backward && c.reached.contains(b) && !c.reached.contains(a) {
c.reached.insert(a.to_string());
c.obtained_when.insert(a.to_string(), c.obtained.clone());
}
}
for (node, given) in &grants.by_node {
if c.reached.contains(node.as_str()) {
c.obtained.extend(given.iter().cloned());
}
}
for (quest, (nodes, given)) in &grants.by_quest {
if nodes.iter().all(|n| c.reached.contains(n.as_str())) {
c.obtained.insert(Grant::Quest(quest.clone()));
c.obtained.extend(given.iter().cloned());
}
}
if (c.reached.len(), c.obtained.len()) == before {
return c;
}
}
}
}
#[derive(Debug, Clone, Default)]
pub struct Grants {
pub by_node: BTreeMap<String, BTreeSet<Grant>>,
pub by_quest: BTreeMap<String, (BTreeSet<String>, BTreeSet<Grant>)>,
}
impl Grants {
#[must_use]
pub fn of(c: &Campaign, graph: &LayoutGraphContent) -> Grants {
let mut g = Grants::default();
let mut on_objective: BTreeMap<(&str, &str), BTreeSet<Grant>> = BTreeMap::new();
let mut on_quest: BTreeMap<&str, BTreeSet<Grant>> = BTreeMap::new();
let mut npc_flags: BTreeMap<&str, BTreeSet<Grant>> = BTreeMap::new();
for tree in &c.dialogue.content.dialogues {
let set = npc_flags.entry(tree.npc.0.as_str()).or_default();
for node in &tree.nodes {
for opt in &node.options {
for eff in &opt.effects {
if let Some(f) = eff.set_flag() {
set.insert(Grant::Flag(f.0.clone()));
}
}
}
}
}
for q in &c.quests.content.quests {
let mut done: BTreeSet<Grant> = BTreeSet::new();
for eff in &q.on_complete {
eff.visit_deep(&mut |e| {
if let Some(f) = e.set_flag() {
done.insert(Grant::Flag(f.0.clone()));
}
});
}
on_quest.insert(q.id.0.as_str(), done);
for (obj, effects) in &q.on_objective_complete {
let entry = on_objective
.entry((q.id.0.as_str(), obj.0.as_str()))
.or_default();
for eff in effects {
eff.visit_deep(&mut |e| {
if let Some(f) = e.set_flag() {
entry.insert(Grant::Flag(f.0.clone()));
}
});
}
}
for obj in &q.objectives {
if let Objective::TalkTo { id, npc, .. } = obj
&& let Some(flags) = npc_flags.get(npc.0.as_str())
{
on_objective
.entry((q.id.0.as_str(), id.0.as_str()))
.or_default()
.extend(flags.iter().cloned());
}
}
}
for beat in &graph.beats {
let node = beat.node.0.clone();
let given = on_objective
.get(&(beat.quest.0.as_str(), beat.objective.0.as_str()))
.cloned()
.unwrap_or_default();
g.by_node.entry(node.clone()).or_default().extend(given);
let q = g
.by_quest
.entry(beat.quest.0.clone())
.or_insert_with(|| (BTreeSet::new(), BTreeSet::new()));
q.0.insert(node);
q.1 = on_quest
.get(beat.quest.0.as_str())
.cloned()
.unwrap_or_default();
}
g
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
pub struct LayoutBinding {
pub nodes: usize,
pub edges: usize,
pub traversal_edges: usize,
pub shortcut_edges: usize,
pub one_way_edges: usize,
pub gated_edges: usize,
pub carry_edges: usize,
pub stations: usize,
pub gate_stations: usize,
pub beats: usize,
pub spine_beats: usize,
pub path_steps: usize,
pub metric_refs: usize,
pub brief_facts: usize,
pub plan: crate::siteplan::PlanBinding,
}
impl LayoutBinding {
#[must_use]
pub fn of(c: &Campaign) -> LayoutBinding {
let mut b = LayoutBinding {
brief_facts: c
.geometry_brief
.as_ref()
.map_or(0, |g| g.content.facts.len()),
..LayoutBinding::default()
};
let Some(graph) = c.layout_graph.as_ref().map(|g| &g.content) else {
b.plan = crate::siteplan::PlanBinding::of(c);
return b;
};
b.plan = crate::siteplan::PlanBinding::of(c);
b.nodes = graph.nodes.len();
b.edges = graph.edges.len();
b.beats = graph.beats.len();
let spine = c.quest_plan.content.spine();
b.spine_beats = graph
.beats
.iter()
.filter(|beat| spine.contains(beat.quest.0.as_str()))
.count();
b.path_steps = graph.critical_path.len().saturating_sub(1);
b.metric_refs = graph.nodes.len();
for n in &graph.nodes {
b.stations += n.stations.len();
b.gate_stations += n
.stations
.iter()
.filter(|s| s.kind == StationKind::Gate)
.count();
}
for e in &graph.edges {
if e.is_traversal() {
b.traversal_edges += 1;
}
if e.shortcut() {
b.shortcut_edges += 1;
}
if e.is_traversal() && e.direction().is_some() {
b.one_way_edges += 1;
}
if e.gating().is_some_and(|g| !g.is_empty()) {
b.gated_edges += 1;
}
if matches!(e, Edge::Carry { .. }) {
b.carry_edges += 1;
}
}
b
}
#[must_use]
pub fn line(&self) -> String {
format!(
"layout-graph binding: {n} node(s), {e} edge(s) ({t} traversal, {ow} one-way, \
{s} shortcut, {g} gated, {c} carry), {st} station(s) of which {gs} gate(s), {b} beat(s) \
of which {sb} on the mandatory spine, {p} critical-path step(s), \
{m} metrics reference(s); geometry-brief binding: {f} fact(s).",
n = self.nodes,
e = self.edges,
t = self.traversal_edges,
ow = self.one_way_edges,
s = self.shortcut_edges,
g = self.gated_edges,
c = self.carry_edges,
st = self.stations,
gs = self.gate_stations,
b = self.beats,
sb = self.spine_beats,
p = self.path_steps,
m = self.metric_refs,
f = self.brief_facts,
)
}
#[must_use]
pub fn plan_line(&self) -> String {
self.plan.line()
}
}
pub fn check(c: &Campaign, reads: &mut Reads, d: &mut Vec<Diagnostic>) {
let table = Metrics::table();
if let Some(brief) = &c.geometry_brief {
brief_checks(&brief.content, d);
}
let Some(graph) = c.layout_graph.as_ref().map(|g| &g.content) else {
return;
};
let known: BTreeSet<&str> = graph.nodes.iter().map(|n| n.id.0.as_str()).collect();
let malformed = wellformed(graph, &known, d);
metric_names(graph, &table, d);
place_classes(graph, &table, d);
stations(graph, d);
if malformed {
return;
}
mission(c, graph, d);
shortcut_loops(graph, d);
pacing(c, graph, &table, reads, d);
d.extend(reachability(c));
}
fn stations(graph: &LayoutGraphContent, d: &mut Vec<Diagnostic>) {
let entry = crate::siteplan::ENTRY_ANCHOR;
for (i, n) in graph.nodes.iter().enumerate() {
for (j, s) in n.stations.iter().enumerate() {
let name = s.anchor.as_str();
let reserved = ["anchor/node-", "anchor/seam-", "anchor/unlock-"]
.into_iter()
.find(|p| name.starts_with(p));
let why = if let Some(prefix) = reserved {
format!("its name begins `{prefix}`")
} else if name == entry {
format!("`{entry}` is the name the entry place stands under")
} else {
continue;
};
d.push(Diagnostic::error(
DW_STATION_RESERVED,
"layout-graph",
format!("/content/nodes/{i}/stations/{j}/anchor"),
format!(
"station `{name}` takes a name the engine derives: {why}. The derivation \
synthesizes this campaign's whole spatial vocabulary — `{entry}`, an \
`anchor/node-…` for each place, and an `anchor/seam-…` plus an \
`anchor/unlock-…` for each barred connection — so those three prefixes and \
`{entry}` are reserved whether or not this graph happens to produce the \
name today. Name the station something of your own, and the quest layer \
references it exactly as it references a derived one."
),
));
}
}
let mut first: BTreeMap<&str, &NodeId> = BTreeMap::new();
for (i, n) in graph.nodes.iter().enumerate() {
for (j, s) in n.stations.iter().enumerate() {
let name = s.anchor.as_str();
if let Some(other) = first.get(name) {
d.push(Diagnostic::error(
DW_STATION_DUPLICATE,
"layout-graph",
format!("/content/nodes/{i}/stations/{j}/anchor"),
format!(
"station `{name}` is declared by `{here}` and by `{other}`. A station \
name is unique within the AREA — the scope every anchor reference \
resolves in — and a site-plan campaign has exactly one, so the \
campaign's whole vocabulary shares it. Two places cannot both answer \
to one name, because a quest naming it would have two answers and \
nothing would say which. Rename one of them, or declare it on one \
place only: a quest in either place may name a station of the other.",
here = n.id,
),
));
} else {
first.insert(name, &n.id);
}
}
}
}
fn brief_checks(brief: &GeometryBriefContent, d: &mut Vec<Diagnostic>) {
let mut seen: BTreeSet<&str> = BTreeSet::new();
for (i, f) in brief.facts.iter().enumerate() {
if !f.id.is_valid_syntax() {
d.push(Diagnostic::error(
crate::codes::ID_SYNTAX,
"geometry-brief",
format!("/content/facts/{i}/id"),
format!(
"malformed fact id `{}` — a brief fact is named `fact/<kebab-case>`, so that \
a site plan's identity can bind to it by name.",
f.id
),
));
}
if !seen.insert(f.id.0.as_str()) {
d.push(Diagnostic::error(
crate::codes::ID_DUPLICATE,
"geometry-brief",
format!("/content/facts/{i}/id"),
format!(
"duplicate fact id `{}` — rename one, because an identity binding to this \
name would otherwise hold the map to whichever number was written last.",
f.id
),
));
}
}
}
fn wellformed(graph: &LayoutGraphContent, known: &BTreeSet<&str>, d: &mut Vec<Diagnostic>) -> bool {
let before = d.len();
let mut seen_nodes: BTreeSet<&str> = BTreeSet::new();
for (i, n) in graph.nodes.iter().enumerate() {
if !n.id.is_valid_syntax() {
d.push(Diagnostic::error(
crate::codes::ID_SYNTAX,
"layout-graph",
format!("/content/nodes/{i}/id"),
format!(
"malformed node id `{}` — a place is named `node/<kebab-case>`.",
n.id
),
));
}
if !seen_nodes.insert(n.id.0.as_str()) {
d.push(Diagnostic::error(
DW_GRAPH_MALFORMED,
"layout-graph",
format!("/content/nodes/{i}/id"),
format!(
"duplicate node id `{}` — two places cannot share a name, because every \
edge, beat and critical-path step that names it would then name both.",
n.id
),
));
}
if n.intent.trim().is_empty() {
d.push(Diagnostic::error(
DW_GRAPH_MALFORMED,
"layout-graph",
format!("/content/nodes/{i}/intent"),
format!(
"place `{}` declares an empty `intent` — no check keys on this label, which \
is exactly why it has to be written: it is the recorded judgement a \
reviewer and the later per-place brief read.",
n.id
),
));
}
}
let mut seen_edges: BTreeSet<&str> = BTreeSet::new();
for (i, e) in graph.edges.iter().enumerate() {
if !e.id().is_valid_syntax() {
d.push(Diagnostic::error(
crate::codes::ID_SYNTAX,
"layout-graph",
format!("/content/edges/{i}/id"),
format!(
"malformed edge id `{}` — a connection is named `edge/<kebab-case>`.",
e.id()
),
));
}
if !seen_edges.insert(e.id().0.as_str()) {
d.push(Diagnostic::error(
DW_GRAPH_MALFORMED,
"layout-graph",
format!("/content/edges/{i}/id"),
format!(
"duplicate edge id `{}` — rename one, because a seam is allocated per edge \
and two edges of one name would allocate one seam between them.",
e.id()
),
));
}
for (end, node) in [("a", e.a()), ("b", e.b())] {
if !known.contains(node.0.as_str()) {
d.push(Diagnostic::error(
DW_GRAPH_MALFORMED,
"layout-graph",
format!("/content/edges/{i}/{end}"),
format!(
"connection `{}` ends at `{node}`, which is not a declared place — \
declare that node, or point the end at one of the {n} that exist.",
e.id(),
n = known.len(),
),
));
}
}
if e.a() == e.b() {
d.push(Diagnostic::error(
DW_GRAPH_MALFORMED,
"layout-graph",
format!("/content/edges/{i}"),
format!(
"connection `{}` has both ends in `{}` — a self-loop states nothing a place \
does not already state, at every class, so it is refused rather than \
silently carried into a seam allocation with no face to sit on.",
e.id(),
e.a(),
),
));
}
}
for (field, node) in [("entry", &graph.entry), ("goal", &graph.goal)] {
if !known.contains(node.0.as_str()) {
d.push(Diagnostic::error(
DW_GRAPH_MALFORMED,
"layout-graph",
format!("/content/{field}"),
format!(
"`{field}` names `{node}`, which is not a declared place — every proof over \
this graph starts or ends there, so it cannot be a name nothing defines."
),
));
}
}
for (i, node) in graph.critical_path.iter().enumerate() {
if !known.contains(node.0.as_str()) {
d.push(Diagnostic::error(
DW_GRAPH_MALFORMED,
"layout-graph",
format!("/content/critical_path/{i}"),
format!("the critical path steps through `{node}`, which is not a declared place."),
));
}
}
for (i, beat) in graph.beats.iter().enumerate() {
if !known.contains(beat.node.0.as_str()) {
d.push(Diagnostic::error(
DW_GRAPH_MALFORMED,
"layout-graph",
format!("/content/beats/{i}/node"),
format!(
"beat `{q}` / `{o}` happens in `{n}`, which is not a declared place.",
q = beat.quest,
o = beat.objective,
n = beat.node,
),
));
}
}
d.len() != before
}
fn metric_names(graph: &LayoutGraphContent, table: &Metrics, d: &mut Vec<Diagnostic>) {
for (i, n) in graph.nodes.iter().enumerate() {
for (field, kind, named) in [
("size_class", MetricKind::SizeClass, n.size_class.as_ref()),
("way_class", MetricKind::WayClass, n.way_class.as_ref()),
] {
let Some(named) = named else { continue };
if let Err(unknown) = table.resolve(kind, named) {
d.push(unknown.diagnostic("layout-graph", &format!("/content/nodes/{i}/{field}")));
}
}
}
}
fn place_classes(graph: &LayoutGraphContent, table: &Metrics, d: &mut Vec<Diagnostic>) {
let sizes = table.names_of(MetricKind::SizeClass).join(", ");
let ways = table.names_of(MetricKind::WayClass).join(", ");
for (i, n) in graph.nodes.iter().enumerate() {
let (size, way) = (n.size_class.as_ref(), n.way_class.as_ref());
let (both, neither) = (
size.is_some() && way.is_some(),
size.is_none() && way.is_none(),
);
if !both && !neither {
continue;
}
let (found, prescription) = if both {
(
format!(
"declares BOTH `size_class: \"{s}\"` and `way_class: \"{w}\"`",
s = size.expect("both"),
w = way.expect("both"),
),
"delete whichever one this place is not. A size class bounds a footprint on \
both horizontal axes and a way class bounds a cross-section and leaves the \
run free, so they are two different questions about the same box and every \
geometric rule below would have to pick between them with no rule to pick \
by"
.to_string(),
)
} else {
(
"declares neither `size_class` nor `way_class`".to_string(),
format!(
"give it one. A place with no standard is a place nothing can judge — \
`DW0832` has nothing to hold its extents to and the pacing projection \
has nothing to cross it in. Defined size classes: {sizes}. Defined way \
classes: {ways}",
sizes = sizes,
ways = ways,
),
)
};
d.push(Diagnostic::error(
DW_PLACE_CLASS,
"layout-graph",
format!("/content/nodes/{i}"),
format!(
"`{node}` {found} — a place is classified exactly once. To fix it, \
{prescription}.",
node = n.id,
),
));
}
}
fn mission(c: &Campaign, graph: &LayoutGraphContent, d: &mut Vec<Diagnostic>) {
let quests: BTreeMap<&str, BTreeSet<&str>> = c
.quests
.content
.quests
.iter()
.map(|q| {
(
q.id.0.as_str(),
q.objectives.iter().map(|o| o.id().0.as_str()).collect(),
)
})
.collect();
let produced = crate::validate::produced_flags(c);
let unwritten = if quests.is_empty() {
" Stage 5 declares no quests at all here, so everything this graph borrows from the \
mission is missing, every one of these lines says the same thing, and none of them is \
the finding: see `DW0150`, which names that state. This clears when stage 5 is written."
} else {
""
};
let mut bound: BTreeMap<(&str, &str), usize> = BTreeMap::new();
for (i, beat) in graph.beats.iter().enumerate() {
match quests.get(beat.quest.0.as_str()) {
None => d.push(Diagnostic::error(
DW_GRAPH_MISSION,
"layout-graph",
format!("/content/beats/{i}/quest"),
format!(
"beat names quest `{}`, which the quest documents do not declare — the graph \
says where the mission happens, so it can only name beats the mission \
has.{unwritten}",
beat.quest,
),
)),
Some(objectives) if !objectives.contains(beat.objective.0.as_str()) => {
d.push(Diagnostic::error(
DW_GRAPH_MISSION,
"layout-graph",
format!("/content/beats/{i}/objective"),
format!(
"beat names objective `{o}`, which quest `{q}` does not declare.",
o = beat.objective,
q = beat.quest,
),
));
}
Some(_) => {
*bound
.entry((beat.quest.0.as_str(), beat.objective.0.as_str()))
.or_default() += 1;
}
}
}
for (i, e) in graph.edges.iter().enumerate() {
let Some(g) = e.gating() else { continue };
for (k, f) in g.flags.iter().enumerate() {
if !produced.contains(f.0.as_str()) {
d.push(Diagnostic::error(
DW_GRAPH_MISSION,
"layout-graph",
format!("/content/edges/{i}/gating/flags/{k}"),
format!(
"connection `{e_id}` waits on flag `{f}`, which no `set-flag` effect ever \
produces — a body could never hold it, so the connection is a wall \
wearing a gate's clothes. Produce the flag, or gate on one the campaign \
really sets.",
e_id = e.id(),
),
));
}
}
if let Some(q) = &g.quest
&& !quests.contains_key(q.0.as_str())
{
d.push(Diagnostic::error(
DW_GRAPH_MISSION,
"layout-graph",
format!("/content/edges/{i}/gating/quest"),
format!(
"connection `{e_id}` waits on quest `{q}`, which the quest documents do not \
declare.{unwritten}",
e_id = e.id(),
),
));
}
if matches!(e, Edge::Carry { .. }) && g.is_empty() {
d.push(Diagnostic::error(
DW_GRAPH_MISSION,
"layout-graph",
format!("/content/edges/{i}/gating"),
format!(
"carry connection `{e_id}` says nothing about what makes it carry. A carry \
with an empty `gating` is live from world load, which leaves a hole in the \
graph's own claim; name the flag or the quest whose completion arms the \
link that realises it.",
e_id = e.id(),
),
));
}
if matches!(e, Edge::Barred { .. }) && g.is_empty() {
d.push(Diagnostic::error(
DW_GRAPH_MISSION,
"layout-graph",
format!("/content/edges/{i}/gating"),
format!(
"barred connection `{e_id}` says nothing about what opens it. A barred way \
with an empty `gating` is passable from world load, which is not barred; \
name the flag or the quest whose completion opens it, and `DW0818` then \
holds that name to something the campaign really produces.",
e_id = e.id(),
),
));
}
}
for q in &c.quests.content.quests {
for (oi, obj) in q.objectives.iter().enumerate() {
let n = bound
.get(&(q.id.0.as_str(), obj.id().0.as_str()))
.copied()
.unwrap_or(0);
if n == 0 {
d.push(Diagnostic::error(
DW_GRAPH_MISSION,
"quests",
format!(
"/content/quests/{qi}/objectives/{oi}",
qi = quest_index(c, q)
),
format!(
"objective `{o}` of quest `{q}` happens somewhere and the layout graph \
does not say where. Every objective is place-bound — a body has to be \
standing somewhere to talk, to reach, to fight or to take — so add a \
`beats[]` entry binding it to a node. This is the direction that keeps \
space and mission from silently disagreeing: a graph may be authored \
before the quests, never in ignorance of them.",
o = obj.id(),
q = q.id,
),
));
} else if n > 1 {
d.push(Diagnostic::error(
DW_GRAPH_MISSION,
"layout-graph",
"/content/beats",
format!(
"objective `{o}` of quest `{q}` is bound to {n} places. A beat happens in \
exactly one place; two bindings make every proof over this graph pick \
one of them and no rule says which.",
o = obj.id(),
q = q.id,
),
));
}
}
}
}
fn quest_index(c: &Campaign, q: &crate::Quest) -> usize {
c.quests
.content
.quests
.iter()
.position(|x| std::ptr::eq(x, q))
.unwrap_or(0)
}
fn shortcut_loops(graph: &LayoutGraphContent, d: &mut Vec<Diagnostic>) {
for (i, e) in graph.edges.iter().enumerate() {
if !e.shortcut() {
continue;
}
if connected_without(graph, e.id(), e.a(), e.b()) {
continue;
}
d.push(Diagnostic::error(
DW_SHORTCUT_NO_LOOP,
"layout-graph",
format!("/content/edges/{i}/shortcut"),
format!(
"connection `{e_id}` is marked a shortcut and closes no loop: with it removed, \
`{a}` and `{b}` are no longer connected at all. A shortcut is the way back into \
ground a body has already crossed, so an edge that is the ONLY way between its \
two places is a corridor wearing a shortcut's name. Either drop the mark, or add \
the long way round it is meant to shorten.",
e_id = e.id(),
a = e.a(),
b = e.b(),
),
));
}
}
fn connected_without(graph: &LayoutGraphContent, skip: &EdgeId, a: &NodeId, b: &NodeId) -> bool {
let mut seen: BTreeSet<&str> = BTreeSet::new();
let mut stack = vec![a.0.as_str()];
seen.insert(a.0.as_str());
while let Some(at) = stack.pop() {
if at == b.0.as_str() {
return true;
}
for e in &graph.edges {
if !e.is_traversal() || e.id() == skip {
continue;
}
let (x, y) = (e.a().0.as_str(), e.b().0.as_str());
let other = if x == at {
y
} else if y == at {
x
} else {
continue;
};
if seen.insert(other) {
stack.push(other);
}
}
}
false
}
fn pacing(
c: &Campaign,
graph: &LayoutGraphContent,
table: &Metrics,
reads: &mut Reads,
d: &mut Vec<Diagnostic>,
) {
let runs: BTreeMap<&str, u64> = c
.site_plan
.as_ref()
.map(|p| {
p.content
.boxes
.iter()
.map(|b| {
let (dx, dz) = (u64::from(b.extent[0].get()), u64::from(b.extent[1].get()));
(b.node.0.as_str(), dx.max(dz))
})
.collect()
})
.unwrap_or_default();
let mut blocks: u64 = 0;
let mut legs = 0usize;
let mut unprojected: Vec<String> = Vec::new();
for node_id in &graph.critical_path {
let Some(node) = graph.nodes.iter().find(|n| &n.id == node_id) else {
continue;
};
if let Some(way) = node.way_class.as_ref() {
let Ok(entry) = table.resolve(MetricKind::WayClass, way) else {
continue;
};
let _ = entry.value(reads);
match runs.get(node_id.0.as_str()) {
Some(run) => {
blocks += *run;
legs += 1;
}
None => unprojected.push(format!("`{node_id}`")),
}
continue;
}
let Some(size) = node.size_class.as_ref() else {
continue; };
let Ok(entry) = table.resolve(MetricKind::SizeClass, size) else {
continue; };
if let crate::metrics::MetricValue::SizeClass(sc) = entry.value(reads) {
blocks += u64::from(sc.nominal_traverse_blocks);
legs += 1;
}
}
let Ok(per_minute) = table.resolve(MetricKind::Pacing, "route-blocks-per-minute") else {
return;
};
let crate::metrics::MetricValue::Count(rate) = per_minute.value(reads) else {
return;
};
let rate = u64::from(*rate).max(1);
d.push(Diagnostic::warning(
DW_PACING,
"layout-graph",
"/content/critical_path",
format!(
"the critical path crosses {legs} place(s) over {steps} step(s), a nominal \
{blocks} blocks of route, which at {rate} blocks of route per minute of play \
projects to about {minutes} minute(s) against this world's `target_minutes` of \
{target}{un}. It carries no threshold and refuses nothing — see `DW0822` in \
`docs/reference/dsl/layout.md` for what the number is worth.",
steps = graph.critical_path.len().saturating_sub(1),
minutes = blocks.div_ceil(rate),
target = c.world.content.target_minutes,
un = if unprojected.is_empty() {
String::new()
} else {
format!(
", with {n} way leg(s) UNPROJECTED and not in that total ({names}) — a way \
class bounds a cross-section and leaves the run free, so what crossing one \
costs is its box's long extent and this campaign has no site plan to read \
it from yet. Embedding the graph is what projects them; nothing is wrong \
here",
n = unprojected.len(),
names = unprojected.join(", "),
)
},
),
));
}
#[must_use]
pub fn reachability(c: &Campaign) -> Vec<Diagnostic> {
let mut d = Vec::new();
let Some(graph) = c.layout_graph.as_ref().map(|g| &g.content) else {
return d;
};
let known: BTreeSet<&str> = graph.nodes.iter().map(|n| n.id.0.as_str()).collect();
if !known.contains(graph.entry.0.as_str()) {
return d; }
let grants = Grants::of(c, graph);
let closure = Closure::run(graph, &grants);
let caveat = unwritten_mission_caveat(c, graph);
unreached(graph, &closure, caveat, &mut d);
critical_path(c, graph, &grants, caveat, &mut d);
strands(graph, &closure, caveat, &mut d);
d
}
fn unwritten_mission_caveat(c: &Campaign, graph: &LayoutGraphContent) -> &'static str {
let gates_on_a_flag = graph
.edges
.iter()
.any(|e| e.gating().is_some_and(|g| !g.flags.is_empty()));
if c.quests.content.quests.is_empty() && gates_on_a_flag {
" Stage 5 declares no quests here, so no `set-flag` effect exists yet and every \
flag-gated way in this graph is shut to this proof: a place behind one is closed off \
for that reason alone and opens when stage 5 is written (see `DW0150`). Anything \
unreached for any OTHER reason is a real finding now, and this line does not say which \
of the two you are looking at — the graph does."
} else {
""
}
}
fn unreached(graph: &LayoutGraphContent, closure: &Closure, caveat: &str, d: &mut Vec<Diagnostic>) {
for (i, n) in graph.nodes.iter().enumerate() {
if closure.reached.contains(n.id.0.as_str()) {
continue;
}
let near = graph
.edges
.iter()
.filter(|e| e.is_traversal())
.find_map(|e| {
let (a, b) = (e.a().0.as_str(), e.b().0.as_str());
if a == n.id.0 && closure.reached.contains(b) {
Some(b)
} else if b == n.id.0 && closure.reached.contains(a) {
Some(a)
} else {
None
}
});
let hint = match near {
Some(other) => format!(
"the nearest place a body can stand is `{other}`, so the missing link is between \
those two — either its gating demands something no reached beat grants, or it \
runs one way and the wrong way"
),
None => "no connection reaches it from anywhere a body can stand at all".to_string(),
};
d.push(Diagnostic::error(
DW_NODE_UNREACHED,
"layout-graph",
format!("/content/nodes/{i}"),
format!(
"place `{id}` is never reached: {hint}. Of the {total} place(s) this graph \
declares, {n} are reachable from `{entry}` under the campaign's own \
gating.{caveat}",
id = n.id,
total = graph.nodes.len(),
n = closure.reached.len(),
entry = graph.entry,
),
));
}
}
fn critical_path(
c: &Campaign,
graph: &LayoutGraphContent,
grants: &Grants,
caveat: &str,
d: &mut Vec<Diagnostic>,
) {
let path = &graph.critical_path;
let mut fault = |path_suffix: &str, msg: String| {
d.push(Diagnostic::error(
DW_CRITICAL_PATH,
"layout-graph",
format!("/content/critical_path{path_suffix}"),
msg,
));
};
if path.first() != Some(&graph.entry) || path.last() != Some(&graph.goal) {
fault(
"",
format!(
"the critical path must run from `{entry}` to `{goal}`; it runs from {from} to \
{to}. It is authored rather than derived precisely so that it is a claim, and a \
claim that does not start where a body starts is not one.",
entry = graph.entry,
goal = graph.goal,
from = path.first().map_or("nowhere".into(), |n| format!("`{n}`")),
to = path.last().map_or("nowhere".into(), |n| format!("`{n}`")),
),
);
}
let mut held: BTreeSet<Grant> = BTreeSet::new();
let mut visited: BTreeSet<&str> = BTreeSet::new();
let mut steps = 0usize;
for (i, pair) in path.windows(2).enumerate() {
let (from, to) = (&pair[0], &pair[1]);
visited.insert(from.0.as_str());
collect_grants(grants, &visited, &mut held);
let edge = graph.edges.iter().find(|e| {
e.is_traversal()
&& ((e.a() == from && e.b() == to && e.direction() != Some(Direction::BToA))
|| (e.b() == from && e.a() == to && e.direction() != Some(Direction::AToB)))
});
match edge {
None => fault(
&format!("/{}", i + 1),
format!(
"the critical path steps from `{from}` to `{to}` and no connection runs that \
way. Either the two places share no edge at all, or the one they share runs \
the other way."
),
),
Some(e) if !Closure::satisfied(e.gating(), &held) => {
let unwritten = if e.gating().is_some_and(|g| !g.flags.is_empty()) {
caveat
} else {
""
};
fault(
&format!("/{}", i + 1),
format!(
"the critical path steps from `{from}` to `{to}` over `{e_id}`, which is \
not open yet at that point in the walk: nothing bound to the {v} \
place(s) already visited grants what it waits on. Move the beat that \
opens it earlier on the path, or route the path through the place that \
grants it.{unwritten}",
e_id = e.id(),
v = visited.len(),
),
);
}
Some(_) => {}
}
steps += 1;
}
if let Some(last) = path.last() {
visited.insert(last.0.as_str());
}
let spine = c.quest_plan.content.spine();
let mut required = 0usize;
for beat in &graph.beats {
if !spine.contains(beat.quest.0.as_str()) {
continue;
}
required += 1;
if !visited.contains(beat.node.0.as_str()) {
fault(
"",
format!(
"beat `{q}` / `{o}` happens in `{n}`, which the critical path never visits — \
and `{q}` is on the mandatory spine, so a body walking this path would reach \
the goal without doing it.",
q = beat.quest,
o = beat.objective,
n = beat.node,
),
);
}
}
let _ = (steps, required);
}
fn collect_grants(grants: &Grants, visited: &BTreeSet<&str>, held: &mut BTreeSet<Grant>) {
for (node, given) in &grants.by_node {
if visited.contains(node.as_str()) {
held.extend(given.iter().cloned());
}
}
for (quest, (nodes, given)) in &grants.by_quest {
if nodes.iter().all(|n| visited.contains(n.as_str())) {
held.insert(Grant::Quest(quest.clone()));
held.extend(given.iter().cloned());
}
}
}
fn strands(graph: &LayoutGraphContent, closure: &Closure, caveat: &str, d: &mut Vec<Diagnostic>) {
let spine: BTreeSet<&str> = graph.critical_path.iter().map(|n| n.0.as_str()).collect();
for (i, e) in graph.edges.iter().enumerate() {
if !e.is_traversal() {
continue;
}
let Some(dir) = e.direction() else { continue };
let (from, to) = match dir {
Direction::AToB => (e.a(), e.b()),
Direction::BToA => (e.b(), e.a()),
};
let Some(held) = closure.obtained_when.get(from.0.as_str()) else {
continue; };
if rejoins(graph, to, held, &spine) {
continue;
}
d.push(Diagnostic::error(
DW_ONE_WAY_STRANDS,
"layout-graph",
format!("/content/edges/{i}"),
format!(
"connection `{e_id}` runs one way from `{from}` into `{to}`, and from `{to}` \
there is no way back to the critical path. A body can only be in `{to}` having \
taken this connection, so a walk that takes it is a softlock. Add a way out of \
`{to}` — the shortcut back is the usual one — or make the connection \
two-way.{caveat}",
e_id = e.id(),
),
));
}
}
fn rejoins(
graph: &LayoutGraphContent,
at: &NodeId,
held: &BTreeSet<Grant>,
spine: &BTreeSet<&str>,
) -> bool {
let mut seen: BTreeSet<&str> = BTreeSet::new();
let mut stack = vec![at.0.as_str()];
seen.insert(at.0.as_str());
while let Some(here) = stack.pop() {
if spine.contains(here) {
return true;
}
for e in &graph.edges {
if !e.is_traversal() || !Closure::satisfied(e.gating(), held) {
continue;
}
let (a, b) = (e.a().0.as_str(), e.b().0.as_str());
let next = if a == here && e.direction() != Some(Direction::BToA) {
b
} else if b == here && e.direction() != Some(Direction::AToB) {
a
} else {
continue;
};
if seen.insert(next) {
stack.push(next);
}
}
}
false
}