1pub 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};
33pub use thread_id::StructuredId;
36
37pub const THREAD_VERSION: &str = "thread/0.1";
39
40pub type Extra = std::collections::BTreeMap<String, serde_json::Value>;
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct WorldManifest {
52 pub thread: String,
54 pub world: WorldMeta,
55 #[serde(default)]
56 pub environment: Environment,
57 #[serde(default)]
59 pub spawns: Vec<Spawn>,
60 #[serde(default)]
62 pub assets: Vec<Asset>,
63 #[serde(default)]
65 pub prefabs: Vec<Prefab>,
66 #[serde(default)]
68 pub placements: Vec<Placement>,
69 #[serde(default)]
71 pub portals: Vec<Portal>,
72 #[serde(default)]
74 pub behaviors: Vec<Behavior>,
75 #[serde(default)]
78 pub styles: Vec<StyleRule>,
79 #[serde(default)]
81 pub presence: Option<Presence>,
82 #[serde(flatten, default, skip_serializing_if = "Extra::is_empty")]
84 pub extra: Extra,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct WorldMeta {
90 pub id: String,
92 pub title: String,
93 #[serde(default, skip_serializing_if = "String::is_empty")]
99 pub description: String,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub author: Option<Identity>,
103 #[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 #[serde(flatten, default, skip_serializing_if = "Extra::is_empty")]
110 pub extra: Extra,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct Identity {
116 pub id: String,
118 #[serde(default)]
119 pub name: String,
120}
121
122#[derive(Debug, Clone, Default, Serialize, Deserialize)]
125pub struct Environment {
126 #[serde(default)]
128 pub year: Option<i64>,
129 #[serde(default)]
130 pub sky: Option<Sky>,
131 #[serde(default)]
132 pub bounds: Option<Bounds>,
133 #[serde(default)]
136 pub rules: WorldRules,
137 #[serde(flatten, default, skip_serializing_if = "Extra::is_empty")]
139 pub extra: Extra,
140}
141
142#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
147pub struct WorldRules {
148 #[serde(default)]
150 pub survival: bool,
151 #[serde(default)]
153 pub gathering: bool,
154 #[serde(default)]
156 pub combat: bool,
157}
158
159#[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#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct Bounds {
171 pub min: [f32; 3],
172 pub max: [f32; 3],
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct Spawn {
178 #[serde(default)]
180 pub name: String,
181 pub position: [f32; 3],
182 #[serde(default)]
183 pub yaw: f32,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct Asset {
189 pub id: String,
190 pub uri: String,
192 pub kind: AssetKind,
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
197#[serde(rename_all = "lowercase")]
198pub enum AssetKind {
199 Gltf,
200 Texture,
201 Wasm,
202 Weft,
204 Audio,
205 Other,
206}
207
208#[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 #[serde(flatten, default, skip_serializing_if = "Extra::is_empty")]
217 pub extra: Extra,
218}
219
220#[derive(Debug, Clone, Default, Serialize, Deserialize)]
222pub struct MeshRef {
223 #[serde(default)]
225 pub asset: Option<String>,
226 #[serde(default)]
228 pub builtin: Option<String>,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub shape: Option<shape::Shape>,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub resolution: Option<u32>,
238}
239
240#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct MaterialRef {
243 #[serde(default)]
244 pub base_color_texture: Option<String>,
245 #[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 #[serde(default)]
259 pub emissive: f32,
260 #[serde(default, skip_serializing_if = "Option::is_none")]
265 pub texture: Option<texture::TextureRecipe>,
266}
267
268impl Default for MaterialRef {
269 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#[derive(Debug, Clone, Serialize, Deserialize)]
288pub struct Placement {
289 pub prefab: StructuredId,
290 #[serde(default)]
291 pub name: String,
292 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
295 pub kind: Option<String>,
296 #[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 #[serde(default)]
306 pub codex: Option<String>,
307 #[serde(default)]
309 pub behavior: Option<String>,
310 #[serde(default)]
313 pub interaction: Option<Interaction>,
314 #[serde(default, skip_serializing_if = "Option::is_none")]
318 pub text: Option<TextPanel>,
319 #[serde(default, skip_serializing_if = "Option::is_none")]
323 pub animate: Option<Animate>,
324 #[serde(default, skip_serializing_if = "Option::is_none")]
329 pub solid: Option<bool>,
330 #[serde(default, skip_serializing_if = "Option::is_none")]
334 pub light: Option<LightEmitter>,
335 #[serde(default)]
337 pub data: serde_json::Value,
338 #[serde(default)]
343 pub children: Vec<Placement>,
344 #[serde(flatten, default, skip_serializing_if = "Extra::is_empty")]
346 pub extra: Extra,
347}
348
349impl Placement {
350 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
368pub struct TextPanel {
369 pub content: String,
370 #[serde(default = "default_text_size")]
373 pub size: f32,
374 #[serde(default = "default_ink")]
376 pub color: [f32; 3],
377 #[serde(default = "default_paper")]
379 pub background: [f32; 3],
380 #[serde(default, skip_serializing_if = "Vec::is_empty")]
386 pub links: Vec<TextLink>,
387}
388
389#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
392pub struct TextLink {
393 pub text: String,
394 pub to: String,
395}
396
397#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
401pub struct LightEmitter {
402 #[serde(default = "default_light_color")]
404 pub color: [f32; 3],
405 #[serde(default = "one_f32")]
407 pub intensity: f32,
408 #[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#[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 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
475pub struct Interaction {
476 pub label: String,
478 #[serde(default = "one_u32")]
480 pub hits: u32,
481 #[serde(default)]
483 pub effects: Vec<InteractionEffect>,
484}
485
486#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
489#[serde(rename_all = "snake_case")]
490pub enum InteractionEffect {
491 GiveItem {
493 item: StructuredId,
494 #[serde(default = "one_u32")]
495 count: u32,
496 },
497 Despawn,
499 Message(String),
501 Effect(String),
503 Navigate(String),
507}
508
509#[derive(Debug, Clone, Serialize, Deserialize)]
519pub struct StyleRule {
520 pub select: String,
522 #[serde(default, skip_serializing_if = "Option::is_none")]
525 pub interaction: Option<Interaction>,
526}
527
528enum 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#[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 pub to: String,
573 #[serde(default)]
574 pub label: String,
575 #[serde(default)]
577 pub preview: PreviewPolicy,
578 #[serde(flatten, default, skip_serializing_if = "Extra::is_empty")]
580 pub extra: Extra,
581}
582
583#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
585#[serde(rename_all = "lowercase")]
586pub enum PreviewPolicy {
587 None,
589 #[default]
591 Static,
592 Live,
594}
595
596#[derive(Debug, Clone, Serialize, Deserialize)]
598pub struct Behavior {
599 pub id: String,
600 #[serde(default, skip_serializing_if = "String::is_empty")]
602 pub wasm: String,
603 #[serde(default, skip_serializing_if = "Option::is_none")]
606 pub weft: Option<String>,
607 #[serde(default, skip_serializing_if = "Option::is_none")]
612 pub weft_pack: Option<String>,
613 #[serde(default, skip_serializing_if = "Option::is_none")]
615 pub weft_export: Option<String>,
616 #[serde(default)]
618 pub on: Vec<String>,
619}
620
621#[derive(Debug, Clone, Default, Serialize, Deserialize)]
630pub struct Presence {
631 #[serde(default, skip_serializing_if = "Option::is_none")]
639 pub mode: Option<String>,
640 #[serde(default, skip_serializing_if = "Option::is_none")]
642 pub relay: Option<String>,
643 #[serde(default, skip_serializing_if = "Vec::is_empty")]
646 pub relays: Vec<String>,
647 #[serde(default, skip_serializing_if = "Option::is_none")]
653 pub rendezvous: Option<String>,
654 #[serde(default, skip_serializing_if = "Option::is_none")]
657 pub max_occupants: Option<u32>,
658 #[serde(default)]
660 pub voice: bool,
661 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
665 pub owner_required: bool,
666 #[serde(flatten, default, skip_serializing_if = "Extra::is_empty")]
668 pub extra: Extra,
669}
670
671pub 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 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 pub fn mode_disagrees(&self) -> bool {
720 self.mode
721 .as_deref()
722 .is_some_and(|m| m != self.effective_mode())
723 }
724
725 pub fn relay_list(&self) -> Vec<String> {
728 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
743fn 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#[derive(Debug)]
762pub enum ManifestError {
763 Json(serde_json::Error),
764 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 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 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 pub fn to_json(&self) -> String {
800 serde_json::to_string_pretty(self).unwrap_or_default()
801 }
802
803 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 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 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 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 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 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 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 assert!(m.placements.iter().any(|p| p.data.get("price").is_some()));
1051 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 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 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 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 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 let all: Vec<_> = m.placements.iter().flat_map(|p| p.iter_tree()).collect();
1139 assert_eq!(all.len(), 2);
1140
1141 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 assert_eq!(m.computed_interaction(&p[0]).unwrap().label, "Id");
1183 assert_eq!(m.computed_interaction(&p[1]).unwrap().label, "Type");
1185 assert_eq!(m.computed_interaction(&p[2]).unwrap().label, "Inline");
1187 }
1188
1189 #[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 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 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 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 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 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 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 #[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 assert_eq!(p(r#"{ "relays": [] }"#).effective_mode(), "solo");
1298 assert_eq!(p(r#"{ "relays": ["", " "] }"#).effective_mode(), "solo");
1300 assert_eq!(p(r#"{ "relays": ["wss://a"] }"#).effective_mode(), "relay");
1301 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 assert_eq!(
1309 p(r#"{ "rendezvous": "wss://r/rtc/k", "relays": ["wss://a"] }"#).effective_mode(),
1310 "relay"
1311 );
1312
1313 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 #[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 assert_eq!(back["world"]["id"], "w");
1356 assert_eq!(back["presence"]["relays"][0], "wss://a");
1357 }
1358
1359 #[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}