1use std::collections::HashSet;
18use std::f32::consts::PI;
19use std::path::{Path, PathBuf};
20
21use infinite_manifest::{
22 Environment, Interaction, InteractionEffect, LightEmitter, MaterialRef, MeshRef, Placement,
23 Portal, Prefab, PreviewPolicy, Sky, Spawn, TextLink, TextPanel, WorldManifest, WorldMeta,
24 THREAD_VERSION,
25};
26use thread_id::StructuredId;
27
28use crate::bookmarks::Bookmarks;
29use crate::recents::Recents;
30
31pub const HOME_LOCATOR: &str = "thread://home";
34
35pub const FRONT_DOOR: &str = "thread://pixygon.io";
38
39pub fn default_dir() -> PathBuf {
42 std::env::var_os("XDG_CONFIG_HOME")
43 .map(PathBuf::from)
44 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
45 .unwrap_or_else(|| PathBuf::from("."))
46 .join("infinite")
47 .join("home")
48}
49
50pub fn world_path(dir: &Path) -> PathBuf {
52 dir.join("world.json")
53}
54
55pub fn is_home(locator: &str) -> bool {
59 infinite_manifest::Locator::parse(locator)
60 .is_some_and(|l| l.host == "home" && l.path.is_empty())
61}
62
63pub struct PaletteEntry {
67 pub id: StructuredId,
68 pub label: &'static str,
69 pub scale: [f32; 3],
70 pub rest_y: f32,
71}
72
73pub const PALETTE: &[PaletteEntry] = &[
76 PaletteEntry {
77 id: StructuredId(60_10_0001),
78 label: "Stone",
79 scale: [1.0, 1.0, 1.0],
80 rest_y: 0.5,
81 },
82 PaletteEntry {
83 id: StructuredId(60_10_0002),
84 label: "Wood",
85 scale: [1.0, 1.0, 1.0],
86 rest_y: 0.5,
87 },
88 PaletteEntry {
89 id: StructuredId(60_10_0003),
90 label: "Moss",
91 scale: [1.0, 1.0, 1.0],
92 rest_y: 0.5,
93 },
94 PaletteEntry {
95 id: StructuredId(60_10_0004),
96 label: "Brass",
97 scale: [1.0, 1.0, 1.0],
98 rest_y: 0.5,
99 },
100 PaletteEntry {
101 id: StructuredId(60_10_0005),
102 label: "Pillar",
103 scale: [0.6, 3.0, 0.6],
104 rest_y: 1.5,
105 },
106 PaletteEntry {
107 id: StructuredId(60_10_0006),
108 label: "Slab",
109 scale: [2.0, 0.25, 2.0],
110 rest_y: 0.125,
111 },
112 PaletteEntry {
113 id: StructuredId(60_10_0007),
114 label: "Lantern",
115 scale: [0.35, 0.35, 0.35],
116 rest_y: 1.8,
117 },
118];
119
120fn palette_prefab(entry: &PaletteEntry) -> Prefab {
122 let (builtin, base_color, metallic, roughness) = match entry.label {
123 "Stone" => ("cube", [0.55, 0.55, 0.60, 1.0], 0.0, 0.9),
124 "Wood" => ("cube", [0.45, 0.30, 0.18, 1.0], 0.0, 0.8),
125 "Moss" => ("cube", [0.25, 0.45, 0.20, 1.0], 0.0, 1.0),
126 "Brass" => ("cube", [0.75, 0.60, 0.25, 1.0], 0.8, 0.35),
127 "Pillar" => ("cylinder", [0.85, 0.82, 0.78, 1.0], 0.0, 0.6),
128 "Slab" => ("cube", [0.35, 0.34, 0.38, 1.0], 0.0, 0.85),
129 "Lantern" => ("cube", [1.0, 0.85, 0.50, 1.0], 0.0, 0.2),
130 _ => ("cube", [1.0, 1.0, 1.0, 1.0], 0.0, 1.0),
131 };
132 Prefab {
133 id: entry.id,
134 mesh: MeshRef {
135 asset: None,
136 builtin: Some(builtin.to_string()),
137 shape: None,
138 resolution: None,
139 },
140 material: Some(MaterialRef {
141 base_color,
142 metallic,
143 roughness,
144 ..Default::default()
145 }),
146 extra: Default::default(),
147 }
148}
149
150const DAIS_ID: StructuredId = StructuredId(60_10_0201);
159const WAYSTONE_ID: StructuredId = StructuredId(60_10_0202);
160const KEEPER_ID: StructuredId = StructuredId(60_10_0203);
161const LAMP_POST_ID: StructuredId = StructuredId(60_10_0204);
162const LAMP_HEAD_ID: StructuredId = StructuredId(60_10_0205);
163const RING_STONE_ID: StructuredId = StructuredId(60_10_0206);
164const BOARD_ID: StructuredId = StructuredId(60_10_0207);
165
166fn estate_prefabs() -> Vec<Prefab> {
167 let pf = |id, builtin: &str, base_color, metallic, roughness| Prefab {
168 id,
169 mesh: MeshRef {
170 asset: None,
171 builtin: Some(builtin.to_string()),
172 shape: None,
173 resolution: None,
174 },
175 material: Some(MaterialRef {
176 base_color,
177 metallic,
178 roughness,
179 ..Default::default()
180 }),
181 extra: Default::default(),
182 };
183 vec![
184 pf(DAIS_ID, "cylinder", [0.32, 0.28, 0.26, 1.0], 0.0, 0.85),
185 pf(WAYSTONE_ID, "cube", [0.16, 0.30, 0.38, 1.0], 0.25, 0.35),
186 pf(KEEPER_ID, "capsule", [0.92, 0.88, 0.80, 1.0], 0.0, 0.6),
187 pf(LAMP_POST_ID, "cylinder", [0.20, 0.19, 0.22, 1.0], 0.6, 0.5),
188 pf(LAMP_HEAD_ID, "cube", [1.0, 0.85, 0.55, 1.0], 0.0, 0.2),
189 pf(RING_STONE_ID, "cube", [0.34, 0.40, 0.32, 1.0], 0.0, 0.95),
190 pf(BOARD_ID, "quad", [0.96, 0.93, 0.86, 1.0], 0.0, 0.9),
191 ]
192}
193
194fn place(prefab: StructuredId, name: &str, position: [f32; 3], scale: [f32; 3]) -> Placement {
196 Placement {
197 prefab,
198 name: name.to_string(),
199 kind: None,
200 class: vec![],
201 position,
202 rotation: [0.0, 0.0, 0.0, 1.0],
203 scale,
204 codex: None,
205 animate: None,
206 solid: None,
207 light: None,
208 text: None,
209 behavior: None,
210 interaction: None,
211 data: serde_json::Value::Null,
212 children: vec![],
213 extra: Default::default(),
214 }
215}
216
217fn guide_text() -> TextPanel {
219 TextPanel {
220 content: "YOUR HOME ON THE THREAD\n\
221 \n\
222 This place is yours: a local world,\n\
223 kept on your own machine.\n\
224 \n\
225 Walk with W A S D; hold right-click\n\
226 to look. Walk into a framed veil to\n\
227 cross worlds; H brings you back here.\n\
228 \n\
229 Star a world with M — its door joins\n\
230 the west side. Your recent walks\n\
231 gather on the east.\n\
232 \n\
233 Ask the waystone to search, or\n\
234 press Tab wherever you stand.\n\
235 \n\
236 Press F to build — one day you'll\n\
237 open your door and receive guests.\n\
238 \n\
239 Begin at The Nexus, meet travelers\n\
240 in The Commons, or find your way\n\
241 at The Waystone."
242 .to_string(),
243 size: 0.029,
244 color: [0.13, 0.11, 0.09],
245 background: [0.96, 0.93, 0.86],
246 links: vec![
247 TextLink {
248 text: "The Nexus".into(),
249 to: "thread://pixygon.io#entry".into(),
250 },
251 TextLink {
252 text: "The Commons".into(),
253 to: "thread://commons.pixygon.io#entry".into(),
254 },
255 TextLink {
256 text: "The Waystone".into(),
257 to: "thread://waystone.pixygon.io#entry".into(),
258 },
259 ],
260 }
261}
262
263fn estate_placements() -> Vec<Placement> {
264 let mut out = Vec::new();
265
266 let mut dais = place(DAIS_ID, "estate:dais", [0.0, 0.06, 0.0], [6.4, 0.12, 6.4]);
269 dais.solid = Some(false);
270 out.push(dais);
271
272 let mut waystone = place(
276 WAYSTONE_ID,
277 "estate:waystone",
278 [-2.0, 1.05, 1.2],
279 [0.7, 2.1, 0.45],
280 );
281 waystone.light = Some(LightEmitter {
282 color: [0.35, 0.85, 1.0],
283 intensity: 1.5,
284 range: 9.0,
285 });
286 waystone.interaction = Some(Interaction {
287 label: "Search the Thread".to_string(),
288 hits: 1,
289 effects: vec![InteractionEffect::Message(
290 "The waystone listens. (Tab opens the search anywhere.)".to_string(),
291 )],
292 });
293 waystone.data = serde_json::json!({ "panel": "worlds" });
294 out.push(waystone);
295
296 let mut keeper = place(
300 KEEPER_ID,
301 "estate:keeper",
302 [2.0, 0.85, 1.2],
303 [0.57, 0.57, 0.57],
304 );
305 keeper.solid = Some(false); keeper.codex = Some("the-veilwalkers".to_string());
307 keeper.interaction = Some(Interaction {
308 label: "Greet the keeper".to_string(),
309 hits: 1,
310 effects: vec![InteractionEffect::Message(
311 "Welcome home, traveler. Walk into a veil to cross; I keep the doors while you're away.".to_string(),
312 )],
313 });
314 out.push(keeper);
315
316 let mut board = place(BOARD_ID, "estate:guide", [3.4, 1.55, 1.6], [2.4, 2.6, 1.0]);
318 let a = (-14.0f32).to_radians() / 2.0;
319 board.rotation = [0.0, a.sin(), 0.0, a.cos()];
320 board.solid = Some(false);
321 board.text = Some(guide_text());
322 out.push(board);
323
324 for (i, az) in [45.0f32, 135.0, 225.0, 315.0].iter().enumerate() {
326 let (dx, dz) = (az.to_radians().sin(), -az.to_radians().cos());
327 let (x, z) = (dx * 7.0, dz * 7.0);
328 out.push(place(
329 LAMP_POST_ID,
330 &format!("estate:lamp-post:{i}"),
331 [x, 1.0, z],
332 [0.16, 2.0, 0.16],
333 ));
334 let mut head = place(
335 LAMP_HEAD_ID,
336 &format!("estate:lamp:{i}"),
337 [x, 2.2, z],
338 [0.32, 0.32, 0.32],
339 );
340 head.light = Some(LightEmitter {
341 color: [1.0, 0.78, 0.45],
342 intensity: 1.3,
343 range: 8.0,
344 });
345 head.solid = Some(false);
346 out.push(head);
347 }
348
349 for (i, az) in [20.0f32, 65.0, 110.0, 155.0, 205.0, 250.0, 295.0, 340.0]
352 .iter()
353 .enumerate()
354 {
355 let (dx, dz) = (az.to_radians().sin(), -az.to_radians().cos());
356 let h = 0.7 + 0.15 * ((i * 3 % 4) as f32);
357 out.push(place(
358 RING_STONE_ID,
359 &format!("estate:stone:{i}"),
360 [dx * 11.0, h / 2.0, dz * 11.0],
361 [1.1, h, 0.9],
362 ));
363 }
364 out
365}
366
367fn estate_sky() -> Sky {
370 Sky {
371 zenith: [0.07, 0.09, 0.20],
372 horizon: [0.80, 0.42, 0.24],
373 sun_dir: Some([0.30, 0.35, 0.25]),
374 }
375}
376
377pub fn upgrade(dir: &Path) -> bool {
383 edit_manifest(dir, |m| {
384 if m.placements.iter().any(|p| p.name == "estate:waystone") {
385 if let Some(board) = m.placements.iter_mut().find(|p| p.name == "estate:guide") {
389 let fresh = guide_text();
390 if board.text.as_ref() != Some(&fresh) {
391 board.text = Some(fresh);
392 return true;
393 }
394 }
395 return false;
396 }
397 for pf in estate_prefabs() {
398 if !m.prefabs.iter().any(|p| p.id == pf.id) {
399 m.prefabs.push(pf);
400 }
401 }
402 m.placements.extend(estate_placements());
403 if let Some(hearth) = m
405 .placements
406 .iter_mut()
407 .find(|p| p.name == "hearth" && p.light.is_none())
408 {
409 hearth.light = Some(LightEmitter {
410 color: [1.0, 0.45, 0.20],
411 intensity: 1.4,
412 range: 7.0,
413 });
414 }
415 let old_seed_sky = Sky {
417 zenith: [0.10, 0.12, 0.22],
418 horizon: [0.55, 0.35, 0.28],
419 sun_dir: Some([0.35, 0.45, 0.30]),
420 };
421 if m.environment.sky.as_ref() == Some(&old_seed_sky) {
422 m.environment.sky = Some(estate_sky());
423 }
424 if m.world
425 .description
426 .starts_with("Your own corner of the Thread")
427 {
428 m.world.description = home_description();
429 }
430 true
431 })
432}
433
434fn home_description() -> String {
435 "Your own corner of the Thread — the place every session begins. Starred worlds \
436 gather as veils to the west, your recent walks to the east; the waystone searches \
437 everything, and build mode (F) grows the place block by block."
438 .to_string()
439}
440
441pub fn ensure(dir: &Path) -> PathBuf {
444 let path = world_path(dir);
445 if path.exists() {
446 return path;
447 }
448 let _ = std::fs::create_dir_all(dir);
449 match serde_json::to_string_pretty(&starter_world()) {
450 Ok(json) => {
451 if let Err(e) = std::fs::write(&path, json) {
452 tracing::warn!("home: cannot write {}: {e}", path.display());
453 } else {
454 tracing::info!("home: seeded starter home at {}", path.display());
455 }
456 }
457 Err(e) => tracing::warn!("home: serialize failed: {e}"),
458 }
459 path
460}
461
462fn starter_world() -> WorldManifest {
466 let unit_quat = [0.0, 0.0, 0.0, 1.0];
467 let floor_id = StructuredId(60_10_0100);
468 let mut prefabs = vec![Prefab {
469 id: floor_id,
470 mesh: MeshRef {
471 asset: None,
472 builtin: Some("plane".to_string()),
473 shape: None,
474 resolution: None,
475 },
476 material: Some(MaterialRef {
477 base_color: [0.16, 0.13, 0.11, 1.0],
478 roughness: 0.95,
479 ..Default::default()
480 }),
481 extra: Default::default(),
482 }];
483 prefabs.extend(PALETTE.iter().map(palette_prefab));
484 prefabs.extend(estate_prefabs());
485
486 WorldManifest {
487 thread: THREAD_VERSION.to_string(),
488 world: WorldMeta {
489 id: "home".to_string(),
490 title: "Home".to_string(),
491 description: home_description(),
492 author: None,
493 codex: vec!["the-thread".to_string()],
494 license: None,
495 extra: Default::default(),
496 },
497 environment: Environment {
498 year: None,
499 sky: Some(estate_sky()),
500 bounds: None,
501 rules: Default::default(), extra: Default::default(),
503 },
504 spawns: vec![Spawn {
505 name: "entry".to_string(),
506 position: [0.0, 0.0, 5.0],
507 yaw: PI,
508 }],
509 assets: vec![],
510 prefabs,
511 placements: {
512 let mut v = vec![
513 Placement {
514 prefab: floor_id,
515 name: "floor".to_string(),
516 kind: None,
517 class: vec![],
518 position: [0.0, 0.0, 0.0],
519 rotation: unit_quat,
520 scale: [22.0, 1.0, 22.0],
521 codex: None,
522 animate: None,
523 solid: None,
524 light: None,
525 text: None,
526 behavior: None,
527 interaction: None,
528 data: serde_json::Value::Null,
529 children: vec![],
530 extra: Default::default(),
531 },
532 Placement {
533 prefab: StructuredId(60_10_0004), name: "hearth".to_string(),
535 kind: None,
536 class: vec![],
537 position: [2.5, 0.25, 0.0],
538 rotation: unit_quat,
539 scale: [1.2, 0.5, 1.2],
540 codex: None,
541 animate: None,
542 solid: None,
543 light: Some(LightEmitter {
544 color: [1.0, 0.45, 0.20],
545 intensity: 1.4,
546 range: 7.0,
547 }),
548 text: None,
549 behavior: None,
550 interaction: None,
551 data: serde_json::Value::Null,
552 children: vec![],
553 extra: Default::default(),
554 },
555 ];
556 v.extend(estate_placements());
557 v
558 },
559 portals: vec![Portal {
560 id: "front-door".to_string(),
561 position: [0.0, 1.4, -8.0],
562 rotation: unit_quat,
563 scale: [2.0, 3.0, 0.2],
564 to: FRONT_DOOR.to_string(),
565 label: "The Nexus".to_string(),
566 preview: PreviewPolicy::Live,
567 extra: Default::default(),
568 }],
569 behaviors: vec![],
570 styles: vec![],
571 presence: None,
572 extra: Default::default(),
573 }
574}
575
576pub fn served_manifest(dir: &Path, room_url: &str) -> Option<String> {
580 let text = std::fs::read_to_string(world_path(dir)).ok()?;
581 let mut m = WorldManifest::from_json(&text).ok()?;
582 m.presence = Some(infinite_manifest::Presence {
583 mode: Some("p2p".to_string()),
584 relay: None,
585 relays: vec![room_url.to_string()],
586 rendezvous: None,
587 max_occupants: Some(8),
588 voice: true,
589 owner_required: true,
590 extra: Default::default(),
591 });
592 serde_json::to_string_pretty(&m).ok()
593}
594
595fn edit_manifest(dir: &Path, mutate: impl FnOnce(&mut WorldManifest) -> bool) -> bool {
599 let path = world_path(dir);
600 let Ok(text) = std::fs::read_to_string(&path) else {
601 tracing::warn!("home: cannot read {}", path.display());
602 return false;
603 };
604 let mut manifest = match WorldManifest::from_json(&text) {
605 Ok(m) => m,
606 Err(e) => {
607 tracing::warn!(
608 "home: {} is not a valid manifest — left untouched: {e}",
609 path.display()
610 );
611 return false;
612 }
613 };
614 if !mutate(&mut manifest) {
615 return false;
616 }
617 match serde_json::to_string_pretty(&manifest) {
618 Ok(json) => std::fs::write(&path, json)
619 .map_err(|e| tracing::warn!("home: cannot write {}: {e}", path.display()))
620 .is_ok(),
621 Err(e) => {
622 tracing::warn!("home: serialize failed: {e}");
623 false
624 }
625 }
626}
627
628pub fn sync_veils(dir: &Path, bookmarks: &Bookmarks) -> bool {
637 edit_manifest(dir, |m| {
638 let before = serde_json::to_string(&m.portals).unwrap_or_default();
639 m.portals.retain(|p| !p.id.starts_with("fav:"));
640 let existing: HashSet<String> = m
643 .portals
644 .iter()
645 .filter(|p| !p.id.starts_with("recent:"))
646 .map(|p| p.to.clone())
647 .collect();
648 let favs: Vec<_> = bookmarks
649 .entries()
650 .iter()
651 .filter(|b| b.locator != HOME_LOCATOR && !existing.contains(&b.locator))
652 .collect();
653 let n = favs.len();
654 for (i, b) in favs.iter().enumerate() {
655 let theta = (195.0 + (i as f32 + 0.5) * 150.0 / n as f32).to_radians();
658 m.portals.push(Portal {
659 id: format!("fav:{}", b.locator),
660 position: [8.5 * theta.sin(), 1.4, -8.5 * theta.cos()],
661 rotation: [0.0, 0.0, 0.0, 1.0],
662 scale: [2.0, 3.0, 0.2],
663 to: b.locator.clone(),
664 label: b.label.clone(),
665 preview: PreviewPolicy::Live,
666 extra: Default::default(),
667 });
668 }
669 let starred: HashSet<String> = m
671 .portals
672 .iter()
673 .filter(|p| p.id.starts_with("fav:"))
674 .map(|p| p.to.clone())
675 .collect();
676 m.portals
677 .retain(|p| !p.id.starts_with("recent:") || !starred.contains(&p.to));
678 serde_json::to_string(&m.portals).unwrap_or_default() != before
679 })
680}
681
682pub fn sync_recents(dir: &Path, recents: &Recents) -> bool {
688 edit_manifest(dir, |m| {
689 let before = serde_json::to_string(&m.portals).unwrap_or_default();
690 m.portals.retain(|p| !p.id.starts_with("recent:"));
691 let existing: HashSet<String> = m.portals.iter().map(|p| p.to.clone()).collect();
692 let fresh: Vec<_> = recents
693 .entries()
694 .iter()
695 .filter(|r| r.locator != HOME_LOCATOR && !existing.contains(&r.locator))
696 .take(crate::recents::CAP)
697 .collect();
698 let n = fresh.len();
699 for (i, r) in fresh.iter().enumerate() {
700 let theta = (15.0 + (i as f32 + 0.5) * 150.0 / n as f32).to_radians();
702 m.portals.push(Portal {
703 id: format!("recent:{}", r.locator),
704 position: [8.5 * theta.sin(), 1.4, -8.5 * theta.cos()],
705 rotation: [0.0, 0.0, 0.0, 1.0],
706 scale: [2.0, 3.0, 0.2],
707 to: r.locator.clone(),
708 label: r.label.clone(),
709 preview: PreviewPolicy::Live,
710 extra: Default::default(),
711 });
712 }
713 serde_json::to_string(&m.portals).unwrap_or_default() != before
714 })
715}
716
717pub fn add_placement(dir: &Path, entry: &PaletteEntry, position: [f32; 3]) -> bool {
721 edit_manifest(dir, |m| {
722 if !m.prefabs.iter().any(|p| p.id == entry.id) {
723 m.prefabs.push(palette_prefab(entry));
724 }
725 let next = m
726 .placements
727 .iter()
728 .filter_map(|p| p.name.strip_prefix("built:")?.parse::<u64>().ok())
729 .max()
730 .map_or(1, |n| n + 1);
731 m.placements.push(Placement {
732 prefab: entry.id,
733 name: format!("built:{next}"),
734 kind: None,
735 class: vec![],
736 position,
737 rotation: [0.0, 0.0, 0.0, 1.0],
738 scale: entry.scale,
739 codex: None,
740 animate: None,
741 solid: None,
742 light: None,
743 text: None,
744 behavior: None,
745 interaction: None,
746 data: serde_json::Value::Null,
747 children: vec![],
748 extra: Default::default(),
749 });
750 true
751 })
752}
753
754pub fn remove_placement_near(dir: &Path, position: [f32; 3], radius: f32) -> bool {
758 edit_manifest(dir, |m| {
759 let dist_sq = |p: &Placement| -> f32 {
760 let d = [
761 p.position[0] - position[0],
762 p.position[1] - position[1],
763 p.position[2] - position[2],
764 ];
765 d[0] * d[0] + d[1] * d[1] + d[2] * d[2]
766 };
767 let nearest = m
768 .placements
769 .iter()
770 .enumerate()
771 .filter(|(_, p)| p.name.starts_with("built:") && dist_sq(p) <= radius * radius)
772 .min_by(|(_, a), (_, b)| dist_sq(a).total_cmp(&dist_sq(b)))
773 .map(|(i, _)| i);
774 match nearest {
775 Some(i) => {
776 m.placements.remove(i);
777 true
778 }
779 None => false,
780 }
781 })
782}
783
784#[cfg(test)]
785mod tests {
786 use super::*;
787
788 fn temp_home(tag: &str) -> PathBuf {
789 let dir = std::env::temp_dir().join(format!("loom-home-{}-{tag}", std::process::id()));
790 let _ = std::fs::remove_dir_all(&dir);
791 dir
792 }
793
794 fn read(dir: &Path) -> WorldManifest {
795 WorldManifest::from_json(&std::fs::read_to_string(world_path(dir)).unwrap()).unwrap()
796 }
797
798 #[test]
799 fn ensure_seeds_a_valid_walkable_starter_home_once() {
800 let dir = temp_home("seed");
801 let path = ensure(&dir);
802 assert!(path.exists());
803
804 let m = read(&dir);
805 assert_eq!(m.world.id, "home");
806 assert!(!m.spawns.is_empty(), "a home you can arrive in");
807 assert!(
808 m.placements.iter().any(|p| p.name == "floor"),
809 "a floor to stand on"
810 );
811 assert!(
812 m.portals.iter().any(|p| p.to == FRONT_DOOR),
813 "never a dead end — the front door veil is there"
814 );
815
816 let marker = m.placements.len();
818 ensure(&dir);
819 assert_eq!(read(&dir).placements.len(), marker);
820
821 let _ = std::fs::remove_dir_all(&dir);
822 }
823
824 #[test]
825 fn starring_and_unstarring_worlds_grows_and_prunes_the_veil_ring() {
826 let dir = temp_home("veils");
827 ensure(&dir);
828 let store = dir.join("bookmarks.json");
829 let mut b = Bookmarks::load(&store);
830 b.add("The Forge", "thread://pixiel.ai/forge");
831 b.add("The Grove", "thread://garden.pixygon.io/grove");
832 b.add("Home itself", HOME_LOCATOR);
834 b.add("Front door", FRONT_DOOR); assert!(sync_veils(&dir, &b));
837 let m = read(&dir);
838 let favs: Vec<_> = m
839 .portals
840 .iter()
841 .filter(|p| p.id.starts_with("fav:"))
842 .collect();
843 assert_eq!(favs.len(), 2);
844 assert!(favs.iter().any(|p| p.to == "thread://pixiel.ai/forge"));
845 assert_eq!(m.portals.iter().filter(|p| p.to == FRONT_DOOR).count(), 1);
846
847 b.remove("thread://pixiel.ai/forge");
849 assert!(sync_veils(&dir, &b));
850 let m = read(&dir);
851 assert!(!m.portals.iter().any(|p| p.to == "thread://pixiel.ai/forge"));
852 assert!(m.portals.iter().any(|p| p.to == FRONT_DOOR));
853
854 assert!(!sync_veils(&dir, &b));
856
857 let _ = std::fs::remove_dir_all(&dir);
858 }
859
860 #[test]
861 fn served_manifest_is_an_owner_tied_p2p_room_and_disk_stays_clean() {
862 let dir = temp_home("served");
863 ensure(&dir);
864 let room = "ws://192.0.2.17:4200/thread/home";
865 let text = served_manifest(&dir, room).expect("serves");
866 let m = WorldManifest::from_json(&text).unwrap();
867 let p = m.presence.expect("presence injected");
868 assert_eq!(p.mode.as_deref(), Some("p2p"));
869 assert!(p.owner_required, "the room dies with its owner");
870 assert_eq!(p.relays, vec![room.to_string()]);
871 assert!(read(&dir).presence.is_none(), "local home untouched");
873 let _ = std::fs::remove_dir_all(&dir);
874 }
875
876 #[test]
877 fn fresh_homes_carry_the_estate_and_old_homes_upgrade_once() {
878 let dir = temp_home("estate");
879 ensure(&dir);
880 let m = read(&dir);
881 let guide = m
883 .placements
884 .iter()
885 .find(|p| p.name == "estate:guide")
886 .expect("guide board");
887 let text = guide.text.as_ref().expect("guide has text");
888 assert!(text.content.contains("YOUR HOME"), "welcome copy present");
889 assert_eq!(text.links.len(), 3, "guide links to Nexus/Commons/Waystone");
890 let stone = m
891 .placements
892 .iter()
893 .find(|p| p.name == "estate:waystone")
894 .expect("waystone");
895 assert_eq!(
896 stone.data.get("panel").and_then(|v| v.as_str()),
897 Some("worlds")
898 );
899 assert!(stone.light.is_some(), "the waystone glows");
900 assert!(
901 m.placements
902 .iter()
903 .filter(|p| p.name.starts_with("estate:lamp:"))
904 .count()
905 >= 4
906 );
907 assert!(!upgrade(&dir));
909
910 let mut old = read(&dir);
913 old.placements.retain(|p| !p.name.starts_with("estate:"));
914 std::fs::write(
915 world_path(&dir),
916 serde_json::to_string_pretty(&old).unwrap(),
917 )
918 .unwrap();
919 assert!(add_placement(&dir, &PALETTE[0], [4.0, 0.5, 4.0]));
920 assert!(upgrade(&dir));
921 let m = read(&dir);
922 assert!(m.placements.iter().any(|p| p.name == "estate:waystone"));
923 assert!(
924 m.placements.iter().any(|p| p.name == "built:1"),
925 "user blocks survive"
926 );
927 assert!(!upgrade(&dir), "second upgrade is a no-op");
928 let _ = std::fs::remove_dir_all(&dir);
929 }
930
931 #[test]
932 fn recent_walks_become_veils_on_the_east_and_never_double_a_fav() {
933 let dir = temp_home("recents");
934 ensure(&dir);
935 let store = dir.join("recents.json");
936 let mut r = Recents::load(&store);
937 r.visit("The Weavery", "thread://weft.pixygon.io");
938 r.visit("The Commons", "thread://commons.pixygon.io");
939 r.visit("Front door", FRONT_DOOR); assert!(sync_recents(&dir, &r));
942 let m = read(&dir);
943 let recents: Vec<_> = m
944 .portals
945 .iter()
946 .filter(|p| p.id.starts_with("recent:"))
947 .collect();
948 assert_eq!(recents.len(), 2);
949 for p in &recents {
951 assert!(
952 p.position[0] > 0.0,
953 "recent veil {} on the east (x {})",
954 p.id,
955 p.position[0]
956 );
957 }
958 assert_eq!(
959 m.portals.iter().filter(|p| p.to == FRONT_DOOR).count(),
960 1,
961 "no doubles"
962 );
963
964 let bstore = dir.join("bookmarks.json");
966 let mut b = Bookmarks::load(&bstore);
967 b.add("The Commons", "thread://commons.pixygon.io");
968 assert!(sync_veils(&dir, &b));
969 assert!(sync_recents(&dir, &r));
970 let m = read(&dir);
971 assert_eq!(
972 m.portals
973 .iter()
974 .filter(|p| p.to == "thread://commons.pixygon.io")
975 .count(),
976 1,
977 "starred+recent world keeps a single veil"
978 );
979 for p in m.portals.iter().filter(|p| p.id.starts_with("fav:")) {
981 assert!(p.position[0] < 0.0, "fav veil {} on the west", p.id);
982 }
983 assert!(!sync_recents(&dir, &r));
985 let _ = std::fs::remove_dir_all(&dir);
986 }
987
988 #[test]
989 fn build_mode_places_and_removes_only_built_blocks() {
990 let dir = temp_home("build");
991 ensure(&dir);
992
993 assert!(add_placement(&dir, &PALETTE[0], [3.0, 0.5, 3.0]));
994 assert!(add_placement(&dir, &PALETTE[4], [5.0, 1.5, 3.0]));
995 let m = read(&dir);
996 assert_eq!(
997 m.placements
998 .iter()
999 .filter(|p| p.name.starts_with("built:"))
1000 .count(),
1001 2
1002 );
1003 assert!(m.placements.iter().any(|p| p.name == "built:1"));
1005 assert!(m.placements.iter().any(|p| p.name == "built:2"));
1006
1007 assert!(!remove_placement_near(&dir, [2.5, 0.25, 0.0], 0.6));
1009 assert!(read(&dir).placements.iter().any(|p| p.name == "hearth"));
1010
1011 assert!(remove_placement_near(&dir, [3.0, 0.5, 3.0], 1.5));
1013 let m = read(&dir);
1014 assert_eq!(
1015 m.placements
1016 .iter()
1017 .filter(|p| p.name.starts_with("built:"))
1018 .count(),
1019 1
1020 );
1021
1022 assert!(m.prefabs.iter().any(|p| p.id == PALETTE[4].id));
1024
1025 let _ = std::fs::remove_dir_all(&dir);
1026 }
1027}