delvewright_dsl/loop.rs
1//! Loops: an endless corridor (spec-0086).
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6use crate::{FlagId, LoopId, Mark, QuestEffect, StateCompare, StateId, StealthZone};
7
8#[cfg(doc)]
9use crate::LethalVolume;
10
11/// A stage-5 **loop** (spec-0086): a slab a body crosses and is returned from,
12/// by a whole-block offset, to an earlier section that looks exactly the same —
13/// its position inside the cell, its facing and its velocity all kept.
14///
15/// # It is a region with a standing property
16///
17/// A loop acts on whatever body enters a volume, every tick, the way a
18/// [`LethalVolume`] does; nothing completes and nobody is addressed. So it is
19/// declared beside the lethal volume, with the same region type
20/// ([`StealthZone`], resolved through the one `Plan::zone_box`), and not as a
21/// trigger with a relative teleport in it: the seamlessness proof is a property
22/// of the region-plus-offset pair, which a compiler would otherwise have to
23/// recognise by pattern-matching a trigger's effect list.
24///
25/// # The offset is derived, never typed
26///
27/// `to` is a [`Mark`] naming where the slab's own anchor cell lands, so the
28/// offset is `cell(to) − cell(region.anchor)` — a whole-block vector by
29/// construction, and a judgement about the world (*the fourth bay's anchor lands
30/// on the second's*) rather than a vector the compiler could compute.
31///
32/// # The gate is the release
33///
34/// The loop **holds** while its gate is open and stands down while it is shut,
35/// read against the party: flags are campaign state, and a `requires_state` term
36/// must name a `party` datum (`DW0949`). A loop with no gate term holds forever
37/// and is refused at the document (`DW0949`).
38#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
39#[serde(deny_unknown_fields)]
40pub struct Loop {
41 /// Unique loop id (`loop/<kebab>`).
42 pub id: LoopId,
43 /// The slab a body crosses: an anchor-centred box (`anchor ± extent`), one
44 /// axis of which is the crossing axis. The existing zone type, for the reason
45 /// [`LethalVolume::region`] gives.
46 pub region: StealthZone,
47 /// Where the slab's own anchor cell lands: the loop's offset is
48 /// `cell(to) − cell(region.anchor)`. Lies inside its anchor's piece
49 /// (`DW0897`).
50 pub to: Mark,
51 /// Flags that must all be set for the loop to hold.
52 #[serde(default, skip_serializing_if = "Vec::is_empty")]
53 pub requires_flags: Vec<FlagId>,
54 /// Flags any one of which stands the loop down — `forbids_flags:
55 /// [flag/the-bell-found]` is a loop that ends the moment the bell is found.
56 #[serde(default, skip_serializing_if = "Vec::is_empty")]
57 pub forbids_flags: Vec<FlagId>,
58 /// Numeric gate terms: every comparison must hold for the loop to hold. Each
59 /// names a `party`-scoped datum (`DW0949`). The third field of the one gate,
60 /// carried by every gate consumer.
61 #[serde(default, skip_serializing_if = "Vec::is_empty")]
62 pub requires_state: Vec<StateCompare>,
63 /// A `party`-scoped datum the loop raises by one on every move, before
64 /// `on_cross` runs — the counter a crossing-counted release reads.
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub counts: Option<StateId>,
67 /// The dungeon's answer to a move: effects run on every move, after the body
68 /// is moved and the count raised, from the server command source (no acting
69 /// player). Each effect's own `when` keys a write to a count. A `teleport`
70 /// here is refused (`DW0949`).
71 #[serde(default, skip_serializing_if = "Vec::is_empty")]
72 pub on_cross: Vec<QuestEffect>,
73}
74
75// ---------------------------------------------------------------------------
76// Validation
77// ---------------------------------------------------------------------------
78
79use std::collections::{BTreeMap, BTreeSet};
80
81use crate::diagnostic::{Diagnostic, DwCode, ExitTier, codes};
82use crate::envelope::Campaign;
83use crate::registry::AnchorRegistry;
84use crate::validate::{AnchorProviders, station_kind_diag};
85
86crate::dw_code! {
87 /// (spec-0086 §3.2, §3.4, §3.5) **A loop whose release is not a fact about the
88 /// party, or that has none.**
89 ///
90 /// One rule — *the gate is the release, and the release is the party's* —
91 /// asked four ways: a loop with no gate term at all (it holds forever, a
92 /// soft-lock spelled out); a `requires_state` term naming a `player`-scoped
93 /// datum (one player released and another looped is a party split in two);
94 /// a `counts` naming a `player`-scoped datum (for the same reason); and a
95 /// `teleport` inside `on_cross` (the body was just moved, and a second move in
96 /// the same tick is two carries with one position). Validation-tier (exit 1).
97 /// Prescription: a `party` datum, a flag, or a release the party reaches.
98 pub const LOOP_GATE: DwCode = DwCode::new("DW0949", ExitTier::Build);
99}
100
101/// Stage-5 loop structural checks (spec-0086): id syntax and uniqueness, the two
102/// anchors resolvable, and the release a fact about the party (`DW0949`).
103///
104/// Everything geometric — the slab's shape, the move clearing it, the closed and
105/// identical view — is about the solved layout and lives in the compiler
106/// (`compiler::loop`).
107pub(crate) fn loop_checks(c: &Campaign, anchors: &dyn AnchorRegistry, d: &mut Vec<Diagnostic>) {
108 let loops = &c.quests.content.loops;
109 if loops.is_empty() {
110 return;
111 }
112 let providers = AnchorProviders::build(c, anchors);
113 let scope_of: BTreeMap<&str, crate::StateScope> = c
114 .quests
115 .content
116 .state
117 .iter()
118 .map(|s| (s.id.as_str(), s.scope))
119 .collect();
120 let mut seen_id: BTreeSet<&str> = BTreeSet::new();
121 for (i, l) in loops.iter().enumerate() {
122 let at = |tail: &str| format!("/content/loops/{i}{tail}");
123 if !l.id.is_valid_syntax() {
124 d.push(Diagnostic::error(
125 codes::ID_SYNTAX,
126 "quests",
127 at("/id"),
128 format!(
129 "malformed loop id `{}` — loop ids must be lowercase kebab-case with the \
130 `loop/` prefix (e.g. `loop/long-gallery`)",
131 l.id
132 ),
133 ));
134 }
135 if !seen_id.insert(l.id.as_str()) {
136 d.push(Diagnostic::error(
137 codes::ID_DUPLICATE,
138 "quests",
139 at("/id"),
140 format!("duplicate loop id `{}`", l.id),
141 ));
142 }
143 for (field, anchor, what) in [
144 (
145 "/region/anchor",
146 l.region.anchor.as_str(),
147 "a loop's slab centre",
148 ),
149 ("/to/anchor", l.to.anchor.as_str(), "a loop's landing"),
150 ] {
151 if let Some(f) = station_kind_diag(
152 &providers,
153 anchor,
154 crate::layout::StationKind::Point,
155 what,
156 "quests",
157 at(field),
158 ) {
159 d.push(f);
160 }
161 if !providers.resolvable(anchor) {
162 d.push(Diagnostic::error(
163 codes::ANCHOR_UNRESOLVED,
164 "quests",
165 at(field),
166 format!(
167 "loop `{}` names anchor `{anchor}` ({what}), which no prefab bound in \
168 this campaign provides — {}",
169 l.id,
170 providers.anchor_remedy(
171 "use an anchor the prefab exposes (anchor names come from prefab \
172 metadata; do NOT invent one)"
173 ),
174 ),
175 ));
176 }
177 }
178 // `DW0949`: the gate is the release, and a loop with none holds forever.
179 if l.gate().is_empty() {
180 d.push(Diagnostic::error(
181 LOOP_GATE,
182 "quests",
183 at(""),
184 format!(
185 "loop `{}` declares no gate term — no `requires_flags`, no `forbids_flags`, \
186 no `requires_state` — so it holds forever and a party that walks into it \
187 can never leave: that is a soft-lock spelled out, not a mechanism. Give it \
188 a release the party reaches: `forbids_flags: [flag/<found>]` ends it when \
189 a flag is set, and `requires_state: [{{\"state\": <counts>, \"op\": \
190 \"at-most\", \"value\": n}}]` on its own `counts` datum ends it after a \
191 number of crossings",
192 l.id
193 ),
194 ));
195 }
196 // …and the release is a fact about the party.
197 for (k, cmp) in l.requires_state.iter().enumerate() {
198 if scope_of.get(cmp.state.as_str()) == Some(&crate::StateScope::Player) {
199 d.push(Diagnostic::error(
200 LOOP_GATE,
201 "quests",
202 at(&format!("/requires_state/{k}")),
203 format!(
204 "loop `{}` reads `{}` in its gate term `requires_state/{k}`, and that \
205 datum is `player`-scoped: a release one player holds and another does \
206 not splits the party into a looped half and a free half. The release \
207 is a fact about the party — declare the datum `party`-scoped, or \
208 release on a flag",
209 l.id,
210 cmp.state.as_str()
211 ),
212 ));
213 }
214 }
215 if let Some(counts) = &l.counts {
216 match scope_of.get(counts.as_str()) {
217 None => d.push(Diagnostic::error(
218 codes::STATE_UNDECLARED,
219 "quests",
220 at("/counts"),
221 format!(
222 "loop `{}` counts its crossings into `{}`, which the campaign never \
223 declares. Add it to the stage-5 `state` list as a `party` datum, or \
224 fix the id",
225 l.id,
226 counts.as_str()
227 ),
228 )),
229 Some(crate::StateScope::Player) => d.push(Diagnostic::error(
230 LOOP_GATE,
231 "quests",
232 at("/counts"),
233 format!(
234 "loop `{}` counts its crossings into `{}`, which is `player`-scoped: \
235 the count a release reads is a fact about the party, and a count each \
236 player keeps for themselves is a release one of them holds and \
237 another does not. Declare the datum `party`-scoped",
238 l.id,
239 counts.as_str()
240 ),
241 )),
242 Some(crate::StateScope::Party) => {}
243 }
244 }
245 // A `teleport` inside `on_cross`, at any nesting depth.
246 fn teleports(effs: &[QuestEffect], path: &str, out: &mut Vec<String>) {
247 for (j, e) in effs.iter().enumerate() {
248 let here = format!("{path}/{j}");
249 if matches!(e.verb, crate::Verb::Teleport { .. }) {
250 out.push(here.clone());
251 }
252 for (pseg, _k, list) in e.nested_effect_lists_labeled() {
253 teleports(list, &format!("{here}/{pseg}"), out);
254 }
255 }
256 }
257 let mut found = Vec::new();
258 teleports(&l.on_cross, &at("/on_cross"), &mut found);
259 for path in found {
260 d.push(Diagnostic::error(
261 LOOP_GATE,
262 "quests",
263 path.clone(),
264 format!(
265 "loop `{}` runs a `teleport` in its `on_cross` (term `{path}`): the body was \
266 just moved by the loop, and a second move in the same tick is two carries \
267 with one position. A place that looks different is a `teleport` of its \
268 own, fired from a trigger the party reaches — take it out of the loop",
269 l.id
270 ),
271 ));
272 }
273 }
274}