use std::collections::{BTreeMap, BTreeSet};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::{AreaId, BranchId, BranchPointId, EndingId, FlagId, NpcId, QuestId};
#[cfg(doc)]
use crate::QuestEffect;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct QuestPlanContent {
pub quests: Vec<PlannedQuest>,
pub finale: QuestId,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub branch_points: Vec<BranchPoint>,
}
impl QuestPlanContent {
#[must_use]
pub fn spine(&self) -> BTreeSet<&str> {
let deps: BTreeMap<&str, &[QuestId]> = self
.quests
.iter()
.map(|q| (q.id.as_str(), q.depends_on.as_slice()))
.collect();
let mut spine: BTreeSet<&str> = BTreeSet::new();
let mut stack = vec![self.finale.as_str()];
while let Some(q) = stack.pop() {
if !spine.insert(q) {
continue;
}
for dep in deps.get(q).copied().unwrap_or(&[]) {
stack.push(dep.as_str());
}
}
spine
}
#[must_use]
pub fn optional(&self) -> BTreeSet<&str> {
self.quests
.iter()
.filter(|q| !q.mandatory)
.map(|q| q.id.as_str())
.collect()
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct BranchPoint {
pub id: BranchPointId,
pub opens_at: QuestId,
pub forks_on: Vec<FlagId>,
pub branches: Vec<BranchDecl>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct BranchDecl {
pub id: BranchId,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub flags: Vec<FlagId>,
pub leads_to: String,
}
impl BranchDecl {
pub fn converges_at(&self) -> Option<QuestId> {
let q = QuestId(self.leads_to.clone());
q.is_valid_syntax().then_some(q)
}
pub fn ending(&self) -> Option<EndingId> {
let e = EndingId(self.leads_to.clone());
e.is_valid_syntax().then_some(e)
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Happening {
pub verb: HappeningVerb,
pub text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subject: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct HappeningSubject<'a> {
pub id: &'a str,
pub derived: bool,
}
#[derive(
Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
)]
#[serde(rename_all = "kebab-case")]
pub enum HappeningVerb {
Dies,
Survives,
Departs,
Arrives,
Learns,
Believes,
Gains,
Loses,
Opens,
Seals,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct PlannedQuest {
pub id: QuestId,
pub goal: String,
pub area: AreaId,
pub npcs: Vec<NpcId>,
pub depends_on: Vec<QuestId>,
pub mandatory: bool,
pub act: u32,
}
use crate::Verb;
use crate::diagnostic::{Diagnostic, DwCode, ExitTier, codes};
use crate::envelope::Campaign;
use crate::validate::{graph_has_cycle, produced_flags};
crate::dw_code! {
pub const PLAN_CYCLE: DwCode = DwCode::new("DW0130", ExitTier::Build);
}
crate::dw_code! {
pub const FINALE_UNKNOWN: DwCode = DwCode::new("DW0131", ExitTier::Build);
}
crate::dw_code! {
pub const PLAN_NOT_CONVERGENT: DwCode = DwCode::new("DW0132", ExitTier::Build);
}
crate::dw_code! {
pub const OPTIONAL_ON_SPINE: DwCode = DwCode::new("DW0866", ExitTier::Build);
}
crate::dw_code! {
pub const MANDATORY_ON_OPTIONAL: DwCode = DwCode::new("DW0867", ExitTier::Build);
}
crate::dw_code! {
pub const MAINLINE_KEY_OPTIONAL: DwCode = DwCode::new("DW0868", ExitTier::Build);
}
pub(crate) fn plan_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
let plan = &c.quest_plan.content;
let planned_ids: BTreeSet<&str> = plan.quests.iter().map(|q| q.id.as_str()).collect();
let optional: BTreeSet<&str> = plan.optional();
let edges: BTreeMap<&str, Vec<&str>> = plan
.quests
.iter()
.map(|q| {
let deps = q
.depends_on
.iter()
.map(|x| x.as_str())
.filter(|x| planned_ids.contains(x))
.collect();
(q.id.as_str(), deps)
})
.collect();
let nodes: Vec<&str> = plan.quests.iter().map(|q| q.id.as_str()).collect();
if graph_has_cycle(&nodes, &edges) {
d.push(Diagnostic::error(
PLAN_CYCLE,
"quest-plan",
"/content/quests",
"stage-4 quest `depends_on` graph contains a cycle — the plan must be a DAG; remove a \
`depends_on` edge so the quests form an acyclic order",
));
return; }
if !planned_ids.contains(plan.finale.as_str()) {
d.push(Diagnostic::error(
FINALE_UNKNOWN,
"quest-plan",
"/content/finale",
format!(
"stage-4 `finale` `{}` is not a declared quest — set `finale` to the id of an \
existing planned quest (the one that ends the delve)",
plan.finale
),
));
return;
}
let reach = plan.spine();
for (i, q) in plan.quests.iter().enumerate() {
if optional.contains(q.id.as_str()) {
continue;
}
if !reach.contains(q.id.as_str()) {
d.push(Diagnostic::error(
PLAN_NOT_CONVERGENT,
"quest-plan",
format!("/content/quests/{i}"),
format!(
"quest `{}` is not a (transitive) dependency of finale `{}`, so the plan does \
not converge on the finale — add a `depends_on` chain so `{}` eventually \
depends on `{}` (or drop `{}` if it is not part of this delve)",
q.id, plan.finale, plan.finale, q.id, q.id
),
));
}
}
partition(c, &optional, &reach, d);
}
fn partition(
c: &Campaign,
optional: &BTreeSet<&str>,
spine: &BTreeSet<&str>,
d: &mut Vec<Diagnostic>,
) {
if optional.is_empty() {
return;
}
let plan = &c.quest_plan.content;
for (i, q) in plan.quests.iter().enumerate() {
if !optional.contains(q.id.as_str()) || !spine.contains(q.id.as_str()) {
continue;
}
let how = if q.id.as_str() == plan.finale.as_str() {
"it IS the finale".to_string()
} else {
format!("finale `{}` transitively depends on it", plan.finale)
};
d.push(Diagnostic::error(
OPTIONAL_ON_SPINE,
"quest-plan",
format!("/content/quests/{i}/mandatory"),
format!(
"quest `{}` declares `mandatory: false`, but {} — so the delve cannot be \
completed without it and calling it optional would be a claim the \
completability proof then rests on. Set `mandatory: true`, or cut the \
`depends_on` chain that puts it in the finale's closure. Do not leave it for \
the proof to sort out: the skip world is exactly the world in which this \
quest is never played, and the finale never fires there",
q.id, how
),
));
}
for (i, q) in plan.quests.iter().enumerate() {
if optional.contains(q.id.as_str()) {
continue; }
for (j, dep) in q.depends_on.iter().enumerate() {
if !optional.contains(dep.as_str()) {
continue;
}
d.push(Diagnostic::error(
MANDATORY_ON_OPTIONAL,
"quest-plan",
format!("/content/quests/{i}/depends_on/{j}"),
format!(
"mandatory quest `{}` declares `depends_on` `{}`, which is optional — a \
quest on the critical path cannot wait on content the party may never \
play, so this edge makes the mainline unreachable in the skip world. \
Either mark `{}` mandatory, or drop the edge and attach `{}` to the \
spine some other way",
q.id, dep, dep, dep
),
));
}
}
let declared: BTreeSet<&str> = plan.quests.iter().map(|q| q.id.as_str()).collect();
for (i, q) in c.quests.content.quests.iter().enumerate() {
if optional.contains(q.id.as_str()) || !declared.contains(q.id.as_str()) {
continue;
}
let crate::Trigger::QuestComplete { quest } = &q.trigger else {
continue;
};
if !optional.contains(quest.as_str()) {
continue;
}
d.push(Diagnostic::error(
MANDATORY_ON_OPTIONAL,
"quests",
format!("/content/quests/{i}/trigger/quest"),
format!(
"mandatory quest `{}` is triggered by the completion of `{}`, which is \
optional — the party may never complete `{}`, so `{}` would never activate \
and the mainline would stop there. Trigger `{}` from a mandatory quest, or \
mark `{}` mandatory",
q.id, quest, quest, q.id, q.id, quest
),
));
}
mainline_key(c, optional, d);
}
fn mainline_key(c: &Campaign, optional: &BTreeSet<&str>, d: &mut Vec<Diagnostic>) {
let mut only_optional: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
let mut disqualified: BTreeSet<&str> = BTreeSet::new();
crate::for_each_campaign_effect(c, &mut |_path, site, eff| {
let Verb::SetFlag { flag, .. } = &eff.verb else {
return;
};
let flag = flag.as_str();
let owner = match site {
crate::EffectSite::Objective { quest, .. }
| crate::EffectSite::QuestComplete { quest } => quest.as_str(),
_ => {
disqualified.insert(flag);
return;
}
};
match optional.get(owner) {
Some(q) => only_optional.entry(flag).or_default().insert(*q),
None => disqualified.insert(flag),
};
});
for t in &c.dialogue.content.dialogues {
for n in &t.nodes {
for o in &n.options {
for e in &o.effects {
if let crate::DialogueEffect::SetFlag { flag } = e {
disqualified.insert(flag.as_str());
}
}
}
}
}
for trap in &c.quests.content.traps {
if let Some(dis) = &trap.disarm {
disqualified.insert(dis.sets_flag.as_str());
}
}
for (i, q) in c.quests.content.quests.iter().enumerate() {
if optional.contains(q.id.as_str()) {
continue;
}
for (j, o) in q.objectives.iter().enumerate() {
for (m, f) in o.requires_flags().iter().enumerate() {
let flag = f.as_str();
if disqualified.contains(flag) {
continue;
}
let Some(producers) = only_optional.get(flag) else {
continue; };
let names = producers.iter().copied().collect::<Vec<_>>().join("`, `");
d.push(Diagnostic::error(
MAINLINE_KEY_OPTIONAL,
"quests",
format!("/content/quests/{i}/objectives/{j}/requires_flags/{m}"),
format!(
"objective `{}` of mandatory quest `{}` requires flag `{}`, and the \
only effect that ever sets `{}` is rooted in optional quest(s) \
`{}` — so a party that plays only the mainline can never open \
this beat, and the delve is not completable with zero optional \
participation. Move the `set-flag` onto a mandatory quest, mark \
the producing quest mandatory, or drop the gate",
o.id(),
q.id,
flag,
flag,
names
),
));
}
}
}
}
pub(crate) fn branch_point_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
let quests: BTreeSet<&str> = c
.quest_plan
.content
.quests
.iter()
.map(|q| q.id.as_str())
.collect();
let endings: BTreeSet<String> = declared_endings(c);
let flags: BTreeSet<String> = produced_flags(c);
let mut seen_points: BTreeSet<&str> = BTreeSet::new();
let mut seen_branches: BTreeSet<&str> = BTreeSet::new();
for (i, bp) in c.quest_plan.content.branch_points.iter().enumerate() {
let base = format!("/content/branch_points/{i}");
if !bp.id.is_valid_syntax() {
d.push(Diagnostic::error(
codes::ID_SYNTAX,
"quest-plan",
format!("{base}/id"),
format!(
"`{}` is not a valid branch-point id — use `branch-point/<kebab-case>`",
bp.id.as_str()
),
));
} else if !seen_points.insert(bp.id.as_str()) {
d.push(Diagnostic::error(
codes::ID_DUPLICATE,
"quest-plan",
format!("{base}/id"),
format!("duplicate branch-point id `{}`", bp.id.as_str()),
));
}
if !quests.contains(bp.opens_at.as_str()) {
d.push(Diagnostic::error(
codes::DANGLING_REF,
"quest-plan",
format!("{base}/opens_at"),
format!(
"branch point `{}` opens at `{}`, which is not a planned quest — name the \
quest at which the story actually forks",
bp.id.as_str(),
bp.opens_at.as_str()
),
));
}
for (j, f) in bp.forks_on.iter().enumerate() {
if !flags.contains(f.as_str()) {
d.push(Diagnostic::error(
codes::FLAG_UNKNOWN,
"quest-plan",
format!("{base}/forks_on/{j}"),
format!(
"branch point `{}` forks on `{}`, which no `set-flag` effect produces — a \
fork nothing can set is not a fork",
bp.id.as_str(),
f.as_str()
),
));
}
}
let fork_set: BTreeSet<&str> = bp.forks_on.iter().map(|f| f.as_str()).collect();
for (j, b) in bp.branches.iter().enumerate() {
let bpath = format!("{base}/branches/{j}");
if !b.id.is_valid_syntax() {
d.push(Diagnostic::error(
codes::ID_SYNTAX,
"quest-plan",
format!("{bpath}/id"),
format!(
"`{}` is not a valid branch id — use `branch/<kebab-case>`",
b.id.as_str()
),
));
} else if !seen_branches.insert(b.id.as_str()) {
d.push(Diagnostic::error(
codes::ID_DUPLICATE,
"quest-plan",
format!("{bpath}/id"),
format!(
"duplicate branch id `{}` — branch ids are campaign-wide unique because \
each one names an emitted `validation/branch-chronicle-<id>.md`",
b.id.as_str()
),
));
}
for (k, f) in b.flags.iter().enumerate() {
if !fork_set.contains(f.as_str()) {
d.push(Diagnostic::error(
codes::DANGLING_REF,
"quest-plan",
format!("{bpath}/flags/{k}"),
format!(
"branch `{}` holds `{}`, which its branch point does not list in \
`forks_on` — a branch may only pin flags its own fork owns",
b.id.as_str(),
f.as_str()
),
));
}
}
match (b.converges_at(), b.ending()) {
(Some(q), _) => {
if !quests.contains(q.as_str()) {
d.push(Diagnostic::error(
codes::DANGLING_REF,
"quest-plan",
format!("{bpath}/leads_to"),
format!(
"branch `{}` converges at `{}`, which is not a planned quest",
b.id.as_str(),
q.as_str()
),
));
}
}
(None, Some(e)) => {
if !endings.contains(e.as_str()) {
d.push(Diagnostic::error(
codes::DANGLING_REF,
"quest-plan",
format!("{bpath}/leads_to"),
format!(
"branch `{}` runs to `{}`, which no `campaign-complete` effect \
declares — name the ending on the `campaign-complete` that ends \
this branch",
b.id.as_str(),
e.as_str()
),
));
}
}
(None, None) => d.push(Diagnostic::error(
codes::ID_SYNTAX,
"quest-plan",
format!("{bpath}/leads_to"),
format!(
"`{}` is neither a `quest/<kebab>` (the branches converge there) nor an \
`ending/<kebab>` (this branch runs to it) — the prefix is what says which \
one a branch leads to",
b.leads_to
),
)),
}
}
}
}
pub(crate) fn happening_subject_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
let npcs: BTreeSet<&str> = c.npcs.content.npcs.iter().map(|n| n.id.as_str()).collect();
let actors: BTreeSet<&str> = c
.quests
.content
.actors
.iter()
.map(|a| a.id.as_str())
.collect();
let waves: BTreeSet<&str> = c
.quests
.content
.waves
.iter()
.map(|w| w.id.as_str())
.collect();
let check = |subject: &str, stage: &str, path: String, d: &mut Vec<Diagnostic>| {
let known = match subject.split_once('/') {
Some(("npc", _)) => npcs.contains(subject),
Some(("actor", _)) => actors.contains(subject),
Some(("wave", _)) => waves.contains(subject),
Some(("anchor", _)) | Some(("item", _)) => true,
_ => false,
};
if !known {
d.push(Diagnostic::error(
codes::DANGLING_REF,
stage,
path,
format!(
"`happening.subject` names `{subject}`, which is not a declared `npc/`, \
`actor/` or `wave/` id (`anchor/` and `item/` labels are also accepted). A \
subject the compiler cannot resolve cannot be reasoned about, so the \
contradiction proof would silently skip this beat"
),
));
}
};
for (i, q) in c.quests.content.quests.iter().enumerate() {
if let Some(h) = &q.happening
&& let Some(s) = &h.subject
{
check(
s,
"quests",
format!("/content/quests/{i}/happening/subject"),
d,
);
}
for (j, o) in q.objectives.iter().enumerate() {
if let Some(h) = o.happening()
&& let Some(s) = &h.subject
{
check(
s,
"quests",
format!("/content/quests/{i}/objectives/{j}/happening/subject"),
d,
);
}
}
}
let mut effect_subjects: Vec<(String, String)> = Vec::new();
crate::for_each_campaign_effect(c, &mut |path, _site, eff| {
if let Some(s) = eff.happening_subject().filter(|s| !s.derived) {
effect_subjects.push((format!("{path}/happening/subject"), s.id.to_string()));
}
});
for (path, s) in effect_subjects {
check(&s, "quests", path, d);
}
for (i, t) in c.dialogue.content.dialogues.iter().enumerate() {
for (j, n) in t.nodes.iter().enumerate() {
for (k, o) in n.options.iter().enumerate() {
if let Some(h) = &o.happening
&& let Some(s) = &h.subject
{
check(
s,
"dialogue",
format!("/content/dialogues/{i}/nodes/{j}/options/{k}/happening/subject"),
d,
);
}
}
}
}
}
pub fn declared_endings(c: &Campaign) -> BTreeSet<String> {
let mut out = BTreeSet::new();
crate::for_each_campaign_effect(c, &mut |_p, _site, eff| {
if let Verb::CampaignComplete {
ending: Some(e), ..
} = &eff.verb
{
out.insert(e.as_str().to_string());
}
});
out
}
pub(crate) fn plan_id_syntax(c: &Campaign, d: &mut Vec<Diagnostic>) {
for (i, q) in c.quest_plan.content.quests.iter().enumerate() {
crate::ids::id_syntax!(d, q.id, "quest-plan", format!("/content/quests/{i}/id"));
}
}
pub(crate) fn plan_id_uniqueness(c: &Campaign, d: &mut Vec<Diagnostic>) {
crate::ids::dup_check(
c.quest_plan
.content
.quests
.iter()
.enumerate()
.map(|(i, q)| (q.id.as_str(), format!("/content/quests/{i}/id"))),
"quest-plan",
"quest",
d,
);
}
pub(crate) fn plan_dangling_refs(c: &Campaign, d: &mut Vec<Diagnostic>) {
use crate::ids::dangling;
let area_ids = crate::world::declared_area_ids(c);
let npc_ids: BTreeSet<&str> = c.npcs.content.npcs.iter().map(|n| n.id.as_str()).collect();
let planned_ids: BTreeSet<&str> = c
.quest_plan
.content
.quests
.iter()
.map(|q| q.id.as_str())
.collect();
for (i, q) in c.quest_plan.content.quests.iter().enumerate() {
dangling(
d,
area_ids.contains(q.area.as_str()),
"quest-plan",
format!("/content/quests/{i}/area"),
format!(
"quest references unknown area `{}` — {}",
q.area,
crate::placement::Placement::of(c).area_remedy(),
),
);
for (k, npc) in q.npcs.iter().enumerate() {
dangling(
d,
npc_ids.contains(npc.as_str()),
"quest-plan",
format!("/content/quests/{i}/npcs/{k}"),
format!(
"quest references unknown npc `{npc}` — declare it in stage 2 or correct the \
reference"
),
);
}
for (k, dep) in q.depends_on.iter().enumerate() {
dangling(
d,
planned_ids.contains(dep.as_str()),
"quest-plan",
format!("/content/quests/{i}/depends_on/{k}"),
format!(
"quest depends on unknown quest `{dep}` — declare it in the stage-4 quest \
plan or correct the `depends_on` entry"
),
);
}
}
}
#[cfg(test)]
mod spine_tests {
use super::QuestPlanContent;
fn plan(finale: &str, quests: &[(&str, &[&str])]) -> QuestPlanContent {
let quests: Vec<serde_json::Value> = quests
.iter()
.map(|(id, deps)| {
serde_json::json!({
"id": id,
"goal": "g",
"area": "area/keep",
"npcs": [],
"depends_on": deps,
"mandatory": true,
"act": 1,
})
})
.collect();
serde_json::from_value(serde_json::json!({
"finale": finale,
"quests": quests,
}))
.expect("plan fixture parses")
}
fn sorted(p: &QuestPlanContent) -> Vec<String> {
p.spine().into_iter().map(str::to_owned).collect()
}
#[test]
fn a_chain_is_wholly_spine() {
let p = plan(
"quest/c",
&[
("quest/a", &[]),
("quest/b", &["quest/a"]),
("quest/c", &["quest/b"]),
],
);
assert_eq!(sorted(&p), ["quest/a", "quest/b", "quest/c"]);
}
#[test]
fn a_quest_the_finale_does_not_depend_on_is_off_the_spine() {
let p = plan("quest/end", &[("quest/end", &[]), ("quest/side-trip", &[])]);
assert_eq!(sorted(&p), ["quest/end"]);
}
#[test]
fn a_diamond_counts_the_join_once() {
let p = plan(
"quest/d",
&[
("quest/a", &[]),
("quest/b", &["quest/a"]),
("quest/c", &["quest/a"]),
("quest/d", &["quest/b", "quest/c"]),
],
);
assert_eq!(sorted(&p), ["quest/a", "quest/b", "quest/c", "quest/d"]);
}
#[test]
fn a_cycle_terminates_and_yields_a_set() {
let p = plan(
"quest/b",
&[("quest/a", &["quest/b"]), ("quest/b", &["quest/a"])],
);
assert_eq!(sorted(&p), ["quest/a", "quest/b"]);
}
#[test]
fn a_dangling_dependency_is_reported_and_expands_no_further() {
let p = plan("quest/end", &[("quest/end", &["quest/ghost"])]);
assert_eq!(sorted(&p), ["quest/end", "quest/ghost"]);
}
#[test]
fn an_undeclared_finale_is_the_whole_spine() {
let p = plan(
"quest/ghost",
&[("quest/a", &[]), ("quest/b", &["quest/a"])],
);
assert_eq!(sorted(&p), ["quest/ghost"]);
}
}