1use 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
31pub const ENTER_RADIUS: f32 = 2.2;
33pub const INSPECT_RADIUS: f32 = 2.6;
35const TRAVERSE_COOLDOWN: f32 = 1.2;
37pub const FEET_DROP: f32 = 0.9;
39pub const PREVIEW_RADIUS: f32 = 18.0;
43const DEFAULT_THREAD_BASE: &str = "https://api.pixygon.io/v1";
45
46#[derive(Debug, Clone)]
48pub struct PortalNav {
49 pub id: String,
50 pub position: Vec3,
51 pub to: String,
53 pub label: String,
54 pub preview: bool,
57 #[allow(clippy::derive_partial_eq_without_eq)]
59 pub yaw: f32,
60}
61
62#[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 pub name: String,
72 pub behavior: Option<String>,
74 pub interaction: Option<Interaction>,
77 pub hits_done: u32,
80 pub data: serde_json::Value,
82}
83
84#[derive(Debug, Clone, Copy)]
88pub struct ColliderNav {
89 pub half_extents: Vec3,
90 pub position: Vec3,
91 pub rotation: Quat,
92}
93
94#[derive(Debug, Clone)]
97pub struct PortalPreview {
98 pub title: String,
99 pub sky: SkyNav,
100 pub veils: usize,
102 pub manifest_text: String,
105 pub asset_base: PathBuf,
108 pub asset_base_url: Option<String>,
109 pub presence_relays: Vec<String>,
112 pub world_id: String,
113}
114
115#[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
134pub struct LoadedWorldMeta {
137 pub title: String,
138 pub world_id: String,
140 pub spawn: Vec3,
142 pub portals: Vec<PortalNav>,
143 pub interactables: Vec<InteractableNav>,
144 pub sky: SkyNav,
145 pub presence_relays: Vec<String>,
148 pub colliders: Vec<ColliderNav>,
151}
152
153pub trait WorldLoader {
157 fn load(
162 &mut self,
163 manifest: &WorldManifest,
164 assets: &AssetSource,
165 anchor: Vec3,
166 ) -> LoadedWorldMeta;
167}
168
169pub struct Focus {
171 pub label: String,
172 pub codex: Option<String>,
173 pub web: Option<String>,
174 pub behavior: Option<String>,
176 pub interact_label: Option<String>,
179 pub data: serde_json::Value,
183}
184
185#[cfg(feature = "behaviors")]
187struct BehaviorSlot {
188 id: String,
190 ticks: bool,
192 module: WasmBehavior,
193}
194
195struct ActiveWorld {
196 title: String,
197 world_id: String,
198 description: String,
201 locator: Option<String>,
203 source: String,
205 portals: Vec<PortalNav>,
206 interactables: Vec<InteractableNav>,
207 sky: SkyNav,
208 presence_relays: Vec<String>,
209 owner_required: bool,
212 colliders: Vec<ColliderNav>,
213 rules: WorldRules,
215 #[cfg(feature = "behaviors")]
217 behaviors: Vec<BehaviorSlot>,
218 weft_behaviors: Vec<WeftSlot>,
220}
221
222struct WeftSlot {
224 id: String,
225 behavior: crate::behavior_weft::WeftBehavior,
226 on: Vec<String>,
229}
230
231fn load_weft_behaviors(manifest: &WorldManifest, assets: &AssetSource) -> Vec<WeftSlot> {
235 manifest
236 .behaviors
237 .iter()
238 .filter_map(|b| {
239 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
274enum PreviewSlot {
277 Pending(Receiver<Option<PortalPreview>>),
278 Ready(PortalPreview),
279 Failed,
280}
281
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
286enum NavKind {
287 Normal,
288 Back,
289 Forward,
290 Reload,
293}
294
295struct Pending {
297 rx: Receiver<Result<TravelResult, String>>,
298 anchor: Vec3,
299 locator: String,
301 kind: NavKind,
302}
303
304pub struct Navigator {
306 worlds_root: PathBuf,
307 resolver_base: String,
308 active: Option<ActiveWorld>,
309 pending: Option<Pending>,
310 cooldown: f32,
311 armed: bool,
315 last_error: Option<String>,
317 back_stack: Vec<String>,
319 forward_stack: Vec<String>,
320 home: Option<String>,
322 actor_sub: Option<String>,
325 previews: HashMap<String, PreviewSlot>,
328 weft_tick_accum: f32,
331 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 pub fn set_actor(&mut self, sub: Option<String>) {
363 self.actor_sub = sub.filter(|s| !s.trim().is_empty());
364 }
365
366 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 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 pub fn locator(&self) -> Option<&str> {
386 self.active.as_ref().and_then(|w| w.locator.as_deref())
387 }
388
389 pub fn world_source(&self) -> Option<&str> {
392 self.active.as_ref().map(|w| w.source.as_str())
393 }
394
395 pub fn world_description(&self) -> Option<&str> {
397 self.active.as_ref().map(|w| w.description.as_str())
398 }
399
400 pub fn home(&self) -> Option<&str> {
402 self.home.as_deref()
403 }
404
405 pub fn set_home(&mut self, locator: impl Into<String>) {
407 self.home = Some(locator.into());
408 }
409
410 pub fn back_history(&self) -> &[String] {
412 &self.back_stack
413 }
414
415 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 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 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 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; }
461 self.begin_travel(dest, anchor);
462 true
463 }
464
465 pub fn sky(&self) -> Option<SkyNav> {
467 self.active.as_ref().map(|w| w.sky)
468 }
469
470 pub fn rules(&self) -> WorldRules {
473 self.active.as_ref().map(|w| w.rules).unwrap_or_default()
474 }
475
476 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 pub fn presence_owner_required(&self) -> bool {
488 self.active.as_ref().is_some_and(|w| w.owner_required)
489 }
490
491 pub fn is_traveling(&self) -> bool {
493 self.pending.is_some()
494 }
495
496 pub fn colliders(&self) -> &[ColliderNav] {
499 self.active
500 .as_ref()
501 .map(|w| w.colliders.as_slice())
502 .unwrap_or(&[])
503 }
504
505 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 pub fn portals(&self) -> &[PortalNav] {
518 self.active
519 .as_ref()
520 .map(|w| w.portals.as_slice())
521 .unwrap_or(&[])
522 }
523
524 pub fn armed(&self) -> bool {
529 self.armed && self.pending.is_none() && self.cooldown <= 0.0
530 }
531
532 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 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 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 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(); }
576 it.hits_done = 0;
577 }
578 let mut actions = effects_to_actions(&inter.effects);
579 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 }
969 Err(TryRecvError::Empty) => {}
970 Err(TryRecvError::Disconnected) => self.pending = None,
971 }
972 return None;
973 }
974 if self.cooldown > 0.0 {
976 self.cooldown = (self.cooldown - dt).max(0.0);
977 return None;
978 }
979 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, }
995 }
996}
997
998#[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
1028fn 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
1056fn 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
1066fn nearest_portal(portals: &[PortalNav], pos: Vec3, radius: f32) -> Option<usize> {
1068 nearest_by(portals.iter().map(|p| p.position), pos, radius)
1069}
1070
1071fn nearest_interactable(items: &[InteractableNav], pos: Vec3, radius: f32) -> Option<usize> {
1073 nearest_by(items.iter().map(|p| p.position), pos, radius)
1074}
1075
1076fn 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, })
1098 .collect()
1099}
1100
1101fn 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 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 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 assert!(nav.near_portal(Vec3::new(0.0, 0.0, 0.0)).is_some());
1216 }
1217
1218 #[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 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 assert!(nav
1249 .world_source()
1250 .is_some_and(|s| s.contains("<world id=\"meadow\"")));
1251 }
1252
1253 #[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 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 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 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 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 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 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 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 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 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 #[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 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 #[cfg(feature = "behaviors")]
1602 #[test]
1603 fn interact_dispatches_to_the_worlds_wasm_behavior() {
1604 use crate::behavior::Action;
1605
1606 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 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 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 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 #[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 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 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 #[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 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 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}