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