Skip to main content

thread_engine/
walk.rs

1//! Scripted walks — the engine as its own conformance oracle.
2//!
3//! A [`Script`] is a recorded path: start at a Locator, then step through named
4//! veils. [`walk`] drives a *real* [`Navigator`](crate::navigator::Navigator)
5//! (resolve → load → veilwalk, background fetch and all) through that script with a
6//! renderer-free loader, and reports the worlds it actually landed in. That makes
7//! it a deterministic, headless check that the engine navigates a corpus the way it
8//! was authored — dogfooding Loom as the oracle, no GPU and no browser required.
9//!
10//! ```no_run
11//! use thread_engine::walk::{walk, Script, Step};
12//! let script = Script {
13//!     start: "thread://nexus.pixygon.io/nexus".into(),
14//!     steps: vec![Step::ByLabel("The Forge".into())],
15//! };
16//! let outcome = walk("worlds", &script, 4000);
17//! assert_eq!(outcome.visited, ["The Nexus", "The Forge"]);
18//! ```
19
20use std::path::PathBuf;
21use std::time::Duration;
22
23use glam::Vec3;
24use infinite_manifest::{Locator, WorldManifest};
25
26use crate::assets::AssetSource;
27use crate::engine::Loom;
28use crate::navigator::{InteractableNav, LoadedWorldMeta, PortalNav, SkyNav, WorldLoader};
29
30/// How to choose which veil to step through at a given point in a [`Script`].
31#[derive(Debug, Clone)]
32pub enum Step {
33    /// The veil whose label matches (case-insensitive).
34    ByLabel(String),
35    /// The Nth veil in the active world (author order).
36    ByIndex(usize),
37    /// The veil whose destination Locator is on this host.
38    ToHost(String),
39}
40
41/// A recorded walk: where to start, then the veils to take.
42#[derive(Debug, Clone)]
43pub struct Script {
44    pub start: String,
45    pub steps: Vec<Step>,
46}
47
48/// What actually happened when the engine walked a [`Script`].
49#[derive(Debug, Clone)]
50pub struct WalkOutcome {
51    /// Titles of the worlds landed in, in order (including the start world).
52    pub visited: Vec<String>,
53    /// The step that couldn't be completed, if any (the walk stops there).
54    pub error: Option<String>,
55}
56
57impl WalkOutcome {
58    /// Whether the whole script completed with no failed step.
59    pub fn ok(&self) -> bool {
60        self.error.is_none()
61    }
62}
63
64/// A renderer-free [`WorldLoader`]: maps a manifest to the navigation metadata the
65/// engine needs, drawing nothing. The whole "renderer" of this oracle.
66struct HeadlessLoader;
67
68impl WorldLoader for HeadlessLoader {
69    fn load(
70        &mut self,
71        manifest: &WorldManifest,
72        _assets: &AssetSource,
73        _anchor: Vec3,
74    ) -> LoadedWorldMeta {
75        LoadedWorldMeta {
76            title: manifest.world.title.clone(),
77            world_id: manifest.world.id.clone(),
78            spawn: Vec3::from_array(manifest.default_spawn().position),
79            portals: manifest
80                .portals
81                .iter()
82                .map(|p| PortalNav {
83                    id: p.id.clone(),
84                    position: Vec3::from_array(p.position),
85                    to: p.to.clone(),
86                    label: p.label.clone(),
87                    preview: p.preview != infinite_manifest::PreviewPolicy::None,
88                    yaw: 0.0, // headless walker never renders windows
89                })
90                .collect(),
91            interactables: manifest
92                .placements
93                .iter()
94                .filter(|pl| {
95                    pl.codex.is_some() || pl.behavior.is_some() || pl.interaction.is_some()
96                })
97                .map(|pl| InteractableNav {
98                    position: Vec3::from_array(pl.position),
99                    label: pl.name.clone(),
100                    codex: pl.codex.clone(),
101                    web: pl
102                        .data
103                        .get("url")
104                        .and_then(|u| u.as_str())
105                        .map(str::to_string),
106                    name: pl.name.clone(),
107                    behavior: pl.behavior.clone(),
108                    interaction: pl.interaction.clone(),
109                    hits_done: 0,
110                    data: pl.data.clone(),
111                })
112                .collect(),
113            sky: SkyNav::default(),
114            presence_relays: manifest
115                .presence
116                .as_ref()
117                .map(|p| p.relay_list())
118                .unwrap_or_default(),
119            colliders: vec![],
120        }
121    }
122}
123
124const DT: f32 = 1.0 / 30.0;
125
126/// Drive the engine through `script` over the corpus at `worlds_root`, spending at
127/// most `max_frames` update ticks total. Returns where it landed.
128pub fn walk(worlds_root: impl Into<PathBuf>, script: &Script, max_frames: usize) -> WalkOutcome {
129    let mut loom = Loom::new(worlds_root, HeadlessLoader);
130    let mut visited = Vec::new();
131    let mut player = Vec3::ZERO;
132    let mut budget = max_frames;
133
134    loom.open(&script.start, Vec3::ZERO);
135    if !pump_until_arrival(&mut loom, &mut player, &mut visited, &mut budget) {
136        return WalkOutcome {
137            visited,
138            error: Some(format!("could not open '{}'", script.start)),
139        };
140    }
141
142    for (i, step) in script.steps.iter().enumerate() {
143        if !pump_until_armed(&mut loom, &mut player, &mut budget) {
144            return WalkOutcome {
145                visited,
146                error: Some(format!("step {i}: engine never armed (out of budget)")),
147            };
148        }
149        let Some(dest) = choose(loom.portals(), step) else {
150            return WalkOutcome {
151                visited,
152                error: Some(format!(
153                    "step {i}: no veil matching {step:?} in '{}'",
154                    loom.title().unwrap_or("?")
155                )),
156            };
157        };
158        // Step onto the chosen veil; the engine owns the traversal from here.
159        player = dest;
160        if !pump_until_arrival(&mut loom, &mut player, &mut visited, &mut budget) {
161            return WalkOutcome {
162                visited,
163                error: Some(format!("step {i}: veilwalk did not complete")),
164            };
165        }
166    }
167
168    WalkOutcome {
169        visited,
170        error: None,
171    }
172}
173
174/// The position of the veil a step selects, if present.
175fn choose(veils: &[PortalNav], step: &Step) -> Option<Vec3> {
176    let veil = match step {
177        Step::ByIndex(n) => veils.get(*n),
178        Step::ByLabel(l) => veils.iter().find(|p| p.label.eq_ignore_ascii_case(l)),
179        Step::ToHost(h) => veils
180            .iter()
181            .find(|p| Locator::parse(&p.to).is_some_and(|loc| loc.host == *h)),
182    };
183    veil.map(|v| v.position)
184}
185
186/// Pump `update` until the engine reports an arrival (records its title) or the
187/// frame budget runs out. Handles the in-flight (background fetch) window.
188fn pump_until_arrival(
189    loom: &mut Loom<HeadlessLoader>,
190    player: &mut Vec3,
191    visited: &mut Vec<String>,
192    budget: &mut usize,
193) -> bool {
194    while *budget > 0 {
195        *budget -= 1;
196        if let Some(spawn) = loom.update(DT, *player) {
197            *player = spawn;
198            visited.push(loom.title().unwrap_or("?").to_string());
199            return true;
200        }
201        if loom.is_traveling() {
202            std::thread::sleep(Duration::from_millis(2));
203        }
204    }
205    false
206}
207
208/// Pump `update` until the engine is armed, or budget runs out. A spawn may
209/// legitimately sit inside a veil's trigger zone (the engine stays safely
210/// disarmed there), so — like a real traveler — the oracle steps clear of the
211/// nearest veil while it waits.
212fn pump_until_armed(
213    loom: &mut Loom<HeadlessLoader>,
214    player: &mut Vec3,
215    budget: &mut usize,
216) -> bool {
217    const WALK_SPEED: f32 = 4.0;
218    while *budget > 0 {
219        if loom.armed() {
220            return true;
221        }
222        *budget -= 1;
223        loom.update(DT, *player);
224        if let Some(dir) = away_from_nearest_veil(loom.portals(), *player) {
225            *player += dir * (WALK_SPEED * DT);
226        }
227    }
228    false
229}
230
231/// The horizontal direction that steps `player` out of the nearest veil's trigger
232/// zone, if it's standing in one.
233fn away_from_nearest_veil(veils: &[PortalNav], player: Vec3) -> Option<Vec3> {
234    let clearance = crate::navigator::ENTER_RADIUS + 0.5;
235    let nearest = veils.iter().min_by(|a, b| {
236        a.position
237            .distance(player)
238            .total_cmp(&b.position.distance(player))
239    })?;
240    if nearest.position.distance(player) >= clearance {
241        return None;
242    }
243    let mut dir = player - nearest.position;
244    dir.y = 0.0;
245    Some(dir.try_normalize().unwrap_or(Vec3::X))
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    fn worlds_root() -> String {
253        format!("{}/../../worlds", env!("CARGO_MANIFEST_DIR"))
254    }
255
256    /// The reference corpus is content and travels with the browser that serves
257    /// it, not with the engine. Published on its own, these tests report that
258    /// they had nothing to walk rather than failing over files that were never
259    /// this crate's.
260    fn corpus_present() -> bool {
261        let there = std::path::Path::new(&worlds_root()).is_dir();
262        if !there {
263            eprintln!("no reference corpus beside this crate — skipping");
264        }
265        there
266    }
267
268    #[test]
269    fn walks_the_constellation_by_label() {
270        if !corpus_present() {
271            return;
272        }
273        let script = Script {
274            start: "thread://nexus.pixygon.io/nexus".into(),
275            steps: vec![
276                Step::ByLabel("The Forge".into()),
277                Step::ByLabel("The Nexus".into()),
278            ],
279        };
280        let out = walk(worlds_root(), &script, 8000);
281        assert!(out.ok(), "walk failed: {:?}", out.error);
282        assert_eq!(out.visited, ["The Nexus", "The Forge", "The Nexus"]);
283    }
284
285    #[test]
286    fn walking_by_index_lands_somewhere_real() {
287        if !corpus_present() {
288            return;
289        }
290        let script = Script {
291            start: "thread://nexus.pixygon.io/nexus".into(),
292            steps: vec![Step::ByIndex(0)],
293        };
294        let out = walk(worlds_root(), &script, 8000);
295        assert!(out.ok(), "walk failed: {:?}", out.error);
296        assert_eq!(out.visited.first().map(String::as_str), Some("The Nexus"));
297        assert_eq!(out.visited.len(), 2, "start + one hop");
298    }
299
300    #[test]
301    fn a_missing_veil_is_reported_not_panicked() {
302        if !corpus_present() {
303            return;
304        }
305        let script = Script {
306            start: "thread://nexus.pixygon.io/nexus".into(),
307            steps: vec![Step::ByLabel("No Such Veil".into())],
308        };
309        let out = walk(worlds_root(), &script, 8000);
310        assert!(!out.ok());
311        assert!(out.error.as_deref().unwrap().contains("No Such Veil"));
312        assert_eq!(
313            out.visited,
314            ["The Nexus"],
315            "we still recorded the start world"
316        );
317    }
318}