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();
367 for w in &corpus.worlds {
368 let centre_origin: std::collections::HashSet<StructuredId> = w
369 .manifest
370 .prefabs
371 .iter()
372 .filter(|p| {
373 matches!(
374 p.mesh.builtin.as_deref(),
375 Some("cube" | "sphere" | "cylinder")
376 )
377 })
378 .map(|p| p.id)
379 .collect();
380 for pl in &w.manifest.placements {
381 if !centre_origin.contains(&pl.prefab) {
382 continue;
383 }
384 if pl.solid == Some(false) || pl.text.is_some() || pl.light.is_some() {
387 continue;
388 }
389 let bottom = pl.position[1] - pl.scale[1].abs() / 2.0;
390 if bottom < -GROUND_TOLERANCE {
391 sunk.push(format!(
392 "{}: '{}' sits {:.2} m below the ground plane",
393 w.name,
394 if pl.name.is_empty() {
395 "(unnamed)"
396 } else {
397 &pl.name
398 },
399 -bottom
400 ));
401 }
402 }
403 }
404 let shown: Vec<String> = sunk.iter().take(12).cloned().collect();
405 let mut notes = shown;
406 if sunk.len() > notes.len() {
407 notes.push(format!("… and {} more", sunk.len() - notes.len()));
408 }
409 clauses.push(Clause {
410 name: "geometry rests on the ground plane",
411 severity: Severity::Warn,
412 pass: sunk.is_empty(),
413 notes,
414 });
415
416 Report {
417 clauses,
418 worlds: corpus.worlds.len(),
419 }
420}
421
422const GROUND_TOLERANCE: f32 = 0.05;
426
427fn analyze_graph(
430 worlds: &[World],
431 names: &std::collections::HashSet<&str>,
432) -> (Option<String>, Vec<String>, usize, usize) {
433 if worlds.is_empty() {
434 return (None, Vec::new(), 0, 0);
435 }
436
437 let entry = worlds
439 .iter()
440 .find(|w| w.name == "nexus")
441 .or_else(|| worlds.iter().max_by_key(|w| w.manifest.portals.len()))
442 .map(|w| w.name.clone());
443
444 let mut internal = 0usize;
446 let mut external = 0usize;
447 let mut adj: std::collections::HashMap<&str, Vec<String>> = std::collections::HashMap::new();
448 for w in worlds {
449 for p in &w.manifest.portals {
450 match Locator::parse(&p.to) {
451 Some(loc) if names.contains(dest_key(&loc)) => {
452 internal += 1;
453 adj.entry(w.name.as_str())
454 .or_default()
455 .push(dest_key(&loc).to_string());
456 }
457 _ => external += 1,
458 }
459 }
460 }
461
462 let mut seen = std::collections::HashSet::new();
464 if let Some(start) = &entry {
465 let mut queue = vec![start.clone()];
466 seen.insert(start.clone());
467 while let Some(cur) = queue.pop() {
468 if let Some(nexts) = adj.get(cur.as_str()) {
469 for n in nexts {
470 if seen.insert(n.clone()) {
471 queue.push(n.clone());
472 }
473 }
474 }
475 }
476 }
477
478 let mut orphans: Vec<String> = worlds
479 .iter()
480 .map(|w| w.name.clone())
481 .filter(|n| !seen.contains(n))
482 .collect();
483 orphans.sort();
484 (entry, orphans, internal, external)
485}
486
487#[cfg(test)]
488mod tests {
489 use super::*;
490
491 fn corpus_from(worlds: Vec<(&str, WorldManifest)>) -> Corpus {
492 Corpus {
493 worlds: worlds
494 .into_iter()
495 .map(|(n, m)| World {
496 name: n.into(),
497 manifest: m,
498 dir: None,
499 })
500 .collect(),
501 load_errors: Vec::new(),
502 }
503 }
504
505 fn world(id: &str, spawns: bool, portals: &[(&str, &str, &str)]) -> WorldManifest {
506 let spawn_json = if spawns {
507 r#""spawns": [{ "name": "entry", "position": [0,0,0] }],"#
508 } else {
509 ""
510 };
511 let portal_json: Vec<String> = portals
512 .iter()
513 .map(|(pid, to, label)| {
514 format!(
515 r#"{{ "id": "{pid}", "position": [0,0,0], "to": "{to}", "label": "{label}" }}"#
516 )
517 })
518 .collect();
519 let text = format!(
520 r#"{{ "thread": "thread/0.1", "world": {{ "id": "{id}", "title": "{id}" }},
521 {spawn_json} "portals": [{}] }}"#,
522 portal_json.join(",")
523 );
524 WorldManifest::from_json(&text).unwrap()
525 }
526
527 #[test]
528 fn a_connected_labeled_corpus_passes_cleanly() {
529 let c = corpus_from(vec![
530 (
531 "nexus",
532 world("nexus", true, &[("to-a", "thread://a.io/alpha", "Alpha")]),
533 ),
534 (
535 "alpha",
536 world("alpha", true, &[("to-n", "thread://x.io/nexus", "Nexus")]),
537 ),
538 ]);
539 let r = run(&c);
540 assert!(r.passed(), "no error clauses should fail");
541 assert!(
542 r.clauses.iter().all(|cl| cl.pass),
543 "and no warnings either: {:?}",
544 r.clauses
545 );
546 }
547
548 #[test]
549 fn a_missing_spawn_is_an_error_and_fails_the_suite() {
550 let c = corpus_from(vec![("alpha", world("alpha", false, &[]))]);
551 let r = run(&c);
552 assert!(!r.passed());
553 let spawn_clause = r
554 .clauses
555 .iter()
556 .find(|c| c.name == "worlds declare a spawn")
557 .unwrap();
558 assert!(!spawn_clause.pass);
559 assert_eq!(spawn_clause.severity, Severity::Error);
560 }
561
562 #[test]
563 fn an_orphan_world_warns_but_still_passes() {
564 let c = corpus_from(vec![
566 (
567 "nexus",
568 world("nexus", true, &[("to-a", "thread://a.io/alpha", "Alpha")]),
569 ),
570 ("alpha", world("alpha", true, &[])),
571 ("lonely", world("lonely", true, &[])),
572 ]);
573 let r = run(&c);
574 assert!(
575 r.passed(),
576 "an orphan is a warning, not a conformance failure"
577 );
578 let conn = r
579 .clauses
580 .iter()
581 .find(|c| c.name == "constellation is connected")
582 .unwrap();
583 assert!(!conn.pass);
584 assert_eq!(conn.severity, Severity::Warn);
585 assert!(conn.notes.iter().any(|n| n.contains("lonely")));
586 }
587
588 #[test]
596 fn the_shipped_worlds_corpus_is_conformant() {
597 let root = format!("{}/../../worlds", env!("CARGO_MANIFEST_DIR"));
598 let root = std::path::Path::new(&root);
599 if !root.is_dir() {
600 eprintln!("no corpus beside this crate — nothing to guard here");
601 return;
602 }
603 let corpus = load_corpus(root);
604 let report = run(&corpus);
605 assert!(
606 report.worlds >= 8,
607 "a corpus is present but nearly empty — found {}",
608 report.worlds
609 );
610 for c in &report.clauses {
611 if c.severity == Severity::Error {
612 assert!(c.pass, "error clause '{}' failed: {:?}", c.name, c.notes);
613 }
614 }
615 assert!(report.passed());
616 }
617
618 #[test]
619 fn transport_contract_flags_missing_cors_as_error() {
620 let ok = transport_clauses(true, 200, "application/json", "*");
621 assert!(clauses_pass(&ok));
622
623 let no_cors = transport_clauses(true, 200, "application/json", "");
624 assert!(
625 !clauses_pass(&no_cors),
626 "missing CORS breaks cross-origin browsers"
627 );
628 let cors_clause = no_cors
629 .iter()
630 .find(|c| c.name == "serves any-origin CORS")
631 .unwrap();
632 assert_eq!(cors_clause.severity, Severity::Error);
633
634 let text_plain = transport_clauses(true, 200, "text/plain", "*");
636 assert!(clauses_pass(&text_plain));
637 assert!(
638 !text_plain
639 .iter()
640 .find(|c| c.name == "declares JSON content-type")
641 .unwrap()
642 .pass
643 );
644 }
645
646 #[test]
647 fn single_corpus_runs_the_spec_clauses_on_one_fetched_world() {
648 let text = r#"{ "thread": "thread/0.1", "world": { "id": "w", "title": "Live" },
649 "spawns": [{ "name": "entry", "position": [0,0,0] }],
650 "portals": [{ "id": "p", "position": [0,0,0], "to": "thread://x.io/y", "label": "Y" }] }"#;
651 let corpus = single_corpus("myhost.com", text);
652 let report = run(&corpus);
653 assert_eq!(report.worlds, 1);
654 assert!(report.passed());
655
656 let bad = single_corpus("myhost.com", "{ nope }");
658 assert!(!run(&bad).passed());
659 }
660
661 #[test]
662 fn external_links_are_counted_not_penalized() {
663 let c = corpus_from(vec![(
664 "nexus",
665 world(
666 "nexus",
667 true,
668 &[("out", "thread://someone-elses-host.com/room", "Elsewhere")],
669 ),
670 )]);
671 let r = run(&c);
672 assert!(r.passed());
673 let conn = r
674 .clauses
675 .iter()
676 .find(|c| c.name == "constellation is connected")
677 .unwrap();
678 assert!(conn.notes.iter().any(|n| n.contains("1 external link")));
679 }
680
681 #[test]
685 fn sunk_geometry_is_reported_and_base_origin_models_are_left_alone() {
686 let m: WorldManifest = serde_json::from_str(
687 r#"{ "thread": "thread/0.1", "world": { "id": "w", "title": "W" },
688 "spawns": [{ "name": "entry", "position": [0,0,0] }],
689 "assets": [{ "id": "urn", "uri": "https://q/x.glb", "kind": "gltf" }],
690 "prefabs": [
691 { "id": 60930001, "mesh": { "builtin": "cylinder" } },
692 { "id": 60930002, "mesh": { "builtin": "plane" } },
693 { "id": 60930003, "mesh": { "asset": "urn" } }
694 ],
695 "placements": [
696 { "prefab": 60930001, "name": "buried post",
697 "position": [0,0.5,0], "scale": [0.3,1.4,0.3] },
698 { "prefab": 60930001, "name": "standing post",
699 "position": [2,0.7,0], "scale": [0.3,1.4,0.3] },
700 { "prefab": 60930001, "name": "half-sunk boulder",
701 "position": [4,0.2,0], "scale": [1,1,1], "solid": false },
702 { "prefab": 60930002, "name": "floor",
703 "position": [0,0,0], "scale": [30,1,30] },
704 { "prefab": 60930003, "name": "a bought column",
705 "position": [6,0,0], "scale": [1,1,1] }
706 ] }"#,
707 )
708 .expect("manifest");
709 let report = run(&corpus_from(vec![("w", m)]));
710 let c = report
711 .clauses
712 .iter()
713 .find(|c| c.name == "geometry rests on the ground plane")
714 .expect("the clause runs");
715 assert_eq!(
716 c.severity,
717 Severity::Warn,
718 "half-buried geometry is legitimate"
719 );
720 assert!(!c.pass, "the buried post is reported");
721 assert_eq!(c.notes.len(), 1, "and nothing else is: {:?}", c.notes);
722 assert!(c.notes[0].contains("buried post"), "{:?}", c.notes);
723 assert!(report.passed(), "a Warn clause never breaks conformance");
724 }
725
726 #[test]
731 fn a_world_missing_its_own_files_fails_and_links_out_are_left_alone() {
732 let dir = std::env::temp_dir().join("thread-c8-test");
733 let _ = std::fs::remove_dir_all(&dir);
734 std::fs::create_dir_all(dir.join("models")).expect("tmp");
735 std::fs::write(dir.join("models/here.glb"), b"glb").expect("write");
736 let m: WorldManifest = serde_json::from_str(
737 r#"{ "thread": "thread/0.1", "world": { "id": "w", "title": "W" },
738 "spawns": [{ "name": "e", "position": [0,0,0] }],
739 "assets": [
740 { "id": "a", "uri": "models/here.glb", "kind": "gltf" },
741 { "id": "b", "uri": "models/gone.glb", "kind": "gltf" },
742 { "id": "c", "uri": "https://cdn.example/x.glb", "kind": "gltf" },
743 { "id": "d", "uri": "behaviors/absent.wasm", "kind": "wasm" }
744 ] }"#,
745 )
746 .expect("manifest");
747 let corpus = Corpus {
748 worlds: vec![World { name: "w".into(), manifest: m, dir: Some(dir.clone()) }],
749 load_errors: Vec::new(),
750 };
751 let report = run(&corpus);
752 let c = report.clauses.iter().find(|c| c.name == "declared files are present").unwrap();
753 assert_eq!(c.severity, Severity::Error);
754 assert!(!c.pass);
755 assert_eq!(
756 c.notes.len(),
757 1,
758 "only the missing local file a browser must draw — not the link out, \
759 and not the wasm the spec lets a browser ignore: {:?}",
760 c.notes
761 );
762 assert!(c.notes[0].contains("gone.glb"), "{:?}", c.notes);
763 assert!(!report.passed(), "a room without its models is not conformant");
764
765 let fetched = single_corpus("host", &serde_json::to_string(&corpus.worlds[0].manifest).unwrap());
767 let c = run(&fetched)
768 .clauses
769 .iter()
770 .find(|c| c.name == "declared files are present")
771 .cloned()
772 .unwrap();
773 assert!(c.pass, "nothing to check without a directory: {:?}", c.notes);
774 let _ = std::fs::remove_dir_all(&dir);
775 }
776}