mod locator;
pub use locator::{well_known_url, Locator, SCHEME};
use thread_id::StructuredId;
use serde::{Deserialize, Serialize};
pub const THREAD_VERSION: &str = "thread/0.1";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorldManifest {
pub thread: String,
pub world: WorldMeta,
#[serde(default)]
pub environment: Environment,
#[serde(default)]
pub spawns: Vec<Spawn>,
#[serde(default)]
pub assets: Vec<Asset>,
#[serde(default)]
pub prefabs: Vec<Prefab>,
#[serde(default)]
pub placements: Vec<Placement>,
#[serde(default)]
pub portals: Vec<Portal>,
#[serde(default)]
pub behaviors: Vec<Behavior>,
#[serde(default)]
pub presence: Option<Presence>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorldMeta {
pub id: String,
pub title: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub author: Option<Identity>,
#[serde(default)]
pub codex: Vec<String>,
#[serde(default)]
pub license: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Identity {
pub id: String,
#[serde(default)]
pub name: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Environment {
#[serde(default)]
pub year: Option<i64>,
#[serde(default)]
pub sky: Option<Sky>,
#[serde(default)]
pub bounds: Option<Bounds>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Sky {
pub zenith: [f32; 3],
pub horizon: [f32; 3],
#[serde(default)]
pub sun_dir: Option<[f32; 3]>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Bounds {
pub min: [f32; 3],
pub max: [f32; 3],
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Spawn {
#[serde(default)]
pub name: String,
pub position: [f32; 3],
#[serde(default)]
pub yaw: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Asset {
pub id: String,
pub uri: String,
pub kind: AssetKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AssetKind {
Gltf,
Texture,
Wasm,
Audio,
Other,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Prefab {
pub id: StructuredId,
pub mesh: MeshRef,
#[serde(default)]
pub material: Option<MaterialRef>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MeshRef {
#[serde(default)]
pub asset: Option<String>,
#[serde(default)]
pub builtin: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MaterialRef {
#[serde(default)]
pub base_color_texture: Option<String>,
#[serde(default)]
pub orm_texture: Option<String>,
#[serde(default)]
pub normal_texture: Option<String>,
#[serde(default = "white")]
pub base_color: [f32; 4],
#[serde(default)]
pub metallic: f32,
#[serde(default = "one")]
pub roughness: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Placement {
pub prefab: StructuredId,
#[serde(default)]
pub name: String,
pub position: [f32; 3],
#[serde(default = "unit_quat")]
pub rotation: [f32; 4],
#[serde(default = "unit_scale")]
pub scale: [f32; 3],
#[serde(default)]
pub codex: Option<String>,
#[serde(default)]
pub behavior: Option<String>,
#[serde(default)]
pub data: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Portal {
pub id: String,
pub position: [f32; 3],
#[serde(default = "unit_quat")]
pub rotation: [f32; 4],
#[serde(default = "unit_scale")]
pub scale: [f32; 3],
pub to: String,
#[serde(default)]
pub label: String,
#[serde(default)]
pub preview: PreviewPolicy,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PreviewPolicy {
None,
#[default]
Static,
Live,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Behavior {
pub id: String,
pub wasm: String,
#[serde(default)]
pub on: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Presence {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub relay: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub relays: Vec<String>,
#[serde(default)]
pub max_occupants: Option<u32>,
#[serde(default)]
pub voice: bool,
}
impl Presence {
pub fn relay_list(&self) -> Vec<String> {
let mut out = self.relays.clone();
if let Some(r) = &self.relay {
if !out.contains(r) {
out.push(r.clone());
}
}
out
}
}
fn white() -> [f32; 4] { [1.0, 1.0, 1.0, 1.0] }
fn one() -> f32 { 1.0 }
fn unit_quat() -> [f32; 4] { [0.0, 0.0, 0.0, 1.0] }
fn unit_scale() -> [f32; 3] { [1.0, 1.0, 1.0] }
#[derive(Debug)]
pub enum ManifestError {
Json(serde_json::Error),
Invalid(Vec<String>),
}
impl std::fmt::Display for ManifestError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ManifestError::Json(e) => write!(f, "manifest JSON error: {e}"),
ManifestError::Invalid(errs) => write!(f, "invalid manifest: {}", errs.join("; ")),
}
}
}
impl std::error::Error for ManifestError {}
impl WorldManifest {
pub fn from_json(text: &str) -> Result<Self, ManifestError> {
let m: WorldManifest = serde_json::from_str(text).map_err(ManifestError::Json)?;
m.validate()?;
Ok(m)
}
pub fn to_json(&self) -> String {
serde_json::to_string_pretty(self).unwrap_or_default()
}
pub fn default_spawn(&self) -> Spawn {
self.spawns.first().cloned().unwrap_or(Spawn {
name: String::new(),
position: [0.0, 0.0, 0.0],
yaw: 0.0,
})
}
pub fn anchor(&self, name: &str) -> Option<[f32; 3]> {
if let Some(s) = self.spawns.iter().find(|s| s.name == name) {
return Some(s.position);
}
self.portals.iter().find(|p| p.id == name).map(|p| p.position)
}
pub fn validate(&self) -> Result<(), ManifestError> {
let mut errs = Vec::new();
if !self.thread.starts_with("thread/") {
errs.push(format!("unknown format tag '{}' (expected 'thread/…')", self.thread));
}
if self.world.id.trim().is_empty() {
errs.push("world.id must not be empty".into());
}
let asset_ids: std::collections::HashSet<&str> =
self.assets.iter().map(|a| a.id.as_str()).collect();
let prefab_ids: std::collections::HashSet<StructuredId> =
self.prefabs.iter().map(|p| p.id).collect();
let behavior_ids: std::collections::HashSet<&str> =
self.behaviors.iter().map(|b| b.id.as_str()).collect();
for p in &self.prefabs {
match (&p.mesh.asset, &p.mesh.builtin) {
(None, None) => errs.push(format!("prefab {} has neither mesh.asset nor mesh.builtin", p.id)),
(Some(_), Some(_)) => errs.push(format!("prefab {} sets both mesh.asset and mesh.builtin", p.id)),
(Some(a), None) if !asset_ids.contains(a.as_str()) => {
errs.push(format!("prefab {} references unknown asset '{a}'", p.id))
}
_ => {}
}
}
for (i, pl) in self.placements.iter().enumerate() {
if !prefab_ids.contains(&pl.prefab) {
errs.push(format!("placement[{i}] references unknown prefab {}", pl.prefab));
}
if let Some(b) = &pl.behavior {
if !behavior_ids.contains(b.as_str()) {
errs.push(format!("placement[{i}] references unknown behavior '{b}'"));
}
}
}
for b in &self.behaviors {
if !asset_ids.contains(b.wasm.as_str()) {
errs.push(format!("behavior '{}' references unknown wasm asset '{}'", b.id, b.wasm));
}
}
for p in &self.portals {
if Locator::parse(&p.to).is_none() {
errs.push(format!("portal '{}' has an invalid destination Locator '{}'", p.id, p.to));
}
}
if errs.is_empty() {
Ok(())
} else {
Err(ManifestError::Invalid(errs))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn load(rel: &str) -> Result<WorldManifest, ManifestError> {
let path = format!("{}/../../worlds/{rel}", env!("CARGO_MANIFEST_DIR"));
let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path}: {e}"));
WorldManifest::from_json(&text)
}
#[test]
fn example_archive_is_conformant() {
let m = load("codex-archive/world.json").unwrap();
assert_eq!(m.world.id, "codex-archive");
assert!(m.placements.iter().any(|p| p.codex.is_some()), "archive links Codex entries");
assert!(!m.portals.is_empty(), "archive links onward");
}
#[test]
fn example_market_is_conformant_and_links_back() {
let m = load("market/world.json").unwrap();
assert_eq!(m.world.id, "market");
assert!(m.placements.iter().any(|p| p.data.get("price").is_some()));
let back = m.portals.iter().find_map(|p| Locator::parse(&p.to));
assert_eq!(back.map(|l| l.path), Some("codex-archive".to_string()));
}
#[test]
fn all_hosted_world_fixtures_are_conformant() {
let dir = format!("{}/../../worlds", env!("CARGO_MANIFEST_DIR"));
let mut n = 0;
for entry in std::fs::read_dir(&dir).unwrap().flatten() {
let wj = entry.path().join("world.json");
if wj.exists() {
let text = std::fs::read_to_string(&wj).unwrap();
WorldManifest::from_json(&text).unwrap_or_else(|e| panic!("{}: {e}", wj.display()));
n += 1;
}
}
assert!(n >= 8, "expected the hosted constellation fixtures, found {n}");
}
#[test]
fn rejects_dangling_prefab_reference() {
let bad = r#"{
"thread": "thread/0.1",
"world": { "id": "x", "title": "X" },
"placements": [{ "prefab": "60000001", "position": [0,0,0] }]
}"#;
match WorldManifest::from_json(bad) {
Err(ManifestError::Invalid(errs)) => assert!(errs.iter().any(|e| e.contains("unknown prefab"))),
other => panic!("expected Invalid, got {other:?}"),
}
}
#[test]
fn roundtrips_json() {
let m = load("market/world.json").unwrap();
let back = WorldManifest::from_json(&m.to_json()).unwrap();
assert_eq!(back.world.id, m.world.id);
assert_eq!(back.placements.len(), m.placements.len());
}
#[test]
fn presence_relay_list_prefers_relays_and_stays_backward_compatible() {
let legacy: Presence = serde_json::from_str(r#"{ "relay": "wss://a/thread/w" }"#).unwrap();
assert_eq!(legacy.relay_list(), ["wss://a/thread/w"]);
let modern: Presence =
serde_json::from_str(r#"{ "relays": ["wss://a/thread/w", "wss://b/thread/w"] }"#).unwrap();
assert_eq!(modern.relay_list(), ["wss://a/thread/w", "wss://b/thread/w"]);
let both: Presence = serde_json::from_str(
r#"{ "relay": "wss://a/thread/w", "relays": ["wss://a/thread/w", "wss://b/thread/w"] }"#,
)
.unwrap();
assert_eq!(both.relay_list(), ["wss://a/thread/w", "wss://b/thread/w"]);
let solo = Presence::default();
assert!(solo.relay_list().is_empty());
}
}