Skip to main content

delvewright_dsl/
shortcut.rs

1//! Shortcuts: a route that unlocks from one side and stays open.
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6use crate::{AnchorId, QuestEffect, ShortcutId};
7
8/// A stage-5 **shortcut door** (spec-0016 §2) — the souls loop-back.
9///
10/// The owner's definition of the pattern: between two rest points there are two
11/// routes. The **short** one starts sealed and holds nothing; the **long** one is
12/// full of enemies and mechanisms. You earn the far side the hard way, pull one
13/// mechanism, and the short route opens **forever**. That moment is the design.
14///
15/// The compiler owns three obligations, none of them optional:
16/// 1. the `unlock` affordance is reachable while the gate is still sealed — the
17///    long route genuinely exists (`DW0373`);
18/// 2. opening the gate genuinely shortens the trip across it — a shortcut that
19///    pays nothing is a leak, not a shortcut (`DW0360`);
20/// 3. permanence is **structural**: no `close-gate` may target a shortcut gate
21///    (`DW0372`). There is no re-sealing verb to reach for.
22///
23/// `close-gate` on a NON-shortcut gate (the point-of-no-return staging beat) is
24/// untouched by this — the two verbs are deliberately disjoint.
25#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
26#[serde(deny_unknown_fields)]
27pub struct Shortcut {
28    /// Unique shortcut id (`shortcut/<kebab>`).
29    pub id: ShortcutId,
30    /// The gate anchor this shortcut opens. Sealed from world-load (the prefab
31    /// carries the physical fill), and its metadata must declare the fill `block`
32    /// the compiler clears — the same requirement `close-gate` has.
33    pub gate: AnchorId,
34    /// The FAR-side anchor whose interaction fires the permanent open. The
35    /// compiler summons the affordance there and polls it, reusing the v0.4
36    /// interaction-entity `use` primitive.
37    pub unlock: AnchorId,
38    /// Effects fired once, when the shortcut opens — the bar lifting, the
39    /// elevator descending, the sound of a door you will never have to earn
40    /// again. Emitted server-source-safe (the poll lives on the tick).
41    #[serde(default, skip_serializing_if = "Vec::is_empty")]
42    pub on_unlock: Vec<QuestEffect>,
43}
44
45// ---------------------------------------------------------------------------
46// Validation
47// ---------------------------------------------------------------------------
48
49use std::collections::BTreeSet;
50
51use crate::diagnostic::{Diagnostic, DwCode, ExitTier};
52use crate::envelope::Campaign;
53use crate::registry::AnchorRegistry;
54use crate::validate::{
55    AnchorProviders, for_each_effect_deep, for_each_trigger_effect_deep, station_kind_diag,
56};
57
58crate::dw_code! {
59    /// (spec-0016 §2) A `shortcut` declaration is structurally invalid: a
60    /// malformed or duplicate `shortcut/<id>`, a `gate`/`unlock` anchor no area's
61    /// prefab provides, or a `gate` that IS the `unlock` (the mechanism must sit
62    /// on the far side, not in the doorway).
63    pub const SHORTCUT_INVALID: DwCode = DwCode::new("DW0371", ExitTier::Build);
64}
65
66crate::dw_code! {
67    /// (spec-0016 §2) A `close-gate` effect targets a gate a `shortcut` owns.
68    /// A shortcut opens **permanently** — that is the whole pattern — so its
69    /// permanence is structural: there is no verb that can put it back. Use a
70    /// different gate for the point-of-no-return beat.
71    pub const SHORTCUT_RESEALED: DwCode = DwCode::new("DW0372", ExitTier::Build);
72}
73
74/// Validate the stage-5 `shortcuts` section (spec-0016 §2).
75///
76/// Two rules, two codes:
77/// * `DW0371` — the declaration must resolve: a well-formed, unique
78///   `shortcut/<id>`, a `gate` and an `unlock` some area's prefab provides, and
79///   the two must be different anchors (the mechanism sits on the FAR side, not
80///   in the doorway it opens).
81/// * `DW0372` — no `close-gate` anywhere may target a gate a shortcut owns. A
82///   shortcut opens permanently; making that structural is cheaper and safer than
83///   trusting every author to never reach for the re-seal verb. `close-gate` on
84///   any other gate (the point-of-no-return beat) is untouched.
85///
86/// Anchor resolution stays lenient for pool areas the compiler resolves later —
87/// the same policy as the trap and trigger checks.
88pub(crate) fn shortcut_checks(c: &Campaign, anchors: &dyn AnchorRegistry, d: &mut Vec<Diagnostic>) {
89    let quests = &c.quests.content;
90    if quests.shortcuts.is_empty() {
91        return;
92    }
93    let providers = AnchorProviders::build(c, anchors);
94
95    let mut seen: BTreeSet<&str> = BTreeSet::new();
96    for (i, sc) in quests.shortcuts.iter().enumerate() {
97        if !sc.id.is_valid_syntax() {
98            d.push(Diagnostic::error(
99                SHORTCUT_INVALID,
100                "quests",
101                format!("/content/shortcuts/{i}/id"),
102                format!(
103                    "malformed shortcut id `{}` — shortcut ids must be lowercase kebab-case with \
104                     the `shortcut/` prefix (e.g. `shortcut/keep-lift`)",
105                    sc.id
106                ),
107            ));
108        }
109        if !seen.insert(sc.id.as_str()) {
110            d.push(Diagnostic::error(
111                SHORTCUT_INVALID,
112                "quests",
113                format!("/content/shortcuts/{i}/id"),
114                format!(
115                    "duplicate shortcut id `{}` — rename one so every shortcut id is unique",
116                    sc.id
117                ),
118            ));
119        }
120        // A shortcut's two anchors are two SHAPES: the `gate` is the region it
121        // clears on unlock, and the `unlock` is the cell its far-side affordance
122        // stands on. They travelled together through one resolvable() call
123        // because a name was all either needed.
124        for (field, anchor, demands) in [
125            ("gate", &sc.gate, crate::layout::StationKind::Gate),
126            ("unlock", &sc.unlock, crate::layout::StationKind::Point),
127        ] {
128            if let Some(f) = station_kind_diag(
129                &providers,
130                anchor.as_str(),
131                demands,
132                &format!("a shortcut's `{field}`"),
133                "quests",
134                format!("/content/shortcuts/{i}/{field}"),
135            ) {
136                d.push(f);
137                continue;
138            }
139            if !providers.resolvable(anchor.as_str()) {
140                d.push(Diagnostic::error(
141                    SHORTCUT_INVALID,
142                    "quests",
143                    format!("/content/shortcuts/{i}/{field}"),
144                    format!(
145                        "shortcut `{field}` anchor `{anchor}` is not provided by any area's \
146                         prefab — {}",
147                        providers.anchor_remedy(
148                            "use an anchor a prefab exposes (anchor names come from prefab \
149                             metadata; do NOT invent one)"
150                        ),
151                    ),
152                ));
153            }
154        }
155        if sc.gate == sc.unlock {
156            d.push(Diagnostic::error(
157                SHORTCUT_INVALID,
158                "quests",
159                format!("/content/shortcuts/{i}/unlock"),
160                format!(
161                    "shortcut `{}` unlocks at its own gate anchor `{}` — the mechanism belongs on \
162                     the FAR side of the door you have not opened yet, which is the entire point \
163                     of the pattern (spec-0016 §2)",
164                    sc.id, sc.gate
165                ),
166            ));
167        }
168    }
169
170    // `close-gate` may never target a shortcut gate: permanence is structural.
171    let owned: BTreeSet<&str> = quests.shortcuts.iter().map(|s| s.gate.as_str()).collect();
172    let report = |path: String, anchor: &str, d: &mut Vec<Diagnostic>| {
173        d.push(Diagnostic::error(
174            SHORTCUT_RESEALED,
175            "quests",
176            path,
177            format!(
178                "`close-gate` targets `{anchor}`, a gate a `shortcut` owns — a shortcut opens \
179                 PERMANENTLY (spec-0016 §2), so nothing may re-seal it. Use a different gate for \
180                 the point-of-no-return beat, or drop the shortcut declaration."
181            ),
182        ));
183    };
184    for (qi, q) in quests.quests.iter().enumerate() {
185        for_each_effect_deep(q, |path, eff| {
186            if let Some(a) = eff.close_gate_anchor()
187                && owned.contains(a.as_str())
188            {
189                report(format!("/content/quests/{qi}/{path}/anchor"), a.as_str(), d);
190            }
191        });
192    }
193    for (ti, t) in quests.triggers.iter().enumerate() {
194        for_each_trigger_effect_deep(t, |path, eff| {
195            if let Some(a) = eff.close_gate_anchor()
196                && owned.contains(a.as_str())
197            {
198                report(
199                    format!("/content/triggers/{ti}/{path}/anchor"),
200                    a.as_str(),
201                    d,
202                );
203            }
204        });
205    }
206}