Skip to main content

thread_engine/
navigator.rs

1//! **Veilwalking** — the navigation state machine of the Thread, renderer-agnostic.
2//!
3//! This is the part of a browser that decides *which world is active*, *when to
4//! step through a veil*, and *what the player is standing in front of* — all pure
5//! math on positions and Locators. It knows nothing about the GPU: to actually
6//! place a resolved world into a renderer it calls back through the [`WorldLoader`]
7//! seam, which the browser implements. That single trait is the engine/UI split —
8//! it's what lets a *second* browser (a different renderer, an embedded view)
9//! reuse veilwalking without touching this file.
10//!
11//! Stepping into a veil kicks off a background resolve+fetch (network-first, local
12//! fallback — see [`crate::resolver`]); when the destination arrives it's handed to
13//! the loader and the player is teleported to its spawn.
14
15use std::path::{Path, PathBuf};
16use std::sync::mpsc::{channel, Receiver, TryRecvError};
17
18use std::collections::HashMap;
19
20use glam::{Quat, Vec3};
21use infinite_manifest::{Interaction, InteractionEffect, WorldManifest, WorldRules};
22
23use crate::assets::AssetSource;
24use crate::behavior::Action;
25#[cfg(feature = "behaviors")]
26use crate::behavior::{Actor, Behavior, InteractEvent};
27#[cfg(feature = "behaviors")]
28use crate::behavior_wasm::WasmBehavior;
29use crate::resolver::{self, TravelResult};
30
31/// How close (metres) the player must be to a veil to step through.
32pub const ENTER_RADIUS: f32 = 2.2;
33/// How close the player must be to a pedestal to inspect it.
34pub const INSPECT_RADIUS: f32 = 2.6;
35/// Grace period after arriving before another veil can trigger (prevents bounce).
36const TRAVERSE_COOLDOWN: f32 = 1.2;
37/// Drop from the player's origin to their feet (capsule half-height-ish).
38pub const FEET_DROP: f32 = 0.9;
39/// How close (metres) the player must be to a veil before its destination is
40/// fetched for a live preview — comfortably outside [`ENTER_RADIUS`] so the
41/// signpost is up before you can step through.
42pub const PREVIEW_RADIUS: f32 = 18.0;
43/// Default Thread API base for the live resolver.
44const DEFAULT_THREAD_BASE: &str = "https://api.pixygon.io/v1";
45
46/// A veil in the active world — a hyperlink you step through (renderer-agnostic).
47#[derive(Debug, Clone)]
48pub struct PortalNav {
49    pub id: String,
50    pub position: Vec3,
51    /// Destination Locator (`thread://…`).
52    pub to: String,
53    pub label: String,
54    /// Whether the far side may be previewed (manifest `preview` policy;
55    /// `none` opts a veil out — an opaque doorway).
56    pub preview: bool,
57    /// The doorway's facing (yaw about Y), for window parallax.
58    #[allow(clippy::derive_partial_eq_without_eq)]
59    pub yaw: f32,
60}
61
62/// An inspectable placement (a Codex pedestal, a web signboard, or a placement
63/// with a bound behavior module).
64#[derive(Debug, Clone, Default)]
65pub struct InteractableNav {
66    pub position: Vec3,
67    pub label: String,
68    pub codex: Option<String>,
69    pub web: Option<String>,
70    /// The placement's manifest `name` — the identity a behavior receives.
71    pub name: String,
72    /// Id of the behavior module bound to this placement, if any.
73    pub behavior: Option<String>,
74    /// A codeless declarative interaction (label + effects), if the placement
75    /// declared one. The simple path — no WASM behavior needed.
76    pub interaction: Option<Interaction>,
77    /// Progress toward completing the declarative interaction (a tree takes a few
78    /// chops). Resets to 0 once `interaction.hits` is reached and effects fire.
79    pub hits_done: u32,
80    /// The placement's manifest `data` block, passed through to its behavior.
81    pub data: serde_json::Value,
82}
83
84/// A solid volume in the active world — placement geometry as an oriented box,
85/// for a browser to hand to its physics so floors hold and walls block. Portals
86/// never appear here (a veil is stepped *through*). Renderer-agnostic.
87#[derive(Debug, Clone, Copy)]
88pub struct ColliderNav {
89    pub half_extents: Vec3,
90    pub position: Vec3,
91    pub rotation: Quat,
92}
93
94/// What's on the far side of a veil — its title, sky, and how many veils lead
95/// onward. Fetched live as the player approaches (see [`Navigator::portal_preview`]).
96#[derive(Debug, Clone)]
97pub struct PortalPreview {
98    pub title: String,
99    pub sky: SkyNav,
100    /// Number of veils in the destination world.
101    pub veils: usize,
102    /// The destination's raw manifest text — enough for a browser to render a
103    /// **true window** into the far side (a snapshot from its spawn).
104    pub manifest_text: String,
105    /// Where the destination's relative assets resolve (local base + optional
106    /// hosted base URL) — mirrors [`crate::resolver::TravelResult`].
107    pub asset_base: PathBuf,
108    pub asset_base_url: Option<String>,
109    /// The far side's presence relays + room key — enough for a browser to
110    /// listen at the threshold and show WHO IS THERE through the window.
111    pub presence_relays: Vec<String>,
112    pub world_id: String,
113}
114
115/// A simple gradient sky (zenith→horizon) + sun direction. Renderer-agnostic; the
116/// browser maps this onto its own atmosphere.
117#[derive(Debug, Clone, Copy)]
118pub struct SkyNav {
119    pub zenith: [f32; 3],
120    pub horizon: [f32; 3],
121    pub sun_dir: [f32; 3],
122}
123
124impl Default for SkyNav {
125    fn default() -> Self {
126        SkyNav {
127            zenith: [0.05, 0.06, 0.12],
128            horizon: [0.18, 0.16, 0.24],
129            sun_dir: [0.3, 0.7, 0.2],
130        }
131    }
132}
133
134/// What a [`WorldLoader`] reports after placing a world into its renderer — the
135/// navigation metadata the [`Navigator`] needs, with no GPU handles.
136pub struct LoadedWorldMeta {
137    pub title: String,
138    /// The world's stable id (`world.id`) — the presence room key for bare relays.
139    pub world_id: String,
140    /// Where to teleport the player on arrival.
141    pub spawn: Vec3,
142    pub portals: Vec<PortalNav>,
143    pub interactables: Vec<InteractableNav>,
144    pub sky: SkyNav,
145    /// This world's presence relays (from `presence.relay_list()`), primary first.
146    /// Empty → the world is solo. The browser tries them in order with fallback.
147    pub presence_relays: Vec<String>,
148    /// Placement geometry as oriented boxes, for the browser's physics. A loader
149    /// with no collision story (headless, text) just returns empty.
150    pub colliders: Vec<ColliderNav>,
151}
152
153/// The rendering seam. A browser implements this to place a *validated* manifest
154/// into its renderer and hand back the navigation metadata. The [`Navigator`]
155/// owns everything else (resolution, arming, cooldown, focus).
156pub trait WorldLoader {
157    /// Place `manifest` (already parsed + validated) into the renderer, resolving
158    /// its glTF/texture assets through `assets` (which fetches remote URLs from a
159    /// cache), and return the navigation metadata. The `anchor` is where the player
160    /// will stand (for e.g. a void floor).
161    fn load(
162        &mut self,
163        manifest: &WorldManifest,
164        assets: &AssetSource,
165        anchor: Vec3,
166    ) -> LoadedWorldMeta;
167}
168
169/// What the player is standing in front of (Codex pedestal and/or web signboard).
170pub struct Focus {
171    pub label: String,
172    pub codex: Option<String>,
173    pub web: Option<String>,
174    /// Id of the behavior module bound here — `interact()` will dispatch to it.
175    pub behavior: Option<String>,
176    /// The creator-defined verb for a declarative interaction here ("Chop",
177    /// "Mine"), if any — drives the "[E] …" prompt.
178    pub interact_label: Option<String>,
179    /// The placement's manifest `data` block — so a browser can act on a bound
180    /// placement (e.g. a `buy` stall's item + price) even when the sandboxed
181    /// module layer is compiled out (graceful degradation).
182    pub data: serde_json::Value,
183}
184
185/// One instantiated behavior module for the active world.
186#[cfg(feature = "behaviors")]
187struct BehaviorSlot {
188    /// The manifest behavior id placements bind to.
189    id: String,
190    /// Whether the manifest declared `"tick"` in this behavior's `on[]`.
191    ticks: bool,
192    module: WasmBehavior,
193}
194
195struct ActiveWorld {
196    title: String,
197    world_id: String,
198    /// The world's one-line description (`world.description`) — browsers show it
199    /// on the arrival card when a traveler steps through a veil.
200    description: String,
201    /// The Locator this world was reached by (`None` for a local file load).
202    locator: Option<String>,
203    /// The raw manifest text — the world's source, view-source-able.
204    source: String,
205    portals: Vec<PortalNav>,
206    interactables: Vec<InteractableNav>,
207    sky: SkyNav,
208    presence_relays: Vec<String>,
209    /// The room lives only while its owner hosts it (a home with guests) —
210    /// when its presence connection dies, the browser walks guests home.
211    owner_required: bool,
212    colliders: Vec<ColliderNav>,
213    /// Opt-in game mechanics this world enforces (default all off).
214    rules: WorldRules,
215    /// The world's sandboxed behavior modules (behavior-abi-v0.1).
216    #[cfg(feature = "behaviors")]
217    behaviors: Vec<BehaviorSlot>,
218    /// The world's **Weft** behaviors (weft-v0.1) — the native path, always on.
219    weft_behaviors: Vec<WeftSlot>,
220}
221
222/// One loaded Weft behavior, bound by its manifest id.
223struct WeftSlot {
224    id: String,
225    behavior: crate::behavior_weft::WeftBehavior,
226    /// The events this behavior subscribed to (`behaviors[].on`): a slot
227    /// listing `"tick"` gets the world's heartbeat.
228    on: Vec<String>,
229}
230
231/// Load every `weft`-kind behavior in the manifest: fetch the asset, parse,
232/// **verify** (the trust boundary), reject on any failure — a broken module is
233/// simply absent; the world renders on (super-stable tenet).
234fn load_weft_behaviors(manifest: &WorldManifest, assets: &AssetSource) -> Vec<WeftSlot> {
235    manifest
236        .behaviors
237        .iter()
238        .filter_map(|b| {
239            // Two native paths: a raw module asset (`weft`), or a **package
240            // export** (`weft_pack` + `weft_export` — the markup's `weft-use`).
241            // Both end at the same trust boundary: verify before evaluate.
242            let result = if let Some(wid) = b.weft.as_ref() {
243                let asset = manifest.assets.iter().find(|a| &a.id == wid)?;
244                let path = assets.resolve(&asset.uri)?;
245                let text = std::fs::read_to_string(&path).ok()?;
246                crate::behavior_weft::WeftBehavior::from_json(&text)
247            } else if let (Some(pid), Some(export)) = (b.weft_pack.as_ref(), b.weft_export.as_ref())
248            {
249                let asset = manifest.assets.iter().find(|a| &a.id == pid)?;
250                let path = assets.resolve(&asset.uri)?;
251                let text = std::fs::read_to_string(&path).ok()?;
252                crate::behavior_weft::WeftBehavior::from_package_json(&text, export)
253            } else {
254                return None;
255            };
256            match result {
257                Ok(behavior) => {
258                    tracing::info!("weft behavior '{}' verified + loaded", b.id);
259                    Some(WeftSlot {
260                        id: b.id.clone(),
261                        behavior,
262                        on: b.on.clone(),
263                    })
264                }
265                Err(e) => {
266                    tracing::warn!("weft behavior '{}' refused: {e}", b.id);
267                    None
268                }
269            }
270        })
271        .collect()
272}
273
274/// A live preview of a veil's destination: in flight, arrived, or given up.
275/// Failure is remembered so an unreachable host is asked exactly once a session.
276enum PreviewSlot {
277    Pending(Receiver<Option<PortalPreview>>),
278    Ready(PortalPreview),
279    Failed,
280}
281
282/// How a veilwalk relates to session history: a fresh navigation pushes the
283/// departed world onto the back stack (and clears forward); back/forward move
284/// between the stacks without forking history — exactly the web's model.
285#[derive(Debug, Clone, Copy, PartialEq, Eq)]
286enum NavKind {
287    Normal,
288    Back,
289    Forward,
290    /// Same world, fresh contents (e.g. build mode re-applying an edited home
291    /// file) — history is untouched: a reload is not a navigation.
292    Reload,
293}
294
295/// An in-flight veilwalk (a background resolve+fetch), plus where to anchor it.
296struct Pending {
297    rx: Receiver<Result<TravelResult, String>>,
298    anchor: Vec3,
299    /// The destination Locator (recorded into history on arrival).
300    locator: String,
301    kind: NavKind,
302}
303
304/// The veilwalking state machine: active world, in-flight travel, cooldown, arming.
305pub struct Navigator {
306    worlds_root: PathBuf,
307    resolver_base: String,
308    active: Option<ActiveWorld>,
309    pending: Option<Pending>,
310    cooldown: f32,
311    /// A veil only fires when "armed" — set false after any traversal attempt and
312    /// re-armed only once the player has stepped away from every veil. Prevents
313    /// arrival-bounce and, on a failed veilwalk, spam-retry (walk out and back).
314    armed: bool,
315    /// Last veilwalk failure, surfaced in the HUD until the next attempt.
316    last_error: Option<String>,
317    /// Session history: worlds walked *from* (most recent last) / *back from*.
318    back_stack: Vec<String>,
319    forward_stack: Vec<String>,
320    /// The session's home world — by default, wherever it opened.
321    home: Option<String>,
322    /// The traveler's Passport `sub`, exposed to behavior modules as
323    /// `actor.passport_sub` — the id only, never the token (passport-v0.1 §4).
324    actor_sub: Option<String>,
325    /// Live previews of veil destinations, keyed by Locator. Session-cached —
326    /// a destination's signpost is the same from every world that links it.
327    previews: HashMap<String, PreviewSlot>,
328    /// Accumulator gating Weft tick dispatch to a steady ~4 Hz — behaviors
329    /// get a heartbeat, not a framerate (fuel discipline).
330    weft_tick_accum: f32,
331    /// The session's ground plane: every world anchors at THIS height, so a
332    /// hundred veilwalks can never drift the traveler into the sky. Set from
333    /// the first world's anchor; deliberately never derived from the player's
334    /// live (possibly airborne) position again.
335    base_y: Option<f32>,
336}
337
338impl Navigator {
339    pub fn new(worlds_root: impl Into<PathBuf>) -> Self {
340        let resolver_base = std::env::var("INFINITE_THREAD_BASE")
341            .unwrap_or_else(|_| DEFAULT_THREAD_BASE.to_string());
342        Self {
343            worlds_root: worlds_root.into(),
344            resolver_base,
345            active: None,
346            pending: None,
347            cooldown: 0.0,
348            armed: false,
349            last_error: None,
350            back_stack: Vec::new(),
351            forward_stack: Vec::new(),
352            home: None,
353            actor_sub: None,
354            previews: HashMap::new(),
355            weft_tick_accum: 0.0,
356            base_y: None,
357        }
358    }
359
360    /// Name the traveler behind interactions: their Passport `sub` reaches
361    /// behavior modules as `actor.passport_sub`. `None` → anonymous.
362    pub fn set_actor(&mut self, sub: Option<String>) {
363        self.actor_sub = sub.filter(|s| !s.trim().is_empty());
364    }
365
366    /// The most recent veilwalk failure (for a HUD notice), if any.
367    pub fn last_error(&self) -> Option<&str> {
368        self.last_error.as_deref()
369    }
370
371    pub fn title(&self) -> Option<&str> {
372        self.active.as_ref().map(|w| w.title.as_str())
373    }
374
375    /// The active world's stable id (its presence room key). Empty if none loaded.
376    pub fn world_id(&self) -> &str {
377        self.active
378            .as_ref()
379            .map(|w| w.world_id.as_str())
380            .unwrap_or("")
381    }
382
383    /// The active world's Locator — the address bar. `None` before the first
384    /// arrival or for a world loaded from a local file.
385    pub fn locator(&self) -> Option<&str> {
386        self.active.as_ref().and_then(|w| w.locator.as_deref())
387    }
388
389    /// The active world's raw manifest text — **view-source**, the Thread's
390    /// learn-by-copying primitive. `None` when no world is loaded.
391    pub fn world_source(&self) -> Option<&str> {
392        self.active.as_ref().map(|w| w.source.as_str())
393    }
394
395    /// The active world's one-line description (may be empty) — for arrival cards.
396    pub fn world_description(&self) -> Option<&str> {
397        self.active.as_ref().map(|w| w.description.as_str())
398    }
399
400    /// The session's home world Locator — by default, wherever the session opened.
401    pub fn home(&self) -> Option<&str> {
402        self.home.as_deref()
403    }
404
405    /// Override the home world (a browser preference).
406    pub fn set_home(&mut self, locator: impl Into<String>) {
407        self.home = Some(locator.into());
408    }
409
410    /// The back-history Locators (most recent last). For a HUD/history panel.
411    pub fn back_history(&self) -> &[String] {
412        &self.back_stack
413    }
414
415    /// Whether `back()` / `forward()` would go anywhere.
416    pub fn can_go_back(&self) -> bool {
417        !self.back_stack.is_empty()
418    }
419    pub fn can_go_forward(&self) -> bool {
420        !self.forward_stack.is_empty()
421    }
422
423    /// Veilwalk back to the previous world (the web's back button). Returns
424    /// whether a walk began — false when idle history is empty or travel is
425    /// already in flight.
426    pub fn back(&mut self, anchor: Vec3) -> bool {
427        if self.pending.is_some() {
428            return false;
429        }
430        let Some(dest) = self.back_stack.pop() else {
431            return false;
432        };
433        self.begin_travel_kind(dest, anchor, NavKind::Back);
434        true
435    }
436
437    /// Veilwalk forward again after `back()` (the web's forward button).
438    pub fn forward(&mut self, anchor: Vec3) -> bool {
439        if self.pending.is_some() {
440            return false;
441        }
442        let Some(dest) = self.forward_stack.pop() else {
443            return false;
444        };
445        self.begin_travel_kind(dest, anchor, NavKind::Forward);
446        true
447    }
448
449    /// Veilwalk to the session's home world (a normal navigation — it forks
450    /// history like any other walk). Returns whether a walk began.
451    pub fn go_home(&mut self, anchor: Vec3) -> bool {
452        if self.pending.is_some() {
453            return false;
454        }
455        let Some(dest) = self.home.clone() else {
456            return false;
457        };
458        if self.locator() == Some(dest.as_str()) {
459            return false; // already home
460        }
461        self.begin_travel(dest, anchor);
462        true
463    }
464
465    /// The active world's sky/atmosphere, if a world is loaded.
466    pub fn sky(&self) -> Option<SkyNav> {
467        self.active.as_ref().map(|w| w.sky)
468    }
469
470    /// The opt-in game mechanics the active world enforces. Default all off (no
471    /// world, home, or a place that declares no rules) — the browser stays a browser.
472    pub fn rules(&self) -> WorldRules {
473        self.active.as_ref().map(|w| w.rules).unwrap_or_default()
474    }
475
476    /// The active world's presence relays (primary first, then fallbacks). Empty
477    /// when no world is loaded or the world is solo.
478    pub fn presence_relays(&self) -> &[String] {
479        self.active
480            .as_ref()
481            .map(|w| w.presence_relays.as_slice())
482            .unwrap_or(&[])
483    }
484
485    /// Whether the active world's room is owner-tied (`presence.owner_required`):
486    /// when such a room closes, the browser SHOULD walk a guest back home.
487    pub fn presence_owner_required(&self) -> bool {
488        self.active.as_ref().is_some_and(|w| w.owner_required)
489    }
490
491    /// Whether a veilwalk is in flight (destination being resolved/fetched).
492    pub fn is_traveling(&self) -> bool {
493        self.pending.is_some()
494    }
495
496    /// The active world's solid volumes — placement geometry as oriented boxes
497    /// for the browser's physics. Empty when no world is loaded.
498    pub fn colliders(&self) -> &[ColliderNav] {
499        self.active
500            .as_ref()
501            .map(|w| w.colliders.as_slice())
502            .unwrap_or(&[])
503    }
504
505    /// The live preview of a veil's destination, once its fetch has landed —
506    /// keyed by the portal's `to` Locator. `None` while in flight, after a
507    /// failed fetch, or for a veil that opted out (`preview: "none"`).
508    pub fn portal_preview(&self, to: &str) -> Option<&PortalPreview> {
509        match self.previews.get(to) {
510            Some(PreviewSlot::Ready(p)) => Some(p),
511            _ => None,
512        }
513    }
514
515    /// The veils in the active world — for a browser to render as doorways, draw
516    /// on a minimap, or (headless) walk toward. Empty when no world is loaded.
517    pub fn portals(&self) -> &[PortalNav] {
518        self.active
519            .as_ref()
520            .map(|w| w.portals.as_slice())
521            .unwrap_or(&[])
522    }
523
524    /// Whether the navigator is idle and **armed** — no travel in flight, off the
525    /// arrival cooldown, and the player has stepped clear of every veil since the
526    /// last traversal. When true, stepping into a veil will fire immediately; a
527    /// browser can use this to show a "▶ step through" prompt only when it'll work.
528    pub fn armed(&self) -> bool {
529        self.armed && self.pending.is_none() && self.cooldown <= 0.0
530    }
531
532    /// The veil the player is standing in front of, as `(label, destination)`.
533    pub fn near_portal(&self, player_pos: Vec3) -> Option<(&str, &str)> {
534        let w = self.active.as_ref()?;
535        let i = nearest_portal(&w.portals, player_pos, ENTER_RADIUS)?;
536        Some((w.portals[i].label.as_str(), w.portals[i].to.as_str()))
537    }
538
539    /// The inspectable placement the player is standing at (Codex pedestal or web
540    /// signboard), as owned data for the HUD + inspect action.
541    pub fn focused(&self, player_pos: Vec3) -> Option<Focus> {
542        let w = self.active.as_ref()?;
543        let i = nearest_interactable(&w.interactables, player_pos, INSPECT_RADIUS)?;
544        let it = &w.interactables[i];
545        Some(Focus {
546            label: it.label.clone(),
547            codex: it.codex.clone(),
548            web: it.web.clone(),
549            behavior: it.behavior.clone(),
550            interact_label: it.interaction.as_ref().map(|i| i.label.clone()),
551            data: it.data.clone(),
552        })
553    }
554
555    /// The player interacted with whatever they're standing at: dispatch the event
556    /// to the placement's bound behavior module and return the [`Action`]s it asks
557    /// for — the *browser* performs them (the sandbox never touches IO). Empty when
558    /// nothing is focused, nothing is bound, or the `behaviors` feature is off.
559    pub fn interact(&mut self, player_pos: Vec3) -> Vec<Action> {
560        let Some(w) = self.active.as_mut() else {
561            return Vec::new();
562        };
563        let Some(i) = nearest_interactable(&w.interactables, player_pos, INSPECT_RADIUS) else {
564            return Vec::new();
565        };
566
567        // Codeless declarative interaction — no WASM. Count the hit; once `hits`
568        // presses land, fire the effects as browser Actions.
569        if let Some(inter) = w.interactables[i].interaction.clone() {
570            {
571                let it = &mut w.interactables[i];
572                it.hits_done += 1;
573                if it.hits_done < inter.hits.max(1) {
574                    return Vec::new(); // more hits to complete it
575                }
576                it.hits_done = 0;
577            }
578            let mut actions = effects_to_actions(&inter.effects);
579            // `despawn` removes the object outright: drop its interactable + the
580            // nearest world collider here, and tell the browser to hide its mesh.
581            if inter
582                .effects
583                .iter()
584                .any(|e| matches!(e, InteractionEffect::Despawn))
585            {
586                let pos = w.interactables[i].position;
587                w.interactables.remove(i);
588                if let Some(ci) = nearest_by(w.colliders.iter().map(|c| c.position), pos, 3.0) {
589                    w.colliders.remove(ci);
590                }
591                actions.push(Action::Despawn {
592                    position: pos.to_array(),
593                });
594            }
595            return actions;
596        }
597
598        let it = &w.interactables[i];
599        let Some(bid) = it.behavior.clone() else {
600            return Vec::new();
601        };
602        // Native path first: a Weft behavior bound to this placement.
603        if let Some(slot) = w.weft_behaviors.iter_mut().find(|s| s.id == bid) {
604            let name = if it.name.is_empty() {
605                it.label.clone()
606            } else {
607                it.name.clone()
608            };
609            let slug = it.codex.clone();
610            return slot.behavior.on_interact(&name, slug.as_deref());
611        }
612        #[cfg(feature = "behaviors")]
613        {
614            // The placement's `codex` slug is mirrored into `data.slug` (when the
615            // author didn't set one) so a viewer module doesn't need it duplicated.
616            let mut data = it.data.clone();
617            if let Some(slug) = &it.codex {
618                if data.get("slug").is_none() {
619                    if !data.is_object() {
620                        data = serde_json::json!({});
621                    }
622                    if let Some(obj) = data.as_object_mut() {
623                        obj.insert("slug".into(), slug.clone().into());
624                    }
625                }
626            }
627            let event = InteractEvent {
628                placement: if it.name.is_empty() {
629                    it.label.clone()
630                } else {
631                    it.name.clone()
632                },
633                actor: Actor {
634                    passport_sub: self.actor_sub.clone(),
635                },
636                world: w.world_id.clone(),
637                data,
638            };
639            if let Some(slot) = w.behaviors.iter_mut().find(|s| s.id == bid) {
640                return slot.module.on_interact(&event);
641            }
642        }
643        let _ = (bid, &self.actor_sub);
644        Vec::new()
645    }
646
647    /// Deliver an async host event (e.g. a settled `purchase_result`) to every
648    /// behavior module in the active world and return the [`Action`]s they ask
649    /// for. Empty when no world is loaded or the `behaviors` feature is off.
650    pub fn dispatch_event(&mut self, event: &serde_json::Value) -> Vec<Action> {
651        #[cfg(feature = "behaviors")]
652        if let Some(w) = self.active.as_mut() {
653            return w
654                .behaviors
655                .iter_mut()
656                .flat_map(|slot| slot.module.on_event(event))
657                .collect();
658        }
659        let _ = event;
660        Vec::new()
661    }
662
663    /// Advance the active world's ticking behavior modules (declared `"tick"` in
664    /// their `on[]`) — **the world's heartbeat**. WASM modules tick every call;
665    /// Weft modules tick at a steady ~4 Hz (fuel discipline) and their Actions
666    /// are returned for the browser to perform — worlds that behave, not just
667    /// react.
668    pub fn tick_behaviors(&mut self, dt: f32) -> Vec<Action> {
669        #[cfg(feature = "behaviors")]
670        if let Some(w) = self.active.as_mut() {
671            let dt_ms = (dt * 1000.0).round() as i32;
672            for slot in w.behaviors.iter_mut().filter(|s| s.ticks) {
673                slot.module.on_tick(dt_ms);
674            }
675        }
676        const TICK_SECS: f32 = 0.25;
677        self.weft_tick_accum += dt.max(0.0);
678        if self.weft_tick_accum < TICK_SECS {
679            return Vec::new();
680        }
681        let elapsed_ms = (self.weft_tick_accum * 1000.0) as i64;
682        self.weft_tick_accum = 0.0;
683        let Some(w) = self.active.as_mut() else {
684            return Vec::new();
685        };
686        let mut actions = Vec::new();
687        for slot in w
688            .weft_behaviors
689            .iter_mut()
690            .filter(|s| s.on.iter().any(|e| e == "tick"))
691        {
692            actions.extend(slot.behavior.on_tick(elapsed_ms));
693        }
694        actions
695    }
696
697    /// Load a world from a local `world.json` (the startup path). Returns the
698    /// spawn to teleport the player to.
699    pub fn load_file(
700        &mut self,
701        loader: &mut dyn WorldLoader,
702        path: &Path,
703        anchor: Vec3,
704    ) -> Option<Vec3> {
705        let text = match std::fs::read_to_string(path) {
706            Ok(t) => t,
707            Err(e) => {
708                tracing::warn!("navigator: cannot read {}: {e}", path.display());
709                return None;
710            }
711        };
712        let base = path.parent().unwrap_or(Path::new(".")).to_path_buf();
713        self.apply_world(
714            loader,
715            &text,
716            &AssetSource::local(base),
717            anchor,
718            None,
719            NavKind::Normal,
720        )
721    }
722
723    /// Re-apply a *local* world file in place — same world, fresh contents,
724    /// keeping `locator` and touching no history. Build mode uses this after
725    /// each edit to the home file: the scene rebuilds, but a reload is not a
726    /// navigation, so back/forward don't fork.
727    pub fn reload_file(
728        &mut self,
729        loader: &mut dyn WorldLoader,
730        path: &Path,
731        locator: Option<String>,
732        anchor: Vec3,
733    ) -> Option<Vec3> {
734        let text = match std::fs::read_to_string(path) {
735            Ok(t) => t,
736            Err(e) => {
737                tracing::warn!("navigator: cannot reload {}: {e}", path.display());
738                return None;
739            }
740        };
741        let base = path.parent().unwrap_or(Path::new(".")).to_path_buf();
742        self.apply_world(
743            loader,
744            &text,
745            &AssetSource::local(base),
746            anchor,
747            locator,
748            NavKind::Reload,
749        )
750    }
751
752    /// Turn resolved manifest text into the live world (validate here, place via
753    /// the loader); returns the spawn. `locator`+`kind` record the arrival into
754    /// session history.
755    fn apply_world(
756        &mut self,
757        loader: &mut dyn WorldLoader,
758        manifest_text: &str,
759        assets: &AssetSource,
760        anchor: Vec3,
761        locator: Option<String>,
762        kind: NavKind,
763    ) -> Option<Vec3> {
764        // `from_text` accepts both source forms — JSON and `.thread` markup — so
765        // a host serving markup is as walkable as one serving JSON.
766        let manifest = match WorldManifest::from_text(manifest_text) {
767            Ok(m) => m,
768            Err(e) => {
769                tracing::warn!("navigator: invalid world manifest: {e}");
770                return None;
771            }
772        };
773        let loaded = loader.load(&manifest, assets, anchor);
774        tracing::info!(
775            "veilwalked into '{}' — {} veils",
776            loaded.title,
777            loaded.portals.len(),
778        );
779        // Record the world we're leaving into history (the web's model: a fresh
780        // navigation forks — clears forward; back/forward move between stacks).
781        if let Some(prev) = self.active.as_ref().and_then(|w| w.locator.clone()) {
782            match kind {
783                NavKind::Normal => {
784                    self.back_stack.push(prev);
785                    self.forward_stack.clear();
786                }
787                NavKind::Back => self.forward_stack.push(prev),
788                NavKind::Forward => self.back_stack.push(prev),
789                NavKind::Reload => {}
790            }
791        }
792        self.base_y.get_or_insert(anchor.y);
793        // Arrive STANDING: the spawn is a feet/floor position; the player's
794        // origin sits a capsule's drop above it (grounding settles the rest).
795        let spawn = loaded.spawn + Vec3::new(0.0, FEET_DROP, 0.0);
796        self.active = Some(ActiveWorld {
797            title: loaded.title,
798            world_id: loaded.world_id,
799            description: manifest.world.description.clone(),
800            locator,
801            source: manifest_text.to_string(),
802            portals: loaded.portals,
803            interactables: loaded.interactables,
804            sky: loaded.sky,
805            presence_relays: loaded.presence_relays,
806            owner_required: manifest.presence.as_ref().is_some_and(|p| p.owner_required),
807            colliders: loaded.colliders,
808            rules: manifest.environment.rules,
809            #[cfg(feature = "behaviors")]
810            behaviors: load_behaviors(&manifest, assets),
811            weft_behaviors: load_weft_behaviors(&manifest, assets),
812        });
813        self.cooldown = TRAVERSE_COOLDOWN;
814        Some(spawn)
815    }
816
817    /// Open a world by Locator (the startup entry to the live Thread). Kicks off
818    /// the async resolve+fetch; the world loads on the next `update` poll. The
819    /// first opened Locator becomes the session's home unless overridden.
820    pub fn open(&mut self, locator: &str, anchor: Vec3) {
821        tracing::info!("opening {locator} on the Thread…");
822        if self.home.is_none() {
823            self.home = Some(locator.to_string());
824        }
825        self.begin_travel(locator.to_string(), anchor);
826    }
827
828    /// Begin veilwalking to a Locator: resolve + fetch on a background thread.
829    fn begin_travel(&mut self, locator: String, anchor: Vec3) {
830        self.begin_travel_kind(locator, anchor, NavKind::Normal);
831    }
832
833    fn begin_travel_kind(&mut self, locator: String, anchor: Vec3, kind: NavKind) {
834        // Anchor drift is how travelers end up standing on air: quantize every
835        // walk's anchor to the session ground plane.
836        let anchor = Vec3::new(anchor.x, self.base_y.unwrap_or(anchor.y), anchor.z);
837        let (tx, rx) = channel();
838        let base = self.resolver_base.clone();
839        let root = self.worlds_root.clone();
840        let loc = locator.clone();
841        std::thread::spawn(move || {
842            let result = resolver::fetch_world(&base, &root, &loc);
843            // Download the world's remote assets into the cache here, off the main
844            // thread, so the GPU load only ever reads local files.
845            if let Ok(tr) = &result {
846                if let Ok(manifest) = WorldManifest::from_text(&tr.manifest_text) {
847                    AssetSource::hosted(&tr.asset_base, tr.asset_base_url.clone(), &tr.asset_base)
848                        .prefetch(&manifest);
849                }
850            }
851            let _ = tx.send(result);
852        });
853        self.pending = Some(Pending {
854            rx,
855            anchor,
856            locator,
857            kind,
858        });
859        self.cooldown = TRAVERSE_COOLDOWN;
860        self.armed = false;
861        self.last_error = None;
862    }
863
864    /// Advance live destination previews: land any in-flight fetch, and start
865    /// one for the nearest unpreviewd veil the player has approached. One fetch
866    /// at a time — calm, sequential, and an unreachable host can't fan out.
867    fn update_previews(&mut self, player_pos: Vec3) {
868        for slot in self.previews.values_mut() {
869            if let PreviewSlot::Pending(rx) = slot {
870                match rx.try_recv() {
871                    Ok(Some(p)) => *slot = PreviewSlot::Ready(p),
872                    Ok(None) | Err(TryRecvError::Disconnected) => *slot = PreviewSlot::Failed,
873                    Err(TryRecvError::Empty) => {}
874                }
875            }
876        }
877        if self
878            .previews
879            .values()
880            .any(|s| matches!(s, PreviewSlot::Pending(_)))
881        {
882            return;
883        }
884        let Some(w) = self.active.as_ref() else {
885            return;
886        };
887        let Some(portal) = w
888            .portals
889            .iter()
890            .filter(|p| p.preview && !self.previews.contains_key(&p.to))
891            .map(|p| (p, (p.position - player_pos).length()))
892            .filter(|(_, d)| *d <= PREVIEW_RADIUS)
893            .min_by(|a, b| a.1.total_cmp(&b.1))
894            .map(|(p, _)| p)
895        else {
896            return;
897        };
898        let (tx, rx) = channel();
899        let base = self.resolver_base.clone();
900        let root = self.worlds_root.clone();
901        let to = portal.to.clone();
902        std::thread::spawn(move || {
903            let preview = resolver::fetch_world(&base, &root, &to)
904                .ok()
905                .and_then(|tr| {
906                    let p = preview_from_manifest(&tr.manifest_text, &tr);
907                    // Pull the far side's remote assets into the cache while we're
908                    // still on this background thread — the browser's snapshot
909                    // render then reads locally, never stalling a frame.
910                    if p.is_some() {
911                        if let Ok(m) = WorldManifest::from_text(&tr.manifest_text) {
912                            AssetSource::hosted(
913                                &tr.asset_base,
914                                tr.asset_base_url.clone(),
915                                &tr.asset_base,
916                            )
917                            .prefetch(&m);
918                        }
919                    }
920                    p
921                });
922            let _ = tx.send(preview);
923        });
924        self.previews
925            .insert(portal.to.clone(), PreviewSlot::Pending(rx));
926    }
927
928    /// Per-frame: complete an in-flight veilwalk, or start one if the player
929    /// stepped into a veil. Returns `Some(spawn)` to reposition the player.
930    pub fn update(
931        &mut self,
932        dt: f32,
933        loader: &mut dyn WorldLoader,
934        player_pos: Vec3,
935    ) -> Option<Vec3> {
936        self.update_previews(player_pos);
937        // 1. A veilwalk is in flight — see if the destination arrived.
938        if let Some(pending) = &self.pending {
939            match pending.rx.try_recv() {
940                Ok(Ok(tr)) => {
941                    let p = self.pending.take().unwrap();
942                    let assets = AssetSource::hosted(
943                        &tr.asset_base,
944                        tr.asset_base_url.clone(),
945                        &tr.asset_base,
946                    );
947                    return self.apply_world(
948                        loader,
949                        &tr.manifest_text,
950                        &assets,
951                        p.anchor,
952                        Some(p.locator),
953                        p.kind,
954                    );
955                }
956                Ok(Err(e)) => {
957                    tracing::warn!("veilwalk failed: {e}");
958                    self.last_error = Some(short_error(&e));
959                    // A failed back/forward must not eat the history entry —
960                    // restore it so the button still works after a blip.
961                    let p = self.pending.take().unwrap();
962                    match p.kind {
963                        NavKind::Back => self.back_stack.push(p.locator),
964                        NavKind::Forward => self.forward_stack.push(p.locator),
965                        NavKind::Normal | NavKind::Reload => {}
966                    }
967                    // stays disarmed: walk out and back to retry
968                }
969                Err(TryRecvError::Empty) => {}
970                Err(TryRecvError::Disconnected) => self.pending = None,
971            }
972            return None;
973        }
974        // 2. Cooldown after arriving.
975        if self.cooldown > 0.0 {
976            self.cooldown = (self.cooldown - dt).max(0.0);
977            return None;
978        }
979        // 3. Arming: a veil fires only when armed, and re-arms only once the
980        // player has stepped away from every veil (no bounce, no spam-retry).
981        let w = self.active.as_ref()?;
982        match nearest_portal(&w.portals, player_pos, ENTER_RADIUS) {
983            None => {
984                self.armed = true;
985                None
986            }
987            Some(idx) if self.armed => {
988                let to = w.portals[idx].to.clone();
989                let anchor = player_pos - Vec3::new(0.0, FEET_DROP, 0.0);
990                self.begin_travel(to, anchor);
991                None
992            }
993            Some(_) => None, // standing at a veil but not armed → wait
994        }
995    }
996}
997
998/// Instantiate a world's declared behavior modules (wasm assets → sandboxes).
999/// Every failure degrades to "that module just isn't there" — a world with a
1000/// broken behavior still renders and walks (the super-stable tenet).
1001#[cfg(feature = "behaviors")]
1002fn load_behaviors(manifest: &WorldManifest, assets: &AssetSource) -> Vec<BehaviorSlot> {
1003    manifest
1004        .behaviors
1005        .iter()
1006        .filter_map(|b| {
1007            let asset = manifest.assets.iter().find(|a| a.id == b.wasm)?;
1008            let path = assets.resolve(&asset.uri)?;
1009            let bytes = std::fs::read(&path).ok()?;
1010            match WasmBehavior::load(&bytes) {
1011                Ok(module) => {
1012                    tracing::info!("behavior '{}' loaded ({} KB)", b.id, bytes.len() / 1024);
1013                    Some(BehaviorSlot {
1014                        id: b.id.clone(),
1015                        ticks: b.on.iter().any(|e| e == "tick"),
1016                        module,
1017                    })
1018                }
1019                Err(e) => {
1020                    tracing::warn!("behavior '{}' failed to load: {e}", b.id);
1021                    None
1022                }
1023            }
1024        })
1025        .collect()
1026}
1027
1028/// Extract the far side's signpost from fetched manifest text — pure, no IO.
1029/// Invalid manifests preview as nothing (a veil never breaks over its far side).
1030fn preview_from_manifest(text: &str, tr: &TravelResult) -> Option<PortalPreview> {
1031    let m = WorldManifest::from_text(text).ok()?;
1032    let mut sky = SkyNav::default();
1033    if let Some(s) = &m.environment.sky {
1034        sky.zenith = s.zenith;
1035        sky.horizon = s.horizon;
1036        if let Some(d) = s.sun_dir {
1037            sky.sun_dir = d;
1038        }
1039    }
1040    Some(PortalPreview {
1041        title: m.world.title.clone(),
1042        sky,
1043        veils: m.portals.len(),
1044        manifest_text: text.to_string(),
1045        asset_base: tr.asset_base.clone(),
1046        asset_base_url: tr.asset_base_url.clone(),
1047        presence_relays: m
1048            .presence
1049            .as_ref()
1050            .map(|p| p.relay_list())
1051            .unwrap_or_default(),
1052        world_id: m.world.id.clone(),
1053    })
1054}
1055
1056/// Trim a raw fetch error to a short, friendly HUD line.
1057fn short_error(e: &str) -> String {
1058    let first = e.split(&['(', '—'][..]).next().unwrap_or(e).trim();
1059    let msg = if first.is_empty() { e } else { first };
1060    format!(
1061        "couldn't reach that world ({})",
1062        msg.chars().take(60).collect::<String>().trim()
1063    )
1064}
1065
1066/// Index of the nearest portal within `radius` of `pos`, if any.
1067fn nearest_portal(portals: &[PortalNav], pos: Vec3, radius: f32) -> Option<usize> {
1068    nearest_by(portals.iter().map(|p| p.position), pos, radius)
1069}
1070
1071/// Index of the nearest inspectable within `radius` of `pos`, if any.
1072fn nearest_interactable(items: &[InteractableNav], pos: Vec3, radius: f32) -> Option<usize> {
1073    nearest_by(items.iter().map(|p| p.position), pos, radius)
1074}
1075
1076/// Translate a declarative interaction's effects into browser [`Action`]s — the
1077/// browser performs them. `Despawn` needs runtime object removal and is handled
1078/// in a later slice, so it produces no Action yet.
1079fn effects_to_actions(effects: &[InteractionEffect]) -> Vec<Action> {
1080    effects
1081        .iter()
1082        .filter_map(|e| match e {
1083            InteractionEffect::Message(text) => Some(Action::Notify {
1084                text: text.clone(),
1085                level: None,
1086            }),
1087            InteractionEffect::GiveItem { item, count } => Some(Action::GiveItem {
1088                item: item.number(),
1089                count: *count,
1090            }),
1091            InteractionEffect::Effect(name) => Some(Action::Notify {
1092                text: format!("✨ {name}"),
1093                level: None,
1094            }),
1095            InteractionEffect::Navigate(to) => Some(Action::Navigate { to: to.clone() }),
1096            InteractionEffect::Despawn => None, // runtime removal — next slice
1097        })
1098        .collect()
1099}
1100
1101/// Index of the nearest point within `radius`, if any.
1102fn nearest_by(positions: impl Iterator<Item = Vec3>, pos: Vec3, radius: f32) -> Option<usize> {
1103    positions
1104        .enumerate()
1105        .map(|(i, p)| (i, (p - pos).length()))
1106        .filter(|(_, d)| *d <= radius)
1107        .min_by(|a, b| a.1.total_cmp(&b.1))
1108        .map(|(i, _)| i)
1109}
1110
1111#[cfg(test)]
1112mod tests {
1113    use super::*;
1114
1115    fn portal(pos: Vec3, label: &str) -> PortalNav {
1116        PortalNav {
1117            id: label.into(),
1118            position: pos,
1119            to: String::new(),
1120            label: label.into(),
1121            preview: true,
1122            yaw: 0.0,
1123        }
1124    }
1125
1126    #[test]
1127    fn declarative_effects_become_browser_actions() {
1128        // A creator's effects (parsed from manifest JSON) map to Actions the
1129        // browser performs. Despawn is deferred → emits nothing yet.
1130        let effects: Vec<InteractionEffect> = serde_json::from_str(
1131            r#"[ {"give_item":{"item":"20100007","count":3}}, {"message":"Chopped!"}, "despawn" ]"#,
1132        )
1133        .unwrap();
1134        let actions = effects_to_actions(&effects);
1135        assert_eq!(
1136            actions.len(),
1137            2,
1138            "give_item + message emit; despawn deferred"
1139        );
1140        assert!(matches!(actions[0], Action::GiveItem { item: 7, count: 3 }));
1141        assert!(matches!(&actions[1], Action::Notify { text, .. } if text == "Chopped!"));
1142    }
1143
1144    #[test]
1145    fn nearest_portal_picks_the_closest_within_reach() {
1146        let portals = vec![
1147            portal(Vec3::new(10.0, 0.0, 0.0), "far"),
1148            portal(Vec3::new(1.0, 0.0, 0.0), "near"),
1149        ];
1150        assert_eq!(
1151            nearest_portal(&portals, Vec3::new(0.0, 0.0, 0.0), 2.2),
1152            Some(1)
1153        );
1154        assert_eq!(
1155            nearest_portal(&portals, Vec3::new(0.0, 0.0, 5.0), 2.2),
1156            None
1157        );
1158    }
1159
1160    /// A minimal in-memory loader — proves the navigator drives a world through the
1161    /// `WorldLoader` seam with no renderer at all (the whole point of the split).
1162    struct FakeLoader {
1163        loaded: u32,
1164    }
1165    impl WorldLoader for FakeLoader {
1166        fn load(
1167            &mut self,
1168            manifest: &WorldManifest,
1169            _assets: &AssetSource,
1170            anchor: Vec3,
1171        ) -> LoadedWorldMeta {
1172            self.loaded += 1;
1173            LoadedWorldMeta {
1174                title: manifest.world.title.clone(),
1175                world_id: manifest.world.id.clone(),
1176                spawn: anchor,
1177                portals: vec![portal(Vec3::new(0.0, 0.0, 0.0), "onward")],
1178                interactables: vec![],
1179                sky: SkyNav::default(),
1180                presence_relays: vec![],
1181                colliders: vec![],
1182            }
1183        }
1184    }
1185
1186    const WORLD: &str = r#"{
1187        "thread": "thread/0.1",
1188        "world": { "id": "x", "title": "Test World" },
1189        "spawns": [{ "name": "entry", "position": [1, 2, 3] }]
1190    }"#;
1191
1192    #[test]
1193    fn applies_a_world_through_the_loader_seam() {
1194        let mut nav = Navigator::new(".");
1195        let mut loader = FakeLoader { loaded: 0 };
1196        let spawn = nav.apply_world(
1197            &mut loader,
1198            WORLD,
1199            &AssetSource::local("."),
1200            Vec3::new(5.0, 0.0, 5.0),
1201            None,
1202            NavKind::Normal,
1203        );
1204        assert_eq!(
1205            loader.loaded, 1,
1206            "the navigator placed the world via the loader"
1207        );
1208        assert_eq!(
1209            spawn,
1210            Some(Vec3::new(5.0, FEET_DROP, 5.0)),
1211            "player arrives standing: anchor + the capsule's feet drop"
1212        );
1213        assert_eq!(nav.title(), Some("Test World"));
1214        // A veil is present; standing on it (once armed) begins travel.
1215        assert!(nav.near_portal(Vec3::new(0.0, 0.0, 0.0)).is_some());
1216    }
1217
1218    /// Browsers accept `.thread` markup wherever they accept JSON — the same
1219    /// text seam (`apply_world`) compiles either form. The shipped Meadow is
1220    /// the fixture: a world whose only source is markup.
1221    #[test]
1222    fn thread_markup_loads_through_the_same_seam_as_json() {
1223        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1224            .join("../../worlds/meadow/world.thread");
1225        // The reference corpus is content, and content lives with the browser
1226        // that serves it. Published on its own the engine travels without it,
1227        // so this reports rather than fails — a missing file that was never
1228        // this crate's is not a broken crate.
1229        if !path.exists() {
1230            eprintln!("no reference corpus beside this crate — skipping");
1231            return;
1232        }
1233        let text = std::fs::read_to_string(&path).expect("the Meadow ships with the repo");
1234        let mut nav = Navigator::new(".");
1235        let mut loader = FakeLoader { loaded: 0 };
1236        let spawn = nav.apply_world(
1237            &mut loader,
1238            &text,
1239            &AssetSource::local("."),
1240            Vec3::ZERO,
1241            None,
1242            NavKind::Normal,
1243        );
1244        assert!(spawn.is_some(), "markup compiles and loads");
1245        assert_eq!(loader.loaded, 1);
1246        assert_eq!(nav.title(), Some("The Meadow"));
1247        // View-source shows what the author wrote — the markup itself.
1248        assert!(nav
1249            .world_source()
1250            .is_some_and(|s| s.contains("<world id=\"meadow\"")));
1251    }
1252
1253    /// The Meadow's waystone — the first Weft-powered object in a shipped
1254    /// world: markup `weft=` → manifest asset + behavior → **verified** on
1255    /// load → interact answers with a running touch count. The Thread's
1256    /// native code, end to end, through a real world file.
1257    #[test]
1258    fn the_meadows_waystone_answers_through_weft() {
1259        struct MeadowLoader;
1260        impl WorldLoader for MeadowLoader {
1261            fn load(
1262                &mut self,
1263                m: &WorldManifest,
1264                _a: &AssetSource,
1265                anchor: Vec3,
1266            ) -> LoadedWorldMeta {
1267                LoadedWorldMeta {
1268                    title: m.world.title.clone(),
1269                    world_id: m.world.id.clone(),
1270                    spawn: anchor,
1271                    portals: vec![],
1272                    interactables: m
1273                        .placements
1274                        .iter()
1275                        .map(|pl| InteractableNav {
1276                            position: Vec3::from_array(pl.position),
1277                            label: pl.name.clone(),
1278                            codex: pl.codex.clone(),
1279                            web: None,
1280                            name: pl.name.clone(),
1281                            behavior: pl.behavior.clone(),
1282                            interaction: pl.interaction.clone(),
1283                            hits_done: 0,
1284                            data: pl.data.clone(),
1285                        })
1286                        .collect(),
1287                    sky: SkyNav::default(),
1288                    presence_relays: vec![],
1289                    colliders: vec![],
1290                }
1291            }
1292        }
1293        let world = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1294            .join("../../worlds/meadow/world.thread");
1295        // The reference corpus is content, and content lives with the browser
1296        // that serves it. Published on its own the engine travels without it,
1297        // so this reports rather than fails — a missing file that was never
1298        // this crate's is not a broken crate.
1299        if !world.exists() {
1300            eprintln!("no reference corpus beside this crate — skipping");
1301            return;
1302        }
1303        let mut nav = Navigator::new(world.parent().unwrap());
1304        let mut loader = MeadowLoader;
1305        nav.load_file(&mut loader, &world, Vec3::ZERO)
1306            .expect("the Meadow loads");
1307        // The waystone stands at (0, 1, -2); stand beside it and touch twice.
1308        let by_the_stone = Vec3::new(0.0, 1.0, -1.4);
1309        assert_eq!(
1310            nav.interact(by_the_stone),
1311            vec![Action::Notify {
1312                text: "The waystone hums — 1 travelers have touched it.".into(),
1313                level: None
1314            }],
1315        );
1316        assert_eq!(
1317            nav.interact(by_the_stone),
1318            vec![Action::Notify {
1319                text: "The waystone hums — 2 travelers have touched it.".into(),
1320                level: None
1321            }],
1322            "state persists between touches"
1323        );
1324    }
1325
1326    #[test]
1327    fn invalid_manifest_is_rejected_without_loading() {
1328        let mut nav = Navigator::new(".");
1329        let mut loader = FakeLoader { loaded: 0 };
1330        let spawn = nav.apply_world(
1331            &mut loader,
1332            "{ not a world }",
1333            &AssetSource::local("."),
1334            Vec3::ZERO,
1335            None,
1336            NavKind::Normal,
1337        );
1338        assert_eq!(spawn, None);
1339        assert_eq!(
1340            loader.loaded, 0,
1341            "a bad manifest never reaches the renderer"
1342        );
1343        assert_eq!(nav.title(), None);
1344    }
1345
1346    #[test]
1347    fn a_manifest_previews_as_its_title_sky_and_veil_count() {
1348        let text = r#"{
1349            "thread": "thread/0.1",
1350            "world": { "id": "far", "title": "The Far Side" },
1351            "environment": { "sky": { "zenith": [0.1, 0.2, 0.3], "horizon": [0.4, 0.5, 0.6] } },
1352            "portals": [
1353                { "id": "a", "position": [0, 0, 0], "to": "thread://x" },
1354                { "id": "b", "position": [5, 0, 0], "to": "thread://y" }
1355            ]
1356        }"#;
1357        let tr = TravelResult {
1358            manifest_text: text.to_string(),
1359            asset_base: PathBuf::from("."),
1360            asset_base_url: None,
1361        };
1362        let p = preview_from_manifest(text, &tr).expect("valid manifest previews");
1363        assert_eq!(p.title, "The Far Side");
1364        assert_eq!(p.sky.zenith, [0.1, 0.2, 0.3]);
1365        assert_eq!(p.sky.horizon, [0.4, 0.5, 0.6]);
1366        assert_eq!(p.veils, 2);
1367
1368        // A broken far side previews as nothing — the veil never breaks over it.
1369        let tr = TravelResult {
1370            manifest_text: String::new(),
1371            asset_base: PathBuf::from("."),
1372            asset_base_url: None,
1373        };
1374        assert!(preview_from_manifest("{ nope }", &tr).is_none());
1375    }
1376
1377    #[test]
1378    fn previews_and_colliders_are_empty_until_a_world_provides_them() {
1379        let nav = Navigator::new(".");
1380        assert!(nav.portal_preview("thread://anywhere").is_none());
1381        assert!(nav.colliders().is_empty());
1382    }
1383
1384    #[test]
1385    fn colliders_flow_from_the_loader_to_the_browser() {
1386        struct SolidLoader;
1387        impl WorldLoader for SolidLoader {
1388            fn load(
1389                &mut self,
1390                m: &WorldManifest,
1391                _a: &AssetSource,
1392                anchor: Vec3,
1393            ) -> LoadedWorldMeta {
1394                LoadedWorldMeta {
1395                    title: m.world.title.clone(),
1396                    world_id: m.world.id.clone(),
1397                    spawn: anchor,
1398                    portals: vec![],
1399                    interactables: vec![],
1400                    sky: SkyNav::default(),
1401                    presence_relays: vec![],
1402                    colliders: vec![ColliderNav {
1403                        half_extents: Vec3::new(2.0, 1.0, 2.0),
1404                        position: Vec3::new(0.0, 4.0, 0.0),
1405                        rotation: Quat::IDENTITY,
1406                    }],
1407                }
1408            }
1409        }
1410        let mut nav = Navigator::new(".");
1411        nav.apply_world(
1412            &mut SolidLoader,
1413            WORLD,
1414            &AssetSource::local("."),
1415            Vec3::ZERO,
1416            None,
1417            NavKind::Normal,
1418        );
1419        assert_eq!(nav.colliders().len(), 1);
1420        assert_eq!(nav.colliders()[0].half_extents, Vec3::new(2.0, 1.0, 2.0));
1421    }
1422
1423    /// Arrive somewhere via `apply_world` with a Locator, as a completed veilwalk would.
1424    fn arrive(nav: &mut Navigator, loader: &mut FakeLoader, loc: &str, kind: NavKind) {
1425        nav.apply_world(
1426            loader,
1427            WORLD,
1428            &AssetSource::local("."),
1429            Vec3::ZERO,
1430            Some(loc.into()),
1431            kind,
1432        )
1433        .expect("world applies");
1434    }
1435
1436    #[test]
1437    fn history_works_like_the_webs_back_and_forward() {
1438        let mut nav = Navigator::new(".");
1439        let mut loader = FakeLoader { loaded: 0 };
1440
1441        // Walk A → B → C: two entries behind us, none ahead.
1442        arrive(&mut nav, &mut loader, "thread://a", NavKind::Normal);
1443        arrive(&mut nav, &mut loader, "thread://b", NavKind::Normal);
1444        arrive(&mut nav, &mut loader, "thread://c", NavKind::Normal);
1445        assert_eq!(nav.back_history(), ["thread://a", "thread://b"]);
1446        assert!(nav.can_go_back() && !nav.can_go_forward());
1447        assert_eq!(nav.locator(), Some("thread://c"));
1448
1449        // Going back to B: C moves to the forward stack. (`back()` pops the
1450        // destination off the back stack before travel begins — mimic that here,
1451        // since `arrive` shortcuts the async walk.)
1452        assert_eq!(nav.back_stack.pop().as_deref(), Some("thread://b"));
1453        arrive(&mut nav, &mut loader, "thread://b", NavKind::Back);
1454        assert_eq!(nav.back_history(), ["thread://a"]);
1455        assert!(nav.can_go_forward());
1456
1457        // A fresh navigation from B forks history — forward is gone.
1458        arrive(&mut nav, &mut loader, "thread://d", NavKind::Normal);
1459        assert_eq!(nav.back_history(), ["thread://a", "thread://b"]);
1460        assert!(!nav.can_go_forward());
1461    }
1462
1463    #[test]
1464    fn a_reload_is_not_a_navigation() {
1465        let mut nav = Navigator::new(".");
1466        let mut loader = FakeLoader { loaded: 0 };
1467        arrive(&mut nav, &mut loader, "thread://a", NavKind::Normal);
1468        arrive(&mut nav, &mut loader, "thread://home", NavKind::Normal);
1469        assert_eq!(nav.back_history(), ["thread://a"]);
1470
1471        // Build mode re-applies the home file: same world, fresh contents.
1472        arrive(&mut nav, &mut loader, "thread://home", NavKind::Reload);
1473        assert_eq!(nav.back_history(), ["thread://a"], "history didn't fork");
1474        assert!(!nav.can_go_forward());
1475        assert_eq!(nav.locator(), Some("thread://home"), "the address survives");
1476        assert_eq!(loader.loaded, 3, "the scene really rebuilt");
1477    }
1478
1479    #[test]
1480    fn view_source_returns_the_manifest_text_and_home_is_where_you_opened() {
1481        let mut nav = Navigator::new(".");
1482        let mut loader = FakeLoader { loaded: 0 };
1483        arrive(&mut nav, &mut loader, "thread://a", NavKind::Normal);
1484        assert_eq!(
1485            nav.world_source(),
1486            Some(WORLD),
1487            "view-source is the exact bytes"
1488        );
1489
1490        // `open()` records the session's home (a real open spawns a fetch thread,
1491        // so exercise the recording path directly here).
1492        assert!(nav.home().is_none());
1493        nav.set_home("thread://a");
1494        assert_eq!(nav.home(), Some("thread://a"));
1495        assert!(!nav.go_home(Vec3::ZERO), "already home → no walk begins");
1496    }
1497
1498    #[test]
1499    fn back_with_empty_history_is_a_safe_no_op() {
1500        let mut nav = Navigator::new(".");
1501        assert!(!nav.back(Vec3::ZERO));
1502        assert!(!nav.forward(Vec3::ZERO));
1503        assert!(!nav.go_home(Vec3::ZERO), "no home set → no walk");
1504    }
1505
1506    #[test]
1507    fn set_actor_keeps_only_real_subs() {
1508        let mut nav = Navigator::new(".");
1509        nav.set_actor(Some("  ".into()));
1510        assert!(nav.actor_sub.is_none(), "blank subs read as anonymous");
1511        nav.set_actor(Some("did:pixygon:abc".into()));
1512        assert_eq!(nav.actor_sub.as_deref(), Some("did:pixygon:abc"));
1513        nav.set_actor(None);
1514        assert!(nav.actor_sub.is_none());
1515    }
1516
1517    #[test]
1518    fn dispatch_event_without_a_world_is_a_safe_no_op() {
1519        let mut nav = Navigator::new(".");
1520        assert!(nav
1521            .dispatch_event(&serde_json::json!({"event": "purchase_result"}))
1522            .is_empty());
1523    }
1524
1525    /// The host-side commerce fallback contract: `focused()` surfaces a stall's
1526    /// `data` block, so a browser without the sandboxed module layer can still
1527    /// open the purchase dialog from `behavior:"buy"` + `data.item`.
1528    #[test]
1529    fn focused_carries_the_stall_data_for_hostside_commerce() {
1530        struct StallLoader;
1531        impl WorldLoader for StallLoader {
1532            fn load(
1533                &mut self,
1534                m: &WorldManifest,
1535                _a: &AssetSource,
1536                anchor: Vec3,
1537            ) -> LoadedWorldMeta {
1538                LoadedWorldMeta {
1539                    title: m.world.title.clone(),
1540                    world_id: m.world.id.clone(),
1541                    spawn: anchor,
1542                    portals: vec![],
1543                    interactables: m
1544                        .placements
1545                        .iter()
1546                        .map(|pl| InteractableNav {
1547                            position: Vec3::from_array(pl.position),
1548                            label: pl.name.clone(),
1549                            codex: pl.codex.clone(),
1550                            web: None,
1551                            name: pl.name.clone(),
1552                            behavior: pl.behavior.clone(),
1553                            interaction: pl.interaction.clone(),
1554                            hits_done: 0,
1555                            data: pl.data.clone(),
1556                        })
1557                        .collect(),
1558                    sky: SkyNav::default(),
1559                    presence_relays: vec![],
1560                    colliders: vec![],
1561                }
1562            }
1563        }
1564        // A market-shaped stall (matches worlds/market/world.json's wares).
1565        let world = r#"{
1566            "thread": "thread/0.1",
1567            "world": { "id": "market", "title": "The Bazaar" },
1568            "spawns": [{ "name": "entry", "position": [0, 0, 4] }],
1569            "prefabs": [{ "id": "60000002", "mesh": { "builtin": "cube" } }],
1570            "placements": [
1571                { "prefab": "60000002", "name": "wares:blade", "position": [0, 0, 0],
1572                  "behavior": "buy",
1573                  "data": { "item": "21010001", "name": "Veilwalker Blade",
1574                            "price": 250, "currency": "gold" } }
1575            ],
1576            "behaviors": [{ "id": "buy", "wasm": "commerce-wasm", "on": ["interact"] }],
1577            "assets": [{ "id": "commerce-wasm", "uri": "missing.wasm", "kind": "wasm" }]
1578        }"#;
1579        let mut nav = Navigator::new(".");
1580        nav.apply_world(
1581            &mut StallLoader,
1582            world,
1583            &AssetSource::local("."),
1584            Vec3::ZERO,
1585            None,
1586            NavKind::Normal,
1587        )
1588        .expect("world applies");
1589
1590        let f = nav
1591            .focused(Vec3::new(0.0, 0.0, 0.5))
1592            .expect("standing at the stall");
1593        assert_eq!(f.behavior.as_deref(), Some("buy"));
1594        assert_eq!(f.data["item"], "21010001");
1595        assert_eq!(f.data["name"], "Veilwalker Blade");
1596    }
1597
1598    /// The full behavior path: a manifest declares a wasm module + a bound
1599    /// placement, the navigator instantiates the sandbox on world load, and
1600    /// `interact()` at the placement returns the module's actions.
1601    #[cfg(feature = "behaviors")]
1602    #[test]
1603    fn interact_dispatches_to_the_worlds_wasm_behavior() {
1604        use crate::behavior::Action;
1605
1606        // A conformant module: bump allocator + fixed `notify` reply.
1607        const REPLY: &str = r#"{"actions":[{"action":"notify","text":"the pedestal hums"}]}"#;
1608        let wat = format!(
1609            r#"(module
1610              (memory (export "memory") 1)
1611              (global $heap (mut i32) (i32.const 4096))
1612              (data (i32.const 0) "{data}")
1613              (func (export "thread_alloc") (param i32) (result i32)
1614                (local $p i32)
1615                global.get $heap local.set $p
1616                global.get $heap local.get 0 i32.add global.set $heap
1617                local.get $p)
1618              (func (export "thread_on_interact") (param i32 i32) (result i64)
1619                i64.const {len}))"#,
1620            data = REPLY.replace('"', "\\\""),
1621            len = REPLY.len(),
1622        );
1623        let dir = std::env::temp_dir().join(format!("loom-nav-behavior-{}", std::process::id()));
1624        let _ = std::fs::create_dir_all(&dir);
1625        std::fs::write(dir.join("hum.wasm"), wat::parse_str(&wat).unwrap()).unwrap();
1626
1627        // A loader that surfaces the bound placement (as pbr_load/walk do).
1628        struct BindLoader;
1629        impl WorldLoader for BindLoader {
1630            fn load(
1631                &mut self,
1632                m: &WorldManifest,
1633                _a: &AssetSource,
1634                anchor: Vec3,
1635            ) -> LoadedWorldMeta {
1636                LoadedWorldMeta {
1637                    title: m.world.title.clone(),
1638                    world_id: m.world.id.clone(),
1639                    spawn: anchor,
1640                    portals: vec![],
1641                    interactables: m
1642                        .placements
1643                        .iter()
1644                        .map(|pl| InteractableNav {
1645                            position: Vec3::from_array(pl.position),
1646                            label: pl.name.clone(),
1647                            codex: pl.codex.clone(),
1648                            web: None,
1649                            name: pl.name.clone(),
1650                            behavior: pl.behavior.clone(),
1651                            interaction: pl.interaction.clone(),
1652                            hits_done: 0,
1653                            data: pl.data.clone(),
1654                        })
1655                        .collect(),
1656                    sky: SkyNav::default(),
1657                    presence_relays: vec![],
1658                    colliders: vec![],
1659                }
1660            }
1661        }
1662
1663        let world = r#"{
1664            "thread": "thread/0.1",
1665            "world": { "id": "humming-hall", "title": "Humming Hall" },
1666            "spawns": [{ "name": "entry", "position": [0, 0, 4] }],
1667            "prefabs": [{ "id": "60000002", "mesh": { "builtin": "cube" } }],
1668            "placements": [
1669                { "prefab": "60000002", "name": "pedestal", "position": [0, 0, 0],
1670                  "behavior": "hum" }
1671            ],
1672            "behaviors": [{ "id": "hum", "wasm": "hum-wasm", "on": ["interact"] }],
1673            "assets": [{ "id": "hum-wasm", "uri": "hum.wasm", "kind": "wasm" }]
1674        }"#;
1675
1676        let mut nav = Navigator::new(&dir);
1677        let mut loader = BindLoader;
1678        nav.apply_world(
1679            &mut loader,
1680            world,
1681            &AssetSource::local(&dir),
1682            Vec3::ZERO,
1683            None,
1684            NavKind::Normal,
1685        )
1686        .expect("world applies");
1687
1688        // Standing at the pedestal, interact → the module's action comes back.
1689        let actions = nav.interact(Vec3::new(0.0, 0.0, 0.5));
1690        assert_eq!(
1691            actions,
1692            vec![Action::Notify {
1693                text: "the pedestal hums".into(),
1694                level: None
1695            }]
1696        );
1697        // Standing nowhere near it → nothing.
1698        assert!(nav.interact(Vec3::new(50.0, 0.0, 50.0)).is_empty());
1699
1700        let _ = std::fs::remove_dir_all(&dir);
1701    }
1702
1703    /// The shipped Archive world + the committed reference `codex-viewer.wasm`
1704    /// (built from `examples/behaviors/codex-viewer`): standing at a pedestal,
1705    /// interact returns `codex.open` with that pedestal's slug — the engine
1706    /// mirrored the placement's `codex` field into `data.slug` for the module.
1707    #[cfg(feature = "behaviors")]
1708    #[test]
1709    fn the_archives_pedestals_open_the_codex_through_wasm() {
1710        use crate::behavior::Action;
1711
1712        struct BindLoader;
1713        impl WorldLoader for BindLoader {
1714            fn load(
1715                &mut self,
1716                m: &WorldManifest,
1717                _a: &AssetSource,
1718                anchor: Vec3,
1719            ) -> LoadedWorldMeta {
1720                LoadedWorldMeta {
1721                    title: m.world.title.clone(),
1722                    world_id: m.world.id.clone(),
1723                    spawn: anchor,
1724                    portals: vec![],
1725                    interactables: m
1726                        .placements
1727                        .iter()
1728                        .map(|pl| InteractableNav {
1729                            position: Vec3::from_array(pl.position),
1730                            label: pl.name.clone(),
1731                            codex: pl.codex.clone(),
1732                            web: None,
1733                            name: pl.name.clone(),
1734                            behavior: pl.behavior.clone(),
1735                            interaction: pl.interaction.clone(),
1736                            hits_done: 0,
1737                            data: pl.data.clone(),
1738                        })
1739                        .collect(),
1740                    sky: SkyNav::default(),
1741                    presence_relays: vec![],
1742                    colliders: vec![],
1743                }
1744            }
1745        }
1746
1747        let world = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1748            .join("../../worlds/codex-archive/world.json");
1749        // The reference corpus is content, and content lives with the browser
1750        // that serves it. Published on its own the engine travels without it,
1751        // so this reports rather than fails — a missing file that was never
1752        // this crate's is not a broken crate.
1753        if !world.exists() {
1754            eprintln!("no reference corpus beside this crate — skipping");
1755            return;
1756        }
1757        let mut nav = Navigator::new(world.parent().unwrap());
1758        let mut loader = BindLoader;
1759        nav.load_file(&mut loader, &world, Vec3::ZERO)
1760            .expect("The Archive loads");
1761
1762        // pedestal:veil sits at [-4.0, 0.5, 0.0] bearing codex "the-veil".
1763        let actions = nav.interact(Vec3::new(-4.0, 0.5, 0.3));
1764        assert_eq!(
1765            actions,
1766            vec![Action::CodexOpen {
1767                slug: "the-veil".into()
1768            }]
1769        );
1770    }
1771
1772    /// The shipped Bazaar + the committed reference `commerce.wasm` (built from
1773    /// `examples/behaviors/commerce`): interact at a stall returns `commerce.buy`
1774    /// for that stall's item, and the browser's settled `purchase_result` comes
1775    /// back as the merchant's `notify` — the full commerce ABI, end to end.
1776    #[cfg(feature = "behaviors")]
1777    #[test]
1778    fn the_bazaars_stalls_sell_through_wasm() {
1779        use crate::behavior::Action;
1780
1781        struct BindLoader;
1782        impl WorldLoader for BindLoader {
1783            fn load(
1784                &mut self,
1785                m: &WorldManifest,
1786                _a: &AssetSource,
1787                anchor: Vec3,
1788            ) -> LoadedWorldMeta {
1789                LoadedWorldMeta {
1790                    title: m.world.title.clone(),
1791                    world_id: m.world.id.clone(),
1792                    spawn: anchor,
1793                    portals: vec![],
1794                    interactables: m
1795                        .placements
1796                        .iter()
1797                        .map(|pl| InteractableNav {
1798                            position: Vec3::from_array(pl.position),
1799                            label: pl.name.clone(),
1800                            codex: pl.codex.clone(),
1801                            web: None,
1802                            name: pl.name.clone(),
1803                            behavior: pl.behavior.clone(),
1804                            interaction: pl.interaction.clone(),
1805                            hits_done: 0,
1806                            data: pl.data.clone(),
1807                        })
1808                        .collect(),
1809                    sky: SkyNav::default(),
1810                    presence_relays: vec![],
1811                    colliders: vec![],
1812                }
1813            }
1814        }
1815
1816        let world =
1817            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../worlds/market/world.json");
1818        let mut nav = Navigator::new(world.parent().unwrap());
1819        nav.set_actor(Some("did:pixygon:abc".into()));
1820        let mut loader = BindLoader;
1821        nav.load_file(&mut loader, &world, Vec3::ZERO)
1822            .expect("The Bazaar loads");
1823
1824        // wares:blade sits at [-3.0, 1.3, 0.0] selling item 21010001.
1825        let actions = nav.interact(Vec3::new(-3.0, 1.3, 0.3));
1826        assert_eq!(
1827            actions,
1828            vec![Action::CommerceBuy {
1829                item: "21010001".into(),
1830                price_ref: serde_json::json!("21010001"),
1831            }]
1832        );
1833
1834        // The browser settles the purchase and replies — the merchant reacts.
1835        let replies = nav.dispatch_event(&serde_json::json!({
1836            "event": "purchase_result", "ok": true, "item": "21010001",
1837            "ref": "thread:u1:market:21010001", "duplicate": false,
1838        }));
1839        assert_eq!(
1840            replies,
1841            vec![Action::Notify {
1842                text: "The merchant wraps your purchase. \"A fine choice.\"".into(),
1843                level: None,
1844            }]
1845        );
1846    }
1847}