Skip to main content

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
52        impl std::fmt::Display for $name {
53            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54                f.write_str(&self.0)
55            }
56        }
57    };
58}
59
60prefixed_id!(
61    /// Area id: `area/<kebab>` (stage 1).
62    AreaId, "area");
63prefixed_id!(
64    /// NPC id: `npc/<kebab>` (stage 2).
65    NpcId, "npc");
66prefixed_id!(
67    /// Class id: `class/<kebab>` (stage 3).
68    ClassId, "class");
69prefixed_id!(
70    /// Quest id: `quest/<kebab>` (stages 4 & 5).
71    QuestId, "quest");
72prefixed_id!(
73    /// Prefab id: `prefab/<kebab>` (resolved against `prefabs/` at compile time).
74    PrefabId, "prefab");
75prefixed_id!(
76    /// Prefab-pool id: `pool/<kebab>` (jigsaw multi-piece assembly, stage 1;
77    /// resolved against `prefabs/` metadata at compile time).
78    PoolId, "pool");
79prefixed_id!(
80    /// Dialogue node id: `dlg/<kebab>` (stage-local to an NPC's dialogue graph).
81    DialogueId, "dlg");
82prefixed_id!(
83    /// Objective id: `obj/<kebab>` (stage-local to stage 5).
84    ObjectiveId, "obj");
85prefixed_id!(
86    /// Anchor id: `anchor/<kebab>` (resolved against prefab metadata).
87    AnchorId, "anchor");
88prefixed_id!(
89    /// Wave id: `wave/<kebab>` (stage-5 `waves` section, DSL v0.3).
90    WaveId, "wave");
91prefixed_id!(
92    /// Flag id: `flag/<kebab>` (declared by `set-flag` effects, read by
93    /// `requires_flags`, DSL v0.3). No separate declaration list — the set of
94    /// flags is exactly those produced by some `set-flag` effect.
95    FlagId, "flag");
96prefixed_id!(
97    /// Environment-trigger id: `trigger/<kebab>` (stage-5 `triggers` section,
98    /// DSL v0.4). Unique within the stage-5 triggers namespace.
99    TriggerId, "trigger");
100prefixed_id!(
101    /// Scripted-actor id: `actor/<kebab>` (stage-5 `actors` section, DSL v0.6,
102    /// spec-0014). Unique within the stage-5 actors namespace; the puppet body
103    /// is tagged `dw_actor_<kebab>`.
104    ActorId, "actor");
105prefixed_id!(
106    /// Trap id: `trap/<kebab>` (stage-5 `traps` section, DSL v0.6, spec-0011).
107    /// Unique within the stage-5 traps namespace.
108    TrapId, "trap");
109prefixed_id!(
110    /// Timed-gate id: `timed-gate/<kebab>` (stage-5 `timed_gates` section,
111    /// spec-0016 §4). Unique within the stage-5 timed-gate namespace.
112    TimedGateId, "timed-gate");
113prefixed_id!(
114    /// Ambush id: `ambush/<kebab>` (stage-5 `ambushes` section, spec-0016 §3).
115    /// Unique within the stage-5 ambushes namespace; the derived environment
116    /// trigger is named `trigger/<kebab>` from the same local id.
117    AmbushId, "ambush");
118prefixed_id!(
119    /// Shortcut id: `shortcut/<kebab>` (stage-5 `shortcuts` section, spec-0016 §2).
120    /// Unique within the stage-5 shortcuts namespace.
121    ShortcutId, "shortcut");
122prefixed_id!(
123    /// Branch-point id: `branch-point/<kebab>` (stage-4 `branch_points`, DSL v0.8,
124    /// spec-0025). One declared fork in the story: the flags it forks on, the quest
125    /// it opens at, and the branches it offers.
126    BranchPointId, "branch-point");
127prefixed_id!(
128    /// Branch id: `branch/<kebab>` (stage-4 `branch_points[].branches`, DSL v0.8,
129    /// spec-0025). One alternative of a branch point. Unique campaign-wide, because
130    /// it names the emitted `validation/branch-chronicle-<kebab>.md`.
131    BranchId, "branch");
132prefixed_id!(
133    /// Ending id: `ending/<kebab>` (DSL v0.8, spec-0025). Declared on the
134    /// `campaign-complete` effect that ends the delve that way, and referenced by
135    /// the branch that runs to it. There is no separate declaration list — the set
136    /// of endings is exactly those named by some `campaign-complete`, the same rule
137    /// [`FlagId`] follows.
138    EndingId, "ending");
139prefixed_id!(
140    /// Loot-fill id: `loot/<kebab>` (stage-5 `loot` section, spec-0021). Unique
141    /// within the stage-5 loot namespace.
142    LootId, "loot");
143prefixed_id!(
144    /// Edit-batch id: `batch/<kebab>` (stage-7 `world-edits` batches, DSL v0.6,
145    /// spec-0017). Unique within the edit script; also the batch's snapshot name
146    /// and its seed-stream label, so renaming a batch deliberately reseeds it.
147    EditBatchId, "batch");
148prefixed_id!(
149    /// Named edit region: `region/<kebab>` (stage-7 `select` verb, DSL v0.6,
150    /// spec-0017). Scoped to its batch; later edits in the batch refer back to it.
151    RegionId, "region");
152
153/// Campaign id: a bare kebab-case token (no type prefix).
154#[derive(
155    Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
156)]
157#[serde(transparent)]
158#[schemars(transparent)]
159pub struct CampaignId(pub String);
160
161impl CampaignId {
162    /// Borrow the raw id string.
163    pub fn as_str(&self) -> &str {
164        &self.0
165    }
166
167    /// True if the id is a bare kebab-case token.
168    pub fn is_valid_syntax(&self) -> bool {
169        is_kebab(&self.0)
170    }
171}
172
173impl std::fmt::Display for CampaignId {
174    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175        f.write_str(&self.0)
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn kebab_rules() {
185        assert!(is_kebab("open-the-door"));
186        assert!(is_kebab("keep"));
187        assert!(is_kebab("area1"));
188        assert!(!is_kebab("Open"));
189        assert!(!is_kebab("open_the_door"));
190        assert!(!is_kebab("open--the"));
191        assert!(!is_kebab("-open"));
192        assert!(!is_kebab(""));
193    }
194
195    #[test]
196    fn prefixed_rules() {
197        assert!(AreaId("area/keep".into()).is_valid_syntax());
198        assert!(!AreaId("area/Keep".into()).is_valid_syntax());
199        assert!(!AreaId("keep".into()).is_valid_syntax());
200        assert!(!AreaId("npc/keeper".into()).is_valid_syntax());
201        assert!(NpcId("npc/keeper".into()).is_valid_syntax());
202    }
203}