Skip to main content

thread_manifest/
lib.rs

1//! # World Manifest — the document format of the Thread
2//!
3//! The Thread is the open, spatial, present, time-aware successor to the web:
4//! worlds are *pearls* strung along one Thread, linked by **portals** (veils you
5//! step through). This crate defines the **World Manifest** — the "HTML" of the
6//! Thread: an open, **renderer-agnostic** description of a place that *any*
7//! browser (Infinite is the first) can resolve and render.
8//!
9//! Design principles (mirroring why the web won):
10//! - **Narrow waist.** Every browser implements this format or nothing interops.
11//! - **Renderer-agnostic.** Geometry is glTF + prefab refs + a standard material
12//!   model — never a specific engine's internals.
13//! - **Static-first, presence-optional.** A world is just files (host it
14//!   anywhere). Presence (shared, multi-user) is an *upgrade* you opt into by
15//!   naming a relay; without one, the world gracefully degrades to solo.
16//! - **Meaning is native.** Anything can link to a canonical [`Codex`] entry.
17//! - **Time is an axis.** A world declares a `year`; the same place is
18//!   addressable across time.
19//!
20//! See [world-manifest-v0.1](https://github.com/Pixygon/thread-spec/blob/main/specs/world-manifest-v0.1.md) for the normative
21//! specification — that document is the standard, and this crate is one
22//! implementation of it.
23
24pub mod arch;
25pub mod lint;
26mod locator;
27pub mod markup;
28pub mod model;
29pub mod plan;
30pub mod shape;
31pub mod texture;
32pub use locator::{well_known_url, Locator, SCHEME};
33
34use serde::{Deserialize, Serialize};
35/// The prefab/item id scheme, re-exported so consumers of the manifest don't need
36/// a direct `thread-id` dependency.
37pub use thread_id::StructuredId;
38
39/// The format version tag carried in every manifest's `thread` field.
40pub const THREAD_VERSION: &str = "thread/0.1";
41
42/// Fields this build doesn't know about, carried through untouched.
43///
44/// A browser may ignore what it doesn't understand — it throws its parse away.
45/// A **tool** may not: it reads a world, changes one thing and writes it back,
46/// and anything it silently dropped is deleted from the author's disk. That
47/// includes every field added by an emitter newer than the reader, which is
48/// most of them over a format's life. So the structs keep the strangers.
49pub type Extra = std::collections::BTreeMap<String, serde_json::Value>;
50
51/// A World Manifest — one place on the Thread.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct WorldManifest {
54    /// Format version, e.g. `"thread/0.1"`.
55    pub thread: String,
56    pub world: WorldMeta,
57    #[serde(default)]
58    pub environment: Environment,
59    /// Where visitors arrive. The first is the default; others are `#place` anchors.
60    #[serde(default)]
61    pub spawns: Vec<Spawn>,
62    /// External content referenced by prefabs/behaviors (glTF, textures, WASM).
63    #[serde(default)]
64    pub assets: Vec<Asset>,
65    /// Deduplicated renderable prefabs, each keyed by a [`StructuredId`].
66    #[serde(default)]
67    pub prefabs: Vec<Prefab>,
68    /// Instances of prefabs placed in the world.
69    #[serde(default)]
70    pub placements: Vec<Placement>,
71    /// Veils to other worlds — the hyperlinks of the Thread.
72    #[serde(default)]
73    pub portals: Vec<Portal>,
74    /// Sandboxed behavior modules (WASM) the world runs.
75    #[serde(default)]
76    pub behaviors: Vec<Behavior>,
77    /// CSS-like style rules — attach properties to placements by type / class /
78    /// id instead of inline on every one (the cascade). See [`StyleRule`].
79    #[serde(default)]
80    pub styles: Vec<StyleRule>,
81    /// Presence policy. Absent → the world is solo (static-first).
82    #[serde(default)]
83    pub presence: Option<Presence>,
84    /// Unknown fields, preserved across a round trip (see [`Extra`]).
85    #[serde(flatten, default, skip_serializing_if = "Extra::is_empty")]
86    pub extra: Extra,
87}
88
89/// Identity + descriptive metadata for the world.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct WorldMeta {
92    /// Stable id, unique within the host (the last path segment of its Locator).
93    pub id: String,
94    pub title: String,
95    /// Optional metadata is **omitted when unset**, never emitted as `null` or
96    /// `""`. A consumer should be able to test presence rather than presence-
97    /// and-emptiness; when a generated world carried `author: null` and
98    /// `description: ""`, the first tool to read it filled nothing in, because
99    /// its `or_insert` saw a key that was already there.
100    #[serde(default, skip_serializing_if = "String::is_empty")]
101    pub description: String,
102    /// Provider-agnostic author identity (DID-style), if declared.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub author: Option<Identity>,
105    /// Codex slugs describing this world (canonical meaning).
106    #[serde(default, skip_serializing_if = "Vec::is_empty")]
107    pub codex: Vec<String>,
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub license: Option<String>,
110    /// Unknown fields, preserved across a round trip (see [`Extra`]).
111    #[serde(flatten, default, skip_serializing_if = "Extra::is_empty")]
112    pub extra: Extra,
113}
114
115/// A portable, provider-agnostic identity (the open form of the Passport).
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct Identity {
118    /// Decentralized-identifier-style id (`did:pixygon:…`, etc.).
119    pub id: String,
120    #[serde(default)]
121    pub name: String,
122}
123
124/// Environmental setup: time, sky, bounds, and the game mechanics this place
125/// enforces.
126#[derive(Debug, Clone, Default, Serialize, Deserialize)]
127pub struct Environment {
128    /// Timeline year of this world (the `@when` axis). `None` = the present.
129    #[serde(default)]
130    pub year: Option<i64>,
131    #[serde(default)]
132    pub sky: Option<Sky>,
133    #[serde(default)]
134    pub bounds: Option<Bounds>,
135    /// Opt-in game mechanics this world enforces on visitors. Absent → none (the
136    /// browser stays a browser; most places are not games).
137    #[serde(default)]
138    pub rules: WorldRules,
139    /// Unknown fields, preserved across a round trip (see [`Extra`]).
140    #[serde(flatten, default, skip_serializing_if = "Extra::is_empty")]
141    pub extra: Extra,
142}
143
144/// Opt-in game mechanics a world enforces. **Default all off** — the browser
145/// ships these as capabilities; a place turns on only what it needs (like a page
146/// opting into pointer lock). Interact/inspect are always available and are NOT
147/// gated here — only the game-y mechanics are.
148#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
149pub struct WorldRules {
150    /// Survival meters (hunger/…) decay and can harm the visitor.
151    #[serde(default)]
152    pub survival: bool,
153    /// Harvestable resource nodes can be gathered.
154    #[serde(default)]
155    pub gathering: bool,
156    /// Offensive combat is allowed.
157    #[serde(default)]
158    pub combat: bool,
159}
160
161/// A simple gradient sky (zenith→horizon) with an optional sun direction.
162#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
163pub struct Sky {
164    pub zenith: [f32; 3],
165    pub horizon: [f32; 3],
166    #[serde(default)]
167    pub sun_dir: Option<[f32; 3]>,
168}
169
170/// Axis-aligned world bounds (advisory).
171#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct Bounds {
173    pub min: [f32; 3],
174    pub max: [f32; 3],
175}
176
177/// An arrival point.
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct Spawn {
180    /// Anchor name (the `#place` in a Locator); the first spawn is the default.
181    #[serde(default)]
182    pub name: String,
183    pub position: [f32; 3],
184    #[serde(default)]
185    pub yaw: f32,
186}
187
188/// External content referenced by id.
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct Asset {
191    pub id: String,
192    /// Where to fetch it — a relative path, absolute URL, or `ipfs://…`.
193    pub uri: String,
194    pub kind: AssetKind,
195}
196
197/// What an [`Asset`] is.
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
199#[serde(rename_all = "lowercase")]
200pub enum AssetKind {
201    Gltf,
202    Texture,
203    Wasm,
204    /// A Weft module (the Thread's native code — weft-v0.1).
205    Weft,
206    Audio,
207    Other,
208}
209
210/// A unique renderable prefab (mesh + optional material), keyed by [`StructuredId`].
211#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct Prefab {
213    pub id: StructuredId,
214    pub mesh: MeshRef,
215    #[serde(default)]
216    pub material: Option<MaterialRef>,
217    /// Unknown fields, preserved across a round trip (see [`Extra`]).
218    #[serde(flatten, default, skip_serializing_if = "Extra::is_empty")]
219    pub extra: Extra,
220}
221
222/// How a prefab's geometry is sourced. Exactly one field should be set.
223#[derive(Debug, Clone, Default, Serialize, Deserialize)]
224pub struct MeshRef {
225    /// The id of a glTF [`Asset`].
226    #[serde(default)]
227    pub asset: Option<String>,
228    /// A built-in primitive: `cube`, `sphere`, `cylinder`, `capsule`, `plane`, `quad`.
229    #[serde(default)]
230    pub builtin: Option<String>,
231    /// A procedural shape recipe (see [`shape`]) — the browser meshes it at
232    /// load time. Ships the *intent*, not the vertices; additive, so browsers
233    /// that predate shapes simply skip the prefab.
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub shape: Option<shape::Shape>,
236    /// Sampling resolution for `shape` meshing (grid cells per axis; default
237    /// 40, clamped by the mesher).
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub resolution: Option<u32>,
240}
241
242/// Standard PBR material (renderer-agnostic). Texture fields are [`Asset`] ids.
243#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct MaterialRef {
245    #[serde(default)]
246    pub base_color_texture: Option<String>,
247    /// Packed occlusion-roughness-metallic (glTF convention).
248    #[serde(default)]
249    pub orm_texture: Option<String>,
250    #[serde(default)]
251    pub normal_texture: Option<String>,
252    #[serde(default = "white")]
253    pub base_color: [f32; 4],
254    #[serde(default)]
255    pub metallic: f32,
256    #[serde(default = "one")]
257    pub roughness: f32,
258    /// Glow strength: `> 0` renders unlit, glowing `base_color` (blooms in
259    /// browsers with HDR). `0` (default) = lit normally.
260    #[serde(default)]
261    pub emissive: f32,
262    /// A procedural material recipe (see [`texture`]) — the browser bakes the
263    /// full PBR set (albedo / normal / occlusion-roughness-metallic) locally.
264    /// Additive; when set, the baked maps replace the `*_texture` asset slots
265    /// and the scalar factors still multiply (tinting works as usual).
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    pub texture: Option<texture::TextureRecipe>,
268}
269
270impl Default for MaterialRef {
271    /// Matches the serde defaults (white base colour, full roughness) — a
272    /// derived Default would zero `base_color` and silently blacken any
273    /// programmatically-built textured material.
274    fn default() -> Self {
275        MaterialRef {
276            base_color_texture: None,
277            orm_texture: None,
278            normal_texture: None,
279            base_color: [1.0, 1.0, 1.0, 1.0],
280            metallic: 0.0,
281            roughness: 1.0,
282            emissive: 0.0,
283            texture: None,
284        }
285    }
286}
287
288/// One placed instance of a prefab.
289#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct Placement {
291    pub prefab: StructuredId,
292    #[serde(default)]
293    pub name: String,
294    /// Semantic tag for `styles` **type** selectors ("tree", "door", "sign") —
295    /// the HTML-element analog. JSON key is `type`.
296    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
297    pub kind: Option<String>,
298    /// CSS-like classes for `styles` **class** selectors (`.harvestable`).
299    #[serde(default, skip_serializing_if = "Vec::is_empty")]
300    pub class: Vec<String>,
301    pub position: [f32; 3],
302    #[serde(default = "unit_quat")]
303    pub rotation: [f32; 4],
304    #[serde(default = "unit_scale")]
305    pub scale: [f32; 3],
306    /// Canonical Codex slug for this object — "inspect" surfaces its lore.
307    #[serde(default)]
308    pub codex: Option<String>,
309    /// Id of a [`Behavior`] bound to this placement.
310    #[serde(default)]
311    pub behavior: Option<String>,
312    /// A **codeless** interaction — the simple path (no WASM). Name the verb and
313    /// list what happens. For anything richer, use [`behavior`](Self::behavior).
314    #[serde(default)]
315    pub interaction: Option<Interaction>,
316    /// Readable text rendered ONTO this placement's surface by the browser —
317    /// plaques, signs, reading boards. Text as content, not as texture: the
318    /// manifest carries the words; every browser typesets them itself.
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub text: Option<TextPanel>,
321    /// Data-driven idle motion, executed by the **browser's** native loop —
322    /// the Thread's idiom for living worlds (the web's idiom is app code
323    /// updating transforms every frame). `None` = static.
324    #[serde(default, skip_serializing_if = "Option::is_none")]
325    pub animate: Option<Animate>,
326    /// Whether this placement blocks movement. Default `true`; set `false`
327    /// for decor a traveler should walk (and jump) through — statues,
328    /// figures, conjured motes. A curved decorative collider is how you get
329    /// bounced into the sky.
330    #[serde(default, skip_serializing_if = "Option::is_none")]
331    pub solid: Option<bool>,
332    /// This placement **emits light** — a point light at its position. Lamps,
333    /// torches, and stage lights are content, not renderer configuration; the
334    /// browser's lighting pass honors them. `None` = not a light source.
335    #[serde(default, skip_serializing_if = "Option::is_none")]
336    pub light: Option<LightEmitter>,
337    /// Arbitrary per-placement data (e.g. a product id + price for commerce).
338    #[serde(default)]
339    pub data: serde_json::Value,
340    /// Child placements, positioned **relative to this one** (the tree / DOM
341    /// model). Their transforms compose with this placement's, so you build
342    /// scenes by *containment* — a table with a lamp on it — instead of absolute
343    /// coordinates. Defaults to none (a flat world is just a forest of depth 1).
344    #[serde(default)]
345    pub children: Vec<Placement>,
346    /// Unknown fields, preserved across a round trip (see [`Extra`]).
347    #[serde(flatten, default, skip_serializing_if = "Extra::is_empty")]
348    pub extra: Extra,
349}
350
351impl Placement {
352    /// This placement and all its descendants, depth-first (parent before
353    /// children). Transform composition is the renderer's job; this is the flat
354    /// list for reference checks and iteration.
355    pub fn iter_tree(&self) -> impl Iterator<Item = &Placement> {
356        let mut stack = vec![self];
357        std::iter::from_fn(move || {
358            let p = stack.pop()?;
359            stack.extend(p.children.iter().rev());
360            Some(p)
361        })
362    }
363}
364
365/// Readable text a browser typesets onto a placement's surface (a `quad`
366/// prefab is the natural carrier). The browser owns fonts and layout — the
367/// world ships only the words, exactly like the old web shipping text and
368/// letting the browser render it.
369#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
370pub struct TextPanel {
371    pub content: String,
372    /// Approximate line height as a fraction of panel height (default 0.09 —
373    /// about ten lines to a panel).
374    #[serde(default = "default_text_size")]
375    pub size: f32,
376    /// Foreground RGB (default near-black ink).
377    #[serde(default = "default_ink")]
378    pub color: [f32; 3],
379    /// Background RGB (default warm parchment).
380    #[serde(default = "default_paper")]
381    pub background: [f32; 3],
382    /// **Hyperlinks inside the text** — the Thread's `<a>` tag. Each link
383    /// names a phrase of `content` and the Locator it leads to; browsers
384    /// render the phrase as a link (colored, underlined) and interacting with
385    /// the panel follows it — text you can walk through, exactly like the old
386    /// web's anchor.
387    #[serde(default, skip_serializing_if = "Vec::is_empty")]
388    pub links: Vec<TextLink>,
389}
390
391/// One hyperlink in a [`TextPanel`]: `text` is the phrase inside the panel's
392/// content it anchors to (first occurrence); `to` is any Locator.
393#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
394pub struct TextLink {
395    pub text: String,
396    pub to: String,
397}
398
399/// A point light emitted from a placement's position. Declarative, like
400/// everything else in a manifest: worlds say *what glows*; the browser's
401/// deferred lighting pass decides how the glow falls on surfaces.
402#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
403pub struct LightEmitter {
404    /// Light RGB (default warm lamplight).
405    #[serde(default = "default_light_color")]
406    pub color: [f32; 3],
407    /// Brightness multiplier (default 1.0).
408    #[serde(default = "one_f32")]
409    pub intensity: f32,
410    /// Reach in metres — beyond this the light is fully faded (default 10).
411    #[serde(default = "default_light_range")]
412    pub range: f32,
413}
414
415fn default_light_color() -> [f32; 3] {
416    [1.0, 0.85, 0.6]
417}
418fn default_light_range() -> f32 {
419    10.0
420}
421
422fn default_text_size() -> f32 {
423    0.09
424}
425fn default_ink() -> [f32; 3] {
426    [0.13, 0.11, 0.10]
427}
428fn default_paper() -> [f32; 3] {
429    [0.93, 0.89, 0.80]
430}
431
432/// Declarative idle motion for a placement. Kinds (v0.1): `"spin"` (rotate
433/// about the object's own Y axis), `"bob"` (rise and fall around the base
434/// position), and `"path"` (travel a looping polyline of `points`, offsets
435/// from the placement's position — a patrol, a tour, a journey retraced).
436/// `speed` scales time (metres/second for `path`); `amp` is metres of travel
437/// for `bob`. Browsers phase-shift instances so fields of movers don't march
438/// in lockstep.
439#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
440pub struct Animate {
441    pub kind: String,
442    #[serde(default = "one_f32")]
443    pub speed: f32,
444    #[serde(default = "quarter_f32")]
445    pub amp: f32,
446    /// Waypoints for `"path"`, as offsets from the placement position. The
447    /// loop closes from the last point back to the first.
448    #[serde(default, skip_serializing_if = "Vec::is_empty")]
449    pub points: Vec<[f32; 3]>,
450}
451
452fn one_f32() -> f32 {
453    1.0
454}
455fn quarter_f32() -> f32 {
456    0.25
457}
458
459/// A declarative, codeless interaction a creator attaches to a placed object.
460///
461/// The whole point is that it's trivial to author: no WASM, no scripting — you
462/// name the verb the visitor sees ("Chop", "Mine", "Pick", "Talk") and list what
463/// happens when it completes. The interact key (E) runs it.
464///
465/// ```json
466/// "interaction": {
467///   "label": "Chop",
468///   "hits": 3,
469///   "effects": [
470///     { "give_item": { "item": "20100001", "count": 3 } },
471///     "despawn",
472///     { "message": "You chopped the old oak." }
473///   ]
474/// }
475/// ```
476#[derive(Debug, Clone, Serialize, Deserialize)]
477pub struct Interaction {
478    /// Creator-defined verb shown in the "[E] …" prompt (e.g. "Chop", "Mine").
479    pub label: String,
480    /// How many interactions to complete it — a tree takes a few chops. Default 1.
481    #[serde(default = "one_u32")]
482    pub hits: u32,
483    /// What happens when it completes, applied top to bottom.
484    #[serde(default)]
485    pub effects: Vec<InteractionEffect>,
486}
487
488/// One outcome of a completed [`Interaction`]. Deliberately small + declarative;
489/// serialized snake_case (unit variants as bare strings) so manifests read clean.
490#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
491#[serde(rename_all = "snake_case")]
492pub enum InteractionEffect {
493    /// Put an item in the visitor's inventory.
494    GiveItem {
495        item: StructuredId,
496        #[serde(default = "one_u32")]
497        count: u32,
498    },
499    /// Remove the interacted object from the world.
500    Despawn,
501    /// Show the visitor a transient message.
502    Message(String),
503    /// Play a named visual/audio effect at the object (an [`Asset`] id or built-in).
504    Effect(String),
505    /// Veilwalk to a Locator — **any object can be a doorway**. This is how
506    /// text becomes teleportable: a link-stone under a paragraph carries the
507    /// linked article's address.
508    Navigate(String),
509}
510
511/// A CSS-like style rule: a **selector** + the properties it attaches to matching
512/// placements. Lets a world define behaviour by `type` / `.class` / `#id` once,
513/// instead of inline on every placement — restyle everything from one rule.
514///
515/// ```json
516/// "styles": [
517///   { "select": ".harvestable", "interaction": { "label": "Chop", "effects": ["despawn"] } }
518/// ]
519/// ```
520#[derive(Debug, Clone, Serialize, Deserialize)]
521pub struct StyleRule {
522    /// A simple selector: `"tree"` (type), `".harvestable"` (class), `"#hero"` (id).
523    pub select: String,
524    /// The declarative interaction this rule attaches. (More properties — mesh,
525    /// material — cascade here as the layer grows.)
526    #[serde(default, skip_serializing_if = "Option::is_none")]
527    pub interaction: Option<Interaction>,
528}
529
530/// A parsed single selector. Specificity mirrors CSS: id > class > type.
531enum Selector {
532    Type(String),
533    Class(String),
534    Id(String),
535}
536
537impl Selector {
538    fn parse(s: &str) -> Option<Selector> {
539        let s = s.trim();
540        if let Some(id) = s.strip_prefix('#') {
541            return (!id.is_empty()).then(|| Selector::Id(id.to_string()));
542        }
543        if let Some(c) = s.strip_prefix('.') {
544            return (!c.is_empty()).then(|| Selector::Class(c.to_string()));
545        }
546        (!s.is_empty()).then(|| Selector::Type(s.to_string()))
547    }
548    fn specificity(&self) -> u8 {
549        match self {
550            Selector::Id(_) => 2,
551            Selector::Class(_) => 1,
552            Selector::Type(_) => 0,
553        }
554    }
555    fn matches(&self, pl: &Placement) -> bool {
556        match self {
557            Selector::Type(t) => pl.kind.as_deref() == Some(t.as_str()),
558            Selector::Class(c) => pl.class.iter().any(|x| x == c),
559            Selector::Id(i) => &pl.name == i,
560        }
561    }
562}
563
564/// A veil to another world — the hyperlink of the Thread.
565#[derive(Debug, Clone, Serialize, Deserialize)]
566pub struct Portal {
567    pub id: String,
568    pub position: [f32; 3],
569    #[serde(default = "unit_quat")]
570    pub rotation: [f32; 4],
571    #[serde(default = "unit_scale")]
572    pub scale: [f32; 3],
573    /// Destination [`Locator`] (`thread://…`), possibly on another host.
574    pub to: String,
575    #[serde(default)]
576    pub label: String,
577    /// How much of the far side to show before stepping through.
578    #[serde(default)]
579    pub preview: PreviewPolicy,
580    /// Unknown fields, preserved across a round trip (see [`Extra`]).
581    #[serde(flatten, default, skip_serializing_if = "Extra::is_empty")]
582    pub extra: Extra,
583}
584
585/// Portal preview fidelity.
586#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
587#[serde(rename_all = "lowercase")]
588pub enum PreviewPolicy {
589    /// No preview — an opaque doorway.
590    None,
591    /// A still image of the destination.
592    #[default]
593    Static,
594    /// A live window into the destination (crowd, weather, time-of-day).
595    Live,
596}
597
598/// A sandboxed WASM behavior module the world runs.
599#[derive(Debug, Clone, Serialize, Deserialize)]
600pub struct Behavior {
601    pub id: String,
602    /// The id of a WASM [`Asset`] (the polyglot floor — Behavior ABI v0.1).
603    #[serde(default, skip_serializing_if = "String::is_empty")]
604    pub wasm: String,
605    /// The id of a **Weft** [`Asset`] — the Thread's native code (weft-v0.1):
606    /// a content-addressed, verified module. Exactly one of `wasm`/`weft`.
607    #[serde(default, skip_serializing_if = "Option::is_none")]
608    pub weft: Option<String>,
609    /// The id of a **Weft package** [`Asset`] (`.weftpack.json`, weft-pack-v0.1)
610    /// — bind a published package's export as this behavior. Use with
611    /// [`weft_export`](Self::weft_export); the browser fetches, verifies, and
612    /// links locally. The markup form is `weft-use="<uri>#<export>"`.
613    #[serde(default, skip_serializing_if = "Option::is_none")]
614    pub weft_pack: Option<String>,
615    /// The package export (petname) that is this behavior's entry.
616    #[serde(default, skip_serializing_if = "Option::is_none")]
617    pub weft_export: Option<String>,
618    /// Event names this module handles (e.g. `interact`, `tick`).
619    #[serde(default)]
620    pub on: Vec<String>,
621}
622
623/// Presence policy — the (optional) upgrade from a solo world to a shared one.
624///
625/// Presence on the Thread is **federated**: a world names its *own* relay(s), so
626/// there is no global chokepoint. Prefer [`relays`](Presence::relays) — an ordered
627/// list of interchangeable, conformant relays (primary first, then fallbacks) — so
628/// no single relay URL is a point of failure. The singular [`relay`](Presence::relay)
629/// is retained for backward compatibility; [`relay_list`](Presence::relay_list)
630/// returns the effective, de-duplicated order to try.
631#[derive(Debug, Clone, Default, Serialize, Deserialize)]
632pub struct Presence {
633    /// Presence tier: `"solo"`, `"p2p"`, or `"relay"` (see presence-topology-v0.1).
634    /// Absent → inferred from whether any relay is named. Forward-looking; a browser
635    /// that only implements the relay tier treats `"p2p"` as a hint it may ignore.
636    ///
637    /// **Declaration, not fact.** The address fields decide what a browser can
638    /// actually do; `mode` states what the author meant, and MUST agree. When
639    /// they disagree the addresses win — see [`Presence::effective_mode`].
640    #[serde(default, skip_serializing_if = "Option::is_none")]
641    pub mode: Option<String>,
642    /// A single presence relay (legacy / old-browser compatibility). Absent → solo.
643    #[serde(default, skip_serializing_if = "Option::is_none")]
644    pub relay: Option<String>,
645    /// Interchangeable relays to try in order (primary first, then fallbacks). Any
646    /// conformant relay works; if one is unreachable the browser tries the next.
647    #[serde(default, skip_serializing_if = "Vec::is_empty")]
648    pub relays: Vec<String>,
649    /// Tier-1 P2P rendezvous URL (`wss://…/rtc/<key>`), meaningful with
650    /// `mode: "p2p"`. A **different protocol** from a relay (mesh `Signal`
651    /// introduction, never poses), so it is a distinct field — `relays` keeps its
652    /// ordered-failover meaning unconditionally and MAY be present alongside as the
653    /// Tier-2 fallback when the mesh can't form (the hybrid story).
654    #[serde(default, skip_serializing_if = "Option::is_none")]
655    pub rendezvous: Option<String>,
656    /// Omitted when unset — `null` here reads as "someone thought about the
657    /// cap and chose nothing", which is not what it means.
658    #[serde(default, skip_serializing_if = "Option::is_none")]
659    pub max_occupants: Option<u32>,
660    /// Always emitted: `false` is a decision about this room, not a gap.
661    #[serde(default)]
662    pub voice: bool,
663    /// The room lives only while its owner is present (a traveler's home with
664    /// guests over). When such a room closes, browsers SHOULD walk guests back
665    /// to their own home rather than leave them in a dead copy of the world.
666    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
667    pub owner_required: bool,
668    /// Unknown fields, preserved across a round trip (see [`Extra`]).
669    #[serde(flatten, default, skip_serializing_if = "Extra::is_empty")]
670    pub extra: Extra,
671}
672
673/// [`Presence::effective_mode`] for callers holding raw JSON.
674///
675/// Deserializing a whole world just to ask one question is the wrong trade for
676/// a tool that will write the file back: a round trip through any *older*
677/// struct than the emitter is a lossy edit of somebody's world. (Ours no longer
678/// is — see [`Extra`] — but a consumer pinned to an earlier release has no way
679/// to know that, and shouldn't have to.) So the rule is available without the
680/// type: pass the `presence` value, or `Value::Null` if the key is absent.
681pub fn effective_mode_of(presence: &serde_json::Value) -> &'static str {
682    let named = |v: &serde_json::Value| match v {
683        serde_json::Value::Array(a) => {
684            a.iter().any(|r| r.as_str().is_some_and(|s| !s.trim().is_empty()))
685        }
686        serde_json::Value::String(s) => !s.trim().is_empty(),
687        _ => false,
688    };
689    if named(&presence["relays"]) || named(&presence["relay"]) {
690        "relay"
691    } else if named(&presence["rendezvous"]) {
692        "p2p"
693    } else {
694        "solo"
695    }
696}
697
698impl Presence {
699    /// What this world can actually do, as opposed to what it says.
700    ///
701    /// `mode` is a declaration; the address fields are the facts, and a
702    /// declaration that contradicts them is not a second source of truth. A
703    /// world claiming `mode: "relay"` with no relay named cannot host anyone,
704    /// and one naming a relay is not solo however it is labelled. An **empty**
705    /// `relays: []` is the absence of a relay, never a considered choice of
706    /// none — a consumer deciding whether to supply one should read this rather
707    /// than test for a key, because which keys are present is an emitter's
708    /// business and this is not.
709    pub fn effective_mode(&self) -> &'static str {
710        if !self.relay_list().is_empty() {
711            "relay"
712        } else if self.rendezvous.is_some() {
713            "p2p"
714        } else {
715            "solo"
716        }
717    }
718
719    /// Whether `mode` claims something the addresses don't support — advisory,
720    /// for linters and conformance rather than for the render path.
721    pub fn mode_disagrees(&self) -> bool {
722        self.mode
723            .as_deref()
724            .is_some_and(|m| m != self.effective_mode())
725    }
726
727    /// The effective, ordered, de-duplicated list of relays to try: `relays` first,
728    /// then the legacy singular `relay` if it isn't already present. Empty → solo.
729    pub fn relay_list(&self) -> Vec<String> {
730        // A blank entry is not a relay you can reach, so it is not a relay —
731        // the same reading that makes an empty list an absence rather than a
732        // choice. Without this, `relays: [""]` counted as a hosted world and
733        // the browser tried to open a socket to nowhere.
734        let mut out: Vec<String> =
735            self.relays.iter().filter(|r| !r.trim().is_empty()).cloned().collect();
736        if let Some(r) = self.relay.as_ref().filter(|r| !r.trim().is_empty()) {
737            if !out.contains(r) {
738                out.push(r.clone());
739            }
740        }
741        out
742    }
743}
744
745// --- serde defaults ---
746fn white() -> [f32; 4] {
747    [1.0, 1.0, 1.0, 1.0]
748}
749fn one() -> f32 {
750    1.0
751}
752fn one_u32() -> u32 {
753    1
754}
755fn unit_quat() -> [f32; 4] {
756    [0.0, 0.0, 0.0, 1.0]
757}
758fn unit_scale() -> [f32; 3] {
759    [1.0, 1.0, 1.0]
760}
761
762/// An error parsing or validating a manifest.
763#[derive(Debug)]
764pub enum ManifestError {
765    Json(serde_json::Error),
766    /// One or more validation problems (each a human-readable message).
767    Invalid(Vec<String>),
768}
769
770impl std::fmt::Display for ManifestError {
771    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
772        match self {
773            ManifestError::Json(e) => write!(f, "manifest JSON error: {e}"),
774            ManifestError::Invalid(errs) => write!(f, "invalid manifest: {}", errs.join("; ")),
775        }
776    }
777}
778impl std::error::Error for ManifestError {}
779
780impl WorldManifest {
781    /// Parse and validate a manifest from JSON text.
782    pub fn from_json(text: &str) -> Result<Self, ManifestError> {
783        let m: WorldManifest = serde_json::from_str(text).map_err(ManifestError::Json)?;
784        m.validate()?;
785        Ok(m)
786    }
787
788    /// Parse and validate a manifest from **either** source form: JSON (the
789    /// "DOM") or Thread markup (the "HTML" — see [`markup`]). Auto-detected by
790    /// the first non-whitespace byte, so browsers, resolvers, and tools accept
791    /// a served `.thread` file exactly as they accept a `world.json`.
792    pub fn from_text(text: &str) -> Result<Self, ManifestError> {
793        if text.trim_start().starts_with('{') {
794            Self::from_json(text)
795        } else {
796            markup::compile(text).map_err(|e| ManifestError::Invalid(vec![e]))
797        }
798    }
799
800    /// Serialize to pretty JSON.
801    pub fn to_json(&self) -> String {
802        serde_json::to_string_pretty(self).unwrap_or_default()
803    }
804
805    /// The default arrival point (first spawn, or the origin).
806    pub fn default_spawn(&self) -> Spawn {
807        self.spawns.first().cloned().unwrap_or(Spawn {
808            name: String::new(),
809            position: [0.0, 0.0, 0.0],
810            yaw: 0.0,
811        })
812    }
813
814    /// Resolve a `#place` anchor to a spawn or a portal position.
815    pub fn anchor(&self, name: &str) -> Option<[f32; 3]> {
816        if let Some(s) = self.spawns.iter().find(|s| s.name == name) {
817            return Some(s.position);
818        }
819        self.portals
820            .iter()
821            .find(|p| p.id == name)
822            .map(|p| p.position)
823    }
824
825    /// The **effective** interaction for a placement after the `styles` cascade:
826    /// an inline `placement.interaction` always wins (like an inline `style=`);
827    /// otherwise the highest-specificity matching rule applies, with later rules
828    /// breaking ties (CSS source order). Returns `None` if nothing matches.
829    pub fn computed_interaction(&self, pl: &Placement) -> Option<Interaction> {
830        if pl.interaction.is_some() {
831            return pl.interaction.clone();
832        }
833        let mut best: Option<(u8, &Interaction)> = None;
834        for rule in &self.styles {
835            let (Some(sel), Some(inter)) = (Selector::parse(&rule.select), &rule.interaction)
836            else {
837                continue;
838            };
839            if sel.matches(pl) && best.is_none_or(|(s, _)| sel.specificity() >= s) {
840                best = Some((sel.specificity(), inter));
841            }
842        }
843        best.map(|(_, i)| i.clone())
844    }
845
846    /// Validate structural integrity. A **conformant** manifest passes this: the
847    /// version is recognized, every reference resolves, and portals address the
848    /// Thread. Returns all problems at once.
849    pub fn validate(&self) -> Result<(), ManifestError> {
850        let mut errs = Vec::new();
851
852        if !self.thread.starts_with("thread/") {
853            errs.push(format!(
854                "unknown format tag '{}' (expected 'thread/…')",
855                self.thread
856            ));
857        }
858        if self.world.id.trim().is_empty() {
859            errs.push("world.id must not be empty".into());
860        }
861
862        let asset_ids: std::collections::HashSet<&str> =
863            self.assets.iter().map(|a| a.id.as_str()).collect();
864        let prefab_ids: std::collections::HashSet<StructuredId> =
865            self.prefabs.iter().map(|p| p.id).collect();
866        let behavior_ids: std::collections::HashSet<&str> =
867            self.behaviors.iter().map(|b| b.id.as_str()).collect();
868
869        for p in &self.prefabs {
870            let sources = [
871                p.mesh.asset.is_some(),
872                p.mesh.builtin.is_some(),
873                p.mesh.shape.is_some(),
874            ];
875            match sources.iter().filter(|s| **s).count() {
876                0 => errs.push(format!(
877                    "prefab {} has no mesh source (asset, builtin, or shape)",
878                    p.id
879                )),
880                1 => {
881                    if let Some(a) = &p.mesh.asset {
882                        if !asset_ids.contains(a.as_str()) {
883                            errs.push(format!("prefab {} references unknown asset '{a}'", p.id));
884                        }
885                    }
886                    if let Some(shape) = &p.mesh.shape {
887                        if let Err(e) = shape.validate() {
888                            errs.push(format!("prefab {} shape: {e}", p.id));
889                        }
890                    }
891                }
892                _ => errs.push(format!(
893                    "prefab {} sets more than one mesh source (asset/builtin/shape)",
894                    p.id
895                )),
896            }
897        }
898
899        // Every placement — including nested children (the tree/DOM model).
900        for (i, pl) in self
901            .placements
902            .iter()
903            .flat_map(|p| p.iter_tree())
904            .enumerate()
905        {
906            if !prefab_ids.contains(&pl.prefab) {
907                errs.push(format!(
908                    "placement[{i}] references unknown prefab {}",
909                    pl.prefab
910                ));
911            }
912            if let Some(b) = &pl.behavior {
913                if !behavior_ids.contains(b.as_str()) {
914                    errs.push(format!("placement[{i}] references unknown behavior '{b}'"));
915                }
916            }
917            if let Some(inter) = &pl.interaction {
918                if inter.label.trim().is_empty() {
919                    errs.push(format!("placement[{i}] interaction has an empty label"));
920                }
921                if inter.hits == 0 {
922                    errs.push(format!(
923                        "placement[{i}] interaction.hits must be at least 1"
924                    ));
925                }
926            }
927            if let Some(a) = &pl.animate {
928                if !matches!(a.kind.as_str(), "spin" | "bob" | "path") {
929                    errs.push(format!(
930                        "placement[{i}] unknown animate kind '{}' (spin|bob|path)",
931                        a.kind
932                    ));
933                }
934                if a.kind == "path" && a.points.len() < 2 {
935                    errs.push(format!(
936                        "placement[{i}] animate path needs at least 2 points"
937                    ));
938                }
939            }
940        }
941
942        for (i, rule) in self.styles.iter().enumerate() {
943            if Selector::parse(&rule.select).is_none() {
944                errs.push(format!(
945                    "styles[{i}] has an empty/invalid selector '{}'",
946                    rule.select
947                ));
948            }
949            if let Some(inter) = &rule.interaction {
950                if inter.label.trim().is_empty() {
951                    errs.push(format!("styles[{i}] interaction has an empty label"));
952                }
953                if inter.hits == 0 {
954                    errs.push(format!("styles[{i}] interaction.hits must be at least 1"));
955                }
956            }
957        }
958
959        for b in &self.behaviors {
960            // Exactly one code source: a wasm asset, a weft module asset, or
961            // a weft package export (`weft_pack` + `weft_export` together).
962            let has_pack = b.weft_pack.is_some();
963            let sources = usize::from(!b.wasm.is_empty())
964                + usize::from(b.weft.is_some())
965                + usize::from(has_pack);
966            match sources {
967                0 => errs.push(format!(
968                    "behavior '{}' names no code (wasm, weft, or weft_pack+weft_export)",
969                    b.id
970                )),
971                1 => {}
972                _ => errs.push(format!(
973                    "behavior '{}' names multiple code sources (pick one)",
974                    b.id
975                )),
976            }
977            if has_pack != b.weft_export.is_some() {
978                errs.push(format!(
979                    "behavior '{}': weft_pack and weft_export go together",
980                    b.id
981                ));
982            }
983            if let Some(w) = &b.weft {
984                if !asset_ids.contains(w.as_str()) {
985                    errs.push(format!(
986                        "behavior '{}' references unknown weft asset '{w}'",
987                        b.id
988                    ));
989                }
990            }
991            if let Some(p) = &b.weft_pack {
992                if !asset_ids.contains(p.as_str()) {
993                    errs.push(format!(
994                        "behavior '{}' references unknown weft package asset '{p}'",
995                        b.id
996                    ));
997                }
998            }
999            if !b.wasm.is_empty() && !asset_ids.contains(b.wasm.as_str()) {
1000                errs.push(format!(
1001                    "behavior '{}' references unknown wasm asset '{}'",
1002                    b.id, b.wasm
1003                ));
1004            }
1005        }
1006
1007        for p in &self.portals {
1008            if Locator::parse(&p.to).is_none() {
1009                errs.push(format!(
1010                    "portal '{}' has an invalid destination Locator '{}'",
1011                    p.id, p.to
1012                ));
1013            }
1014        }
1015
1016        if errs.is_empty() {
1017            Ok(())
1018        } else {
1019            Err(ManifestError::Invalid(errs))
1020        }
1021    }
1022}
1023
1024#[cfg(test)]
1025mod tests {
1026    use super::*;
1027
1028    /// The two shipped example worlds must parse and validate — they are the
1029    /// conformance fixtures (the "two linked pages" of the Thread).
1030    fn load(rel: &str) -> Result<WorldManifest, ManifestError> {
1031        let path = format!("{}/../../worlds/{rel}", env!("CARGO_MANIFEST_DIR"));
1032        let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path}: {e}"));
1033        WorldManifest::from_json(&text)
1034    }
1035
1036    #[test]
1037    fn example_archive_is_conformant() {
1038        let m = load("codex-archive/world.json").unwrap();
1039        assert_eq!(m.world.id, "codex-archive");
1040        assert!(
1041            m.placements.iter().any(|p| p.codex.is_some()),
1042            "archive links Codex entries"
1043        );
1044        assert!(!m.portals.is_empty(), "archive links onward");
1045    }
1046
1047    #[test]
1048    fn example_market_is_conformant_and_links_back() {
1049        let m = load("market/world.json").unwrap();
1050        assert_eq!(m.world.id, "market");
1051        // A product placement carries commerce data.
1052        assert!(m.placements.iter().any(|p| p.data.get("price").is_some()));
1053        // It portals back to the archive — the two worlds form a linked web.
1054        let back = m.portals.iter().find_map(|p| Locator::parse(&p.to));
1055        assert_eq!(back.map(|l| l.path), Some("codex-archive".to_string()));
1056    }
1057
1058    #[test]
1059    fn world_rules_default_off_and_round_trip() {
1060        // Absent → a browser, not a game: every rule off.
1061        let m: WorldManifest =
1062            serde_json::from_str(r#"{"thread":"thread/0.1","world":{"id":"w","title":"W"}}"#)
1063                .unwrap();
1064        assert_eq!(m.environment.rules, WorldRules::default());
1065        assert!(!m.environment.rules.survival && !m.environment.rules.combat);
1066
1067        let declared = WorldRules {
1068            survival: true,
1069            gathering: true,
1070            combat: false,
1071        };
1072        let round: WorldRules =
1073            serde_json::from_str(&serde_json::to_string(&declared).unwrap()).unwrap();
1074        assert_eq!(round, declared);
1075    }
1076
1077    #[test]
1078    fn declarative_interaction_reads_clean() {
1079        let json = r#"{
1080            "label": "Chop",
1081            "hits": 3,
1082            "effects": [
1083                { "give_item": { "item": "20100001", "count": 3 } },
1084                "despawn",
1085                { "message": "You chopped the old oak." },
1086                { "effect": "leaves" }
1087            ]
1088        }"#;
1089        let i: Interaction = serde_json::from_str(json).unwrap();
1090        assert_eq!(i.label, "Chop");
1091        assert_eq!(i.hits, 3);
1092        assert_eq!(i.effects.len(), 4);
1093        assert!(matches!(i.effects[1], InteractionEffect::Despawn));
1094        assert!(
1095            matches!(&i.effects[2], InteractionEffect::Message(m) if m == "You chopped the old oak.")
1096        );
1097
1098        // Minimal authoring: just a label. hits defaults to 1, no effects.
1099        let d: Interaction = serde_json::from_str(r#"{"label":"Pick"}"#).unwrap();
1100        assert_eq!(d.hits, 1);
1101        assert!(d.effects.is_empty());
1102
1103        // Round-trips through JSON unchanged.
1104        let back: Interaction = serde_json::from_str(&serde_json::to_string(&i).unwrap()).unwrap();
1105        assert_eq!(back.effects, i.effects);
1106    }
1107
1108    #[test]
1109    fn interaction_validation_rejects_empty_label() {
1110        let json = r#"{
1111            "thread":"thread/0.1",
1112            "world":{"id":"w","title":"W"},
1113            "prefabs":[{"id":"20100001","mesh":{"builtin":"cube"}}],
1114            "placements":[{"prefab":"20100001","position":[0,0,0],
1115                "interaction":{"label":"  "}}]
1116        }"#;
1117        let msg = WorldManifest::from_json(json).unwrap_err().to_string();
1118        assert!(
1119            msg.contains("empty label"),
1120            "expected empty-label error, got: {msg}"
1121        );
1122    }
1123
1124    #[test]
1125    fn nested_placements_iterate_and_validate() {
1126        // A table with a lamp on it — the child is authored relative to it.
1127        let m = WorldManifest::from_json(
1128            r#"{
1129                "thread":"thread/0.1","world":{"id":"w","title":"W"},
1130                "prefabs":[{"id":"20100001","mesh":{"builtin":"cube"}}],
1131                "placements":[
1132                    {"prefab":"20100001","position":[0,0,0],"children":[
1133                        {"prefab":"20100001","position":[0,1,0]}
1134                    ]}
1135                ]
1136            }"#,
1137        )
1138        .unwrap();
1139        // iter_tree flattens parent + child.
1140        let all: Vec<_> = m.placements.iter().flat_map(|p| p.iter_tree()).collect();
1141        assert_eq!(all.len(), 2);
1142
1143        // Validation recurses: a nested unknown prefab is caught.
1144        let err = WorldManifest::from_json(
1145            r#"{
1146                "thread":"thread/0.1","world":{"id":"w","title":"W"},
1147                "prefabs":[{"id":"20100001","mesh":{"builtin":"cube"}}],
1148                "placements":[
1149                    {"prefab":"20100001","position":[0,0,0],"children":[
1150                        {"prefab":"99999999","position":[0,1,0]}
1151                    ]}
1152                ]
1153            }"#,
1154        )
1155        .unwrap_err()
1156        .to_string();
1157        assert!(
1158            err.contains("unknown prefab"),
1159            "nested ref must validate: {err}"
1160        );
1161    }
1162
1163    #[test]
1164    fn styles_cascade_by_type_class_id_with_specificity() {
1165        let m = WorldManifest::from_json(
1166            r##"{
1167                "thread":"thread/0.1","world":{"id":"w","title":"W"},
1168                "prefabs":[{"id":"20100001","mesh":{"builtin":"cube"}}],
1169                "styles":[
1170                    { "select":"tree",         "interaction": {"label":"Type"} },
1171                    { "select":".harvestable", "interaction": {"label":"Class"} },
1172                    { "select":"#hero",        "interaction": {"label":"Id"} }
1173                ],
1174                "placements":[
1175                    {"prefab":"20100001","type":"tree","class":["harvestable"],"name":"hero","position":[0,0,0]},
1176                    {"prefab":"20100001","type":"tree","position":[1,0,0]},
1177                    {"prefab":"20100001","type":"rock","position":[2,0,0],"interaction":{"label":"Inline"}}
1178                ]
1179            }"##,
1180        )
1181        .unwrap();
1182        let p = &m.placements;
1183        // All three rules match p[0]; id (#hero) wins on specificity.
1184        assert_eq!(m.computed_interaction(&p[0]).unwrap().label, "Id");
1185        // Only the type rule matches p[1].
1186        assert_eq!(m.computed_interaction(&p[1]).unwrap().label, "Type");
1187        // Inline interaction always wins (like inline style=).
1188        assert_eq!(m.computed_interaction(&p[2]).unwrap().label, "Inline");
1189    }
1190
1191    /// Every world the main agent hosts (committed under `worlds/`) must parse
1192    /// and validate through this reference implementation — the cross-check that
1193    /// the server's generator and the browser's format agree.
1194    #[test]
1195    fn all_hosted_world_fixtures_are_conformant() {
1196        let dir = format!("{}/../../worlds", env!("CARGO_MANIFEST_DIR"));
1197        let mut n = 0;
1198        for entry in std::fs::read_dir(&dir).unwrap().flatten() {
1199            let wj = entry.path().join("world.json");
1200            if wj.exists() {
1201                let text = std::fs::read_to_string(&wj).unwrap();
1202                WorldManifest::from_json(&text).unwrap_or_else(|e| panic!("{}: {e}", wj.display()));
1203                n += 1;
1204            }
1205        }
1206        assert!(
1207            n >= 8,
1208            "expected the hosted constellation fixtures, found {n}"
1209        );
1210    }
1211
1212    #[test]
1213    fn rejects_dangling_prefab_reference() {
1214        let bad = r#"{
1215            "thread": "thread/0.1",
1216            "world": { "id": "x", "title": "X" },
1217            "placements": [{ "prefab": "60000001", "position": [0,0,0] }]
1218        }"#;
1219        match WorldManifest::from_json(bad) {
1220            Err(ManifestError::Invalid(errs)) => {
1221                assert!(errs.iter().any(|e| e.contains("unknown prefab")))
1222            }
1223            other => panic!("expected Invalid, got {other:?}"),
1224        }
1225    }
1226
1227    #[test]
1228    fn roundtrips_json() {
1229        let m = load("market/world.json").unwrap();
1230        let back = WorldManifest::from_json(&m.to_json()).unwrap();
1231        assert_eq!(back.world.id, m.world.id);
1232        assert_eq!(back.placements.len(), m.placements.len());
1233    }
1234
1235    #[test]
1236    fn presence_relay_list_prefers_relays_and_stays_backward_compatible() {
1237        // Legacy world: only the singular `relay` → a one-element list.
1238        let legacy: Presence = serde_json::from_str(r#"{ "relay": "wss://a/thread/w" }"#).unwrap();
1239        assert_eq!(legacy.relay_list(), ["wss://a/thread/w"]);
1240
1241        // New world: a `relays` list with fallbacks.
1242        let modern: Presence =
1243            serde_json::from_str(r#"{ "relays": ["wss://a/thread/w", "wss://b/thread/w"] }"#)
1244                .unwrap();
1245        assert_eq!(
1246            modern.relay_list(),
1247            ["wss://a/thread/w", "wss://b/thread/w"]
1248        );
1249
1250        // Both set (a world serving old + new browsers): merged, de-duplicated.
1251        let both: Presence = serde_json::from_str(
1252            r#"{ "relay": "wss://a/thread/w", "relays": ["wss://a/thread/w", "wss://b/thread/w"] }"#,
1253        )
1254        .unwrap();
1255        assert_eq!(both.relay_list(), ["wss://a/thread/w", "wss://b/thread/w"]);
1256
1257        // Neither → solo.
1258        let solo = Presence::default();
1259        assert!(solo.relay_list().is_empty());
1260    }
1261
1262    #[test]
1263    fn presence_p2p_declares_rendezvous_with_optional_relay_fallback() {
1264        // A P2P world names its rendezvous; relays[] may ride along as the Tier-2
1265        // fallback when the mesh can't form.
1266        let p2p: Presence = serde_json::from_str(
1267            r#"{ "mode": "p2p", "rendezvous": "wss://rv.example/rtc/example.com/w",
1268                 "relays": ["wss://a.example/thread/example.com/w"] }"#,
1269        )
1270        .unwrap();
1271        assert_eq!(p2p.mode.as_deref(), Some("p2p"));
1272        assert_eq!(
1273            p2p.rendezvous.as_deref(),
1274            Some("wss://rv.example/rtc/example.com/w")
1275        );
1276        assert_eq!(p2p.relay_list(), ["wss://a.example/thread/example.com/w"]);
1277
1278        // Absent stays absent — and doesn't serialize (old browsers see nothing new).
1279        let relay_only: Presence =
1280            serde_json::from_str(r#"{ "relays": ["wss://a/thread/w"] }"#).unwrap();
1281        assert!(relay_only.rendezvous.is_none());
1282        assert!(!serde_json::to_string(&relay_only)
1283            .unwrap()
1284            .contains("rendezvous"));
1285    }
1286
1287    /// The four cases a consumer actually faces when deciding whether to hand a
1288    /// world a relay. Written down because the obvious test — "is `presence`
1289    /// present?" — passes a world that says `{"mode":"solo"}` and so ships a
1290    /// room designed for people in silence. Key presence is an emitter's
1291    /// business; whether a relay is *named* is the world's.
1292    #[test]
1293    fn what_a_world_can_do_beats_what_it_says() {
1294        let p = |json: &str| serde_json::from_str::<Presence>(json).expect(json);
1295
1296        assert_eq!(p(r#"{}"#).effective_mode(), "solo");
1297        assert_eq!(p(r#"{ "mode": "solo" }"#).effective_mode(), "solo");
1298        // An empty list is the absence of a relay, not a considered choice.
1299        assert_eq!(p(r#"{ "relays": [] }"#).effective_mode(), "solo");
1300        // Nor is a blank string a relay — you cannot open a socket to "".
1301        assert_eq!(p(r#"{ "relays": ["", "  "] }"#).effective_mode(), "solo");
1302        assert_eq!(p(r#"{ "relays": ["wss://a"] }"#).effective_mode(), "relay");
1303        // The legacy singular still counts as naming one.
1304        assert_eq!(p(r#"{ "relay": "wss://a" }"#).effective_mode(), "relay");
1305        assert_eq!(
1306            p(r#"{ "rendezvous": "wss://r/rtc/k" }"#).effective_mode(),
1307            "p2p"
1308        );
1309        // A relay outranks a rendezvous: it is the tier a browser must implement.
1310        assert_eq!(
1311            p(r#"{ "rendezvous": "wss://r/rtc/k", "relays": ["wss://a"] }"#).effective_mode(),
1312            "relay"
1313        );
1314
1315        // A declaration that contradicts the addresses loses, and says so.
1316        let lying = p(r#"{ "mode": "relay" }"#);
1317        assert_eq!(lying.effective_mode(), "solo", "it cannot host anyone");
1318        assert!(lying.mode_disagrees());
1319        let mislabelled = p(r#"{ "mode": "solo", "relays": ["wss://a"] }"#);
1320        assert_eq!(mislabelled.effective_mode(), "relay");
1321        assert!(mislabelled.mode_disagrees());
1322        assert!(!p(r#"{ "mode": "relay", "relays": ["wss://a"] }"#).mode_disagrees());
1323        assert!(
1324            !p(r#"{}"#).mode_disagrees(),
1325            "no claim is not a false claim"
1326        );
1327    }
1328
1329    /// A world manifest is **edited** by tools, not only read by browsers, and
1330    /// an editor that drops what it doesn't understand deletes the future. The
1331    /// browser rule ("ignore unknown fields") is safe because a browser throws
1332    /// its parse away; a tool that reads, changes one thing and writes back
1333    /// must carry the rest through untouched — including fields added by a
1334    /// newer emitter than itself.
1335    #[test]
1336    fn a_round_trip_keeps_what_it_does_not_understand() {
1337        let raw = r#"{
1338            "thread": "thread/0.1",
1339            "world": { "id": "w", "title": "W", "weather": "rain" },
1340            "spawns": [{ "name": "e", "position": [0,0,0] }],
1341            "presence": { "relays": ["wss://a"], "tempo": 90 },
1342            "soundtrack": { "uri": "https://x/y.ogg", "loop": true }
1343        }"#;
1344        let m: WorldManifest = serde_json::from_str(raw).expect("parses");
1345        let back = serde_json::to_value(&m).expect("serializes");
1346
1347        assert_eq!(
1348            back["soundtrack"]["uri"], "https://x/y.ogg",
1349            "a top-level stranger survives"
1350        );
1351        assert_eq!(
1352            back["world"]["weather"], "rain",
1353            "so does one inside `world`"
1354        );
1355        assert_eq!(back["presence"]["tempo"], 90, "and one inside `presence`");
1356        // …and the fields it does understand are unharmed.
1357        assert_eq!(back["world"]["id"], "w");
1358        assert_eq!(back["presence"]["relays"][0], "wss://a");
1359    }
1360
1361    /// The raw-JSON form must answer identically to the typed one, including
1362    /// for the shapes a hand-written world actually contains — an absent key,
1363    /// a null, an empty list, the legacy singular.
1364    #[test]
1365    fn the_json_form_of_the_presence_rule_agrees_with_the_typed_one() {
1366        for raw in [
1367            "null",
1368            "{}",
1369            r#"{ "mode": "solo" }"#,
1370            r#"{ "relays": [] }"#,
1371            r#"{ "relays": [""] }"#,
1372            r#"{ "relays": ["wss://a"] }"#,
1373            r#"{ "relay": "wss://a" }"#,
1374            r#"{ "mode": "relay" }"#,
1375            r#"{ "rendezvous": "wss://r/rtc/k" }"#,
1376            r#"{ "rendezvous": "wss://r/rtc/k", "relays": ["wss://a"] }"#,
1377            r#"{ "mode": "solo", "relays": ["wss://a"] }"#,
1378        ] {
1379            let value: serde_json::Value = serde_json::from_str(raw).expect(raw);
1380            let typed: Presence = serde_json::from_str(raw).unwrap_or_default();
1381            assert_eq!(
1382                effective_mode_of(&value),
1383                typed.effective_mode(),
1384                "the two forms disagree about {raw}"
1385            );
1386        }
1387    }
1388}