1use egui::Rect;
14
15use crate::schema::kdl::{self, Node, Span};
16
17use super::levels::FALLBACK_CAMERA;
18use crate::script::step::{Script, Step};
19
20#[derive(Debug, thiserror::Error)]
24#[error("{message}")]
25pub struct LevelError {
26 pub message: String,
27 pub span: Option<Span>,
28}
29
30impl LevelError {
31 fn at(message: impl Into<String>, span: Span) -> Self {
32 Self {
33 message: message.into(),
34 span: Some(span),
35 }
36 }
37}
38
39impl From<&str> for LevelError {
40 fn from(message: &str) -> Self {
41 Self {
42 message: message.into(),
43 span: None,
44 }
45 }
46}
47
48#[derive(Clone)]
49pub struct Level {
50 pub id: String,
52 pub title: String,
53 pub instructions: String,
55 pub initial_kdl: Option<&'static str>,
58 pub camera: Rect,
62 pub script: Script,
64}
65
66impl Level {
67 pub fn parse(src: &'static str) -> Result<Level, LevelError> {
71 let nodes = kdl::parse(src).map_err(|e| LevelError::at(e.message, e.span))?;
72 let [node] = nodes.as_slice() else {
73 return Err("a level file holds exactly one `level` node".into());
74 };
75 if node.name != "level" {
76 return Err(LevelError::at(
77 format!("expected a `level` node, found `{}`", node.name),
78 node.name_span.clone(),
79 ));
80 }
81 let id = string_arg(node, 0).ok_or_else(|| {
82 LevelError::at(
83 "`level` needs its id as an argument",
84 node.name_span.clone(),
85 )
86 })?;
87 let title = string_prop(node, "title").ok_or_else(|| {
88 LevelError::at("`level` needs a `title` property", node.name_span.clone())
89 })?;
90 let instructions = node
91 .child("instructions")
92 .and_then(|n| string_arg(n, 0))
93 .ok_or_else(|| LevelError::at("missing `instructions`", node.name_span.clone()))?;
94 let initial_kdl = match node.child("initial") {
95 None => None,
96 Some(init) => {
97 if !init.args.is_empty() {
98 return Err(LevelError::at(
99 "`initial` holds the document inline now: `initial { top \"b0\" … }`",
100 init.span.clone(),
101 ));
102 }
103 match (init.children.first(), init.children.last()) {
106 (Some(first), Some(last)) => Some(&src[first.span.start..last.span.end]),
107 _ => None,
108 }
109 }
110 };
111 let script_node = node
112 .child("script")
113 .ok_or_else(|| LevelError::at("missing `script`", node.name_span.clone()))?;
114 let steps: Vec<Step> = script_node
115 .children
116 .iter()
117 .map(|n| {
118 crate::script::parse::step_from_node(n)
119 .map_err(|e| LevelError::at(format!("script: {}", e.message), e.span))
120 })
121 .collect::<Result<_, _>>()?;
122 if !steps.iter().any(|s| {
123 !matches!(
124 s,
125 Step::Camera { .. } | Step::Instruct { .. } | Step::Hold { .. }
126 )
127 }) {
128 return Err(LevelError::at(
129 "the script has no steps",
130 script_node.name_span.clone(),
131 ));
132 }
133 let script = Script::new(steps);
134 let camera = script
135 .camera_at(std::time::Duration::ZERO)
136 .unwrap_or(FALLBACK_CAMERA);
137 Ok(Level {
138 id,
139 title,
140 instructions,
141 initial_kdl,
142 camera,
143 script,
144 })
145 }
146}
147
148fn string_arg(node: &Node, idx: usize) -> Option<String> {
149 node.arg(idx)?.value.as_str().map(str::to_owned)
150}
151
152fn string_prop(node: &Node, key: &str) -> Option<String> {
153 node.prop(key)?.value.value.as_str().map(str::to_owned)
154}
155
156pub fn all_levels() -> Vec<Level> {
158 super::levels::all()
159}
160
161#[cfg(test)]
162mod tests {
163 use std::collections::BTreeSet;
164
165 use blockworx_doc::{
166 document::{DocIndex, Document},
167 id::BlockId,
168 repo::Repo,
169 };
170
171 use super::*;
172 use crate::edit::lower::schema_pin_side;
173 use crate::schema::loc::format_loc;
174 use crate::schema::lower::lower;
175 use crate::schema::model as schema;
176
177 type CueFields = (String, String, (i32, i32, u32, u32), Vec<(String, String)>);
183
184 fn cue_fields(doc: &Document) -> Vec<CueFields> {
187 let title = |id: &BlockId| {
188 doc.block(id).map_or_else(String::new, |block| {
189 block.as_ref().title.name.as_ref().clone()
190 })
191 };
192 let index = DocIndex::of(doc);
193 let mut fields: Vec<CueFields> = index
194 .blocks
195 .keys()
196 .filter(|id| index.is_live_block(**id))
199 .map(|id| {
200 let block = doc
201 .block(id)
202 .expect("the index covers live blocks")
203 .as_ref();
204 let rect = *block.rect.as_ref();
205 let mut pins: Vec<(String, String)> = index.blocks[id]
206 .pins
207 .iter()
208 .filter_map(|pin| doc.pin(pin))
209 .map(|pin| {
210 let pin = pin.as_ref();
211 let slot = *pin.slot.as_ref();
212 (
213 pin.name.as_ref().clone(),
214 format_loc(schema_pin_side(slot.side), slot.offset),
215 )
216 })
217 .collect();
218 pins.sort();
219 (
220 block.title.name.as_ref().clone(),
221 title(block.parent.as_ref()),
222 (rect.top_left.x, rect.top_left.y, rect.size.w, rect.size.h),
223 pins,
224 )
225 })
226 .collect();
227 fields.sort();
228 fields
229 }
230
231 #[test]
236 fn levels_parse_and_their_documents_load() {
237 let levels = all_levels();
238 assert!(!levels.is_empty());
239 let mut ids = BTreeSet::new();
240 let mut titled = 0_usize;
241 let mut nested = 0_usize;
242 let mut locs: BTreeSet<String> = BTreeSet::new();
243 for level in &levels {
244 assert!(
245 ids.insert(level.id.clone()),
246 "duplicate level id {}",
247 level.id
248 );
249 assert!(!level.title.is_empty(), "{}: empty title", level.id);
250 assert!(
251 !level.instructions.is_empty(),
252 "{}: no instructions",
253 level.id
254 );
255 if let Some(initial) = level.initial_kdl {
256 let parsed = schema::Document::parse_kdl(initial, &level.id)
257 .unwrap_or_else(|e| panic!("{}: initial failed to parse: {e:?}", level.id));
258 let seeded = Repo::folding(&lower(&parsed, &level.id).commits)
259 .unwrap_or_else(|e| panic!("{}: the lowered log will not fold: {e}", level.id));
260 let fields = cue_fields(seeded.document());
261
262 titled += fields
263 .iter()
264 .filter(|(title, ..)| !title.is_empty())
265 .count();
266 nested += fields
267 .iter()
268 .filter(|(_, parent, ..)| !parent.is_empty())
269 .count();
270 locs.extend(
271 fields
272 .iter()
273 .flat_map(|(.., pins)| pins.iter().map(|(_, loc)| loc.clone())),
274 );
275 }
276 assert!(
277 !level.script.total().is_zero(),
278 "{}: zero-length script",
279 level.id
280 );
281 assert!(
282 level.script.camera_at(std::time::Duration::ZERO).is_some(),
283 "{}: the script should open with a `camera` command",
284 level.id
285 );
286 }
287 assert!(titled >= 2, "the levels must carry titles to compare");
290 assert!(nested >= 1, "at least one level must nest a block");
291 assert!(
292 locs.len() >= 2,
293 "the levels must place pins on more than one slot: {locs:?}",
294 );
295 }
296}