Skip to main content

blockworx/tutorial/
level.rs

1//! A tutorial level, parsed from its `.kdl` source (see
2//! `docs/tutorial-levels.md` for the format). A level file carries the prose
3//! (title, instructions), the demo as a `script {}` of imperative commands —
4//! including `camera` and `instruct`, which drive the player's view and
5//! centered instruction text — and the starting document inline under an
6//! `initial { … }` node (an ordinary exported document, conventionally at
7//! the bottom so the script stays at hand for editing).
8//!
9//! The finished state is *not* part of the level: it lives in a golden file
10//! beside the level source, generated from the replay and held against it by
11//! the regression tests (see `runner.rs`).
12
13use egui::Rect;
14
15use crate::schema::kdl::{self, Node, Span};
16
17use super::levels::FALLBACK_CAMERA;
18use crate::script::step::{Script, Step};
19
20/// A level-file failure: the message plus, when the failure is located, a
21/// byte span into the level source. `--replay` renders the span as a miette
22/// label over the file; embedded-registry callers just print the message.
23#[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    /// Stable id, e.g. `"first-block"` — the file's identity in errors.
51    pub id: String,
52    pub title: String,
53    /// Shown in the player window's instructions pane.
54    pub instructions: String,
55    /// The document the level starts from; `None` starts empty. A slice of
56    /// the level source: the `initial { … }` node's children, verbatim.
57    pub initial_kdl: Option<&'static str>,
58    /// The world rect the video pane frames at the start: the script's
59    /// opening `camera` command (or a fallback when the script sets none —
60    /// scripts should open with one).
61    pub camera: Rect,
62    /// The demo the player runs, verbatim from the `script {}` node.
63    pub script: Script,
64}
65
66impl Level {
67    /// Parse a level file. The source is `'static` because scripts keep
68    /// `Copy` steps over `&'static str` — and because the initial document
69    /// is served as a slice of it.
70    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                // The document is the node's children, served verbatim as a
104                // slice of the level source (spans make this exact).
105                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
156/// Every level, in play order.
157pub 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    /// What a tutorial cue resolves a target through (`CueTarget::world`,
178    /// `src/script/step.rs`): a block's title, its grid rect, and each
179    /// pin's `loc`. A block's parent is named by *its* title, since nothing
180    /// else identifies one block to another once the bridge has minted
181    /// uuids. Blocks compare as a sorted multiset for the same reason.
182    type CueFields = (String, String, (i32, i32, u32, u32), Vec<(String, String)>);
183
184    /// The cue fields off the folded log — the document a demo actually
185    /// plays against.
186    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            // The root's row is a scope without a block entity behind it
197            // (F9); a cue only ever targets a real block.
198            .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    /// Every embedded level file parses, its referenced documents load
232    /// through the real document pipeline, and the same source lowers to a
233    /// commit log that folds to the document the cues would read — a bad
234    /// file fails `cargo test` with the parse error, not at runtime.
235    #[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        // The field-coverage assertions are only worth as much as the field
288        // set they exercise, so the levels between them must cover it all.
289        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}