Skip to main content

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}