1use std::path::{Path, PathBuf};
16
17use infinite_manifest::{Locator, StructuredId, WorldManifest};
18
19pub mod relay;
20pub mod rendezvous;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Severity {
25 Error,
27 Warn,
29}
30
31#[derive(Debug, Clone)]
33pub struct Clause {
34 pub name: &'static str,
35 pub severity: Severity,
36 pub pass: bool,
37 pub notes: Vec<String>,
39}
40
41#[derive(Debug, Clone)]
43pub struct Report {
44 pub clauses: Vec<Clause>,
45 pub worlds: usize,
47}
48
49impl Report {
50 pub fn passed(&self) -> bool {
53 self.clauses
54 .iter()
55 .all(|c| c.pass || c.severity == Severity::Warn)
56 }
57}
58
59pub struct World {
61 pub name: String,
64 pub manifest: WorldManifest,
65 pub dir: Option<PathBuf>,
69}
70
71pub struct LoadError {
73 pub name: String,
74 pub error: String,
75}
76
77pub struct Corpus {
79 pub worlds: Vec<World>,
80 pub load_errors: Vec<LoadError>,
81}
82
83pub fn load_corpus(root: &Path) -> Corpus {
87 let mut worlds = Vec::new();
88 let mut load_errors = Vec::new();
89
90 let mut entries: Vec<PathBuf> = match std::fs::read_dir(root) {
91 Ok(rd) => rd.flatten().map(|e| e.path()).collect(),
92 Err(e) => {
93 load_errors.push(LoadError {
94 name: root.display().to_string(),
95 error: e.to_string(),
96 });
97 return Corpus {
98 worlds,
99 load_errors,
100 };
101 }
102 };
103 entries.sort();
104
105 for dir in entries {
106 let mut wj = dir.join("world.json");
107 if !wj.exists() {
108 wj = dir.join("world.thread");
109 if !wj.exists() {
110 continue;
111 }
112 }
113 let name = dir
114 .file_name()
115 .map(|n| n.to_string_lossy().into_owned())
116 .unwrap_or_default();
117 match std::fs::read_to_string(&wj) {
118 Ok(text) => match WorldManifest::from_text(&text) {
119 Ok(manifest) => {
120 worlds.push(World { name, manifest, dir: Some(dir.clone()) })
121 }
122 Err(e) => load_errors.push(LoadError {
123 name,
124 error: e.to_string(),
125 }),
126 },
127 Err(e) => load_errors.push(LoadError {
128 name,
129 error: e.to_string(),
130 }),
131 }
132 }
133 Corpus {
134 worlds,
135 load_errors,
136 }
137}
138
139pub fn single_corpus(name: &str, manifest_text: &str) -> Corpus {
143 match WorldManifest::from_text(manifest_text) {
144 Ok(manifest) => Corpus {
145 worlds: vec![World {
146 dir: None,
147 name: name.to_string(),
148 manifest,
149 }],
150 load_errors: Vec::new(),
151 },
152 Err(e) => Corpus {
153 worlds: Vec::new(),
154 load_errors: vec![LoadError {
155 name: name.to_string(),
156 error: e.to_string(),
157 }],
158 },
159 }
160}
161
162pub fn transport_clauses(
166 reachable: bool,
167 status: u16,
168 content_type: &str,
169 cors: &str,
170) -> Vec<Clause> {
171 vec![
172 Clause {
173 name: "reachable over HTTPS",
174 severity: Severity::Error,
175 pass: reachable,
176 notes: if reachable {
177 vec![]
178 } else {
179 vec![format!("HTTP {status}")]
180 },
181 },
182 Clause {
183 name: "serves any-origin CORS",
184 severity: Severity::Error,
185 pass: cors == "*",
186 notes: if cors == "*" {
187 vec![]
188 } else if cors.is_empty() {
189 vec!["missing Access-Control-Allow-Origin (a web browser can't fetch it)".into()]
190 } else {
191 vec![format!("Access-Control-Allow-Origin: {cors} (not '*')")]
192 },
193 },
194 Clause {
195 name: "declares JSON content-type",
196 severity: Severity::Warn,
197 pass: content_type.contains("json"),
198 notes: if content_type.contains("json") {
199 vec![]
200 } else {
201 vec![format!(
202 "Content-Type: '{content_type}' (expected application/json)"
203 )]
204 },
205 },
206 ]
207}
208
209pub fn clauses_pass(clauses: &[Clause]) -> bool {
211 clauses
212 .iter()
213 .all(|c| c.pass || c.severity == Severity::Warn)
214}
215
216fn dest_key(loc: &Locator) -> &str {
219 if loc.path.is_empty() {
220 &loc.host
221 } else {
222 &loc.path
223 }
224}
225
226pub fn run(corpus: &Corpus) -> Report {
228 let mut clauses = Vec::new();
229 let names: std::collections::HashSet<&str> =
230 corpus.worlds.iter().map(|w| w.name.as_str()).collect();
231
232 clauses.push(Clause {
234 name: "world manifests validate",
235 severity: Severity::Error,
236 pass: corpus.load_errors.is_empty(),
237 notes: corpus
238 .load_errors
239 .iter()
240 .map(|e| format!("{}: {}", e.name, e.error))
241 .collect(),
242 });
243
244 let no_spawn: Vec<String> = corpus
246 .worlds
247 .iter()
248 .filter(|w| w.manifest.spawns.is_empty())
249 .map(|w| w.name.clone())
250 .collect();
251 clauses.push(Clause {
252 name: "worlds declare a spawn",
253 severity: Severity::Error,
254 pass: no_spawn.is_empty(),
255 notes: no_spawn.iter().map(|n| format!("{n}: no spawns")).collect(),
256 });
257
258 let mut bad_locators = Vec::new();
260 for w in &corpus.worlds {
261 for p in &w.manifest.portals {
262 if Locator::parse(&p.to).is_none() {
263 bad_locators.push(format!("{}: portal '{}' → '{}'", w.name, p.id, p.to));
264 }
265 }
266 }
267 clauses.push(Clause {
268 name: "portal destinations are valid Locators",
269 severity: Severity::Error,
270 pass: bad_locators.is_empty(),
271 notes: bad_locators,
272 });
273
274 let (entry, orphans, internal, external) = analyze_graph(&corpus.worlds, &names);
278 let mut notes = vec![format!(
279 "entry '{}' · {} internal link(s) · {} external link(s)",
280 entry.as_deref().unwrap_or("—"),
281 internal,
282 external
283 )];
284 notes.extend(
285 orphans
286 .iter()
287 .map(|o| format!("unreachable from entry: {o}")),
288 );
289 clauses.push(Clause {
290 name: "constellation is connected",
291 severity: Severity::Warn,
292 pass: orphans.is_empty(),
293 notes,
294 });
295
296 let mut unlabeled = Vec::new();
298 for w in &corpus.worlds {
299 for p in &w.manifest.portals {
300 if p.label.trim().is_empty() {
301 unlabeled.push(format!("{}: portal '{}' has no label", w.name, p.id));
302 }
303 }
304 }
305 clauses.push(Clause {
306 name: "veils carry labels",
307 severity: Severity::Warn,
308 pass: unlabeled.is_empty(),
309 notes: unlabeled,
310 });
311
312 let mut missing = Vec::new();
330 for w in &corpus.worlds {
331 let Some(dir) = &w.dir else { continue };
332 for a in &w.manifest.assets {
333 let uri = a.uri.trim();
334 if uri.is_empty() || uri.contains("://") || uri.starts_with("//") {
335 continue;
336 }
337 if matches!(a.kind, infinite_manifest::AssetKind::Wasm) {
338 continue;
339 }
340 let rel = uri.split(['?', '#']).next().unwrap_or(uri);
341 if !dir.join(rel).exists() {
342 missing.push(format!("{}: asset '{}' → {rel} is not there", w.name, a.id));
343 }
344 }
345 }
346 clauses.push(Clause {
347 name: "declared files are present",
348 severity: Severity::Error,
349 pass: missing.is_empty(),
350 notes: missing,
351 });
352
353 let mut sunk = Vec::new();
372 for w in &corpus.worlds {
373 let centre_origin: std::collections::HashSet<StructuredId> = w
374 .manifest
375 .prefabs
376 .iter()
377 .filter(|p| {
378 matches!(
379 p.mesh.builtin.as_deref(),
380 Some("cube" | "sphere" | "cylinder" | "capsule")
381 )
382 })
383 .map(|p| p.id)
384 .collect();
385 for pl in &w.manifest.placements {
386 if !centre_origin.contains(&pl.prefab) {
387 continue;
388 }
389 if pl.solid == Some(false) || pl.text.is_some() || pl.light.is_some() {
392 continue;
393 }
394 let bottom = pl.position[1] - pl.scale[1].abs() / 2.0;
395 if bottom < -GROUND_TOLERANCE {
396 sunk.push(format!(
397 "{}: '{}' sits {:.2} m below the ground plane",
398 w.name,
399 if pl.name.is_empty() {
400 "(unnamed)"
401 } else {
402 &pl.name
403 },
404 -bottom
405 ));
406 }
407 }
408 }
409 let shown: Vec<String> = sunk.iter().take(12).cloned().collect();
410 let mut notes = shown;
411 if sunk.len() > notes.len() {
412 notes.push(format!("… and {} more", sunk.len() - notes.len()));
413 }
414 clauses.push(Clause {
415 name: "geometry rests on the ground plane",
416 severity: Severity::Warn,
417 pass: sunk.is_empty(),
418 notes,
419 });
420
421 Report {
422 clauses,
423 worlds: corpus.worlds.len(),
424 }
425}
426
427const GROUND_TOLERANCE: f32 = 0.05;
431
432fn analyze_graph(
435 worlds: &[World],
436 names: &std::collections::HashSet<&str>,
437) -> (Option<String>, Vec<String>, usize, usize) {
438 if worlds.is_empty() {
439 return (None, Vec::new(), 0, 0);
440 }
441
442 let entry = worlds
444 .iter()
445 .find(|w| w.name == "nexus")
446 .or_else(|| worlds.iter().max_by_key(|w| w.manifest.portals.len()))
447 .map(|w| w.name.clone());
448
449 let mut internal = 0usize;
451 let mut external = 0usize;
452 let mut adj: std::collections::HashMap<&str, Vec<String>> = std::collections::HashMap::new();
453 for w in worlds {
454 for p in &w.manifest.portals {
455 match Locator::parse(&p.to) {
456 Some(loc) if names.contains(dest_key(&loc)) => {
457 internal += 1;
458 adj.entry(w.name.as_str())
459 .or_default()
460 .push(dest_key(&loc).to_string());
461 }
462 _ => external += 1,
463 }
464 }
465 }
466
467 let mut seen = std::collections::HashSet::new();
469 if let Some(start) = &entry {
470 let mut queue = vec![start.clone()];
471 seen.insert(start.clone());
472 while let Some(cur) = queue.pop() {
473 if let Some(nexts) = adj.get(cur.as_str()) {
474 for n in nexts {
475 if seen.insert(n.clone()) {
476 queue.push(n.clone());
477 }
478 }
479 }
480 }
481 }
482
483 let mut orphans: Vec<String> = worlds
484 .iter()
485 .map(|w| w.name.clone())
486 .filter(|n| !seen.contains(n))
487 .collect();
488 orphans.sort();
489 (entry, orphans, internal, external)
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495
496 fn corpus_from(worlds: Vec<(&str, WorldManifest)>) -> Corpus {
497 Corpus {
498 worlds: worlds
499 .into_iter()
500 .map(|(n, m)| World {
501 name: n.into(),
502 manifest: m,
503 dir: None,
504 })
505 .collect(),
506 load_errors: Vec::new(),
507 }
508 }
509
510 fn world(id: &str, spawns: bool, portals: &[(&str, &str, &str)]) -> WorldManifest {
511 let spawn_json = if spawns {
512 r#""spawns": [{ "name": "entry", "position": [0,0,0] }],"#
513 } else {
514 ""
515 };
516 let portal_json: Vec<String> = portals
517 .iter()
518 .map(|(pid, to, label)| {
519 format!(
520 r#"{{ "id": "{pid}", "position": [0,0,0], "to": "{to}", "label": "{label}" }}"#
521 )
522 })
523 .collect();
524 let text = format!(
525 r#"{{ "thread": "thread/0.1", "world": {{ "id": "{id}", "title": "{id}" }},
526 {spawn_json} "portals": [{}] }}"#,
527 portal_json.join(",")
528 );
529 WorldManifest::from_json(&text).unwrap()
530 }
531
532 #[test]
533 fn a_connected_labeled_corpus_passes_cleanly() {
534 let c = corpus_from(vec![
535 (
536 "nexus",
537 world("nexus", true, &[("to-a", "thread://a.io/alpha", "Alpha")]),
538 ),
539 (
540 "alpha",
541 world("alpha", true, &[("to-n", "thread://x.io/nexus", "Nexus")]),
542 ),
543 ]);
544 let r = run(&c);
545 assert!(r.passed(), "no error clauses should fail");
546 assert!(
547 r.clauses.iter().all(|cl| cl.pass),
548 "and no warnings either: {:?}",
549 r.clauses
550 );
551 }
552
553 #[test]
554 fn a_missing_spawn_is_an_error_and_fails_the_suite() {
555 let c = corpus_from(vec![("alpha", world("alpha", false, &[]))]);
556 let r = run(&c);
557 assert!(!r.passed());
558 let spawn_clause = r
559 .clauses
560 .iter()
561 .find(|c| c.name == "worlds declare a spawn")
562 .unwrap();
563 assert!(!spawn_clause.pass);
564 assert_eq!(spawn_clause.severity, Severity::Error);
565 }
566
567 #[test]
568 fn an_orphan_world_warns_but_still_passes() {
569 let c = corpus_from(vec![
571 (
572 "nexus",
573 world("nexus", true, &[("to-a", "thread://a.io/alpha", "Alpha")]),
574 ),
575 ("alpha", world("alpha", true, &[])),
576 ("lonely", world("lonely", true, &[])),
577 ]);
578 let r = run(&c);
579 assert!(
580 r.passed(),
581 "an orphan is a warning, not a conformance failure"
582 );
583 let conn = r
584 .clauses
585 .iter()
586 .find(|c| c.name == "constellation is connected")
587 .unwrap();
588 assert!(!conn.pass);
589 assert_eq!(conn.severity, Severity::Warn);
590 assert!(conn.notes.iter().any(|n| n.contains("lonely")));
591 }
592
593 #[test]
601 fn the_shipped_worlds_corpus_is_conformant() {
602 let root = format!("{}/../../worlds", env!("CARGO_MANIFEST_DIR"));
603 let root = std::path::Path::new(&root);
604 if !root.is_dir() {
605 eprintln!("no corpus beside this crate — nothing to guard here");
606 return;
607 }
608 let corpus = load_corpus(root);
609 let report = run(&corpus);
610 assert!(
611 report.worlds >= 8,
612 "a corpus is present but nearly empty — found {}",
613 report.worlds
614 );
615 for c in &report.clauses {
616 if c.severity == Severity::Error {
617 assert!(c.pass, "error clause '{}' failed: {:?}", c.name, c.notes);
618 }
619 }
620 assert!(report.passed());
621 }
622
623 #[test]
624 fn transport_contract_flags_missing_cors_as_error() {
625 let ok = transport_clauses(true, 200, "application/json", "*");
626 assert!(clauses_pass(&ok));
627
628 let no_cors = transport_clauses(true, 200, "application/json", "");
629 assert!(
630 !clauses_pass(&no_cors),
631 "missing CORS breaks cross-origin browsers"
632 );
633 let cors_clause = no_cors
634 .iter()
635 .find(|c| c.name == "serves any-origin CORS")
636 .unwrap();
637 assert_eq!(cors_clause.severity, Severity::Error);
638
639 let text_plain = transport_clauses(true, 200, "text/plain", "*");
641 assert!(clauses_pass(&text_plain));
642 assert!(
643 !text_plain
644 .iter()
645 .find(|c| c.name == "declares JSON content-type")
646 .unwrap()
647 .pass
648 );
649 }
650
651 #[test]
652 fn single_corpus_runs_the_spec_clauses_on_one_fetched_world() {
653 let text = r#"{ "thread": "thread/0.1", "world": { "id": "w", "title": "Live" },
654 "spawns": [{ "name": "entry", "position": [0,0,0] }],
655 "portals": [{ "id": "p", "position": [0,0,0], "to": "thread://x.io/y", "label": "Y" }] }"#;
656 let corpus = single_corpus("myhost.com", text);
657 let report = run(&corpus);
658 assert_eq!(report.worlds, 1);
659 assert!(report.passed());
660
661 let bad = single_corpus("myhost.com", "{ nope }");
663 assert!(!run(&bad).passed());
664 }
665
666 #[test]
667 fn external_links_are_counted_not_penalized() {
668 let c = corpus_from(vec![(
669 "nexus",
670 world(
671 "nexus",
672 true,
673 &[("out", "thread://someone-elses-host.com/room", "Elsewhere")],
674 ),
675 )]);
676 let r = run(&c);
677 assert!(r.passed());
678 let conn = r
679 .clauses
680 .iter()
681 .find(|c| c.name == "constellation is connected")
682 .unwrap();
683 assert!(conn.notes.iter().any(|n| n.contains("1 external link")));
684 }
685
686 #[test]
690 fn sunk_geometry_is_reported_and_base_origin_models_are_left_alone() {
691 let m: WorldManifest = serde_json::from_str(
692 r#"{ "thread": "thread/0.1", "world": { "id": "w", "title": "W" },
693 "spawns": [{ "name": "entry", "position": [0,0,0] }],
694 "assets": [{ "id": "urn", "uri": "https://q/x.glb", "kind": "gltf" }],
695 "prefabs": [
696 { "id": 60930001, "mesh": { "builtin": "cylinder" } },
697 { "id": 60930002, "mesh": { "builtin": "plane" } },
698 { "id": 60930003, "mesh": { "asset": "urn" } }
699 ],
700 "placements": [
701 { "prefab": 60930001, "name": "buried post",
702 "position": [0,0.5,0], "scale": [0.3,1.4,0.3] },
703 { "prefab": 60930001, "name": "standing post",
704 "position": [2,0.7,0], "scale": [0.3,1.4,0.3] },
705 { "prefab": 60930001, "name": "half-sunk boulder",
706 "position": [4,0.2,0], "scale": [1,1,1], "solid": false },
707 { "prefab": 60930002, "name": "floor",
708 "position": [0,0,0], "scale": [30,1,30] },
709 { "prefab": 60930003, "name": "a bought column",
710 "position": [6,0,0], "scale": [1,1,1] }
711 ] }"#,
712 )
713 .expect("manifest");
714 let report = run(&corpus_from(vec![("w", m)]));
715 let c = report
716 .clauses
717 .iter()
718 .find(|c| c.name == "geometry rests on the ground plane")
719 .expect("the clause runs");
720 assert_eq!(
721 c.severity,
722 Severity::Warn,
723 "half-buried geometry is legitimate"
724 );
725 assert!(!c.pass, "the buried post is reported");
726 assert_eq!(c.notes.len(), 1, "and nothing else is: {:?}", c.notes);
727 assert!(c.notes[0].contains("buried post"), "{:?}", c.notes);
728 assert!(report.passed(), "a Warn clause never breaks conformance");
729 }
730
731 #[test]
736 fn a_world_missing_its_own_files_fails_and_links_out_are_left_alone() {
737 let dir = std::env::temp_dir().join("thread-c8-test");
738 let _ = std::fs::remove_dir_all(&dir);
739 std::fs::create_dir_all(dir.join("models")).expect("tmp");
740 std::fs::write(dir.join("models/here.glb"), b"glb").expect("write");
741 let m: WorldManifest = serde_json::from_str(
742 r#"{ "thread": "thread/0.1", "world": { "id": "w", "title": "W" },
743 "spawns": [{ "name": "e", "position": [0,0,0] }],
744 "assets": [
745 { "id": "a", "uri": "models/here.glb", "kind": "gltf" },
746 { "id": "b", "uri": "models/gone.glb", "kind": "gltf" },
747 { "id": "c", "uri": "https://cdn.example/x.glb", "kind": "gltf" },
748 { "id": "d", "uri": "behaviors/absent.wasm", "kind": "wasm" }
749 ] }"#,
750 )
751 .expect("manifest");
752 let corpus = Corpus {
753 worlds: vec![World { name: "w".into(), manifest: m, dir: Some(dir.clone()) }],
754 load_errors: Vec::new(),
755 };
756 let report = run(&corpus);
757 let c = report.clauses.iter().find(|c| c.name == "declared files are present").unwrap();
758 assert_eq!(c.severity, Severity::Error);
759 assert!(!c.pass);
760 assert_eq!(
761 c.notes.len(),
762 1,
763 "only the missing local file a browser must draw — not the link out, \
764 and not the wasm the spec lets a browser ignore: {:?}",
765 c.notes
766 );
767 assert!(c.notes[0].contains("gone.glb"), "{:?}", c.notes);
768 assert!(!report.passed(), "a room without its models is not conformant");
769
770 let fetched = single_corpus("host", &serde_json::to_string(&corpus.worlds[0].manifest).unwrap());
772 let c = run(&fetched)
773 .clauses
774 .iter()
775 .find(|c| c.name == "declared files are present")
776 .cloned()
777 .unwrap();
778 assert!(c.pass, "nothing to check without a directory: {:?}", c.notes);
779 let _ = std::fs::remove_dir_all(&dir);
780 }
781}