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}