1use 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#[derive(Debug, Clone)]
32pub enum Step {
33 ByLabel(String),
35 ByIndex(usize),
37 ToHost(String),
39}
40
41#[derive(Debug, Clone)]
43pub struct Script {
44 pub start: String,
45 pub steps: Vec<Step>,
46}
47
48#[derive(Debug, Clone)]
50pub struct WalkOutcome {
51 pub visited: Vec<String>,
53 pub error: Option<String>,
55}
56
57impl WalkOutcome {
58 pub fn ok(&self) -> bool {
60 self.error.is_none()
61 }
62}
63
64struct 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, })
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
126pub 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 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
174fn 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
186fn 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
208fn 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
231fn 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 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}