Skip to main content

delvewright_dsl/
design.rs

1//! **The design record** — the machine half of an approved look (spec-0061).
2//!
3//! A reference image is approved by a human looking at it, and nothing in this
4//! toolchain reads a picture. What a machine can hold is the **token written
5//! beside the picture at the moment of approving**: the sky it was drawn under.
6//! `design.json` is that record — one row per approved image, each row naming
7//! the file by its stem and stating the [`WorldTime`] and [`WorldWeather`] the
8//! picture shows.
9//!
10//! The rows are compared with the skies the built world can actually be in
11//! (`delvec validate`, `DW0890`), so a delve whose art was approved at night and
12//! whose `world.json` says noon is refused before anything is placed and long
13//! before a frame is rendered.
14//!
15//! # What this document is not
16//!
17//! It is not prose, and it is not the sidecar `tools/creator/refimg.py` writes. The
18//! sidecar records what was *asked for*, before any approval exists; this
19//! records what came back and was *said yes to*. It is not player-visible, so
20//! nothing in it is l10n-inventoried.
21//!
22//! # The vocabulary is the world's own
23//!
24//! [`Reference::time`] and [`Reference::weather`] are the same two enums
25//! `world.json` declares. One authority for what an hour is: a time state added
26//! to the world is an hour a row can state, with no second table to update.
27
28use schemars::JsonSchema;
29use serde::{Deserialize, Serialize};
30
31use crate::ids::is_kebab;
32use crate::{WorldTime, WorldWeather};
33
34/// The directory an approved image of **one scene** lives in, relative to
35/// `design/`.
36pub const CONCEPT_DIR: &str = "concept";
37
38/// The directory an approved view of the **whole map** lives in, relative to
39/// `design/`.
40pub const REFERENCE_DIR: &str = "reference";
41
42/// The two directories a [`Reference::name`] may name, in the order a
43/// diagnostic lists them.
44pub const REFERENCE_DIRS: [&str; 2] = [CONCEPT_DIR, REFERENCE_DIR];
45
46/// **The image extensions this engine counts as an approved image**, and the
47/// one place the set is written down.
48///
49/// It is a set of *image* extensions on purpose. `design/` also carries the
50/// re-issue sidecars `tools/creator/refimg.py` writes, and those are neither counted
51/// as approved images nor refused for having no row — a file that is not an
52/// image is simply not this record's subject.
53pub const IMAGE_EXTENSIONS: [&str; 4] = ["jpeg", "jpg", "png", "webp"];
54
55/// True if `ext` (lowercased, no dot) is one of [`IMAGE_EXTENSIONS`].
56#[must_use]
57pub fn is_image_extension(ext: &str) -> bool {
58    IMAGE_EXTENSIONS.contains(&ext)
59}
60
61/// The design stage's payload: one row per approved reference image.
62#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
63#[serde(deny_unknown_fields)]
64pub struct DesignContent {
65    /// The approved reference images, one row each.
66    ///
67    /// **At least one** (`minItems: 1`): a record of nothing is not a record,
68    /// and a campaign that has approved no design writes no `design.json` at
69    /// all rather than an empty one. The difference matters at staging, where
70    /// zero approved images is the state that is refused.
71    #[schemars(length(min = 1))]
72    pub references: Vec<Reference>,
73}
74
75/// One approved reference image, and the sky it was drawn under.
76#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
77#[serde(deny_unknown_fields)]
78pub struct Reference {
79    /// The image's path **stem** relative to `design/`, under `concept/` (one
80    /// scene) or `reference/` (a view of the whole map) — e.g.
81    /// `concept/shore-far`. The extension is deliberately absent: the name
82    /// resolves against the directory, and a stem two files answer to is a
83    /// candidate rather than a match, which `DW0890` refuses.
84    pub name: String,
85    /// One sentence saying what the picture shows. Agent-facing: it is the
86    /// creator's own note to the next reader of the record, is never put on a
87    /// player's screen, and is not l10n-inventoried.
88    pub shows: String,
89    /// The time of day the picture was drawn under — the creator's reading of
90    /// the approved image, and the only creative judgement on this surface.
91    pub time: WorldTime,
92    /// The weather the picture was drawn under.
93    pub weather: WorldWeather,
94}
95
96impl Reference {
97    /// True if [`Reference::name`] is `concept/<kebab>` or `reference/<kebab>`.
98    ///
99    /// The same shape every id in this DSL takes, for the same reason: a name
100    /// that resolves to a path needs one spelling, or the record and the
101    /// directory can disagree about which file a row is about while both look
102    /// right.
103    #[must_use]
104    pub fn is_valid_name(&self) -> bool {
105        REFERENCE_DIRS.iter().any(|dir| {
106            self.name
107                .strip_prefix(dir)
108                .and_then(|r| r.strip_prefix('/'))
109                .is_some_and(is_kebab)
110        })
111    }
112
113    /// The form a [`Reference::name`] has to take, for the refusal that rejected
114    /// one.
115    #[must_use]
116    pub fn name_form() -> String {
117        REFERENCE_DIRS
118            .iter()
119            .map(|d| format!("`{d}/<kebab>`"))
120            .collect::<Vec<_>>()
121            .join(" or ")
122    }
123
124    /// Which of [`REFERENCE_DIRS`] this row's name is under, when it is
125    /// well-formed.
126    #[must_use]
127    pub fn directory(&self) -> Option<&'static str> {
128        REFERENCE_DIRS
129            .iter()
130            .copied()
131            .find(|dir| self.name.starts_with(&format!("{dir}/")))
132    }
133}
134
135/// **The design record's document-level refusals** — spec-0061 §6 shape d.
136///
137/// These are ordinary schema and referential findings and they carry the codes
138/// every other document's equivalents carry; they are deliberately **not** a
139/// shape of `DW0890`, which is about the disagreement between the record and
140/// the world. A reader who meets one of these is being told the document itself
141/// cannot be read, not that the sky is wrong.
142///
143/// Empty for a campaign with no `design.json`, which is every campaign that has
144/// approved no design yet.
145pub fn check(c: &crate::envelope::Campaign, d: &mut Vec<crate::diagnostic::Diagnostic>) {
146    use crate::diagnostic::{Diagnostic, codes};
147    let Some(env) = &c.design else {
148        return;
149    };
150    let rows = &env.content.references;
151    // A record of nothing is not a record. The exported schema says `minItems:
152    // 1` and serde does not enforce it, so the rule is stated here as well —
153    // and it is the schema tier's refusal, because that is what it is.
154    if rows.is_empty() {
155        d.push(Diagnostic::error(
156            codes::SCHEMA,
157            crate::envelope::Stage::Design.name(),
158            "/content/references",
159            format!(
160                "`references` is empty. The design record's schema requires at least one row \
161                 (`minItems: 1`): a record of nothing is not a record, and it is not how a \
162                 campaign says it has approved no design — that campaign ships no `design.json` \
163                 at all. Either AUTHOR the row for an approved image, naming it and the sky it \
164                 was drawn under (`delvec schema --stage {stage}`), or DELETE `design.json`.",
165                stage = crate::envelope::Stage::Design.name(),
166            ),
167        ));
168    }
169    let mut seen: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
170    for (i, r) in rows.iter().enumerate() {
171        if !r.is_valid_name() {
172            d.push(Diagnostic::error(
173                codes::ID_SYNTAX,
174                crate::envelope::Stage::Design.name(),
175                format!("/content/references/{i}/name"),
176                format!(
177                    "reference name `{name}` is not a path this record can resolve. A name is the \
178                     image's stem relative to `design/`, under one of the two directories an \
179                     approved image lives in: {form}. `{concept}/` holds a picture of one scene \
180                     and `{reference}/` a view of the whole map; nothing else under `design/` is \
181                     an approved image. Write the name without its extension — the row resolves \
182                     against the directory, so it names the picture rather than one encoding of \
183                     it.",
184                    name = r.name,
185                    form = Reference::name_form(),
186                    concept = CONCEPT_DIR,
187                    reference = REFERENCE_DIR,
188                ),
189            ));
190        }
191        if let Some(first) = seen.get(r.name.as_str()) {
192            d.push(Diagnostic::error(
193                codes::ID_DUPLICATE,
194                crate::envelope::Stage::Design.name(),
195                format!("/content/references/{i}/name"),
196                format!(
197                    "reference name `{name}` is already used by row {first}. One approved image \
198                     has one row: two rows for one picture are two answers to the question this \
199                     record exists to answer — which sky was that image approved under. DELETE \
200                     the duplicate row, or CORRECT its `name` to the image it is really about.",
201                    name = r.name,
202                ),
203            ));
204        } else {
205            seen.insert(r.name.as_str(), i);
206        }
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    fn row(name: &str) -> Reference {
215        Reference {
216            name: name.to_string(),
217            shows: "a thing".into(),
218            time: WorldTime::Night,
219            weather: WorldWeather::Clear,
220        }
221    }
222
223    #[test]
224    fn a_name_is_one_of_two_directories_and_a_kebab_stem() {
225        assert!(row("concept/shore-far").is_valid_name());
226        assert!(row("reference/whole-map-1").is_valid_name());
227        assert!(!row("shore-far").is_valid_name());
228        assert!(!row("sketches/shore-far").is_valid_name());
229        assert!(!row("concept/Shore_Far").is_valid_name());
230        assert!(!row("concept/shore/far").is_valid_name());
231        assert!(!row("concept/").is_valid_name());
232    }
233
234    #[test]
235    fn the_directory_is_read_off_the_name() {
236        assert_eq!(row("concept/a").directory(), Some("concept"));
237        assert_eq!(row("reference/a").directory(), Some("reference"));
238        assert_eq!(row("nope/a").directory(), None);
239    }
240
241    /// The extension set is one constant, and the sidecars `design/` also
242    /// carries are not in it.
243    #[test]
244    fn only_image_extensions_are_images() {
245        for ext in IMAGE_EXTENSIONS {
246            assert!(is_image_extension(ext));
247        }
248        assert!(!is_image_extension("json"));
249        assert!(!is_image_extension("md"));
250        assert!(!is_image_extension("PNG"), "the caller lowercases");
251    }
252}