use std::path::{Path, PathBuf};
use std::sync::mpsc::{channel, Receiver, TryRecvError};
use std::collections::HashMap;
use glam::{Quat, Vec3};
use infinite_manifest::{Interaction, InteractionEffect, WorldManifest, WorldRules};
use crate::assets::AssetSource;
use crate::behavior::Action;
#[cfg(feature = "behaviors")]
use crate::behavior::{Actor, Behavior, InteractEvent};
#[cfg(feature = "behaviors")]
use crate::behavior_wasm::WasmBehavior;
use crate::resolver::{self, TravelResult};
pub const ENTER_RADIUS: f32 = 2.2;
pub const INSPECT_RADIUS: f32 = 2.6;
const TRAVERSE_COOLDOWN: f32 = 1.2;
pub const FEET_DROP: f32 = 0.9;
pub const PREVIEW_RADIUS: f32 = 18.0;
const DEFAULT_THREAD_BASE: &str = "https://api.pixygon.io/v1";
#[derive(Debug, Clone)]
pub struct PortalNav {
pub id: String,
pub position: Vec3,
pub to: String,
pub label: String,
pub preview: bool,
#[allow(clippy::derive_partial_eq_without_eq)]
pub yaw: f32,
}
#[derive(Debug, Clone, Default)]
pub struct InteractableNav {
pub position: Vec3,
pub label: String,
pub codex: Option<String>,
pub web: Option<String>,
pub name: String,
pub behavior: Option<String>,
pub interaction: Option<Interaction>,
pub hits_done: u32,
pub data: serde_json::Value,
}
#[derive(Debug, Clone, Copy)]
pub struct ColliderNav {
pub half_extents: Vec3,
pub position: Vec3,
pub rotation: Quat,
}
#[derive(Debug, Clone)]
pub struct PortalPreview {
pub title: String,
pub sky: SkyNav,
pub veils: usize,
pub manifest_text: String,
pub asset_base: PathBuf,
pub asset_base_url: Option<String>,
pub presence_relays: Vec<String>,
pub world_id: String,
}
#[derive(Debug, Clone, Copy)]
pub struct SkyNav {
pub zenith: [f32; 3],
pub horizon: [f32; 3],
pub sun_dir: [f32; 3],
}
impl Default for SkyNav {
fn default() -> Self {
SkyNav {
zenith: [0.05, 0.06, 0.12],
horizon: [0.18, 0.16, 0.24],
sun_dir: [0.3, 0.7, 0.2],
}
}
}
pub struct LoadedWorldMeta {
pub title: String,
pub world_id: String,
pub spawn: Vec3,
pub portals: Vec<PortalNav>,
pub interactables: Vec<InteractableNav>,
pub sky: SkyNav,
pub presence_relays: Vec<String>,
pub colliders: Vec<ColliderNav>,
}
pub trait WorldLoader {
fn load(
&mut self,
manifest: &WorldManifest,
assets: &AssetSource,
anchor: Vec3,
) -> LoadedWorldMeta;
}
pub struct Focus {
pub label: String,
pub codex: Option<String>,
pub web: Option<String>,
pub behavior: Option<String>,
pub interact_label: Option<String>,
pub data: serde_json::Value,
}
#[cfg(feature = "behaviors")]
struct BehaviorSlot {
id: String,
ticks: bool,
module: WasmBehavior,
}
struct ActiveWorld {
title: String,
world_id: String,
description: String,
locator: Option<String>,
source: String,
portals: Vec<PortalNav>,
interactables: Vec<InteractableNav>,
sky: SkyNav,
presence_relays: Vec<String>,
owner_required: bool,
colliders: Vec<ColliderNav>,
rules: WorldRules,
#[cfg(feature = "behaviors")]
behaviors: Vec<BehaviorSlot>,
weft_behaviors: Vec<WeftSlot>,
}
struct WeftSlot {
id: String,
behavior: crate::behavior_weft::WeftBehavior,
on: Vec<String>,
}
fn load_weft_behaviors(manifest: &WorldManifest, assets: &AssetSource) -> Vec<WeftSlot> {
manifest
.behaviors
.iter()
.filter_map(|b| {
let result = if let Some(wid) = b.weft.as_ref() {
let asset = manifest.assets.iter().find(|a| &a.id == wid)?;
let path = assets.resolve(&asset.uri)?;
let text = std::fs::read_to_string(&path).ok()?;
crate::behavior_weft::WeftBehavior::from_json(&text)
} else if let (Some(pid), Some(export)) = (b.weft_pack.as_ref(), b.weft_export.as_ref())
{
let asset = manifest.assets.iter().find(|a| &a.id == pid)?;
let path = assets.resolve(&asset.uri)?;
let text = std::fs::read_to_string(&path).ok()?;
crate::behavior_weft::WeftBehavior::from_package_json(&text, export)
} else {
return None;
};
match result {
Ok(behavior) => {
tracing::info!("weft behavior '{}' verified + loaded", b.id);
Some(WeftSlot {
id: b.id.clone(),
behavior,
on: b.on.clone(),
})
}
Err(e) => {
tracing::warn!("weft behavior '{}' refused: {e}", b.id);
None
}
}
})
.collect()
}
enum PreviewSlot {
Pending(Receiver<Option<PortalPreview>>),
Ready(PortalPreview),
Failed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum NavKind {
Normal,
Back,
Forward,
Reload,
}
struct Pending {
rx: Receiver<Result<TravelResult, String>>,
anchor: Vec3,
locator: String,
kind: NavKind,
}
pub struct Navigator {
worlds_root: PathBuf,
resolver_base: String,
active: Option<ActiveWorld>,
pending: Option<Pending>,
cooldown: f32,
armed: bool,
last_error: Option<String>,
back_stack: Vec<String>,
forward_stack: Vec<String>,
home: Option<String>,
actor_sub: Option<String>,
previews: HashMap<String, PreviewSlot>,
weft_tick_accum: f32,
base_y: Option<f32>,
}
impl Navigator {
pub fn new(worlds_root: impl Into<PathBuf>) -> Self {
let resolver_base = std::env::var("INFINITE_THREAD_BASE")
.unwrap_or_else(|_| DEFAULT_THREAD_BASE.to_string());
Self {
worlds_root: worlds_root.into(),
resolver_base,
active: None,
pending: None,
cooldown: 0.0,
armed: false,
last_error: None,
back_stack: Vec::new(),
forward_stack: Vec::new(),
home: None,
actor_sub: None,
previews: HashMap::new(),
weft_tick_accum: 0.0,
base_y: None,
}
}
pub fn set_actor(&mut self, sub: Option<String>) {
self.actor_sub = sub.filter(|s| !s.trim().is_empty());
}
pub fn last_error(&self) -> Option<&str> {
self.last_error.as_deref()
}
pub fn title(&self) -> Option<&str> {
self.active.as_ref().map(|w| w.title.as_str())
}
pub fn world_id(&self) -> &str {
self.active
.as_ref()
.map(|w| w.world_id.as_str())
.unwrap_or("")
}
pub fn locator(&self) -> Option<&str> {
self.active.as_ref().and_then(|w| w.locator.as_deref())
}
pub fn world_source(&self) -> Option<&str> {
self.active.as_ref().map(|w| w.source.as_str())
}
pub fn world_description(&self) -> Option<&str> {
self.active.as_ref().map(|w| w.description.as_str())
}
pub fn home(&self) -> Option<&str> {
self.home.as_deref()
}
pub fn set_home(&mut self, locator: impl Into<String>) {
self.home = Some(locator.into());
}
pub fn back_history(&self) -> &[String] {
&self.back_stack
}
pub fn can_go_back(&self) -> bool {
!self.back_stack.is_empty()
}
pub fn can_go_forward(&self) -> bool {
!self.forward_stack.is_empty()
}
pub fn back(&mut self, anchor: Vec3) -> bool {
if self.pending.is_some() {
return false;
}
let Some(dest) = self.back_stack.pop() else {
return false;
};
self.begin_travel_kind(dest, anchor, NavKind::Back);
true
}
pub fn forward(&mut self, anchor: Vec3) -> bool {
if self.pending.is_some() {
return false;
}
let Some(dest) = self.forward_stack.pop() else {
return false;
};
self.begin_travel_kind(dest, anchor, NavKind::Forward);
true
}
pub fn go_home(&mut self, anchor: Vec3) -> bool {
if self.pending.is_some() {
return false;
}
let Some(dest) = self.home.clone() else {
return false;
};
if self.locator() == Some(dest.as_str()) {
return false; }
self.begin_travel(dest, anchor);
true
}
pub fn sky(&self) -> Option<SkyNav> {
self.active.as_ref().map(|w| w.sky)
}
pub fn rules(&self) -> WorldRules {
self.active.as_ref().map(|w| w.rules).unwrap_or_default()
}
pub fn presence_relays(&self) -> &[String] {
self.active
.as_ref()
.map(|w| w.presence_relays.as_slice())
.unwrap_or(&[])
}
pub fn presence_owner_required(&self) -> bool {
self.active.as_ref().is_some_and(|w| w.owner_required)
}
pub fn is_traveling(&self) -> bool {
self.pending.is_some()
}
pub fn colliders(&self) -> &[ColliderNav] {
self.active
.as_ref()
.map(|w| w.colliders.as_slice())
.unwrap_or(&[])
}
pub fn portal_preview(&self, to: &str) -> Option<&PortalPreview> {
match self.previews.get(to) {
Some(PreviewSlot::Ready(p)) => Some(p),
_ => None,
}
}
pub fn portals(&self) -> &[PortalNav] {
self.active
.as_ref()
.map(|w| w.portals.as_slice())
.unwrap_or(&[])
}
pub fn armed(&self) -> bool {
self.armed && self.pending.is_none() && self.cooldown <= 0.0
}
pub fn near_portal(&self, player_pos: Vec3) -> Option<(&str, &str)> {
let w = self.active.as_ref()?;
let i = nearest_portal(&w.portals, player_pos, ENTER_RADIUS)?;
Some((w.portals[i].label.as_str(), w.portals[i].to.as_str()))
}
pub fn focused(&self, player_pos: Vec3) -> Option<Focus> {
let w = self.active.as_ref()?;
let i = nearest_interactable(&w.interactables, player_pos, INSPECT_RADIUS)?;
let it = &w.interactables[i];
Some(Focus {
label: it.label.clone(),
codex: it.codex.clone(),
web: it.web.clone(),
behavior: it.behavior.clone(),
interact_label: it.interaction.as_ref().map(|i| i.label.clone()),
data: it.data.clone(),
})
}
pub fn interact(&mut self, player_pos: Vec3) -> Vec<Action> {
let Some(w) = self.active.as_mut() else {
return Vec::new();
};
let Some(i) = nearest_interactable(&w.interactables, player_pos, INSPECT_RADIUS) else {
return Vec::new();
};
if let Some(inter) = w.interactables[i].interaction.clone() {
{
let it = &mut w.interactables[i];
it.hits_done += 1;
if it.hits_done < inter.hits.max(1) {
return Vec::new(); }
it.hits_done = 0;
}
let mut actions = effects_to_actions(&inter.effects);
if inter
.effects
.iter()
.any(|e| matches!(e, InteractionEffect::Despawn))
{
let pos = w.interactables[i].position;
w.interactables.remove(i);
if let Some(ci) = nearest_by(w.colliders.iter().map(|c| c.position), pos, 3.0) {
w.colliders.remove(ci);
}
actions.push(Action::Despawn {
position: pos.to_array(),
});
}
return actions;
}
let it = &w.interactables[i];
let Some(bid) = it.behavior.clone() else {
return Vec::new();
};
if let Some(slot) = w.weft_behaviors.iter_mut().find(|s| s.id == bid) {
let name = if it.name.is_empty() {
it.label.clone()
} else {
it.name.clone()
};
let slug = it.codex.clone();
return slot.behavior.on_interact(&name, slug.as_deref());
}
#[cfg(feature = "behaviors")]
{
let mut data = it.data.clone();
if let Some(slug) = &it.codex {
if data.get("slug").is_none() {
if !data.is_object() {
data = serde_json::json!({});
}
if let Some(obj) = data.as_object_mut() {
obj.insert("slug".into(), slug.clone().into());
}
}
}
let event = InteractEvent {
placement: if it.name.is_empty() {
it.label.clone()
} else {
it.name.clone()
},
actor: Actor {
passport_sub: self.actor_sub.clone(),
},
world: w.world_id.clone(),
data,
};
if let Some(slot) = w.behaviors.iter_mut().find(|s| s.id == bid) {
return slot.module.on_interact(&event);
}
}
let _ = (bid, &self.actor_sub);
Vec::new()
}
pub fn dispatch_event(&mut self, event: &serde_json::Value) -> Vec<Action> {
#[cfg(feature = "behaviors")]
if let Some(w) = self.active.as_mut() {
return w
.behaviors
.iter_mut()
.flat_map(|slot| slot.module.on_event(event))
.collect();
}
let _ = event;
Vec::new()
}
pub fn tick_behaviors(&mut self, dt: f32) -> Vec<Action> {
#[cfg(feature = "behaviors")]
if let Some(w) = self.active.as_mut() {
let dt_ms = (dt * 1000.0).round() as i32;
for slot in w.behaviors.iter_mut().filter(|s| s.ticks) {
slot.module.on_tick(dt_ms);
}
}
const TICK_SECS: f32 = 0.25;
self.weft_tick_accum += dt.max(0.0);
if self.weft_tick_accum < TICK_SECS {
return Vec::new();
}
let elapsed_ms = (self.weft_tick_accum * 1000.0) as i64;
self.weft_tick_accum = 0.0;
let Some(w) = self.active.as_mut() else {
return Vec::new();
};
let mut actions = Vec::new();
for slot in w
.weft_behaviors
.iter_mut()
.filter(|s| s.on.iter().any(|e| e == "tick"))
{
actions.extend(slot.behavior.on_tick(elapsed_ms));
}
actions
}
pub fn load_file(
&mut self,
loader: &mut dyn WorldLoader,
path: &Path,
anchor: Vec3,
) -> Option<Vec3> {
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) => {
tracing::warn!("navigator: cannot read {}: {e}", path.display());
return None;
}
};
let base = path.parent().unwrap_or(Path::new(".")).to_path_buf();
self.apply_world(
loader,
&text,
&AssetSource::local(base),
anchor,
None,
NavKind::Normal,
)
}
pub fn reload_file(
&mut self,
loader: &mut dyn WorldLoader,
path: &Path,
locator: Option<String>,
anchor: Vec3,
) -> Option<Vec3> {
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) => {
tracing::warn!("navigator: cannot reload {}: {e}", path.display());
return None;
}
};
let base = path.parent().unwrap_or(Path::new(".")).to_path_buf();
self.apply_world(
loader,
&text,
&AssetSource::local(base),
anchor,
locator,
NavKind::Reload,
)
}
fn apply_world(
&mut self,
loader: &mut dyn WorldLoader,
manifest_text: &str,
assets: &AssetSource,
anchor: Vec3,
locator: Option<String>,
kind: NavKind,
) -> Option<Vec3> {
let manifest = match WorldManifest::from_text(manifest_text) {
Ok(m) => m,
Err(e) => {
tracing::warn!("navigator: invalid world manifest: {e}");
return None;
}
};
let loaded = loader.load(&manifest, assets, anchor);
tracing::info!(
"veilwalked into '{}' — {} veils",
loaded.title,
loaded.portals.len(),
);
if let Some(prev) = self.active.as_ref().and_then(|w| w.locator.clone()) {
match kind {
NavKind::Normal => {
self.back_stack.push(prev);
self.forward_stack.clear();
}
NavKind::Back => self.forward_stack.push(prev),
NavKind::Forward => self.back_stack.push(prev),
NavKind::Reload => {}
}
}
self.base_y.get_or_insert(anchor.y);
let spawn = loaded.spawn + Vec3::new(0.0, FEET_DROP, 0.0);
self.active = Some(ActiveWorld {
title: loaded.title,
world_id: loaded.world_id,
description: manifest.world.description.clone(),
locator,
source: manifest_text.to_string(),
portals: loaded.portals,
interactables: loaded.interactables,
sky: loaded.sky,
presence_relays: loaded.presence_relays,
owner_required: manifest.presence.as_ref().is_some_and(|p| p.owner_required),
colliders: loaded.colliders,
rules: manifest.environment.rules,
#[cfg(feature = "behaviors")]
behaviors: load_behaviors(&manifest, assets),
weft_behaviors: load_weft_behaviors(&manifest, assets),
});
self.cooldown = TRAVERSE_COOLDOWN;
Some(spawn)
}
pub fn open(&mut self, locator: &str, anchor: Vec3) {
tracing::info!("opening {locator} on the Thread…");
if self.home.is_none() {
self.home = Some(locator.to_string());
}
self.begin_travel(locator.to_string(), anchor);
}
fn begin_travel(&mut self, locator: String, anchor: Vec3) {
self.begin_travel_kind(locator, anchor, NavKind::Normal);
}
fn begin_travel_kind(&mut self, locator: String, anchor: Vec3, kind: NavKind) {
let anchor = Vec3::new(anchor.x, self.base_y.unwrap_or(anchor.y), anchor.z);
let (tx, rx) = channel();
let base = self.resolver_base.clone();
let root = self.worlds_root.clone();
let loc = locator.clone();
std::thread::spawn(move || {
let result = resolver::fetch_world(&base, &root, &loc);
if let Ok(tr) = &result {
if let Ok(manifest) = WorldManifest::from_text(&tr.manifest_text) {
AssetSource::hosted(&tr.asset_base, tr.asset_base_url.clone(), &tr.asset_base)
.prefetch(&manifest);
}
}
let _ = tx.send(result);
});
self.pending = Some(Pending {
rx,
anchor,
locator,
kind,
});
self.cooldown = TRAVERSE_COOLDOWN;
self.armed = false;
self.last_error = None;
}
fn update_previews(&mut self, player_pos: Vec3) {
for slot in self.previews.values_mut() {
if let PreviewSlot::Pending(rx) = slot {
match rx.try_recv() {
Ok(Some(p)) => *slot = PreviewSlot::Ready(p),
Ok(None) | Err(TryRecvError::Disconnected) => *slot = PreviewSlot::Failed,
Err(TryRecvError::Empty) => {}
}
}
}
if self
.previews
.values()
.any(|s| matches!(s, PreviewSlot::Pending(_)))
{
return;
}
let Some(w) = self.active.as_ref() else {
return;
};
let Some(portal) = w
.portals
.iter()
.filter(|p| p.preview && !self.previews.contains_key(&p.to))
.map(|p| (p, (p.position - player_pos).length()))
.filter(|(_, d)| *d <= PREVIEW_RADIUS)
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(p, _)| p)
else {
return;
};
let (tx, rx) = channel();
let base = self.resolver_base.clone();
let root = self.worlds_root.clone();
let to = portal.to.clone();
std::thread::spawn(move || {
let preview = resolver::fetch_world(&base, &root, &to)
.ok()
.and_then(|tr| {
let p = preview_from_manifest(&tr.manifest_text, &tr);
if p.is_some() {
if let Ok(m) = WorldManifest::from_text(&tr.manifest_text) {
AssetSource::hosted(
&tr.asset_base,
tr.asset_base_url.clone(),
&tr.asset_base,
)
.prefetch(&m);
}
}
p
});
let _ = tx.send(preview);
});
self.previews
.insert(portal.to.clone(), PreviewSlot::Pending(rx));
}
pub fn update(
&mut self,
dt: f32,
loader: &mut dyn WorldLoader,
player_pos: Vec3,
) -> Option<Vec3> {
self.update_previews(player_pos);
if let Some(pending) = &self.pending {
match pending.rx.try_recv() {
Ok(Ok(tr)) => {
let p = self.pending.take().unwrap();
let assets = AssetSource::hosted(
&tr.asset_base,
tr.asset_base_url.clone(),
&tr.asset_base,
);
return self.apply_world(
loader,
&tr.manifest_text,
&assets,
p.anchor,
Some(p.locator),
p.kind,
);
}
Ok(Err(e)) => {
tracing::warn!("veilwalk failed: {e}");
self.last_error = Some(short_error(&e));
let p = self.pending.take().unwrap();
match p.kind {
NavKind::Back => self.back_stack.push(p.locator),
NavKind::Forward => self.forward_stack.push(p.locator),
NavKind::Normal | NavKind::Reload => {}
}
}
Err(TryRecvError::Empty) => {}
Err(TryRecvError::Disconnected) => self.pending = None,
}
return None;
}
if self.cooldown > 0.0 {
self.cooldown = (self.cooldown - dt).max(0.0);
return None;
}
let w = self.active.as_ref()?;
match nearest_portal(&w.portals, player_pos, ENTER_RADIUS) {
None => {
self.armed = true;
None
}
Some(idx) if self.armed => {
let to = w.portals[idx].to.clone();
let anchor = player_pos - Vec3::new(0.0, FEET_DROP, 0.0);
self.begin_travel(to, anchor);
None
}
Some(_) => None, }
}
}
#[cfg(feature = "behaviors")]
fn load_behaviors(manifest: &WorldManifest, assets: &AssetSource) -> Vec<BehaviorSlot> {
manifest
.behaviors
.iter()
.filter_map(|b| {
let asset = manifest.assets.iter().find(|a| a.id == b.wasm)?;
let path = assets.resolve(&asset.uri)?;
let bytes = std::fs::read(&path).ok()?;
match WasmBehavior::load(&bytes) {
Ok(module) => {
tracing::info!("behavior '{}' loaded ({} KB)", b.id, bytes.len() / 1024);
Some(BehaviorSlot {
id: b.id.clone(),
ticks: b.on.iter().any(|e| e == "tick"),
module,
})
}
Err(e) => {
tracing::warn!("behavior '{}' failed to load: {e}", b.id);
None
}
}
})
.collect()
}
fn preview_from_manifest(text: &str, tr: &TravelResult) -> Option<PortalPreview> {
let m = WorldManifest::from_text(text).ok()?;
let mut sky = SkyNav::default();
if let Some(s) = &m.environment.sky {
sky.zenith = s.zenith;
sky.horizon = s.horizon;
if let Some(d) = s.sun_dir {
sky.sun_dir = d;
}
}
Some(PortalPreview {
title: m.world.title.clone(),
sky,
veils: m.portals.len(),
manifest_text: text.to_string(),
asset_base: tr.asset_base.clone(),
asset_base_url: tr.asset_base_url.clone(),
presence_relays: m
.presence
.as_ref()
.map(|p| p.relay_list())
.unwrap_or_default(),
world_id: m.world.id.clone(),
})
}
fn short_error(e: &str) -> String {
let first = e.split(&['(', '—'][..]).next().unwrap_or(e).trim();
let msg = if first.is_empty() { e } else { first };
format!(
"couldn't reach that world ({})",
msg.chars().take(60).collect::<String>().trim()
)
}
fn nearest_portal(portals: &[PortalNav], pos: Vec3, radius: f32) -> Option<usize> {
nearest_by(portals.iter().map(|p| p.position), pos, radius)
}
fn nearest_interactable(items: &[InteractableNav], pos: Vec3, radius: f32) -> Option<usize> {
nearest_by(items.iter().map(|p| p.position), pos, radius)
}
fn effects_to_actions(effects: &[InteractionEffect]) -> Vec<Action> {
effects
.iter()
.filter_map(|e| match e {
InteractionEffect::Message(text) => Some(Action::Notify {
text: text.clone(),
level: None,
}),
InteractionEffect::GiveItem { item, count } => Some(Action::GiveItem {
item: item.number(),
count: *count,
}),
InteractionEffect::Effect(name) => Some(Action::Notify {
text: format!("✨ {name}"),
level: None,
}),
InteractionEffect::Navigate(to) => Some(Action::Navigate { to: to.clone() }),
InteractionEffect::Despawn => None, })
.collect()
}
fn nearest_by(positions: impl Iterator<Item = Vec3>, pos: Vec3, radius: f32) -> Option<usize> {
positions
.enumerate()
.map(|(i, p)| (i, (p - pos).length()))
.filter(|(_, d)| *d <= radius)
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(i, _)| i)
}
#[cfg(test)]
mod tests {
use super::*;
fn portal(pos: Vec3, label: &str) -> PortalNav {
PortalNav {
id: label.into(),
position: pos,
to: String::new(),
label: label.into(),
preview: true,
yaw: 0.0,
}
}
#[test]
fn declarative_effects_become_browser_actions() {
let effects: Vec<InteractionEffect> = serde_json::from_str(
r#"[ {"give_item":{"item":"20100007","count":3}}, {"message":"Chopped!"}, "despawn" ]"#,
)
.unwrap();
let actions = effects_to_actions(&effects);
assert_eq!(
actions.len(),
2,
"give_item + message emit; despawn deferred"
);
assert!(matches!(actions[0], Action::GiveItem { item: 7, count: 3 }));
assert!(matches!(&actions[1], Action::Notify { text, .. } if text == "Chopped!"));
}
#[test]
fn nearest_portal_picks_the_closest_within_reach() {
let portals = vec![
portal(Vec3::new(10.0, 0.0, 0.0), "far"),
portal(Vec3::new(1.0, 0.0, 0.0), "near"),
];
assert_eq!(
nearest_portal(&portals, Vec3::new(0.0, 0.0, 0.0), 2.2),
Some(1)
);
assert_eq!(
nearest_portal(&portals, Vec3::new(0.0, 0.0, 5.0), 2.2),
None
);
}
struct FakeLoader {
loaded: u32,
}
impl WorldLoader for FakeLoader {
fn load(
&mut self,
manifest: &WorldManifest,
_assets: &AssetSource,
anchor: Vec3,
) -> LoadedWorldMeta {
self.loaded += 1;
LoadedWorldMeta {
title: manifest.world.title.clone(),
world_id: manifest.world.id.clone(),
spawn: anchor,
portals: vec![portal(Vec3::new(0.0, 0.0, 0.0), "onward")],
interactables: vec![],
sky: SkyNav::default(),
presence_relays: vec![],
colliders: vec![],
}
}
}
const WORLD: &str = r#"{
"thread": "thread/0.1",
"world": { "id": "x", "title": "Test World" },
"spawns": [{ "name": "entry", "position": [1, 2, 3] }]
}"#;
#[test]
fn applies_a_world_through_the_loader_seam() {
let mut nav = Navigator::new(".");
let mut loader = FakeLoader { loaded: 0 };
let spawn = nav.apply_world(
&mut loader,
WORLD,
&AssetSource::local("."),
Vec3::new(5.0, 0.0, 5.0),
None,
NavKind::Normal,
);
assert_eq!(
loader.loaded, 1,
"the navigator placed the world via the loader"
);
assert_eq!(
spawn,
Some(Vec3::new(5.0, FEET_DROP, 5.0)),
"player arrives standing: anchor + the capsule's feet drop"
);
assert_eq!(nav.title(), Some("Test World"));
assert!(nav.near_portal(Vec3::new(0.0, 0.0, 0.0)).is_some());
}
#[test]
fn thread_markup_loads_through_the_same_seam_as_json() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../worlds/meadow/world.thread");
if !path.exists() {
eprintln!("no reference corpus beside this crate — skipping");
return;
}
let text = std::fs::read_to_string(&path).expect("the Meadow ships with the repo");
let mut nav = Navigator::new(".");
let mut loader = FakeLoader { loaded: 0 };
let spawn = nav.apply_world(
&mut loader,
&text,
&AssetSource::local("."),
Vec3::ZERO,
None,
NavKind::Normal,
);
assert!(spawn.is_some(), "markup compiles and loads");
assert_eq!(loader.loaded, 1);
assert_eq!(nav.title(), Some("The Meadow"));
assert!(nav
.world_source()
.is_some_and(|s| s.contains("<world id=\"meadow\"")));
}
#[test]
fn the_meadows_waystone_answers_through_weft() {
struct MeadowLoader;
impl WorldLoader for MeadowLoader {
fn load(
&mut self,
m: &WorldManifest,
_a: &AssetSource,
anchor: Vec3,
) -> LoadedWorldMeta {
LoadedWorldMeta {
title: m.world.title.clone(),
world_id: m.world.id.clone(),
spawn: anchor,
portals: vec![],
interactables: m
.placements
.iter()
.map(|pl| InteractableNav {
position: Vec3::from_array(pl.position),
label: pl.name.clone(),
codex: pl.codex.clone(),
web: None,
name: pl.name.clone(),
behavior: pl.behavior.clone(),
interaction: pl.interaction.clone(),
hits_done: 0,
data: pl.data.clone(),
})
.collect(),
sky: SkyNav::default(),
presence_relays: vec![],
colliders: vec![],
}
}
}
let world = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../worlds/meadow/world.thread");
if !world.exists() {
eprintln!("no reference corpus beside this crate — skipping");
return;
}
let mut nav = Navigator::new(world.parent().unwrap());
let mut loader = MeadowLoader;
nav.load_file(&mut loader, &world, Vec3::ZERO)
.expect("the Meadow loads");
let by_the_stone = Vec3::new(0.0, 1.0, -1.4);
assert_eq!(
nav.interact(by_the_stone),
vec![Action::Notify {
text: "The waystone hums — 1 travelers have touched it.".into(),
level: None
}],
);
assert_eq!(
nav.interact(by_the_stone),
vec![Action::Notify {
text: "The waystone hums — 2 travelers have touched it.".into(),
level: None
}],
"state persists between touches"
);
}
#[test]
fn invalid_manifest_is_rejected_without_loading() {
let mut nav = Navigator::new(".");
let mut loader = FakeLoader { loaded: 0 };
let spawn = nav.apply_world(
&mut loader,
"{ not a world }",
&AssetSource::local("."),
Vec3::ZERO,
None,
NavKind::Normal,
);
assert_eq!(spawn, None);
assert_eq!(
loader.loaded, 0,
"a bad manifest never reaches the renderer"
);
assert_eq!(nav.title(), None);
}
#[test]
fn a_manifest_previews_as_its_title_sky_and_veil_count() {
let text = r#"{
"thread": "thread/0.1",
"world": { "id": "far", "title": "The Far Side" },
"environment": { "sky": { "zenith": [0.1, 0.2, 0.3], "horizon": [0.4, 0.5, 0.6] } },
"portals": [
{ "id": "a", "position": [0, 0, 0], "to": "thread://x" },
{ "id": "b", "position": [5, 0, 0], "to": "thread://y" }
]
}"#;
let tr = TravelResult {
manifest_text: text.to_string(),
asset_base: PathBuf::from("."),
asset_base_url: None,
};
let p = preview_from_manifest(text, &tr).expect("valid manifest previews");
assert_eq!(p.title, "The Far Side");
assert_eq!(p.sky.zenith, [0.1, 0.2, 0.3]);
assert_eq!(p.sky.horizon, [0.4, 0.5, 0.6]);
assert_eq!(p.veils, 2);
let tr = TravelResult {
manifest_text: String::new(),
asset_base: PathBuf::from("."),
asset_base_url: None,
};
assert!(preview_from_manifest("{ nope }", &tr).is_none());
}
#[test]
fn previews_and_colliders_are_empty_until_a_world_provides_them() {
let nav = Navigator::new(".");
assert!(nav.portal_preview("thread://anywhere").is_none());
assert!(nav.colliders().is_empty());
}
#[test]
fn colliders_flow_from_the_loader_to_the_browser() {
struct SolidLoader;
impl WorldLoader for SolidLoader {
fn load(
&mut self,
m: &WorldManifest,
_a: &AssetSource,
anchor: Vec3,
) -> LoadedWorldMeta {
LoadedWorldMeta {
title: m.world.title.clone(),
world_id: m.world.id.clone(),
spawn: anchor,
portals: vec![],
interactables: vec![],
sky: SkyNav::default(),
presence_relays: vec![],
colliders: vec![ColliderNav {
half_extents: Vec3::new(2.0, 1.0, 2.0),
position: Vec3::new(0.0, 4.0, 0.0),
rotation: Quat::IDENTITY,
}],
}
}
}
let mut nav = Navigator::new(".");
nav.apply_world(
&mut SolidLoader,
WORLD,
&AssetSource::local("."),
Vec3::ZERO,
None,
NavKind::Normal,
);
assert_eq!(nav.colliders().len(), 1);
assert_eq!(nav.colliders()[0].half_extents, Vec3::new(2.0, 1.0, 2.0));
}
fn arrive(nav: &mut Navigator, loader: &mut FakeLoader, loc: &str, kind: NavKind) {
nav.apply_world(
loader,
WORLD,
&AssetSource::local("."),
Vec3::ZERO,
Some(loc.into()),
kind,
)
.expect("world applies");
}
#[test]
fn history_works_like_the_webs_back_and_forward() {
let mut nav = Navigator::new(".");
let mut loader = FakeLoader { loaded: 0 };
arrive(&mut nav, &mut loader, "thread://a", NavKind::Normal);
arrive(&mut nav, &mut loader, "thread://b", NavKind::Normal);
arrive(&mut nav, &mut loader, "thread://c", NavKind::Normal);
assert_eq!(nav.back_history(), ["thread://a", "thread://b"]);
assert!(nav.can_go_back() && !nav.can_go_forward());
assert_eq!(nav.locator(), Some("thread://c"));
assert_eq!(nav.back_stack.pop().as_deref(), Some("thread://b"));
arrive(&mut nav, &mut loader, "thread://b", NavKind::Back);
assert_eq!(nav.back_history(), ["thread://a"]);
assert!(nav.can_go_forward());
arrive(&mut nav, &mut loader, "thread://d", NavKind::Normal);
assert_eq!(nav.back_history(), ["thread://a", "thread://b"]);
assert!(!nav.can_go_forward());
}
#[test]
fn a_reload_is_not_a_navigation() {
let mut nav = Navigator::new(".");
let mut loader = FakeLoader { loaded: 0 };
arrive(&mut nav, &mut loader, "thread://a", NavKind::Normal);
arrive(&mut nav, &mut loader, "thread://home", NavKind::Normal);
assert_eq!(nav.back_history(), ["thread://a"]);
arrive(&mut nav, &mut loader, "thread://home", NavKind::Reload);
assert_eq!(nav.back_history(), ["thread://a"], "history didn't fork");
assert!(!nav.can_go_forward());
assert_eq!(nav.locator(), Some("thread://home"), "the address survives");
assert_eq!(loader.loaded, 3, "the scene really rebuilt");
}
#[test]
fn view_source_returns_the_manifest_text_and_home_is_where_you_opened() {
let mut nav = Navigator::new(".");
let mut loader = FakeLoader { loaded: 0 };
arrive(&mut nav, &mut loader, "thread://a", NavKind::Normal);
assert_eq!(
nav.world_source(),
Some(WORLD),
"view-source is the exact bytes"
);
assert!(nav.home().is_none());
nav.set_home("thread://a");
assert_eq!(nav.home(), Some("thread://a"));
assert!(!nav.go_home(Vec3::ZERO), "already home → no walk begins");
}
#[test]
fn back_with_empty_history_is_a_safe_no_op() {
let mut nav = Navigator::new(".");
assert!(!nav.back(Vec3::ZERO));
assert!(!nav.forward(Vec3::ZERO));
assert!(!nav.go_home(Vec3::ZERO), "no home set → no walk");
}
#[test]
fn set_actor_keeps_only_real_subs() {
let mut nav = Navigator::new(".");
nav.set_actor(Some(" ".into()));
assert!(nav.actor_sub.is_none(), "blank subs read as anonymous");
nav.set_actor(Some("did:pixygon:abc".into()));
assert_eq!(nav.actor_sub.as_deref(), Some("did:pixygon:abc"));
nav.set_actor(None);
assert!(nav.actor_sub.is_none());
}
#[test]
fn dispatch_event_without_a_world_is_a_safe_no_op() {
let mut nav = Navigator::new(".");
assert!(nav
.dispatch_event(&serde_json::json!({"event": "purchase_result"}))
.is_empty());
}
#[test]
fn focused_carries_the_stall_data_for_hostside_commerce() {
struct StallLoader;
impl WorldLoader for StallLoader {
fn load(
&mut self,
m: &WorldManifest,
_a: &AssetSource,
anchor: Vec3,
) -> LoadedWorldMeta {
LoadedWorldMeta {
title: m.world.title.clone(),
world_id: m.world.id.clone(),
spawn: anchor,
portals: vec![],
interactables: m
.placements
.iter()
.map(|pl| InteractableNav {
position: Vec3::from_array(pl.position),
label: pl.name.clone(),
codex: pl.codex.clone(),
web: None,
name: pl.name.clone(),
behavior: pl.behavior.clone(),
interaction: pl.interaction.clone(),
hits_done: 0,
data: pl.data.clone(),
})
.collect(),
sky: SkyNav::default(),
presence_relays: vec![],
colliders: vec![],
}
}
}
let world = r#"{
"thread": "thread/0.1",
"world": { "id": "market", "title": "The Bazaar" },
"spawns": [{ "name": "entry", "position": [0, 0, 4] }],
"prefabs": [{ "id": "60000002", "mesh": { "builtin": "cube" } }],
"placements": [
{ "prefab": "60000002", "name": "wares:blade", "position": [0, 0, 0],
"behavior": "buy",
"data": { "item": "21010001", "name": "Veilwalker Blade",
"price": 250, "currency": "gold" } }
],
"behaviors": [{ "id": "buy", "wasm": "commerce-wasm", "on": ["interact"] }],
"assets": [{ "id": "commerce-wasm", "uri": "missing.wasm", "kind": "wasm" }]
}"#;
let mut nav = Navigator::new(".");
nav.apply_world(
&mut StallLoader,
world,
&AssetSource::local("."),
Vec3::ZERO,
None,
NavKind::Normal,
)
.expect("world applies");
let f = nav
.focused(Vec3::new(0.0, 0.0, 0.5))
.expect("standing at the stall");
assert_eq!(f.behavior.as_deref(), Some("buy"));
assert_eq!(f.data["item"], "21010001");
assert_eq!(f.data["name"], "Veilwalker Blade");
}
#[cfg(feature = "behaviors")]
#[test]
fn interact_dispatches_to_the_worlds_wasm_behavior() {
use crate::behavior::Action;
const REPLY: &str = r#"{"actions":[{"action":"notify","text":"the pedestal hums"}]}"#;
let wat = format!(
r#"(module
(memory (export "memory") 1)
(global $heap (mut i32) (i32.const 4096))
(data (i32.const 0) "{data}")
(func (export "thread_alloc") (param i32) (result i32)
(local $p i32)
global.get $heap local.set $p
global.get $heap local.get 0 i32.add global.set $heap
local.get $p)
(func (export "thread_on_interact") (param i32 i32) (result i64)
i64.const {len}))"#,
data = REPLY.replace('"', "\\\""),
len = REPLY.len(),
);
let dir = std::env::temp_dir().join(format!("loom-nav-behavior-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
std::fs::write(dir.join("hum.wasm"), wat::parse_str(&wat).unwrap()).unwrap();
struct BindLoader;
impl WorldLoader for BindLoader {
fn load(
&mut self,
m: &WorldManifest,
_a: &AssetSource,
anchor: Vec3,
) -> LoadedWorldMeta {
LoadedWorldMeta {
title: m.world.title.clone(),
world_id: m.world.id.clone(),
spawn: anchor,
portals: vec![],
interactables: m
.placements
.iter()
.map(|pl| InteractableNav {
position: Vec3::from_array(pl.position),
label: pl.name.clone(),
codex: pl.codex.clone(),
web: None,
name: pl.name.clone(),
behavior: pl.behavior.clone(),
interaction: pl.interaction.clone(),
hits_done: 0,
data: pl.data.clone(),
})
.collect(),
sky: SkyNav::default(),
presence_relays: vec![],
colliders: vec![],
}
}
}
let world = r#"{
"thread": "thread/0.1",
"world": { "id": "humming-hall", "title": "Humming Hall" },
"spawns": [{ "name": "entry", "position": [0, 0, 4] }],
"prefabs": [{ "id": "60000002", "mesh": { "builtin": "cube" } }],
"placements": [
{ "prefab": "60000002", "name": "pedestal", "position": [0, 0, 0],
"behavior": "hum" }
],
"behaviors": [{ "id": "hum", "wasm": "hum-wasm", "on": ["interact"] }],
"assets": [{ "id": "hum-wasm", "uri": "hum.wasm", "kind": "wasm" }]
}"#;
let mut nav = Navigator::new(&dir);
let mut loader = BindLoader;
nav.apply_world(
&mut loader,
world,
&AssetSource::local(&dir),
Vec3::ZERO,
None,
NavKind::Normal,
)
.expect("world applies");
let actions = nav.interact(Vec3::new(0.0, 0.0, 0.5));
assert_eq!(
actions,
vec![Action::Notify {
text: "the pedestal hums".into(),
level: None
}]
);
assert!(nav.interact(Vec3::new(50.0, 0.0, 50.0)).is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(feature = "behaviors")]
#[test]
fn the_archives_pedestals_open_the_codex_through_wasm() {
use crate::behavior::Action;
struct BindLoader;
impl WorldLoader for BindLoader {
fn load(
&mut self,
m: &WorldManifest,
_a: &AssetSource,
anchor: Vec3,
) -> LoadedWorldMeta {
LoadedWorldMeta {
title: m.world.title.clone(),
world_id: m.world.id.clone(),
spawn: anchor,
portals: vec![],
interactables: m
.placements
.iter()
.map(|pl| InteractableNav {
position: Vec3::from_array(pl.position),
label: pl.name.clone(),
codex: pl.codex.clone(),
web: None,
name: pl.name.clone(),
behavior: pl.behavior.clone(),
interaction: pl.interaction.clone(),
hits_done: 0,
data: pl.data.clone(),
})
.collect(),
sky: SkyNav::default(),
presence_relays: vec![],
colliders: vec![],
}
}
}
let world = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../worlds/codex-archive/world.json");
if !world.exists() {
eprintln!("no reference corpus beside this crate — skipping");
return;
}
let mut nav = Navigator::new(world.parent().unwrap());
let mut loader = BindLoader;
nav.load_file(&mut loader, &world, Vec3::ZERO)
.expect("The Archive loads");
let actions = nav.interact(Vec3::new(-4.0, 0.5, 0.3));
assert_eq!(
actions,
vec![Action::CodexOpen {
slug: "the-veil".into()
}]
);
}
#[cfg(feature = "behaviors")]
#[test]
fn the_bazaars_stalls_sell_through_wasm() {
use crate::behavior::Action;
struct BindLoader;
impl WorldLoader for BindLoader {
fn load(
&mut self,
m: &WorldManifest,
_a: &AssetSource,
anchor: Vec3,
) -> LoadedWorldMeta {
LoadedWorldMeta {
title: m.world.title.clone(),
world_id: m.world.id.clone(),
spawn: anchor,
portals: vec![],
interactables: m
.placements
.iter()
.map(|pl| InteractableNav {
position: Vec3::from_array(pl.position),
label: pl.name.clone(),
codex: pl.codex.clone(),
web: None,
name: pl.name.clone(),
behavior: pl.behavior.clone(),
interaction: pl.interaction.clone(),
hits_done: 0,
data: pl.data.clone(),
})
.collect(),
sky: SkyNav::default(),
presence_relays: vec![],
colliders: vec![],
}
}
}
let world =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../worlds/market/world.json");
let mut nav = Navigator::new(world.parent().unwrap());
nav.set_actor(Some("did:pixygon:abc".into()));
let mut loader = BindLoader;
nav.load_file(&mut loader, &world, Vec3::ZERO)
.expect("The Bazaar loads");
let actions = nav.interact(Vec3::new(-3.0, 1.3, 0.3));
assert_eq!(
actions,
vec![Action::CommerceBuy {
item: "21010001".into(),
price_ref: serde_json::json!("21010001"),
}]
);
let replies = nav.dispatch_event(&serde_json::json!({
"event": "purchase_result", "ok": true, "item": "21010001",
"ref": "thread:u1:market:21010001", "duplicate": false,
}));
assert_eq!(
replies,
vec![Action::Notify {
text: "The merchant wraps your purchase. \"A fine choice.\"".into(),
level: None,
}]
);
}
}