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}