use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::{AnchorId, QuestEffect, ShortcutId};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Shortcut {
pub id: ShortcutId,
pub gate: AnchorId,
pub unlock: AnchorId,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub on_unlock: Vec<QuestEffect>,
}
use std::collections::BTreeSet;
use crate::diagnostic::{Diagnostic, DwCode, ExitTier};
use crate::envelope::Campaign;
use crate::registry::AnchorRegistry;
use crate::validate::{
AnchorProviders, for_each_effect_deep, for_each_trigger_effect_deep, station_kind_diag,
};
crate::dw_code! {
pub const SHORTCUT_INVALID: DwCode = DwCode::new("DW0371", ExitTier::Build);
}
crate::dw_code! {
pub const SHORTCUT_RESEALED: DwCode = DwCode::new("DW0372", ExitTier::Build);
}
pub(crate) fn shortcut_checks(c: &Campaign, anchors: &dyn AnchorRegistry, d: &mut Vec<Diagnostic>) {
let quests = &c.quests.content;
if quests.shortcuts.is_empty() {
return;
}
let providers = AnchorProviders::build(c, anchors);
let mut seen: BTreeSet<&str> = BTreeSet::new();
for (i, sc) in quests.shortcuts.iter().enumerate() {
if !sc.id.is_valid_syntax() {
d.push(Diagnostic::error(
SHORTCUT_INVALID,
"quests",
format!("/content/shortcuts/{i}/id"),
format!(
"malformed shortcut id `{}` — shortcut ids must be lowercase kebab-case with \
the `shortcut/` prefix (e.g. `shortcut/keep-lift`)",
sc.id
),
));
}
if !seen.insert(sc.id.as_str()) {
d.push(Diagnostic::error(
SHORTCUT_INVALID,
"quests",
format!("/content/shortcuts/{i}/id"),
format!(
"duplicate shortcut id `{}` — rename one so every shortcut id is unique",
sc.id
),
));
}
for (field, anchor, demands) in [
("gate", &sc.gate, crate::layout::StationKind::Gate),
("unlock", &sc.unlock, crate::layout::StationKind::Point),
] {
if let Some(f) = station_kind_diag(
&providers,
anchor.as_str(),
demands,
&format!("a shortcut's `{field}`"),
"quests",
format!("/content/shortcuts/{i}/{field}"),
) {
d.push(f);
continue;
}
if !providers.resolvable(anchor.as_str()) {
d.push(Diagnostic::error(
SHORTCUT_INVALID,
"quests",
format!("/content/shortcuts/{i}/{field}"),
format!(
"shortcut `{field}` anchor `{anchor}` is not provided by any area's \
prefab — {}",
providers.anchor_remedy(
"use an anchor a prefab exposes (anchor names come from prefab \
metadata; do NOT invent one)"
),
),
));
}
}
if sc.gate == sc.unlock {
d.push(Diagnostic::error(
SHORTCUT_INVALID,
"quests",
format!("/content/shortcuts/{i}/unlock"),
format!(
"shortcut `{}` unlocks at its own gate anchor `{}` — the mechanism belongs on \
the FAR side of the door you have not opened yet, which is the entire point \
of the pattern (spec-0016 §2)",
sc.id, sc.gate
),
));
}
}
let owned: BTreeSet<&str> = quests.shortcuts.iter().map(|s| s.gate.as_str()).collect();
let report = |path: String, anchor: &str, d: &mut Vec<Diagnostic>| {
d.push(Diagnostic::error(
SHORTCUT_RESEALED,
"quests",
path,
format!(
"`close-gate` targets `{anchor}`, a gate a `shortcut` owns — a shortcut opens \
PERMANENTLY (spec-0016 §2), so nothing may re-seal it. Use a different gate for \
the point-of-no-return beat, or drop the shortcut declaration."
),
));
};
for (qi, q) in quests.quests.iter().enumerate() {
for_each_effect_deep(q, |path, eff| {
if let Some(a) = eff.close_gate_anchor()
&& owned.contains(a.as_str())
{
report(format!("/content/quests/{qi}/{path}/anchor"), a.as_str(), d);
}
});
}
for (ti, t) in quests.triggers.iter().enumerate() {
for_each_trigger_effect_deep(t, |path, eff| {
if let Some(a) = eff.close_gate_anchor()
&& owned.contains(a.as_str())
{
report(
format!("/content/triggers/{ti}/{path}/anchor"),
a.as_str(),
d,
);
}
});
}
}