delvewright_dsl/ids.rs
1//! Type-prefixed, kebab-case identifier newtypes (spec-0001 "IDs").
2//!
3//! IDs deserialize permissively from any JSON string so that *syntax* violations
4//! surface as validation diagnostics (`DW0110`) rather than opaque parse errors.
5//! Call [`is_valid_syntax`](AreaId::is_valid_syntax) during validation.
6
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9
10/// True if `s` is a single kebab-case token: `[a-z0-9]+(-[a-z0-9]+)*`.
11pub(crate) fn is_kebab(s: &str) -> bool {
12 !s.is_empty()
13 && s.split('-').all(|seg| {
14 !seg.is_empty()
15 && seg
16 .bytes()
17 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
18 })
19}
20
21/// True if `s` is exactly `<prefix>/<kebab>`.
22pub(crate) fn is_prefixed(s: &str, prefix: &str) -> bool {
23 match s.strip_prefix(prefix).and_then(|r| r.strip_prefix('/')) {
24 Some(rest) => is_kebab(rest),
25 None => false,
26 }
27}
28
29macro_rules! prefixed_id {
30 ($(#[$m:meta])* $name:ident, $prefix:literal) => {
31 $(#[$m])*
32 #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema)]
33 #[serde(transparent)]
34 #[schemars(transparent)]
35 pub struct $name(pub String);
36
37 impl $name {
38 /// The required type prefix (`area`, `npc`, …).
39 pub const PREFIX: &'static str = $prefix;
40
41 /// Borrow the raw id string.
42 pub fn as_str(&self) -> &str {
43 &self.0
44 }
45
46 /// True if the id is well-formed: `<prefix>/<kebab>`.
47 pub fn is_valid_syntax(&self) -> bool {
48 is_prefixed(&self.0, Self::PREFIX)
49 }
50
51 /// The form THIS id has to take, for the diagnostic that rejected
52 /// one — `` `npc/<kebab>` ``, `` `dlg/<kebab>` ``, and so on.
53 ///
54 /// A refusal that says only "ids must be lowercase kebab-case with
55 /// their type prefix" and then lists three examples of other types
56 /// has told the author the general rule and withheld the one fact
57 /// they were missing: which prefix THIS field takes. The prefix is
58 /// a property of the id type, so the answer is derived from the
59 /// type at every site rather than hand-written per site — the
60 /// per-section refusals that already name their own prefix
61 /// (`wave/`, `trap/`, `loot/`, …) are that fact copied by hand,
62 /// which is exactly why the general path never had it.
63 pub fn syntax_form(&self) -> String {
64 format!("`{}/<kebab>`", Self::PREFIX)
65 }
66 }
67
68 impl std::fmt::Display for $name {
69 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70 f.write_str(&self.0)
71 }
72 }
73 };
74}
75
76prefixed_id!(
77 /// Area id: `area/<kebab>` (stage 1).
78 AreaId, "area");
79prefixed_id!(
80 /// NPC id: `npc/<kebab>` (stage 2).
81 NpcId, "npc");
82prefixed_id!(
83 /// Class id: `class/<kebab>` (stage 3).
84 ClassId, "class");
85prefixed_id!(
86 /// Quest id: `quest/<kebab>` (stages 4 & 5).
87 QuestId, "quest");
88prefixed_id!(
89 /// Prefab id: `prefab/<kebab>` (resolved against `prefabs/` at compile time).
90 PrefabId, "prefab");
91prefixed_id!(
92 /// Prefab-pool id: `pool/<kebab>` (jigsaw multi-piece assembly, stage 1;
93 /// resolved against `prefabs/` metadata at compile time).
94 PoolId, "pool");
95prefixed_id!(
96 /// Dialogue node id: `dlg/<kebab>` (stage-local to an NPC's dialogue graph).
97 DialogueId, "dlg");
98prefixed_id!(
99 /// Objective id: `obj/<kebab>` (stage-local to stage 5).
100 ObjectiveId, "obj");
101prefixed_id!(
102 /// Anchor id: `anchor/<kebab>` (resolved against prefab metadata).
103 AnchorId, "anchor");
104prefixed_id!(
105 /// Wave id: `wave/<kebab>` (stage-5 `waves` section, DSL v0.3).
106 WaveId, "wave");
107prefixed_id!(
108 /// Flag id: `flag/<kebab>` (declared by `set-flag` effects, read by
109 /// `requires_flags`, DSL v0.3). No separate declaration list — the set of
110 /// flags is exactly those produced by some `set-flag` effect.
111 FlagId, "flag");
112prefixed_id!(
113 /// Runtime-state id: `state/<kebab>` (stage-5 `state` section, DSL v0.10,
114 /// spec-0031). A named, scoped, integer-valued datum.
115 ///
116 /// Unlike [`FlagId`] this one **is** declared. A datum's scope (per-player or
117 /// party-wide) is a multiplayer semantic that no inference can supply, and a
118 /// counter — unlike a monotonic "this happened" flag — has an initial value
119 /// that only a declaration can state.
120 StateId, "state");
121prefixed_id!(
122 /// Environment-trigger id: `trigger/<kebab>` (stage-5 `triggers` section,
123 /// DSL v0.4). Unique within the stage-5 triggers namespace.
124 TriggerId, "trigger");
125prefixed_id!(
126 /// Scripted-actor id: `actor/<kebab>` (stage-5 `actors` section, DSL v0.6,
127 /// spec-0014). Unique within the stage-5 actors namespace; the puppet body
128 /// is tagged `dw_actor_<kebab>`.
129 ActorId, "actor");
130prefixed_id!(
131 /// Assembly id: `assembly/<kebab>` (stage-5 `assemblies` section, spec-0082).
132 /// A fixed thing built of display entities that plays clips, can be struck
133 /// and strikes back. Its root, parts and hitbox are tagged `dw_asm_<kebab>`.
134 AssemblyId, "assembly");
135prefixed_id!(
136 /// Rig id: `rig/<kebab>` (spec-0082 §3.1). Resolved against the library's
137 /// `rigs/<kebab>/rig.json`, the way `prefab/<kebab>` resolves against a
138 /// piece's metadata: a rig is a library artefact a generator writes, never
139 /// campaign JSON.
140 RigId, "rig");
141prefixed_id!(
142 /// Trap id: `trap/<kebab>` (stage-5 `traps` section, DSL v0.6, spec-0011).
143 /// Unique within the stage-5 traps namespace.
144 TrapId, "trap");
145prefixed_id!(
146 /// Timed-gate id: `timed-gate/<kebab>` (stage-5 `timed_gates` section,
147 /// spec-0016 §4). Unique within the stage-5 timed-gate namespace.
148 TimedGateId, "timed-gate");
149prefixed_id!(
150 /// Ambush id: `ambush/<kebab>` (stage-5 `ambushes` section, spec-0016 §3).
151 /// Unique within the stage-5 ambushes namespace; the derived environment
152 /// trigger is named `trigger/<kebab>` from the same local id.
153 AmbushId, "ambush");
154prefixed_id!(
155 /// Shortcut id: `shortcut/<kebab>` (stage-5 `shortcuts` section, spec-0016 §2).
156 /// Unique within the stage-5 shortcuts namespace.
157 ShortcutId, "shortcut");
158prefixed_id!(
159 /// Branch-point id: `branch-point/<kebab>` (stage-4 `branch_points`, DSL v0.8,
160 /// spec-0025). One declared fork in the story: the flags it forks on, the quest
161 /// it opens at, and the branches it offers.
162 BranchPointId, "branch-point");
163prefixed_id!(
164 /// Branch id: `branch/<kebab>` (stage-4 `branch_points[].branches`, DSL v0.8,
165 /// spec-0025). One alternative of a branch point. Unique campaign-wide, because
166 /// it names the emitted `validation/branch-chronicle-<kebab>.md`.
167 BranchId, "branch");
168prefixed_id!(
169 /// Ending id: `ending/<kebab>` (DSL v0.8, spec-0025). Declared on the
170 /// `campaign-complete` effect that ends the delve that way, and referenced by
171 /// the branch that runs to it. There is no separate declaration list — the set
172 /// of endings is exactly those named by some `campaign-complete`, the same rule
173 /// [`FlagId`] follows.
174 EndingId, "ending");
175prefixed_id!(
176 /// Loot-fill id: `loot/<kebab>` (stage-5 `loot` section, spec-0021). Unique
177 /// within the stage-5 loot namespace.
178 LootId, "loot");
179prefixed_id!(
180 /// Lethal-volume id: `lethal/<kebab>` (stage-5 `lethal_volumes` section, DSL
181 /// v0.10, spec-0031). Unique within the stage-5 lethal-volume namespace; it
182 /// names the volume's emitted tick function, its l10n key, and the volume a
183 /// completability finding blames.
184 LethalVolumeId, "lethal");
185prefixed_id!(
186 /// Loop id: `loop/<kebab>` (stage-5 `loops` section, spec-0086). Unique within
187 /// the stage-5 loop namespace; it names the loop's emitted functions, its
188 /// PackTest pair, its `on_cross` l10n keys and the loop a seamlessness or route
189 /// finding blames.
190 LoopId, "loop");
191prefixed_id!(
192 /// Pulse id: `pulse/<kebab>` (stage-5 `pulses` section, spec-0102). Unique
193 /// within the stage-5 pulse namespace; it names the pulse's emitted
194 /// function, its latch holder, its PackTest templates and its ledger row.
195 PulseId, "pulse");
196prefixed_id!(
197 /// Shop id: `shop/<kebab>` (stage-5 `shops` section, DSL v0.10, spec-0032).
198 /// Unique within the stage-5 shop namespace; it names the shop's interaction
199 /// affordance, its dialog, its `/trigger` routing value and its l10n keys.
200 ShopId, "shop");
201prefixed_id!(
202 /// Recovery-stake id: `stake/<kebab>` (stage-5 `stakes` section, DSL v0.10,
203 /// spec-0032). Unique within the stage-5 stake namespace; it names the
204 /// per-player ledger objectives, the marker hardware's tag and the l10n key of
205 /// the line a collection says.
206 StakeId, "stake");
207prefixed_id!(
208 /// Edit-batch id: `batch/<kebab>` (stage-7 `world-edits` batches, DSL v0.6,
209 /// spec-0017). Unique within the edit script; also the batch's snapshot name
210 /// and its seed-stream label, so renaming a batch deliberately reseeds it.
211 EditBatchId, "batch");
212prefixed_id!(
213 /// Named edit region: `region/<kebab>` (stage-7 `select` verb, DSL v0.6,
214 /// spec-0017). Scoped to its batch; later edits in the batch refer back to it.
215 RegionId, "region");
216prefixed_id!(
217 /// Layout-graph node id: `node/<kebab>` (spec-0049 §3.1). A **place** — a
218 /// room, a courtyard, a stretch of shore, a cavern — named before any
219 /// coordinate exists. Unique within the layout graph.
220 NodeId, "node");
221prefixed_id!(
222 /// Layout-graph edge id: `edge/<kebab>` (spec-0049 §3.1). A connection
223 /// between two places, of a declared class. Unique within the layout graph.
224 EdgeId, "edge");
225prefixed_id!(
226 /// Geometry-brief fact id: `fact/<kebab>` (spec-0049 §4.2). A number with a
227 /// name, taken from the whole map's written brief. Unique within the brief;
228 /// a site plan's `identities[]` bind to these.
229 FactId, "fact");
230prefixed_id!(
231 /// Site-plan datum id: `datum/<kebab>` (spec-0049 §4.1). A named ground
232 /// plane a box's floor sits on. Unique within the site plan.
233 DatumId, "datum");
234prefixed_id!(
235 /// Site-plan volume id: `volume/<kebab>` (spec-0049 §4.1). A mass the WHOLE
236 /// owns — the mountain a cave system is inside, the ground under a village,
237 /// the sky a silhouette needs kept empty. Unique within the site plan.
238 VolumeId, "volume");
239prefixed_id!(
240 /// Atmosphere id: `atmosphere/<kebab>` (stage-1 `atmospheres[]`, spec-0080).
241 /// Unique within the campaign; it names the datapack biome the atmosphere
242 /// ships as (`<ns>:atmosphere/<kebab>`), which a place carries and a
243 /// `set-atmosphere` paints.
244 AtmosphereId, "atmosphere");
245prefixed_id!(
246 /// Site-plan view id: `view/<kebab>` (spec-0049 §4.1). A named exterior
247 /// vantage the walk judges the silhouette from. Unique within the site plan.
248 ViewId, "view");
249
250/// Campaign id: a bare kebab-case token (no type prefix).
251#[derive(
252 Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
253)]
254#[serde(transparent)]
255#[schemars(transparent)]
256pub struct CampaignId(pub String);
257
258impl CampaignId {
259 /// Borrow the raw id string.
260 pub fn as_str(&self) -> &str {
261 &self.0
262 }
263
264 /// True if the id is a bare kebab-case token.
265 pub fn is_valid_syntax(&self) -> bool {
266 is_kebab(&self.0)
267 }
268}
269
270impl std::fmt::Display for CampaignId {
271 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
272 f.write_str(&self.0)
273 }
274}
275
276// ---------------------------------------------------------------------------
277// Validation: the three rules every id collection obeys
278// ---------------------------------------------------------------------------
279
280use std::collections::BTreeSet;
281
282use crate::diagnostic::{Diagnostic, codes};
283
284/// **`DW0110`: refuse a malformed id**, pushing onto `$d`.
285///
286/// The form is taken from the id's own type (`syntax_form`), never written
287/// into this message. This macro is the ONE path every id type's syntax
288/// refusal goes through, and it used to answer all of them with the same
289/// three examples — `area/keep`, `npc/keeper`, `quest/find-key` — so a
290/// rejected dialogue node id was refused by a sentence that never spelled
291/// `dlg/<kebab>`, and the rule it needed lived only in the schema
292/// description. The prefix belongs to the id type, so every site gets it
293/// from the type: the general mechanism was here all along, and only its
294/// message was too narrow to reach what it was rejecting.
295macro_rules! id_syntax {
296 ($d:expr, $id:expr, $stage:expr, $path:expr) => {
297 if !$id.is_valid_syntax() {
298 $d.push($crate::diagnostic::Diagnostic::error(
299 $crate::diagnostic::codes::ID_SYNTAX,
300 $stage,
301 $path,
302 format!(
303 "malformed id `{}` — this field takes {}: the type prefix, a `/`, and \
304 one lowercase kebab-case segment after it ([a-z0-9] and `-`, no second \
305 `/`, no capitals, no underscores)",
306 $id,
307 $id.syntax_form()
308 ),
309 ));
310 }
311 };
312}
313pub(crate) use id_syntax;
314
315/// **`DW0111`: refuse the second of two equal ids** in one namespace, at the
316/// later one's path.
317pub(crate) fn dup_check<'a>(
318 ids: impl Iterator<Item = (&'a str, String)>,
319 stage: &'static str,
320 what: &str,
321 d: &mut Vec<Diagnostic>,
322) {
323 let mut seen: BTreeSet<&str> = BTreeSet::new();
324 for (id, path) in ids {
325 if !seen.insert(id) {
326 d.push(Diagnostic::error(
327 codes::ID_DUPLICATE,
328 stage,
329 path,
330 format!("duplicate {what} id `{id}` — rename one so every {what} id is unique"),
331 ));
332 }
333 }
334}
335
336/// **`DW0112`: refuse a reference that does not resolve** — `ok` is whether it
337/// does, and `msg` is the refusal's own wording.
338pub(crate) fn dangling(
339 d: &mut Vec<Diagnostic>,
340 ok: bool,
341 stage: &'static str,
342 path: String,
343 msg: String,
344) {
345 if !ok {
346 d.push(Diagnostic::error(codes::DANGLING_REF, stage, path, msg));
347 }
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353
354 #[test]
355 fn kebab_rules() {
356 assert!(is_kebab("open-the-door"));
357 assert!(is_kebab("keep"));
358 assert!(is_kebab("area1"));
359 assert!(!is_kebab("Open"));
360 assert!(!is_kebab("open_the_door"));
361 assert!(!is_kebab("open--the"));
362 assert!(!is_kebab("-open"));
363 assert!(!is_kebab(""));
364 }
365
366 #[test]
367 fn prefixed_rules() {
368 assert!(AreaId("area/keep".into()).is_valid_syntax());
369 assert!(!AreaId("area/Keep".into()).is_valid_syntax());
370 assert!(!AreaId("keep".into()).is_valid_syntax());
371 assert!(!AreaId("npc/keeper".into()).is_valid_syntax());
372 assert!(NpcId("npc/keeper".into()).is_valid_syntax());
373 }
374}