pub mod arch;
pub mod lint;
mod locator;
pub mod markup;
pub mod model;
pub mod plan;
pub mod shape;
pub mod texture;
pub use locator::{well_known_url, Locator, SCHEME};
use serde::{Deserialize, Serialize};
pub use thread_id::StructuredId;
pub const THREAD_VERSION: &str = "thread/0.1";
pub type Extra = std::collections::BTreeMap<String, serde_json::Value>;
#[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 styles: Vec<StyleRule>,
#[serde(default)]
pub presence: Option<Presence>,
#[serde(flatten, default, skip_serializing_if = "Extra::is_empty")]
pub extra: Extra,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorldMeta {
pub id: String,
pub title: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub author: Option<Identity>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub codex: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub license: Option<String>,
#[serde(flatten, default, skip_serializing_if = "Extra::is_empty")]
pub extra: Extra,
}
#[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>,
#[serde(default)]
pub rules: WorldRules,
#[serde(flatten, default, skip_serializing_if = "Extra::is_empty")]
pub extra: Extra,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorldRules {
#[serde(default)]
pub survival: bool,
#[serde(default)]
pub gathering: bool,
#[serde(default)]
pub combat: bool,
}
#[derive(Debug, Clone, PartialEq, 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,
Weft,
Audio,
Other,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Prefab {
pub id: StructuredId,
pub mesh: MeshRef,
#[serde(default)]
pub material: Option<MaterialRef>,
#[serde(flatten, default, skip_serializing_if = "Extra::is_empty")]
pub extra: Extra,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MeshRef {
#[serde(default)]
pub asset: Option<String>,
#[serde(default)]
pub builtin: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shape: Option<shape::Shape>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolution: Option<u32>,
}
#[derive(Debug, Clone, 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,
#[serde(default)]
pub emissive: f32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub texture: Option<texture::TextureRecipe>,
}
impl Default for MaterialRef {
fn default() -> Self {
MaterialRef {
base_color_texture: None,
orm_texture: None,
normal_texture: None,
base_color: [1.0, 1.0, 1.0, 1.0],
metallic: 0.0,
roughness: 1.0,
emissive: 0.0,
texture: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Placement {
pub prefab: StructuredId,
#[serde(default)]
pub name: String,
#[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub class: Vec<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 interaction: Option<Interaction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<TextPanel>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub animate: Option<Animate>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub solid: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub light: Option<LightEmitter>,
#[serde(default)]
pub data: serde_json::Value,
#[serde(default)]
pub children: Vec<Placement>,
#[serde(flatten, default, skip_serializing_if = "Extra::is_empty")]
pub extra: Extra,
}
impl Placement {
pub fn iter_tree(&self) -> impl Iterator<Item = &Placement> {
let mut stack = vec![self];
std::iter::from_fn(move || {
let p = stack.pop()?;
stack.extend(p.children.iter().rev());
Some(p)
})
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TextPanel {
pub content: String,
#[serde(default = "default_text_size")]
pub size: f32,
#[serde(default = "default_ink")]
pub color: [f32; 3],
#[serde(default = "default_paper")]
pub background: [f32; 3],
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub links: Vec<TextLink>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TextLink {
pub text: String,
pub to: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LightEmitter {
#[serde(default = "default_light_color")]
pub color: [f32; 3],
#[serde(default = "one_f32")]
pub intensity: f32,
#[serde(default = "default_light_range")]
pub range: f32,
}
fn default_light_color() -> [f32; 3] {
[1.0, 0.85, 0.6]
}
fn default_light_range() -> f32 {
10.0
}
fn default_text_size() -> f32 {
0.09
}
fn default_ink() -> [f32; 3] {
[0.13, 0.11, 0.10]
}
fn default_paper() -> [f32; 3] {
[0.93, 0.89, 0.80]
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Animate {
pub kind: String,
#[serde(default = "one_f32")]
pub speed: f32,
#[serde(default = "quarter_f32")]
pub amp: f32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub points: Vec<[f32; 3]>,
}
fn one_f32() -> f32 {
1.0
}
fn quarter_f32() -> f32 {
0.25
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Interaction {
pub label: String,
#[serde(default = "one_u32")]
pub hits: u32,
#[serde(default)]
pub effects: Vec<InteractionEffect>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InteractionEffect {
GiveItem {
item: StructuredId,
#[serde(default = "one_u32")]
count: u32,
},
Despawn,
Message(String),
Effect(String),
Navigate(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StyleRule {
pub select: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub interaction: Option<Interaction>,
}
enum Selector {
Type(String),
Class(String),
Id(String),
}
impl Selector {
fn parse(s: &str) -> Option<Selector> {
let s = s.trim();
if let Some(id) = s.strip_prefix('#') {
return (!id.is_empty()).then(|| Selector::Id(id.to_string()));
}
if let Some(c) = s.strip_prefix('.') {
return (!c.is_empty()).then(|| Selector::Class(c.to_string()));
}
(!s.is_empty()).then(|| Selector::Type(s.to_string()))
}
fn specificity(&self) -> u8 {
match self {
Selector::Id(_) => 2,
Selector::Class(_) => 1,
Selector::Type(_) => 0,
}
}
fn matches(&self, pl: &Placement) -> bool {
match self {
Selector::Type(t) => pl.kind.as_deref() == Some(t.as_str()),
Selector::Class(c) => pl.class.iter().any(|x| x == c),
Selector::Id(i) => &pl.name == i,
}
}
}
#[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,
#[serde(flatten, default, skip_serializing_if = "Extra::is_empty")]
pub extra: Extra,
}
#[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,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub wasm: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub weft: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub weft_pack: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub weft_export: Option<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, skip_serializing_if = "Option::is_none")]
pub rendezvous: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_occupants: Option<u32>,
#[serde(default)]
pub voice: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub owner_required: bool,
#[serde(flatten, default, skip_serializing_if = "Extra::is_empty")]
pub extra: Extra,
}
pub fn effective_mode_of(presence: &serde_json::Value) -> &'static str {
let named = |v: &serde_json::Value| match v {
serde_json::Value::Array(a) => {
a.iter().any(|r| r.as_str().is_some_and(|s| !s.trim().is_empty()))
}
serde_json::Value::String(s) => !s.trim().is_empty(),
_ => false,
};
if named(&presence["relays"]) || named(&presence["relay"]) {
"relay"
} else if named(&presence["rendezvous"]) {
"p2p"
} else {
"solo"
}
}
impl Presence {
pub fn effective_mode(&self) -> &'static str {
if !self.relay_list().is_empty() {
"relay"
} else if self.rendezvous.is_some() {
"p2p"
} else {
"solo"
}
}
pub fn mode_disagrees(&self) -> bool {
self.mode
.as_deref()
.is_some_and(|m| m != self.effective_mode())
}
pub fn relay_list(&self) -> Vec<String> {
let mut out: Vec<String> =
self.relays.iter().filter(|r| !r.trim().is_empty()).cloned().collect();
if let Some(r) = self.relay.as_ref().filter(|r| !r.trim().is_empty()) {
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 one_u32() -> u32 {
1
}
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 from_text(text: &str) -> Result<Self, ManifestError> {
if text.trim_start().starts_with('{') {
Self::from_json(text)
} else {
markup::compile(text).map_err(|e| ManifestError::Invalid(vec![e]))
}
}
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 computed_interaction(&self, pl: &Placement) -> Option<Interaction> {
if pl.interaction.is_some() {
return pl.interaction.clone();
}
let mut best: Option<(u8, &Interaction)> = None;
for rule in &self.styles {
let (Some(sel), Some(inter)) = (Selector::parse(&rule.select), &rule.interaction)
else {
continue;
};
if sel.matches(pl) && best.is_none_or(|(s, _)| sel.specificity() >= s) {
best = Some((sel.specificity(), inter));
}
}
best.map(|(_, i)| i.clone())
}
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 {
let sources = [
p.mesh.asset.is_some(),
p.mesh.builtin.is_some(),
p.mesh.shape.is_some(),
];
match sources.iter().filter(|s| **s).count() {
0 => errs.push(format!(
"prefab {} has no mesh source (asset, builtin, or shape)",
p.id
)),
1 => {
if let Some(a) = &p.mesh.asset {
if !asset_ids.contains(a.as_str()) {
errs.push(format!("prefab {} references unknown asset '{a}'", p.id));
}
}
if let Some(shape) = &p.mesh.shape {
if let Err(e) = shape.validate() {
errs.push(format!("prefab {} shape: {e}", p.id));
}
}
}
_ => errs.push(format!(
"prefab {} sets more than one mesh source (asset/builtin/shape)",
p.id
)),
}
}
for (i, pl) in self
.placements
.iter()
.flat_map(|p| p.iter_tree())
.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}'"));
}
}
if let Some(inter) = &pl.interaction {
if inter.label.trim().is_empty() {
errs.push(format!("placement[{i}] interaction has an empty label"));
}
if inter.hits == 0 {
errs.push(format!(
"placement[{i}] interaction.hits must be at least 1"
));
}
}
if let Some(a) = &pl.animate {
if !matches!(a.kind.as_str(), "spin" | "bob" | "path") {
errs.push(format!(
"placement[{i}] unknown animate kind '{}' (spin|bob|path)",
a.kind
));
}
if a.kind == "path" && a.points.len() < 2 {
errs.push(format!(
"placement[{i}] animate path needs at least 2 points"
));
}
}
}
for (i, rule) in self.styles.iter().enumerate() {
if Selector::parse(&rule.select).is_none() {
errs.push(format!(
"styles[{i}] has an empty/invalid selector '{}'",
rule.select
));
}
if let Some(inter) = &rule.interaction {
if inter.label.trim().is_empty() {
errs.push(format!("styles[{i}] interaction has an empty label"));
}
if inter.hits == 0 {
errs.push(format!("styles[{i}] interaction.hits must be at least 1"));
}
}
}
for b in &self.behaviors {
let has_pack = b.weft_pack.is_some();
let sources = usize::from(!b.wasm.is_empty())
+ usize::from(b.weft.is_some())
+ usize::from(has_pack);
match sources {
0 => errs.push(format!(
"behavior '{}' names no code (wasm, weft, or weft_pack+weft_export)",
b.id
)),
1 => {}
_ => errs.push(format!(
"behavior '{}' names multiple code sources (pick one)",
b.id
)),
}
if has_pack != b.weft_export.is_some() {
errs.push(format!(
"behavior '{}': weft_pack and weft_export go together",
b.id
));
}
if let Some(w) = &b.weft {
if !asset_ids.contains(w.as_str()) {
errs.push(format!(
"behavior '{}' references unknown weft asset '{w}'",
b.id
));
}
}
if let Some(p) = &b.weft_pack {
if !asset_ids.contains(p.as_str()) {
errs.push(format!(
"behavior '{}' references unknown weft package asset '{p}'",
b.id
));
}
}
if !b.wasm.is_empty() && !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 world_rules_default_off_and_round_trip() {
let m: WorldManifest =
serde_json::from_str(r#"{"thread":"thread/0.1","world":{"id":"w","title":"W"}}"#)
.unwrap();
assert_eq!(m.environment.rules, WorldRules::default());
assert!(!m.environment.rules.survival && !m.environment.rules.combat);
let declared = WorldRules {
survival: true,
gathering: true,
combat: false,
};
let round: WorldRules =
serde_json::from_str(&serde_json::to_string(&declared).unwrap()).unwrap();
assert_eq!(round, declared);
}
#[test]
fn declarative_interaction_reads_clean() {
let json = r#"{
"label": "Chop",
"hits": 3,
"effects": [
{ "give_item": { "item": "20100001", "count": 3 } },
"despawn",
{ "message": "You chopped the old oak." },
{ "effect": "leaves" }
]
}"#;
let i: Interaction = serde_json::from_str(json).unwrap();
assert_eq!(i.label, "Chop");
assert_eq!(i.hits, 3);
assert_eq!(i.effects.len(), 4);
assert!(matches!(i.effects[1], InteractionEffect::Despawn));
assert!(
matches!(&i.effects[2], InteractionEffect::Message(m) if m == "You chopped the old oak.")
);
let d: Interaction = serde_json::from_str(r#"{"label":"Pick"}"#).unwrap();
assert_eq!(d.hits, 1);
assert!(d.effects.is_empty());
let back: Interaction = serde_json::from_str(&serde_json::to_string(&i).unwrap()).unwrap();
assert_eq!(back.effects, i.effects);
}
#[test]
fn interaction_validation_rejects_empty_label() {
let json = r#"{
"thread":"thread/0.1",
"world":{"id":"w","title":"W"},
"prefabs":[{"id":"20100001","mesh":{"builtin":"cube"}}],
"placements":[{"prefab":"20100001","position":[0,0,0],
"interaction":{"label":" "}}]
}"#;
let msg = WorldManifest::from_json(json).unwrap_err().to_string();
assert!(
msg.contains("empty label"),
"expected empty-label error, got: {msg}"
);
}
#[test]
fn nested_placements_iterate_and_validate() {
let m = WorldManifest::from_json(
r#"{
"thread":"thread/0.1","world":{"id":"w","title":"W"},
"prefabs":[{"id":"20100001","mesh":{"builtin":"cube"}}],
"placements":[
{"prefab":"20100001","position":[0,0,0],"children":[
{"prefab":"20100001","position":[0,1,0]}
]}
]
}"#,
)
.unwrap();
let all: Vec<_> = m.placements.iter().flat_map(|p| p.iter_tree()).collect();
assert_eq!(all.len(), 2);
let err = WorldManifest::from_json(
r#"{
"thread":"thread/0.1","world":{"id":"w","title":"W"},
"prefabs":[{"id":"20100001","mesh":{"builtin":"cube"}}],
"placements":[
{"prefab":"20100001","position":[0,0,0],"children":[
{"prefab":"99999999","position":[0,1,0]}
]}
]
}"#,
)
.unwrap_err()
.to_string();
assert!(
err.contains("unknown prefab"),
"nested ref must validate: {err}"
);
}
#[test]
fn styles_cascade_by_type_class_id_with_specificity() {
let m = WorldManifest::from_json(
r##"{
"thread":"thread/0.1","world":{"id":"w","title":"W"},
"prefabs":[{"id":"20100001","mesh":{"builtin":"cube"}}],
"styles":[
{ "select":"tree", "interaction": {"label":"Type"} },
{ "select":".harvestable", "interaction": {"label":"Class"} },
{ "select":"#hero", "interaction": {"label":"Id"} }
],
"placements":[
{"prefab":"20100001","type":"tree","class":["harvestable"],"name":"hero","position":[0,0,0]},
{"prefab":"20100001","type":"tree","position":[1,0,0]},
{"prefab":"20100001","type":"rock","position":[2,0,0],"interaction":{"label":"Inline"}}
]
}"##,
)
.unwrap();
let p = &m.placements;
assert_eq!(m.computed_interaction(&p[0]).unwrap().label, "Id");
assert_eq!(m.computed_interaction(&p[1]).unwrap().label, "Type");
assert_eq!(m.computed_interaction(&p[2]).unwrap().label, "Inline");
}
#[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());
}
#[test]
fn presence_p2p_declares_rendezvous_with_optional_relay_fallback() {
let p2p: Presence = serde_json::from_str(
r#"{ "mode": "p2p", "rendezvous": "wss://rv.example/rtc/example.com/w",
"relays": ["wss://a.example/thread/example.com/w"] }"#,
)
.unwrap();
assert_eq!(p2p.mode.as_deref(), Some("p2p"));
assert_eq!(
p2p.rendezvous.as_deref(),
Some("wss://rv.example/rtc/example.com/w")
);
assert_eq!(p2p.relay_list(), ["wss://a.example/thread/example.com/w"]);
let relay_only: Presence =
serde_json::from_str(r#"{ "relays": ["wss://a/thread/w"] }"#).unwrap();
assert!(relay_only.rendezvous.is_none());
assert!(!serde_json::to_string(&relay_only)
.unwrap()
.contains("rendezvous"));
}
#[test]
fn what_a_world_can_do_beats_what_it_says() {
let p = |json: &str| serde_json::from_str::<Presence>(json).expect(json);
assert_eq!(p(r#"{}"#).effective_mode(), "solo");
assert_eq!(p(r#"{ "mode": "solo" }"#).effective_mode(), "solo");
assert_eq!(p(r#"{ "relays": [] }"#).effective_mode(), "solo");
assert_eq!(p(r#"{ "relays": ["", " "] }"#).effective_mode(), "solo");
assert_eq!(p(r#"{ "relays": ["wss://a"] }"#).effective_mode(), "relay");
assert_eq!(p(r#"{ "relay": "wss://a" }"#).effective_mode(), "relay");
assert_eq!(
p(r#"{ "rendezvous": "wss://r/rtc/k" }"#).effective_mode(),
"p2p"
);
assert_eq!(
p(r#"{ "rendezvous": "wss://r/rtc/k", "relays": ["wss://a"] }"#).effective_mode(),
"relay"
);
let lying = p(r#"{ "mode": "relay" }"#);
assert_eq!(lying.effective_mode(), "solo", "it cannot host anyone");
assert!(lying.mode_disagrees());
let mislabelled = p(r#"{ "mode": "solo", "relays": ["wss://a"] }"#);
assert_eq!(mislabelled.effective_mode(), "relay");
assert!(mislabelled.mode_disagrees());
assert!(!p(r#"{ "mode": "relay", "relays": ["wss://a"] }"#).mode_disagrees());
assert!(
!p(r#"{}"#).mode_disagrees(),
"no claim is not a false claim"
);
}
#[test]
fn a_round_trip_keeps_what_it_does_not_understand() {
let raw = r#"{
"thread": "thread/0.1",
"world": { "id": "w", "title": "W", "weather": "rain" },
"spawns": [{ "name": "e", "position": [0,0,0] }],
"presence": { "relays": ["wss://a"], "tempo": 90 },
"soundtrack": { "uri": "https://x/y.ogg", "loop": true }
}"#;
let m: WorldManifest = serde_json::from_str(raw).expect("parses");
let back = serde_json::to_value(&m).expect("serializes");
assert_eq!(
back["soundtrack"]["uri"], "https://x/y.ogg",
"a top-level stranger survives"
);
assert_eq!(
back["world"]["weather"], "rain",
"so does one inside `world`"
);
assert_eq!(back["presence"]["tempo"], 90, "and one inside `presence`");
assert_eq!(back["world"]["id"], "w");
assert_eq!(back["presence"]["relays"][0], "wss://a");
}
#[test]
fn the_json_form_of_the_presence_rule_agrees_with_the_typed_one() {
for raw in [
"null",
"{}",
r#"{ "mode": "solo" }"#,
r#"{ "relays": [] }"#,
r#"{ "relays": [""] }"#,
r#"{ "relays": ["wss://a"] }"#,
r#"{ "relay": "wss://a" }"#,
r#"{ "mode": "relay" }"#,
r#"{ "rendezvous": "wss://r/rtc/k" }"#,
r#"{ "rendezvous": "wss://r/rtc/k", "relays": ["wss://a"] }"#,
r#"{ "mode": "solo", "relays": ["wss://a"] }"#,
] {
let value: serde_json::Value = serde_json::from_str(raw).expect(raw);
let typed: Presence = serde_json::from_str(raw).unwrap_or_default();
assert_eq!(
effective_mode_of(&value),
typed.effective_mode(),
"the two forms disagree about {raw}"
);
}
}
}