1pub mod arch;
25pub mod lint;
26mod locator;
27pub mod markup;
28pub mod model;
29pub mod plan;
30pub mod shape;
31pub mod texture;
32pub use locator::{well_known_url, Locator, SCHEME};
33
34use serde::{Deserialize, Serialize};
35pub use thread_id::StructuredId;
38
39pub const THREAD_VERSION: &str = "thread/0.1";
41
42pub type Extra = std::collections::BTreeMap<String, serde_json::Value>;
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct WorldManifest {
54 pub thread: String,
56 pub world: WorldMeta,
57 #[serde(default)]
58 pub environment: Environment,
59 #[serde(default)]
61 pub spawns: Vec<Spawn>,
62 #[serde(default)]
64 pub assets: Vec<Asset>,
65 #[serde(default)]
67 pub prefabs: Vec<Prefab>,
68 #[serde(default)]
70 pub placements: Vec<Placement>,
71 #[serde(default)]
73 pub portals: Vec<Portal>,
74 #[serde(default)]
76 pub behaviors: Vec<Behavior>,
77 #[serde(default)]
80 pub styles: Vec<StyleRule>,
81 #[serde(default)]
83 pub presence: Option<Presence>,
84 #[serde(flatten, default, skip_serializing_if = "Extra::is_empty")]
86 pub extra: Extra,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct WorldMeta {
92 pub id: String,
94 pub title: String,
95 #[serde(default, skip_serializing_if = "String::is_empty")]
101 pub description: String,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub author: Option<Identity>,
105 #[serde(default, skip_serializing_if = "Vec::is_empty")]
107 pub codex: Vec<String>,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub license: Option<String>,
110 #[serde(flatten, default, skip_serializing_if = "Extra::is_empty")]
112 pub extra: Extra,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct Identity {
118 pub id: String,
120 #[serde(default)]
121 pub name: String,
122}
123
124#[derive(Debug, Clone, Default, Serialize, Deserialize)]
127pub struct Environment {
128 #[serde(default)]
130 pub year: Option<i64>,
131 #[serde(default)]
132 pub sky: Option<Sky>,
133 #[serde(default)]
134 pub bounds: Option<Bounds>,
135 #[serde(default)]
138 pub rules: WorldRules,
139 #[serde(flatten, default, skip_serializing_if = "Extra::is_empty")]
141 pub extra: Extra,
142}
143
144#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
149pub struct WorldRules {
150 #[serde(default)]
152 pub survival: bool,
153 #[serde(default)]
155 pub gathering: bool,
156 #[serde(default)]
158 pub combat: bool,
159}
160
161#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
163pub struct Sky {
164 pub zenith: [f32; 3],
165 pub horizon: [f32; 3],
166 #[serde(default)]
167 pub sun_dir: Option<[f32; 3]>,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct Bounds {
173 pub min: [f32; 3],
174 pub max: [f32; 3],
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct Spawn {
180 #[serde(default)]
182 pub name: String,
183 pub position: [f32; 3],
184 #[serde(default)]
185 pub yaw: f32,
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct Asset {
191 pub id: String,
192 pub uri: String,
194 pub kind: AssetKind,
195}
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
199#[serde(rename_all = "lowercase")]
200pub enum AssetKind {
201 Gltf,
202 Texture,
203 Wasm,
204 Weft,
206 Audio,
207 Other,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct Prefab {
213 pub id: StructuredId,
214 pub mesh: MeshRef,
215 #[serde(default)]
216 pub material: Option<MaterialRef>,
217 #[serde(flatten, default, skip_serializing_if = "Extra::is_empty")]
219 pub extra: Extra,
220}
221
222#[derive(Debug, Clone, Default, Serialize, Deserialize)]
224pub struct MeshRef {
225 #[serde(default)]
227 pub asset: Option<String>,
228 #[serde(default)]
230 pub builtin: Option<String>,
231 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub shape: Option<shape::Shape>,
236 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub resolution: Option<u32>,
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct MaterialRef {
245 #[serde(default)]
246 pub base_color_texture: Option<String>,
247 #[serde(default)]
249 pub orm_texture: Option<String>,
250 #[serde(default)]
251 pub normal_texture: Option<String>,
252 #[serde(default = "white")]
253 pub base_color: [f32; 4],
254 #[serde(default)]
255 pub metallic: f32,
256 #[serde(default = "one")]
257 pub roughness: f32,
258 #[serde(default)]
261 pub emissive: f32,
262 #[serde(default, skip_serializing_if = "Option::is_none")]
267 pub texture: Option<texture::TextureRecipe>,
268}
269
270impl Default for MaterialRef {
271 fn default() -> Self {
275 MaterialRef {
276 base_color_texture: None,
277 orm_texture: None,
278 normal_texture: None,
279 base_color: [1.0, 1.0, 1.0, 1.0],
280 metallic: 0.0,
281 roughness: 1.0,
282 emissive: 0.0,
283 texture: None,
284 }
285 }
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct Placement {
291 pub prefab: StructuredId,
292 #[serde(default)]
293 pub name: String,
294 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
297 pub kind: Option<String>,
298 #[serde(default, skip_serializing_if = "Vec::is_empty")]
300 pub class: Vec<String>,
301 pub position: [f32; 3],
302 #[serde(default = "unit_quat")]
303 pub rotation: [f32; 4],
304 #[serde(default = "unit_scale")]
305 pub scale: [f32; 3],
306 #[serde(default)]
308 pub codex: Option<String>,
309 #[serde(default)]
311 pub behavior: Option<String>,
312 #[serde(default)]
315 pub interaction: Option<Interaction>,
316 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub text: Option<TextPanel>,
321 #[serde(default, skip_serializing_if = "Option::is_none")]
325 pub animate: Option<Animate>,
326 #[serde(default, skip_serializing_if = "Option::is_none")]
331 pub solid: Option<bool>,
332 #[serde(default, skip_serializing_if = "Option::is_none")]
336 pub light: Option<LightEmitter>,
337 #[serde(default)]
339 pub data: serde_json::Value,
340 #[serde(default)]
345 pub children: Vec<Placement>,
346 #[serde(flatten, default, skip_serializing_if = "Extra::is_empty")]
348 pub extra: Extra,
349}
350
351impl Placement {
352 pub fn iter_tree(&self) -> impl Iterator<Item = &Placement> {
356 let mut stack = vec![self];
357 std::iter::from_fn(move || {
358 let p = stack.pop()?;
359 stack.extend(p.children.iter().rev());
360 Some(p)
361 })
362 }
363}
364
365#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
370pub struct TextPanel {
371 pub content: String,
372 #[serde(default = "default_text_size")]
375 pub size: f32,
376 #[serde(default = "default_ink")]
378 pub color: [f32; 3],
379 #[serde(default = "default_paper")]
381 pub background: [f32; 3],
382 #[serde(default, skip_serializing_if = "Vec::is_empty")]
388 pub links: Vec<TextLink>,
389}
390
391#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
394pub struct TextLink {
395 pub text: String,
396 pub to: String,
397}
398
399#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
403pub struct LightEmitter {
404 #[serde(default = "default_light_color")]
406 pub color: [f32; 3],
407 #[serde(default = "one_f32")]
409 pub intensity: f32,
410 #[serde(default = "default_light_range")]
412 pub range: f32,
413}
414
415fn default_light_color() -> [f32; 3] {
416 [1.0, 0.85, 0.6]
417}
418fn default_light_range() -> f32 {
419 10.0
420}
421
422fn default_text_size() -> f32 {
423 0.09
424}
425fn default_ink() -> [f32; 3] {
426 [0.13, 0.11, 0.10]
427}
428fn default_paper() -> [f32; 3] {
429 [0.93, 0.89, 0.80]
430}
431
432#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
440pub struct Animate {
441 pub kind: String,
442 #[serde(default = "one_f32")]
443 pub speed: f32,
444 #[serde(default = "quarter_f32")]
445 pub amp: f32,
446 #[serde(default, skip_serializing_if = "Vec::is_empty")]
449 pub points: Vec<[f32; 3]>,
450}
451
452fn one_f32() -> f32 {
453 1.0
454}
455fn quarter_f32() -> f32 {
456 0.25
457}
458
459#[derive(Debug, Clone, Serialize, Deserialize)]
477pub struct Interaction {
478 pub label: String,
480 #[serde(default = "one_u32")]
482 pub hits: u32,
483 #[serde(default)]
485 pub effects: Vec<InteractionEffect>,
486}
487
488#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
491#[serde(rename_all = "snake_case")]
492pub enum InteractionEffect {
493 GiveItem {
495 item: StructuredId,
496 #[serde(default = "one_u32")]
497 count: u32,
498 },
499 Despawn,
501 Message(String),
503 Effect(String),
505 Navigate(String),
509}
510
511#[derive(Debug, Clone, Serialize, Deserialize)]
521pub struct StyleRule {
522 pub select: String,
524 #[serde(default, skip_serializing_if = "Option::is_none")]
527 pub interaction: Option<Interaction>,
528}
529
530enum Selector {
532 Type(String),
533 Class(String),
534 Id(String),
535}
536
537impl Selector {
538 fn parse(s: &str) -> Option<Selector> {
539 let s = s.trim();
540 if let Some(id) = s.strip_prefix('#') {
541 return (!id.is_empty()).then(|| Selector::Id(id.to_string()));
542 }
543 if let Some(c) = s.strip_prefix('.') {
544 return (!c.is_empty()).then(|| Selector::Class(c.to_string()));
545 }
546 (!s.is_empty()).then(|| Selector::Type(s.to_string()))
547 }
548 fn specificity(&self) -> u8 {
549 match self {
550 Selector::Id(_) => 2,
551 Selector::Class(_) => 1,
552 Selector::Type(_) => 0,
553 }
554 }
555 fn matches(&self, pl: &Placement) -> bool {
556 match self {
557 Selector::Type(t) => pl.kind.as_deref() == Some(t.as_str()),
558 Selector::Class(c) => pl.class.iter().any(|x| x == c),
559 Selector::Id(i) => &pl.name == i,
560 }
561 }
562}
563
564#[derive(Debug, Clone, Serialize, Deserialize)]
566pub struct Portal {
567 pub id: String,
568 pub position: [f32; 3],
569 #[serde(default = "unit_quat")]
570 pub rotation: [f32; 4],
571 #[serde(default = "unit_scale")]
572 pub scale: [f32; 3],
573 pub to: String,
575 #[serde(default)]
576 pub label: String,
577 #[serde(default)]
579 pub preview: PreviewPolicy,
580 #[serde(flatten, default, skip_serializing_if = "Extra::is_empty")]
582 pub extra: Extra,
583}
584
585#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
587#[serde(rename_all = "lowercase")]
588pub enum PreviewPolicy {
589 None,
591 #[default]
593 Static,
594 Live,
596}
597
598#[derive(Debug, Clone, Serialize, Deserialize)]
600pub struct Behavior {
601 pub id: String,
602 #[serde(default, skip_serializing_if = "String::is_empty")]
604 pub wasm: String,
605 #[serde(default, skip_serializing_if = "Option::is_none")]
608 pub weft: Option<String>,
609 #[serde(default, skip_serializing_if = "Option::is_none")]
614 pub weft_pack: Option<String>,
615 #[serde(default, skip_serializing_if = "Option::is_none")]
617 pub weft_export: Option<String>,
618 #[serde(default)]
620 pub on: Vec<String>,
621}
622
623#[derive(Debug, Clone, Default, Serialize, Deserialize)]
632pub struct Presence {
633 #[serde(default, skip_serializing_if = "Option::is_none")]
641 pub mode: Option<String>,
642 #[serde(default, skip_serializing_if = "Option::is_none")]
644 pub relay: Option<String>,
645 #[serde(default, skip_serializing_if = "Vec::is_empty")]
648 pub relays: Vec<String>,
649 #[serde(default, skip_serializing_if = "Option::is_none")]
655 pub rendezvous: Option<String>,
656 #[serde(default, skip_serializing_if = "Option::is_none")]
659 pub max_occupants: Option<u32>,
660 #[serde(default)]
662 pub voice: bool,
663 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
667 pub owner_required: bool,
668 #[serde(flatten, default, skip_serializing_if = "Extra::is_empty")]
670 pub extra: Extra,
671}
672
673pub fn effective_mode_of(presence: &serde_json::Value) -> &'static str {
682 let named = |v: &serde_json::Value| match v {
683 serde_json::Value::Array(a) => {
684 a.iter().any(|r| r.as_str().is_some_and(|s| !s.trim().is_empty()))
685 }
686 serde_json::Value::String(s) => !s.trim().is_empty(),
687 _ => false,
688 };
689 if named(&presence["relays"]) || named(&presence["relay"]) {
690 "relay"
691 } else if named(&presence["rendezvous"]) {
692 "p2p"
693 } else {
694 "solo"
695 }
696}
697
698impl Presence {
699 pub fn effective_mode(&self) -> &'static str {
710 if !self.relay_list().is_empty() {
711 "relay"
712 } else if self.rendezvous.is_some() {
713 "p2p"
714 } else {
715 "solo"
716 }
717 }
718
719 pub fn mode_disagrees(&self) -> bool {
722 self.mode
723 .as_deref()
724 .is_some_and(|m| m != self.effective_mode())
725 }
726
727 pub fn relay_list(&self) -> Vec<String> {
730 let mut out: Vec<String> =
735 self.relays.iter().filter(|r| !r.trim().is_empty()).cloned().collect();
736 if let Some(r) = self.relay.as_ref().filter(|r| !r.trim().is_empty()) {
737 if !out.contains(r) {
738 out.push(r.clone());
739 }
740 }
741 out
742 }
743}
744
745fn white() -> [f32; 4] {
747 [1.0, 1.0, 1.0, 1.0]
748}
749fn one() -> f32 {
750 1.0
751}
752fn one_u32() -> u32 {
753 1
754}
755fn unit_quat() -> [f32; 4] {
756 [0.0, 0.0, 0.0, 1.0]
757}
758fn unit_scale() -> [f32; 3] {
759 [1.0, 1.0, 1.0]
760}
761
762#[derive(Debug)]
764pub enum ManifestError {
765 Json(serde_json::Error),
766 Invalid(Vec<String>),
768}
769
770impl std::fmt::Display for ManifestError {
771 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
772 match self {
773 ManifestError::Json(e) => write!(f, "manifest JSON error: {e}"),
774 ManifestError::Invalid(errs) => write!(f, "invalid manifest: {}", errs.join("; ")),
775 }
776 }
777}
778impl std::error::Error for ManifestError {}
779
780impl WorldManifest {
781 pub fn from_json(text: &str) -> Result<Self, ManifestError> {
783 let m: WorldManifest = serde_json::from_str(text).map_err(ManifestError::Json)?;
784 m.validate()?;
785 Ok(m)
786 }
787
788 pub fn from_text(text: &str) -> Result<Self, ManifestError> {
793 if text.trim_start().starts_with('{') {
794 Self::from_json(text)
795 } else {
796 markup::compile(text).map_err(|e| ManifestError::Invalid(vec![e]))
797 }
798 }
799
800 pub fn to_json(&self) -> String {
802 serde_json::to_string_pretty(self).unwrap_or_default()
803 }
804
805 pub fn default_spawn(&self) -> Spawn {
807 self.spawns.first().cloned().unwrap_or(Spawn {
808 name: String::new(),
809 position: [0.0, 0.0, 0.0],
810 yaw: 0.0,
811 })
812 }
813
814 pub fn anchor(&self, name: &str) -> Option<[f32; 3]> {
816 if let Some(s) = self.spawns.iter().find(|s| s.name == name) {
817 return Some(s.position);
818 }
819 self.portals
820 .iter()
821 .find(|p| p.id == name)
822 .map(|p| p.position)
823 }
824
825 pub fn computed_interaction(&self, pl: &Placement) -> Option<Interaction> {
830 if pl.interaction.is_some() {
831 return pl.interaction.clone();
832 }
833 let mut best: Option<(u8, &Interaction)> = None;
834 for rule in &self.styles {
835 let (Some(sel), Some(inter)) = (Selector::parse(&rule.select), &rule.interaction)
836 else {
837 continue;
838 };
839 if sel.matches(pl) && best.is_none_or(|(s, _)| sel.specificity() >= s) {
840 best = Some((sel.specificity(), inter));
841 }
842 }
843 best.map(|(_, i)| i.clone())
844 }
845
846 pub fn validate(&self) -> Result<(), ManifestError> {
850 let mut errs = Vec::new();
851
852 if !self.thread.starts_with("thread/") {
853 errs.push(format!(
854 "unknown format tag '{}' (expected 'thread/…')",
855 self.thread
856 ));
857 }
858 if self.world.id.trim().is_empty() {
859 errs.push("world.id must not be empty".into());
860 }
861
862 let asset_ids: std::collections::HashSet<&str> =
863 self.assets.iter().map(|a| a.id.as_str()).collect();
864 let prefab_ids: std::collections::HashSet<StructuredId> =
865 self.prefabs.iter().map(|p| p.id).collect();
866 let behavior_ids: std::collections::HashSet<&str> =
867 self.behaviors.iter().map(|b| b.id.as_str()).collect();
868
869 for p in &self.prefabs {
870 let sources = [
871 p.mesh.asset.is_some(),
872 p.mesh.builtin.is_some(),
873 p.mesh.shape.is_some(),
874 ];
875 match sources.iter().filter(|s| **s).count() {
876 0 => errs.push(format!(
877 "prefab {} has no mesh source (asset, builtin, or shape)",
878 p.id
879 )),
880 1 => {
881 if let Some(a) = &p.mesh.asset {
882 if !asset_ids.contains(a.as_str()) {
883 errs.push(format!("prefab {} references unknown asset '{a}'", p.id));
884 }
885 }
886 if let Some(shape) = &p.mesh.shape {
887 if let Err(e) = shape.validate() {
888 errs.push(format!("prefab {} shape: {e}", p.id));
889 }
890 }
891 }
892 _ => errs.push(format!(
893 "prefab {} sets more than one mesh source (asset/builtin/shape)",
894 p.id
895 )),
896 }
897 }
898
899 for (i, pl) in self
901 .placements
902 .iter()
903 .flat_map(|p| p.iter_tree())
904 .enumerate()
905 {
906 if !prefab_ids.contains(&pl.prefab) {
907 errs.push(format!(
908 "placement[{i}] references unknown prefab {}",
909 pl.prefab
910 ));
911 }
912 if let Some(b) = &pl.behavior {
913 if !behavior_ids.contains(b.as_str()) {
914 errs.push(format!("placement[{i}] references unknown behavior '{b}'"));
915 }
916 }
917 if let Some(inter) = &pl.interaction {
918 if inter.label.trim().is_empty() {
919 errs.push(format!("placement[{i}] interaction has an empty label"));
920 }
921 if inter.hits == 0 {
922 errs.push(format!(
923 "placement[{i}] interaction.hits must be at least 1"
924 ));
925 }
926 }
927 if let Some(a) = &pl.animate {
928 if !matches!(a.kind.as_str(), "spin" | "bob" | "path") {
929 errs.push(format!(
930 "placement[{i}] unknown animate kind '{}' (spin|bob|path)",
931 a.kind
932 ));
933 }
934 if a.kind == "path" && a.points.len() < 2 {
935 errs.push(format!(
936 "placement[{i}] animate path needs at least 2 points"
937 ));
938 }
939 }
940 }
941
942 for (i, rule) in self.styles.iter().enumerate() {
943 if Selector::parse(&rule.select).is_none() {
944 errs.push(format!(
945 "styles[{i}] has an empty/invalid selector '{}'",
946 rule.select
947 ));
948 }
949 if let Some(inter) = &rule.interaction {
950 if inter.label.trim().is_empty() {
951 errs.push(format!("styles[{i}] interaction has an empty label"));
952 }
953 if inter.hits == 0 {
954 errs.push(format!("styles[{i}] interaction.hits must be at least 1"));
955 }
956 }
957 }
958
959 for b in &self.behaviors {
960 let has_pack = b.weft_pack.is_some();
963 let sources = usize::from(!b.wasm.is_empty())
964 + usize::from(b.weft.is_some())
965 + usize::from(has_pack);
966 match sources {
967 0 => errs.push(format!(
968 "behavior '{}' names no code (wasm, weft, or weft_pack+weft_export)",
969 b.id
970 )),
971 1 => {}
972 _ => errs.push(format!(
973 "behavior '{}' names multiple code sources (pick one)",
974 b.id
975 )),
976 }
977 if has_pack != b.weft_export.is_some() {
978 errs.push(format!(
979 "behavior '{}': weft_pack and weft_export go together",
980 b.id
981 ));
982 }
983 if let Some(w) = &b.weft {
984 if !asset_ids.contains(w.as_str()) {
985 errs.push(format!(
986 "behavior '{}' references unknown weft asset '{w}'",
987 b.id
988 ));
989 }
990 }
991 if let Some(p) = &b.weft_pack {
992 if !asset_ids.contains(p.as_str()) {
993 errs.push(format!(
994 "behavior '{}' references unknown weft package asset '{p}'",
995 b.id
996 ));
997 }
998 }
999 if !b.wasm.is_empty() && !asset_ids.contains(b.wasm.as_str()) {
1000 errs.push(format!(
1001 "behavior '{}' references unknown wasm asset '{}'",
1002 b.id, b.wasm
1003 ));
1004 }
1005 }
1006
1007 for p in &self.portals {
1008 if Locator::parse(&p.to).is_none() {
1009 errs.push(format!(
1010 "portal '{}' has an invalid destination Locator '{}'",
1011 p.id, p.to
1012 ));
1013 }
1014 }
1015
1016 if errs.is_empty() {
1017 Ok(())
1018 } else {
1019 Err(ManifestError::Invalid(errs))
1020 }
1021 }
1022}
1023
1024#[cfg(test)]
1025mod tests {
1026 use super::*;
1027
1028 fn load(rel: &str) -> Result<WorldManifest, ManifestError> {
1031 let path = format!("{}/../../worlds/{rel}", env!("CARGO_MANIFEST_DIR"));
1032 let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path}: {e}"));
1033 WorldManifest::from_json(&text)
1034 }
1035
1036 #[test]
1037 fn example_archive_is_conformant() {
1038 let m = load("codex-archive/world.json").unwrap();
1039 assert_eq!(m.world.id, "codex-archive");
1040 assert!(
1041 m.placements.iter().any(|p| p.codex.is_some()),
1042 "archive links Codex entries"
1043 );
1044 assert!(!m.portals.is_empty(), "archive links onward");
1045 }
1046
1047 #[test]
1048 fn example_market_is_conformant_and_links_back() {
1049 let m = load("market/world.json").unwrap();
1050 assert_eq!(m.world.id, "market");
1051 assert!(m.placements.iter().any(|p| p.data.get("price").is_some()));
1053 let back = m.portals.iter().find_map(|p| Locator::parse(&p.to));
1055 assert_eq!(back.map(|l| l.path), Some("codex-archive".to_string()));
1056 }
1057
1058 #[test]
1059 fn world_rules_default_off_and_round_trip() {
1060 let m: WorldManifest =
1062 serde_json::from_str(r#"{"thread":"thread/0.1","world":{"id":"w","title":"W"}}"#)
1063 .unwrap();
1064 assert_eq!(m.environment.rules, WorldRules::default());
1065 assert!(!m.environment.rules.survival && !m.environment.rules.combat);
1066
1067 let declared = WorldRules {
1068 survival: true,
1069 gathering: true,
1070 combat: false,
1071 };
1072 let round: WorldRules =
1073 serde_json::from_str(&serde_json::to_string(&declared).unwrap()).unwrap();
1074 assert_eq!(round, declared);
1075 }
1076
1077 #[test]
1078 fn declarative_interaction_reads_clean() {
1079 let json = r#"{
1080 "label": "Chop",
1081 "hits": 3,
1082 "effects": [
1083 { "give_item": { "item": "20100001", "count": 3 } },
1084 "despawn",
1085 { "message": "You chopped the old oak." },
1086 { "effect": "leaves" }
1087 ]
1088 }"#;
1089 let i: Interaction = serde_json::from_str(json).unwrap();
1090 assert_eq!(i.label, "Chop");
1091 assert_eq!(i.hits, 3);
1092 assert_eq!(i.effects.len(), 4);
1093 assert!(matches!(i.effects[1], InteractionEffect::Despawn));
1094 assert!(
1095 matches!(&i.effects[2], InteractionEffect::Message(m) if m == "You chopped the old oak.")
1096 );
1097
1098 let d: Interaction = serde_json::from_str(r#"{"label":"Pick"}"#).unwrap();
1100 assert_eq!(d.hits, 1);
1101 assert!(d.effects.is_empty());
1102
1103 let back: Interaction = serde_json::from_str(&serde_json::to_string(&i).unwrap()).unwrap();
1105 assert_eq!(back.effects, i.effects);
1106 }
1107
1108 #[test]
1109 fn interaction_validation_rejects_empty_label() {
1110 let json = r#"{
1111 "thread":"thread/0.1",
1112 "world":{"id":"w","title":"W"},
1113 "prefabs":[{"id":"20100001","mesh":{"builtin":"cube"}}],
1114 "placements":[{"prefab":"20100001","position":[0,0,0],
1115 "interaction":{"label":" "}}]
1116 }"#;
1117 let msg = WorldManifest::from_json(json).unwrap_err().to_string();
1118 assert!(
1119 msg.contains("empty label"),
1120 "expected empty-label error, got: {msg}"
1121 );
1122 }
1123
1124 #[test]
1125 fn nested_placements_iterate_and_validate() {
1126 let m = WorldManifest::from_json(
1128 r#"{
1129 "thread":"thread/0.1","world":{"id":"w","title":"W"},
1130 "prefabs":[{"id":"20100001","mesh":{"builtin":"cube"}}],
1131 "placements":[
1132 {"prefab":"20100001","position":[0,0,0],"children":[
1133 {"prefab":"20100001","position":[0,1,0]}
1134 ]}
1135 ]
1136 }"#,
1137 )
1138 .unwrap();
1139 let all: Vec<_> = m.placements.iter().flat_map(|p| p.iter_tree()).collect();
1141 assert_eq!(all.len(), 2);
1142
1143 let err = WorldManifest::from_json(
1145 r#"{
1146 "thread":"thread/0.1","world":{"id":"w","title":"W"},
1147 "prefabs":[{"id":"20100001","mesh":{"builtin":"cube"}}],
1148 "placements":[
1149 {"prefab":"20100001","position":[0,0,0],"children":[
1150 {"prefab":"99999999","position":[0,1,0]}
1151 ]}
1152 ]
1153 }"#,
1154 )
1155 .unwrap_err()
1156 .to_string();
1157 assert!(
1158 err.contains("unknown prefab"),
1159 "nested ref must validate: {err}"
1160 );
1161 }
1162
1163 #[test]
1164 fn styles_cascade_by_type_class_id_with_specificity() {
1165 let m = WorldManifest::from_json(
1166 r##"{
1167 "thread":"thread/0.1","world":{"id":"w","title":"W"},
1168 "prefabs":[{"id":"20100001","mesh":{"builtin":"cube"}}],
1169 "styles":[
1170 { "select":"tree", "interaction": {"label":"Type"} },
1171 { "select":".harvestable", "interaction": {"label":"Class"} },
1172 { "select":"#hero", "interaction": {"label":"Id"} }
1173 ],
1174 "placements":[
1175 {"prefab":"20100001","type":"tree","class":["harvestable"],"name":"hero","position":[0,0,0]},
1176 {"prefab":"20100001","type":"tree","position":[1,0,0]},
1177 {"prefab":"20100001","type":"rock","position":[2,0,0],"interaction":{"label":"Inline"}}
1178 ]
1179 }"##,
1180 )
1181 .unwrap();
1182 let p = &m.placements;
1183 assert_eq!(m.computed_interaction(&p[0]).unwrap().label, "Id");
1185 assert_eq!(m.computed_interaction(&p[1]).unwrap().label, "Type");
1187 assert_eq!(m.computed_interaction(&p[2]).unwrap().label, "Inline");
1189 }
1190
1191 #[test]
1195 fn all_hosted_world_fixtures_are_conformant() {
1196 let dir = format!("{}/../../worlds", env!("CARGO_MANIFEST_DIR"));
1197 let mut n = 0;
1198 for entry in std::fs::read_dir(&dir).unwrap().flatten() {
1199 let wj = entry.path().join("world.json");
1200 if wj.exists() {
1201 let text = std::fs::read_to_string(&wj).unwrap();
1202 WorldManifest::from_json(&text).unwrap_or_else(|e| panic!("{}: {e}", wj.display()));
1203 n += 1;
1204 }
1205 }
1206 assert!(
1207 n >= 8,
1208 "expected the hosted constellation fixtures, found {n}"
1209 );
1210 }
1211
1212 #[test]
1213 fn rejects_dangling_prefab_reference() {
1214 let bad = r#"{
1215 "thread": "thread/0.1",
1216 "world": { "id": "x", "title": "X" },
1217 "placements": [{ "prefab": "60000001", "position": [0,0,0] }]
1218 }"#;
1219 match WorldManifest::from_json(bad) {
1220 Err(ManifestError::Invalid(errs)) => {
1221 assert!(errs.iter().any(|e| e.contains("unknown prefab")))
1222 }
1223 other => panic!("expected Invalid, got {other:?}"),
1224 }
1225 }
1226
1227 #[test]
1228 fn roundtrips_json() {
1229 let m = load("market/world.json").unwrap();
1230 let back = WorldManifest::from_json(&m.to_json()).unwrap();
1231 assert_eq!(back.world.id, m.world.id);
1232 assert_eq!(back.placements.len(), m.placements.len());
1233 }
1234
1235 #[test]
1236 fn presence_relay_list_prefers_relays_and_stays_backward_compatible() {
1237 let legacy: Presence = serde_json::from_str(r#"{ "relay": "wss://a/thread/w" }"#).unwrap();
1239 assert_eq!(legacy.relay_list(), ["wss://a/thread/w"]);
1240
1241 let modern: Presence =
1243 serde_json::from_str(r#"{ "relays": ["wss://a/thread/w", "wss://b/thread/w"] }"#)
1244 .unwrap();
1245 assert_eq!(
1246 modern.relay_list(),
1247 ["wss://a/thread/w", "wss://b/thread/w"]
1248 );
1249
1250 let both: Presence = serde_json::from_str(
1252 r#"{ "relay": "wss://a/thread/w", "relays": ["wss://a/thread/w", "wss://b/thread/w"] }"#,
1253 )
1254 .unwrap();
1255 assert_eq!(both.relay_list(), ["wss://a/thread/w", "wss://b/thread/w"]);
1256
1257 let solo = Presence::default();
1259 assert!(solo.relay_list().is_empty());
1260 }
1261
1262 #[test]
1263 fn presence_p2p_declares_rendezvous_with_optional_relay_fallback() {
1264 let p2p: Presence = serde_json::from_str(
1267 r#"{ "mode": "p2p", "rendezvous": "wss://rv.example/rtc/example.com/w",
1268 "relays": ["wss://a.example/thread/example.com/w"] }"#,
1269 )
1270 .unwrap();
1271 assert_eq!(p2p.mode.as_deref(), Some("p2p"));
1272 assert_eq!(
1273 p2p.rendezvous.as_deref(),
1274 Some("wss://rv.example/rtc/example.com/w")
1275 );
1276 assert_eq!(p2p.relay_list(), ["wss://a.example/thread/example.com/w"]);
1277
1278 let relay_only: Presence =
1280 serde_json::from_str(r#"{ "relays": ["wss://a/thread/w"] }"#).unwrap();
1281 assert!(relay_only.rendezvous.is_none());
1282 assert!(!serde_json::to_string(&relay_only)
1283 .unwrap()
1284 .contains("rendezvous"));
1285 }
1286
1287 #[test]
1293 fn what_a_world_can_do_beats_what_it_says() {
1294 let p = |json: &str| serde_json::from_str::<Presence>(json).expect(json);
1295
1296 assert_eq!(p(r#"{}"#).effective_mode(), "solo");
1297 assert_eq!(p(r#"{ "mode": "solo" }"#).effective_mode(), "solo");
1298 assert_eq!(p(r#"{ "relays": [] }"#).effective_mode(), "solo");
1300 assert_eq!(p(r#"{ "relays": ["", " "] }"#).effective_mode(), "solo");
1302 assert_eq!(p(r#"{ "relays": ["wss://a"] }"#).effective_mode(), "relay");
1303 assert_eq!(p(r#"{ "relay": "wss://a" }"#).effective_mode(), "relay");
1305 assert_eq!(
1306 p(r#"{ "rendezvous": "wss://r/rtc/k" }"#).effective_mode(),
1307 "p2p"
1308 );
1309 assert_eq!(
1311 p(r#"{ "rendezvous": "wss://r/rtc/k", "relays": ["wss://a"] }"#).effective_mode(),
1312 "relay"
1313 );
1314
1315 let lying = p(r#"{ "mode": "relay" }"#);
1317 assert_eq!(lying.effective_mode(), "solo", "it cannot host anyone");
1318 assert!(lying.mode_disagrees());
1319 let mislabelled = p(r#"{ "mode": "solo", "relays": ["wss://a"] }"#);
1320 assert_eq!(mislabelled.effective_mode(), "relay");
1321 assert!(mislabelled.mode_disagrees());
1322 assert!(!p(r#"{ "mode": "relay", "relays": ["wss://a"] }"#).mode_disagrees());
1323 assert!(
1324 !p(r#"{}"#).mode_disagrees(),
1325 "no claim is not a false claim"
1326 );
1327 }
1328
1329 #[test]
1336 fn a_round_trip_keeps_what_it_does_not_understand() {
1337 let raw = r#"{
1338 "thread": "thread/0.1",
1339 "world": { "id": "w", "title": "W", "weather": "rain" },
1340 "spawns": [{ "name": "e", "position": [0,0,0] }],
1341 "presence": { "relays": ["wss://a"], "tempo": 90 },
1342 "soundtrack": { "uri": "https://x/y.ogg", "loop": true }
1343 }"#;
1344 let m: WorldManifest = serde_json::from_str(raw).expect("parses");
1345 let back = serde_json::to_value(&m).expect("serializes");
1346
1347 assert_eq!(
1348 back["soundtrack"]["uri"], "https://x/y.ogg",
1349 "a top-level stranger survives"
1350 );
1351 assert_eq!(
1352 back["world"]["weather"], "rain",
1353 "so does one inside `world`"
1354 );
1355 assert_eq!(back["presence"]["tempo"], 90, "and one inside `presence`");
1356 assert_eq!(back["world"]["id"], "w");
1358 assert_eq!(back["presence"]["relays"][0], "wss://a");
1359 }
1360
1361 #[test]
1365 fn the_json_form_of_the_presence_rule_agrees_with_the_typed_one() {
1366 for raw in [
1367 "null",
1368 "{}",
1369 r#"{ "mode": "solo" }"#,
1370 r#"{ "relays": [] }"#,
1371 r#"{ "relays": [""] }"#,
1372 r#"{ "relays": ["wss://a"] }"#,
1373 r#"{ "relay": "wss://a" }"#,
1374 r#"{ "mode": "relay" }"#,
1375 r#"{ "rendezvous": "wss://r/rtc/k" }"#,
1376 r#"{ "rendezvous": "wss://r/rtc/k", "relays": ["wss://a"] }"#,
1377 r#"{ "mode": "solo", "relays": ["wss://a"] }"#,
1378 ] {
1379 let value: serde_json::Value = serde_json::from_str(raw).expect(raw);
1380 let typed: Presence = serde_json::from_str(raw).unwrap_or_default();
1381 assert_eq!(
1382 effective_mode_of(&value),
1383 typed.effective_mode(),
1384 "the two forms disagree about {raw}"
1385 );
1386 }
1387 }
1388}