1use crate::{Placement, WorldManifest};
15
16#[derive(Debug, Clone)]
18pub struct Finding {
19 pub rule: &'static str,
21 pub message: String,
23}
24
25impl std::fmt::Display for Finding {
26 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27 write!(f, "[{}] {}", self.rule, self.message)
28 }
29}
30
31const PORTAL_ENTER_RADIUS: f32 = 2.2;
34
35const FLOAT_TOLERANCE: f32 = 0.25;
37
38pub fn lint(m: &WorldManifest) -> Vec<Finding> {
40 let mut out = Vec::new();
41 let shape_bounds: std::collections::HashMap<u32, ([f32; 3], [f32; 3])> = m
44 .prefabs
45 .iter()
46 .filter_map(|p| p.mesh.shape.as_ref().map(|sh| (p.id.0, sh.bounds())))
47 .collect();
48 let boxes: Vec<Aabb> = m
49 .placements
50 .iter()
51 .map(|p| Aabb::of(p, shape_bounds.get(&p.prefab.0)))
52 .collect();
53 floating_placements(m, &boxes, &mut out);
54 spawn_hazards(m, &boxes, &mut out);
55 portal_hazards(m, &boxes, &mut out);
56 text_overflow(m, &mut out);
57 darkness(m, &mut out);
58 degenerate_scales(m, &mut out);
59 doubled_placements(m, &mut out);
60 presence_claim(m, &mut out);
61 out
62}
63
64fn presence_claim(m: &WorldManifest, out: &mut Vec<Finding>) {
69 let Some(p) = &m.presence else { return };
70 if p.mode_disagrees() {
71 out.push(Finding {
72 rule: "L9",
73 message: format!(
74 "presence says mode '{}' but names {} — a browser will treat this world as '{}'",
75 p.mode.as_deref().unwrap_or_default(),
76 if p.relay_list().is_empty() {
77 "no relay".to_string()
78 } else {
79 format!("{} relay(s)", p.relay_list().len())
80 },
81 p.effective_mode()
82 ),
83 });
84 }
85}
86
87#[derive(Clone, Copy)]
89struct Aabb {
90 min: [f32; 3],
91 max: [f32; 3],
92 solid: bool,
93 axis_aligned: bool,
96}
97
98impl Aabb {
99 fn of(p: &Placement, shape_bounds: Option<&([f32; 3], [f32; 3])>) -> Aabb {
100 if let Some((bmin, bmax)) = shape_bounds {
103 let lo = [
104 p.position[0] + bmin[0] * p.scale[0],
105 p.position[1] + bmin[1] * p.scale[1],
106 p.position[2] + bmin[2] * p.scale[2],
107 ];
108 let hi = [
109 p.position[0] + bmax[0] * p.scale[0],
110 p.position[1] + bmax[1] * p.scale[1],
111 p.position[2] + bmax[2] * p.scale[2],
112 ];
113 let [qx, qy, qz, qw] = p.rotation;
114 let upright =
115 qx.abs() < 0.02 && qz.abs() < 0.02 && (qy.abs() < 0.04 || qw.abs() < 0.04);
116 return Aabb {
117 min: lo,
118 max: hi,
119 solid: p.solid.unwrap_or(true),
120 axis_aligned: upright,
121 };
122 }
123 let half = [
124 p.scale[0].abs() / 2.0,
125 p.scale[1].abs() / 2.0,
126 p.scale[2].abs() / 2.0,
127 ];
128 let [qx, qy, qz, qw] = p.rotation;
132 let upright = qx.abs() < 0.02 && qz.abs() < 0.02 && (qy.abs() < 0.04 || qw.abs() < 0.04);
133 Aabb {
134 min: [
135 p.position[0] - half[0],
136 p.position[1] - half[1],
137 p.position[2] - half[2],
138 ],
139 max: [
140 p.position[0] + half[0],
141 p.position[1] + half[1],
142 p.position[2] + half[2],
143 ],
144 solid: p.solid.unwrap_or(true),
145 axis_aligned: upright,
146 }
147 }
148
149 fn footprint_overlaps(&self, other: &Aabb) -> bool {
150 self.min[0] < other.max[0]
151 && self.max[0] > other.min[0]
152 && self.min[2] < other.max[2]
153 && self.max[2] > other.min[2]
154 }
155
156 fn contains(&self, p: [f32; 3]) -> bool {
157 (0..3).all(|i| p[i] > self.min[i] && p[i] < self.max[i])
158 }
159}
160
161fn name_of(p: &Placement, i: usize) -> String {
162 if !p.name.is_empty() {
163 format!("'{}'", p.name)
164 } else if let Some(k) = &p.kind {
165 format!("<{k}> #{i}")
166 } else {
167 format!("placement #{i} (prefab {})", p.prefab.0)
168 }
169}
170
171fn floating_placements(m: &WorldManifest, boxes: &[Aabb], out: &mut Vec<Finding>) {
175 for (i, p) in m.placements.iter().enumerate() {
176 if p.solid == Some(false) || p.text.is_some() || p.light.is_some() {
177 continue;
178 }
179 let b = &boxes[i];
180 if !b.axis_aligned {
181 continue; }
183 if b.min[1] <= FLOAT_TOLERANCE {
184 continue; }
186 let supported = boxes.iter().enumerate().any(|(j, other)| {
187 j != i
188 && other.footprint_overlaps(b)
189 && other.max[1] >= b.min[1] - FLOAT_TOLERANCE
190 && other.min[1] < b.min[1] + FLOAT_TOLERANCE
191 });
192 if !supported {
195 out.push(Finding {
196 rule: "L1",
197 message: format!(
198 "{} floats {:.2}m above ground with nothing under it (at {:?})",
199 name_of(p, i),
200 b.min[1],
201 p.position
202 ),
203 });
204 }
205 }
206}
207
208fn spawn_hazards(m: &WorldManifest, boxes: &[Aabb], out: &mut Vec<Finding>) {
210 for s in &m.spawns {
211 let eye = [s.position[0], s.position[1] + 1.0, s.position[2]];
212 for (i, b) in boxes.iter().enumerate() {
213 if b.solid && b.axis_aligned && b.contains(eye) {
214 out.push(Finding {
215 rule: "L2",
216 message: format!(
217 "spawn '{}' is inside solid {} — visitors arrive buried",
218 s.name,
219 name_of(&m.placements[i], i)
220 ),
221 });
222 }
223 }
224 }
225}
226
227fn portal_hazards(m: &WorldManifest, boxes: &[Aabb], out: &mut Vec<Finding>) {
230 for portal in &m.portals {
231 for s in &m.spawns {
232 let d = dist(portal.position, s.position);
233 if d < PORTAL_ENTER_RADIUS + 0.3 {
234 out.push(Finding {
235 rule: "L3",
236 message: format!(
237 "veil '{}' is {:.1}m from spawn '{}' — inside the trigger radius: visitors arrive standing IN the doorway (it can't fire until they step away and return). Keep veils > {:.1}m from spawns",
238 portal.id, d, s.name, PORTAL_ENTER_RADIUS
239 ),
240 });
241 }
242 }
243 for (i, b) in boxes.iter().enumerate() {
244 if b.solid && b.axis_aligned && b.contains(portal.position) {
245 out.push(Finding {
246 rule: "L3",
247 message: format!(
248 "veil '{}' sits inside solid {} — the doorway is walled off",
249 portal.id,
250 name_of(&m.placements[i], i)
251 ),
252 });
253 }
254 }
255 }
256}
257
258fn text_overflow(m: &WorldManifest, out: &mut Vec<Finding>) {
262 for (i, p) in m.placements.iter().enumerate() {
263 let Some(t) = &p.text else { continue };
264 let lines = t.content.lines().count() as f32;
265 let capacity = (1.0 - 1.6 * t.size) / (1.4 * t.size);
266 if lines > capacity.max(1.0) {
267 out.push(Finding {
268 rule: "L4",
269 message: format!(
270 "{}'s text is {} lines but the panel fits ~{:.0} at size {} — shrink the size or trim the copy",
271 name_of(p, i),
272 lines as usize,
273 capacity,
274 t.size
275 ),
276 });
277 }
278 }
279}
280
281fn darkness(m: &WorldManifest, out: &mut Vec<Finding>) {
284 let has_sky = m.environment.sky.is_some();
285 let has_light = m.placements.iter().any(|p| p.light.is_some());
286 let has_emissive = m
287 .prefabs
288 .iter()
289 .any(|pf| pf.material.as_ref().is_some_and(|mat| mat.emissive > 0.0));
290 if !has_sky && !has_light && !has_emissive {
291 out.push(Finding {
292 rule: "L5",
293 message: "no sky, no lights, nothing emissive — this world renders dark".to_string(),
294 });
295 }
296}
297
298fn degenerate_scales(m: &WorldManifest, out: &mut Vec<Finding>) {
301 for (i, p) in m.placements.iter().enumerate() {
302 if p.scale.iter().any(|s| *s <= 0.0) {
303 out.push(Finding {
304 rule: "L6",
305 message: format!("{} has a zero/negative scale {:?}", name_of(p, i), p.scale),
306 });
307 } else if p.scale.iter().any(|s| *s > 500.0) {
308 out.push(Finding {
309 rule: "L6",
310 message: format!(
311 "{} is over 500m on a side ({:?}) — metres, not centimetres?",
312 name_of(p, i),
313 p.scale
314 ),
315 });
316 }
317 }
318}
319
320fn doubled_placements(m: &WorldManifest, out: &mut Vec<Finding>) {
323 for i in 0..m.placements.len() {
324 for j in (i + 1)..m.placements.len() {
325 let (a, b) = (&m.placements[i], &m.placements[j]);
326 if a.prefab == b.prefab && a.position == b.position && a.scale == b.scale {
327 out.push(Finding {
328 rule: "L8",
329 message: format!(
330 "{} and {} are identical twins at {:?} — one is probably a paste",
331 name_of(a, i),
332 name_of(b, j),
333 a.position
334 ),
335 });
336 }
337 }
338 }
339}
340
341fn dist(a: [f32; 3], b: [f32; 3]) -> f32 {
342 let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
343 (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349 use crate::markup;
350
351 fn world(src: &str) -> WorldManifest {
352 markup::compile(src).unwrap()
353 }
354
355 #[test]
356 fn a_clean_world_lints_clean() {
357 let m = world(
358 r#"<world id="w" title="W" sky="dusk">
359 <spawn at="0 0 8"/>
360 <plane at="0 0 0" scale="30 1 30"/>
361 <cube at="3 0.5 0" scale="1 1 1"/>
362 <portal at="0 1.4 -6" to="thread://pixygon.io" label="Out"/>
363 </world>"#,
364 );
365 let findings = lint(&m);
366 assert!(findings.is_empty(), "{findings:?}");
367 }
368
369 #[test]
370 fn floating_buried_and_walkin_are_caught() {
371 let m = world(
372 r#"<world id="w" title="W" sky="dusk">
373 <spawn at="0 0 -5"/>
374 <plane at="0 0 0" scale="30 1 30"/>
375 <cube id="hover" at="5 4 5" scale="1 1 1"/>
376 <cube id="tomb" at="0 1 -5" scale="4 4 4"/>
377 <portal id="door" at="0 1.4 -6" to="thread://pixygon.io" label="Out"/>
378 </world>"#,
379 );
380 let f = lint(&m);
381 let rules: Vec<&str> = f.iter().map(|x| x.rule).collect();
382 assert!(rules.contains(&"L1"), "hovering cube: {f:?}");
383 assert!(rules.contains(&"L2"), "buried spawn: {f:?}");
384 assert!(rules.contains(&"L3"), "walk-in veil: {f:?}");
385 }
386
387 #[test]
388 fn supported_stacks_do_not_flag() {
389 let m = world(
390 r#"<world id="w" title="W" sky="dusk">
391 <spawn at="0 0 8"/>
392 <plane at="0 0 0" scale="30 1 30"/>
393 <cube id="plinth" at="0 0.5 0" scale="1 1 1"/>
394 <cube id="statue" at="0 1.5 0" scale="0.8 1 0.8"/>
395 </world>"#,
396 );
397 let f: Vec<_> = lint(&m).into_iter().filter(|x| x.rule == "L1").collect();
398 assert!(f.is_empty(), "stacked statue is supported: {f:?}");
399 }
400
401 #[test]
402 fn overflow_darkness_scale_and_twins_are_caught() {
403 let mut m = world(
404 r#"<world id="w" title="W">
405 <spawn at="0 0 8"/>
406 <plane at="0 0 0" scale="30 1 30"/>
407 <cube at="4 0.5 4"/>
408 <cube at="4 0.5 4"/>
409 <cube id="mega" at="9 0.5 0" scale="900 1 1"/>
410 </world>"#,
411 );
412 m.placements[0].text = Some(crate::TextPanel {
413 content: (0..40)
414 .map(|i| format!("line {i}"))
415 .collect::<Vec<_>>()
416 .join("\n"),
417 size: 0.05,
418 color: [0.0; 3],
419 background: [1.0; 3],
420 links: vec![],
421 });
422 let f = lint(&m);
423 let rules: Vec<&str> = f.iter().map(|x| x.rule).collect();
424 assert!(rules.contains(&"L4"), "overflowing text: {f:?}");
425 assert!(rules.contains(&"L5"), "dark world: {f:?}");
426 assert!(rules.contains(&"L6"), "typo scale: {f:?}");
427 assert!(rules.contains(&"L8"), "twins: {f:?}");
428 }
429
430 #[test]
433 fn a_presence_claim_the_addresses_cannot_keep_is_reported() {
434 let world = |presence: &str| -> WorldManifest {
435 serde_json::from_str(&format!(
436 r#"{{ "thread": "thread/0.1", "world": {{ "id": "w", "title": "W" }},
437 "spawns": [{{ "name": "e", "position": [0,0,0] }}],
438 "placements": [{{ "prefab": 60930001, "position": [0,0,0], "scale": [1,1,1],
439 "light": {{ "color": [1,1,1], "intensity": 1, "range": 8 }} }}],
440 "presence": {presence} }}"#
441 ))
442 .expect("manifest")
443 };
444 let claims_a_relay_it_hasnt_got = lint(&world(r#"{ "mode": "relay" }"#));
445 assert!(
446 claims_a_relay_it_hasnt_got.iter().any(|f| f.rule == "L9"),
447 "{claims_a_relay_it_hasnt_got:?}"
448 );
449 assert!(lint(&world(r#"{ "mode": "solo" }"#))
450 .iter()
451 .all(|f| f.rule != "L9"));
452 assert!(
453 lint(&world(r#"{ "mode": "relay", "relays": ["wss://a"] }"#))
454 .iter()
455 .all(|f| f.rule != "L9")
456 );
457 assert!(lint(&world(r#"{ "relays": ["wss://a"] }"#))
458 .iter()
459 .all(|f| f.rule != "L9"));
460 }
461}