Skip to main content

thread_engine/
home.rs

1//! The home-space — the browser's `about:home`, except it's a world you own.
2//!
3//! A browser shouldn't greet you with a launcher dialog; it should open
4//! *somewhere*. On the Thread that somewhere is yours: a small local world,
5//! materialized on first launch, that grows with you. Starred worlds
6//! ([`crate::bookmarks`]) appear around it as veils — your personal
7//! constellation, walkable instead of listed — and build mode adds blocks that
8//! persist. It is a plain `world.json` in the standard manifest format:
9//! view-source culture applies to your own home too, and because it *is* a
10//! real world, one day you can host it and receive visitors.
11//!
12//! The home has a well-known Locator, [`HOME_LOCATOR`] (`thread://home`) —
13//! the Thread's `localhost`. The resolver maps it straight to the local file,
14//! never the network, which makes home first-class everywhere a Locator goes:
15//! the address bar, back/forward history, the H key, even a portal's `to`.
16
17use 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
31/// The home-space's well-known Locator — resolved to the local home file,
32/// never the network (the Thread's `localhost`).
33pub const HOME_LOCATOR: &str = "thread://home";
34
35/// The Thread's public front door — the home's one permanent veil, so a fresh
36/// home is never a dead end.
37pub const FRONT_DOOR: &str = "thread://pixygon.io";
38
39/// Where the home world lives: `~/.config/infinite/home/` (beside
40/// `bookmarks.json`, via the same `XDG_CONFIG_HOME` convention).
41pub 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
50/// The home world's manifest file within its directory.
51pub fn world_path(dir: &Path) -> PathBuf {
52    dir.join("world.json")
53}
54
55/// Whether a Locator addresses the traveler's own home-space (`thread://home`,
56/// with or without a `#place` / `@when` suffix). The resolver uses this to keep
57/// home off the network; the browser uses it to gate build mode.
58pub 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
63/// One block kind build mode can place. `scale` is the placed size (builtin
64/// primitives are unit-sized) and `rest_y` the height its center sits at so it
65/// rests on the floor.
66pub struct PaletteEntry {
67    pub id: StructuredId,
68    pub label: &'static str,
69    pub scale: [f32; 3],
70    pub rest_y: f32,
71}
72
73/// The build-mode palette. Prefab ids live in the home's own 60.10.* range so
74/// they never collide with a world's authored prefabs.
75pub 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
120/// The prefab definition behind a palette id (mesh + material).
121fn 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
150// ── The estate: the seeded heart of every home ──────────────────────────
151//
152// A home is a homepage: the first thing a traveler sees, every session. The
153// estate is the browser-seeded furniture that makes it one — a dais, a
154// waystone that searches the Thread, a keeper who explains the place, lamps
155// so it reads warm, and a ring of stones so it reads *held*. Everything is
156// marked `estate:` so syncs and upgrades never touch what the user built.
157
158const 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
194/// A placement with the estate's defaults; callers adjust what differs.
195fn 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
217/// The keeper's welcome board — the home's guide, links and all.
218fn 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    // The dais — a low warm disc the spawn looks across. Visual only: a
267    // 12 cm lip under a capsule collider is a stumble, not a step.
268    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    // The waystone — search, standing left of the walk line. Interacting
273    // opens the browser's worlds panel (`data.panel`); other browsers see a
274    // plain interaction whose message still teaches the key.
275    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    // The keeper — the home's guide, standing to the right of the walk line.
297    // A figure, not a wall of text: inspecting them reads the Codex; the
298    // board beside them carries the practical welcome.
299    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); // capsule colliders bounce jumps — decorative
306    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    // The welcome board beside the keeper, angled toward the spawn.
317    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    // Four lamps at the quarter points between the arcs — warmth and depth.
325    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    // A broken ring of low stones past the veils — the home reads held,
350    // not walled. Heights vary a little so it reads grown, not stamped.
351    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
367/// The seeded sky (kept in one place so [`upgrade`] can tell "still the
368/// default" from "the user painted their own").
369fn 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
377/// Bring an existing home up to the current estate — additive and marked:
378/// only `estate:` pieces are ever added, nothing the user placed is touched,
379/// and a home that already has its estate is left alone. Also warms the
380/// hearth (a light on the seeded hearthstone) and refreshes the seeded sky
381/// and description — but only when they still carry their original values.
382pub fn upgrade(dir: &Path) -> bool {
383    edit_manifest(dir, |m| {
384        if m.placements.iter().any(|p| p.name == "estate:waystone") {
385            // Estate already seeded. The guide board's copy is browser-owned
386            // (it teaches THIS browser's keys) — keep it current across
387            // releases; everything else estate stays as first seeded.
388            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        // Warm the seeded hearth if it never had a glow.
404        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        // Refresh seeded defaults only (a hand-painted sky or description stays).
416        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
441/// Create the starter home if it doesn't exist yet; returns the manifest path.
442/// Never overwrites — the home is the user's, we only seed it once.
443pub 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
462/// The first-launch home: a warm little island under a dawn sky, one hearth
463/// stone, and a single veil to the Thread's front door — never a dead end,
464/// never someone else's lobby.
465fn 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(), // home is a home, not a game
502            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), // a brass hearthstone, off the walk line
534                    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
576/// The home manifest as served to GUESTS (`presence` injected): an owner-tied
577/// P2P room at `room_url`. The disk copy is untouched — the owner's own walk
578/// through their home never sees this block; only the invite host serves it.
579pub 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
595/// Read + parse the home manifest, hand it to `mutate`, and write it back.
596/// A missing or unparseable file is left untouched (never clobber the user's
597/// home over a transient problem). Returns whether a write happened.
598fn 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
628/// Regenerate the home's bookmark veils (`fav:` portals) from the current
629/// bookmark list. Star a world → a veil appears at home; unstar → it fades.
630///
631/// Only `fav:` portals are touched: the front door and anything the user
632/// authored by hand survive every sync. Bookmarks already reachable through a
633/// non-fav portal (e.g. the front door itself) are skipped, as is the home's
634/// own Locator. The constellation re-lays itself out on a ring each sync,
635/// leaving the northern arc clear for the front door.
636pub 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        // Recents don't block a star — a starred world OUTRANKS its recent
641        // veil (the duplicate recent is dropped below).
642        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            // The western arc (195°–345°) is the starred constellation; the
656            // east belongs to recents, and north (±15°) to the front door.
657            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        // Drop any recent veil a fresh star now covers.
670        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
682/// Regenerate the home's recent-walks veils (`recent:` portals) from the
683/// traveler's [`Recents`] — the eastern arc, freshest nearest the front door.
684/// Mirrors [`sync_veils`]'s contract: only `recent:` portals are touched, and
685/// places already reachable through another home veil (a starred world, the
686/// front door) are skipped rather than doubled.
687pub 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            // The eastern arc (15°–165°), freshest closest to the front door.
701            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
717/// Place one block from the palette (build mode). Ensures the palette prefab
718/// exists in the manifest (homes seeded before a palette addition still work),
719/// then appends a uniquely-named `built:` placement. Returns success.
720pub 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
754/// Remove the built block nearest `position` (within `radius`). Only touches
755/// `built:` placements — the floor, the hearth, and anything hand-authored are
756/// not removable by accident. Returns whether a block was removed.
757pub 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        // A second ensure must not clobber (the home is the user's).
817        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        // Neither of these should become a veil:
833        b.add("Home itself", HOME_LOCATOR);
834        b.add("Front door", FRONT_DOOR); // already reachable through the permanent veil
835
836        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        // Unstar → the veil fades on the next sync; the front door survives.
848        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        // No change → no rewrite.
855        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        // The disk copy is the owner's own home — no presence block on it.
872        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        // The homepage essentials: a guide with links, a search stone, warmth.
882        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        // A fresh home is already current — upgrade is a no-op.
908        assert!(!upgrade(&dir));
909
910        // A pre-estate home (simulate by stripping estate pieces) upgrades once,
911        // and the upgrade never touches user-built blocks.
912        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); // already a permanent veil → skipped
940
941        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        // Eastern arc: every recent veil sits at positive x.
950        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        // A world both starred and recent appears once (the star wins).
965        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        // And favs stay west.
980        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        // No change → no rewrite.
984        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        // Names stay unique even after removals in between.
1004        assert!(m.placements.iter().any(|p| p.name == "built:1"));
1005        assert!(m.placements.iter().any(|p| p.name == "built:2"));
1006
1007        // Removal near the hearth (not built:) must not touch it.
1008        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        // Remove the stone block; the pillar survives.
1012        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        // The manifest is still valid and the palette prefabs are present.
1023        assert!(m.prefabs.iter().any(|p| p.id == PALETTE[4].id));
1024
1025        let _ = std::fs::remove_dir_all(&dir);
1026    }
1027}