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