Skip to main content

delvewright_dsl/
timed_gate.rs

1//! Timed gates: a door that closes on a clock once opened.
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6use crate::serde_fields::{is_false, is_zero};
7use crate::{AnchorId, FlagId, TimedGateId};
8
9#[cfg(doc)]
10use crate::TrapDisarm;
11
12/// A stage-5 **timed gate** (spec-0016 §4): a gate region driven by a
13/// deterministic open/close clock, so passage is a timing read rather than a
14/// permanent state.
15///
16/// **The proof is deliberately NOT all-phase passability** — a gate that
17/// punishes bad timing is the entire point. What the
18/// compiler requires is that the gate is *readable*: the set of entry phases from
19/// which a walking player clears the span before it shuts must cover **≥ 20% of
20/// the cycle** (`DW0378`). Below that it is a coin flip, not a skill, and no
21/// amount of learning the level makes it fair.
22#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
23#[serde(deny_unknown_fields)]
24pub struct TimedGate {
25    /// Unique timed-gate id (`timed-gate/<kebab>`).
26    pub id: TimedGateId,
27    /// The gate anchor the clock drives. Its prefab metadata must declare a fill
28    /// `block` (`DW0343`), the same requirement `close-gate` and `shortcut` have.
29    pub gate: AnchorId,
30    /// Ticks the gate stays OPEN each cycle (> 0).
31    pub open_ticks: u32,
32    /// Ticks the gate stays CLOSED each cycle (> 0).
33    pub closed_ticks: u32,
34    /// Ticks after world init before the first open window begins (default 0) —
35    /// how two gates in the same room are put out of step with each other. Must
36    /// be less than the full cycle.
37    #[serde(default, skip_serializing_if = "is_zero")]
38    pub phase: u32,
39    /// Whether the gate **kills** a player caught inside its region when it
40    /// shuts (spec-0016 §4 addendum). A portcullis
41    /// that merely shoves you aside teaches nothing; mistiming the crossing is
42    /// supposed to be a death you learn from, which is the whole point of §4's
43    /// ≥20%-of-cycle window proof — the window is fair, so the penalty can be
44    /// absolute.
45    ///
46    /// This is a real judgement issued by command on the closing tick, not
47    /// suffocation: vanilla's in-wall damage is slow, gear-dependent and
48    /// escapable, so it would make the portcullis a suggestion. `damage` at the
49    /// closing edge is exact and unarguable.
50    ///
51    /// **Defaults to `false`**, so every campaign authored before this field
52    /// existed compiles byte-identically; a delve opts its portcullis in.
53    #[serde(default, skip_serializing_if = "is_false")]
54    pub crush: bool,
55    /// Optional **disarm** affordance (souls dossier §5.2): the third
56    /// rung of the hazard ladder — readable, avoidable, and finally *disable-able*.
57    /// The real games' best timed hazards can be removed for good (Smouldering
58    /// Lake's ballista, the Fringefolk chariot); a clock the party can only ever
59    /// dance with is one rung short of the vocabulary.
60    ///
61    /// Interacting with the affordance suppresses the clock **permanently, with
62    /// the gate resting OPEN** — a jammed portcullis stays up. Permanence is
63    /// structural exactly as a `shortcut`'s is: no emitted function ever re-arms
64    /// the clock, and `DW0389` refuses a campaign that spells a re-seal.
65    ///
66    /// **Defaults to absent**, so every campaign authored before this field
67    /// existed compiles byte-identically; a delve opts its portcullis in.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub disarm: Option<TimedGateDisarm>,
70}
71
72/// A [`TimedGate`]'s disarm affordance (souls dossier §5.2) — the
73/// exact shape a trap's [`TrapDisarm`] takes, and deliberately so: one affordance
74/// grammar for every mechanism the party can switch off.
75///
76/// The player acts on the `via` anchor (a compiler-emitted interaction entity
77/// plus its visible hardware, `DW0420`) to jam the gate. The clock stops with the
78/// span cleared, `sets_flag` is raised party-wide, and nothing in the delve can
79/// put the gate back.
80#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
81#[serde(deny_unknown_fields)]
82pub struct TimedGateDisarm {
83    /// The anchor the player interacts with to jam the gate. Must be an anchor
84    /// some area's prefab provides, and never the gate anchor itself — the
85    /// mechanism belongs beside the doorway, not inside the span that crushes.
86    pub via: AnchorId,
87    /// The flag set when the gate is disarmed (a new flag this gate produces;
88    /// other objectives/triggers may read it via `requires_flags`).
89    pub sets_flag: FlagId,
90}
91
92// ---------------------------------------------------------------------------
93// Validation
94// ---------------------------------------------------------------------------
95
96use std::collections::BTreeSet;
97
98use crate::diagnostic::{Diagnostic, DwCode, ExitTier};
99use crate::envelope::Campaign;
100use crate::registry::AnchorRegistry;
101use crate::validate::{
102    AnchorProviders, for_each_effect_deep, for_each_trigger_effect_deep, station_kind_diag,
103};
104
105crate::dw_code! {
106    /// (spec-0016 §4) A `timed-gate` declaration is structurally invalid: a
107    /// malformed or duplicate `timed-gate/<id>`, an `open_ticks` or
108    /// `closed_ticks` of 0 (a gate that never opens, or never closes — neither is
109    /// a timing gate), a `phase` at or beyond the full cycle, or a gate another
110    /// `timed-gate` or a `shortcut` already owns (two clocks fighting over one
111    /// region, or a clock fighting a permanent open), or a `disarm.via` anchor no
112    /// area's prefab provides / one that IS the gate anchor (the jam lever cannot
113    /// live inside the span it stops).
114    pub const TIMED_GATE_INVALID: DwCode = DwCode::new("DW0377", ExitTier::Build);
115}
116
117crate::dw_code! {
118    /// A `close-gate` effect targets the gate of a `timed-gate` that
119    /// declares a `disarm`. A disarm suppresses the clock **permanently with the
120    /// gate resting open** — a jammed portcullis stays up — so, exactly like a
121    /// `shortcut` (`DW0372`), its permanence is structural: there is no verb that
122    /// can re-arm it. Use a different gate for the beat that must re-seal, or drop
123    /// the `disarm`.
124    pub const TIMED_GATE_REARMED: DwCode = DwCode::new("DW0389", ExitTier::Build);
125}
126
127/// Validate the stage-5 `timed_gates` section (spec-0016 §4), `DW0377` /
128/// `DW0389`.
129///
130/// The structural half only: ids, a cycle that actually cycles, a phase inside
131/// the cycle, one owner per gate region, and a `disarm.via` that
132/// resolves to a real anchor outside the span it jams. The *design* half — that
133/// the gate is a timing read and not a coin flip — needs the nav model's crossing
134/// time and lives in `compiler::nav` (`DW0378`). The fill-block requirement is
135/// `DW0343`, the same rule `close-gate` and `shortcut` obey.
136///
137/// `DW0389` is the permanence rule, and it is the exact mirror of a shortcut's
138/// `DW0372`: a disarmed gate rests OPEN forever, so no `close-gate` anywhere may
139/// name it. Making that structural is cheaper and safer than trusting every
140/// author never to reach for the re-seal verb.
141///
142/// Anchor resolution stays lenient for pool areas the compiler resolves later —
143/// the same policy as the trap and shortcut checks.
144pub(crate) fn timed_gate_checks(
145    c: &Campaign,
146    anchors: &dyn AnchorRegistry,
147    d: &mut Vec<Diagnostic>,
148) {
149    let quests = &c.quests.content;
150    if quests.timed_gates.is_empty() {
151        return;
152    }
153    let providers = AnchorProviders::build(c, anchors);
154    let shortcut_gates: BTreeSet<&str> = quests.shortcuts.iter().map(|s| s.gate.as_str()).collect();
155    let mut seen: BTreeSet<&str> = BTreeSet::new();
156    let mut driven: BTreeSet<&str> = BTreeSet::new();
157    for (i, g) in quests.timed_gates.iter().enumerate() {
158        let err = |path: String, msg: String, d: &mut Vec<Diagnostic>| {
159            d.push(Diagnostic::error(TIMED_GATE_INVALID, "quests", path, msg));
160        };
161        if !g.id.is_valid_syntax() {
162            err(
163                format!("/content/timed_gates/{i}/id"),
164                format!(
165                    "malformed timed-gate id `{}` — ids must be lowercase kebab-case with the \
166                     `timed-gate/` prefix (e.g. `timed-gate/piston-hall`)",
167                    g.id
168                ),
169                d,
170            );
171        }
172        if !seen.insert(g.id.as_str()) {
173            err(
174                format!("/content/timed_gates/{i}/id"),
175                format!("duplicate timed-gate id `{}` — rename one", g.id),
176                d,
177            );
178        }
179        for (field, ticks) in [
180            ("open_ticks", g.open_ticks),
181            ("closed_ticks", g.closed_ticks),
182        ] {
183            if ticks == 0 {
184                err(
185                    format!("/content/timed_gates/{i}/{field}"),
186                    format!(
187                        "timed gate `{}` declares `{field}: 0` — a gate that never {} is not a \
188                         timing gate. Use `open-gate`/`close-gate` for a one-way state change, or \
189                         give both halves of the cycle a real duration.",
190                        g.id,
191                        if field == "open_ticks" {
192                            "opens"
193                        } else {
194                            "closes"
195                        }
196                    ),
197                    d,
198                );
199            }
200        }
201        let cycle = g.open_ticks.saturating_add(g.closed_ticks);
202        if cycle > 0 && g.phase >= cycle {
203            err(
204                format!("/content/timed_gates/{i}/phase"),
205                format!(
206                    "timed gate `{}` declares `phase: {}` at or beyond its own {cycle}-tick cycle \
207                     — a phase is an offset INTO the cycle, so it must be less than it (use \
208                     `phase % cycle`).",
209                    g.id, g.phase
210                ),
211                d,
212            );
213        }
214        // The clock fills and clears a REGION twice a cycle, so `gate` demands a
215        // gate station. This is the first shape question asked of
216        // `timed_gates[].gate` at this tier at all: the name itself is resolved
217        // only by the compiler, so a point named here used to travel all the way
218        // to `DW0343`.
219        if let Some(f) = station_kind_diag(
220            &providers,
221            g.gate.as_str(),
222            crate::layout::StationKind::Gate,
223            "a timed gate's `gate`",
224            "quests",
225            format!("/content/timed_gates/{i}/gate"),
226        ) {
227            d.push(f);
228        }
229        if !driven.insert(g.gate.as_str()) {
230            err(
231                format!("/content/timed_gates/{i}/gate"),
232                format!(
233                    "gate `{}` is driven by two timed gates — two clocks filling and clearing the \
234                     same region race every tick and the region's state becomes emission order, \
235                     not design. One clock per gate.",
236                    g.gate
237                ),
238                d,
239            );
240        }
241        if shortcut_gates.contains(g.gate.as_str()) {
242            err(
243                format!("/content/timed_gates/{i}/gate"),
244                format!(
245                    "gate `{}` is both a `shortcut` gate and a `timed-gate` — a shortcut opens \
246                     PERMANENTLY (spec-0016 §2) and a clock would re-seal it every cycle, which \
247                     is exactly the re-seal `DW0358` exists to forbid. Use two different gates.",
248                    g.gate
249                ),
250                d,
251            );
252        }
253        // The disarm affordance, the same two rules a trap's obeys.
254        if let Some(dis) = &g.disarm {
255            if let Some(f) = station_kind_diag(
256                &providers,
257                dis.via.as_str(),
258                crate::layout::StationKind::Point,
259                "a timed gate's disarm affordance",
260                "quests",
261                format!("/content/timed_gates/{i}/disarm/via"),
262            ) {
263                d.push(f);
264            }
265            if !providers.resolvable(dis.via.as_str()) {
266                err(
267                    format!("/content/timed_gates/{i}/disarm/via"),
268                    format!(
269                        "timed-gate `disarm.via` anchor `{}` is not provided by any area's \
270                         prefab — {}",
271                        dis.via,
272                        providers.anchor_remedy(
273                            "use an anchor some area's prefab exposes for the jam affordance \
274                             (anchor names come from prefab metadata; do NOT invent one)"
275                        ),
276                    ),
277                    d,
278                );
279            }
280            if dis.via == g.gate {
281                err(
282                    format!("/content/timed_gates/{i}/disarm/via"),
283                    format!(
284                        "timed gate `{}` puts its `disarm.via` on its own gate anchor `{}` — the \
285                         jam lever would stand inside the span the portcullis closes on (and, \
286                         with `crush`, kills in). The affordance belongs on ground the player \
287                         can reach and hold WITHOUT gambling on the clock, which is the entire \
288                         point of the third rung.",
289                        g.id, g.gate
290                    ),
291                    d,
292                );
293            }
294        }
295    }
296
297    // `close-gate` may never target a disarmable timed gate: a disarm leaves the
298    // portcullis jammed OPEN forever, so permanence is structural (`DW0389`, the
299    // mirror of a shortcut's `DW0372`).
300    let disarmed: BTreeSet<&str> = quests
301        .timed_gates
302        .iter()
303        .filter(|g| g.disarm.is_some())
304        .map(|g| g.gate.as_str())
305        .collect();
306    if disarmed.is_empty() {
307        return;
308    }
309    let report = |path: String, anchor: &str, d: &mut Vec<Diagnostic>| {
310        d.push(Diagnostic::error(
311            TIMED_GATE_REARMED,
312            "quests",
313            path,
314            format!(
315                "`close-gate` targets `{anchor}`, the gate of a `timed-gate` that declares a \
316                 `disarm` — a disarmed gate rests OPEN permanently (souls dossier \
317                 §5.2: a hazard the party has switched off stays off), so nothing may re-arm \
318                 its clock. Use a different gate for the beat that must re-seal, or drop the \
319                 `disarm` and keep the clock running."
320            ),
321        ));
322    };
323    for (qi, q) in quests.quests.iter().enumerate() {
324        for_each_effect_deep(q, |path, eff| {
325            if let Some(a) = eff.close_gate_anchor()
326                && disarmed.contains(a.as_str())
327            {
328                report(format!("/content/quests/{qi}/{path}/anchor"), a.as_str(), d);
329            }
330        });
331    }
332    for (ti, t) in quests.triggers.iter().enumerate() {
333        for_each_trigger_effect_deep(t, |path, eff| {
334            if let Some(a) = eff.close_gate_anchor()
335                && disarmed.contains(a.as_str())
336            {
337                report(
338                    format!("/content/triggers/{ti}/{path}/anchor"),
339                    a.as_str(),
340                    d,
341                );
342            }
343        });
344    }
345}