Skip to main content

concinnity_world/check/
physics.rs

1//! Physics authoring checks: collider shape strings, and the collision-layer
2//! names a world's PhysicsConfig declares against every reference to them.
3//! Also the authored shape the build sizes a world's spawn reservation from:
4//! which of its assets can create collider-bearing props while it runs.
5
6use crate::world::WorldJsonlAsset;
7
8const COLLIDER_SHAPES: [&str; 5] = ["aabb", "cuboid", "ball", "sphere", "capsule"];
9const BUILTIN_LAYERS: [&str; 4] = ["world", "prop", "character", "trigger"];
10// An interaction group is 32 bits: the built-ins plus at most 28 more.
11const MAX_USER_LAYERS: usize = 32 - BUILTIN_LAYERS.len();
12
13// A `collider` object's `shape`, when present, must be a recognized shape
14// name. Shared by the Prop and TriggerVolume checks.
15pub(crate) fn check_collider_shape(name: &str, args: &serde_json::Value) -> Result<(), String> {
16    let Some(shape) = args
17        .get("collider")
18        .and_then(|c| c.get("shape"))
19        .and_then(|s| s.as_str())
20    else {
21        return Ok(());
22    };
23    if COLLIDER_SHAPES.contains(&shape) {
24        return Ok(());
25    }
26    Err(format!(
27        "Asset '{}': unknown collider shape '{}'; expected one of {}",
28        name,
29        shape,
30        COLLIDER_SHAPES.join(", ")
31    ))
32}
33
34/// Check a physics asset's authored args.
35pub fn check(name: &str, args: &serde_json::Value) -> Result<(), String> {
36    check_collider_shape(name, args)
37}
38
39// World-shape rule: every layer name a world uses must resolve. Declared
40// names must be unique, not shadow a built-in, and fit the 32-bit group
41// space; `no_collide` pairs and collider `layer` fields must name a declared
42// or built-in layer.
43pub(crate) fn check_layers(assets: &[WorldJsonlAsset], errors: &mut Vec<String>) {
44    let norm = |t: &str| t.to_lowercase().replace('_', "");
45    let config = assets
46        .iter()
47        .find(|a| norm(&a.asset_type) == "physicsconfig");
48
49    let mut known: Vec<String> = BUILTIN_LAYERS.iter().map(|s| s.to_string()).collect();
50    if let Some(config) = config {
51        let declared: Vec<&str> = config
52            .args
53            .get("layers")
54            .and_then(|v| v.as_array())
55            .map(|list| list.iter().filter_map(|v| v.as_str()).collect())
56            .unwrap_or_default();
57        if declared.len() > MAX_USER_LAYERS {
58            errors.push(format!(
59                "PhysicsConfig '{}': {} extra layers declared; at most {} fit the 32-bit group space",
60                config.name,
61                declared.len(),
62                MAX_USER_LAYERS
63            ));
64        }
65        for layer in declared {
66            if layer.is_empty() {
67                errors.push(format!("PhysicsConfig '{}': empty layer name", config.name));
68            } else if known.iter().any(|k| k == layer) {
69                errors.push(format!(
70                    "PhysicsConfig '{}': layer '{}' is already defined",
71                    config.name, layer
72                ));
73            } else {
74                known.push(layer.to_string());
75            }
76        }
77        if let Some(pairs) = config.args.get("no_collide").and_then(|v| v.as_array()) {
78            for pair in pairs {
79                let names = pair
80                    .as_array()
81                    .map(|p| p.iter().filter_map(|v| v.as_str()).collect::<Vec<&str>>());
82                let Some(names) = names.filter(|n| n.len() == 2) else {
83                    errors.push(format!(
84                        "PhysicsConfig '{}': no_collide entries are [\"layer_a\", \"layer_b\"] pairs",
85                        config.name
86                    ));
87                    continue;
88                };
89                for layer in names {
90                    if !known.iter().any(|k| k == layer) {
91                        errors.push(format!(
92                            "PhysicsConfig '{}': no_collide names unknown layer '{}'",
93                            config.name, layer
94                        ));
95                    }
96                }
97            }
98        }
99        if let Some(min) = config
100            .args
101            .get("contact_min_impulse")
102            .and_then(|v| v.as_f64())
103            && min < 0.0
104        {
105            errors.push(format!(
106                "PhysicsConfig '{}': contact_min_impulse must not be negative",
107                config.name
108            ));
109        }
110    }
111
112    for asset in assets.iter().filter(|a| norm(&a.asset_type) == "prop") {
113        let Some(layer) = asset
114            .args
115            .get("collider")
116            .and_then(|c| c.get("layer"))
117            .and_then(|l| l.as_str())
118            .filter(|l| !l.is_empty())
119        else {
120            continue;
121        };
122        if !known.iter().any(|k| k == layer) {
123            errors.push(format!(
124                "Prop '{}': collider layer '{}' is not a built-in layer or declared in PhysicsConfig `layers`",
125                asset.name, layer
126            ));
127        }
128    }
129}
130
131/// A `Spawner` whose template names a `Prop` that carries a collider.
132///
133/// The cadence fields are what decide how many copies can be alive at once,
134/// which is what the build reserves physics bodies against.
135#[derive(Debug, Clone, PartialEq)]
136pub struct ColliderSpawner<'a> {
137    /// Name of the spawner asset.
138    pub name: &'a str,
139    /// Seconds between spawns.
140    pub interval: f32,
141    /// Seconds each copy lives before auto-removal; 0 keeps it forever.
142    pub lifetime: f32,
143}
144
145/// Every authored way a world creates collider-bearing props while it runs.
146///
147/// A `Spawner` over a `SkinnedMesh` is not here: a skinned copy claims a
148/// pre-reserved render instance and carries no collider, so it never reaches
149/// physics.
150#[derive(Debug, Clone, Default, PartialEq)]
151pub struct ColliderSpawnSources<'a> {
152    /// Spawners copying a collider-bearing prop on a fixed cadence.
153    pub spawners: Vec<ColliderSpawner<'a>>,
154    /// Names of the `Behavior`s with a `spawn` node over such a prop.
155    pub behaviors: Vec<&'a str>,
156}
157
158/// Collect the world's runtime sources of collider-bearing props.
159///
160/// Every copy such a source emits needs a physics body the authored content
161/// did not account for. The build reserves for the ones whose cadence bounds
162/// their population and warns about the rest, because the simulation refuses
163/// bodies past its reservation.
164pub fn collider_spawn_sources(assets: &[WorldJsonlAsset]) -> ColliderSpawnSources<'_> {
165    let norm = |t: &str| t.to_lowercase().replace('_', "");
166    let collider_props: Vec<&str> = assets
167        .iter()
168        .filter(|a| norm(&a.asset_type) == "prop")
169        .filter(|a| a.args.get("collider").is_some_and(|c| !c.is_null()))
170        .map(|a| a.name.as_str())
171        .collect();
172    let mut sources = ColliderSpawnSources::default();
173    if collider_props.is_empty() {
174        return sources;
175    }
176    let is_collider_prop =
177        |name: &serde_json::Value| name.as_str().is_some_and(|n| collider_props.contains(&n));
178
179    let defaults = concinnity_core::components::cook::Spawner::default();
180    for asset in assets {
181        match norm(&asset.asset_type).as_str() {
182            "spawner" if asset.args.get("template").is_some_and(is_collider_prop) => {
183                let secs = |field: &str, fallback: f32| {
184                    asset
185                        .args
186                        .get(field)
187                        .and_then(|v| v.as_f64())
188                        .map_or(fallback, |v| v as f32)
189                };
190                sources.spawners.push(ColliderSpawner {
191                    name: &asset.name,
192                    interval: secs("interval", defaults.interval),
193                    lifetime: secs("lifetime", defaults.lifetime),
194                });
195            }
196            "behavior"
197                if spawn_templates(&asset.args)
198                    .iter()
199                    .any(|t| is_collider_prop(t)) =>
200            {
201                sources.behaviors.push(&asset.name);
202            }
203            _ => {}
204        }
205    }
206    sources
207}
208
209// Every `template` value of a `spawn` node anywhere in a behavior's args.
210// Nodes nest (inside `if` branches, `repeat` bodies), so the walk is
211// structural rather than a fixed path.
212fn spawn_templates(args: &serde_json::Value) -> Vec<&serde_json::Value> {
213    let mut out = Vec::new();
214    let mut stack = vec![args];
215    while let Some(value) = stack.pop() {
216        match value {
217            serde_json::Value::Object(map) => {
218                for (key, child) in map {
219                    if key == "spawn"
220                        && let Some(template) = child.get("template")
221                    {
222                        out.push(template);
223                    }
224                    stack.push(child);
225                }
226            }
227            serde_json::Value::Array(items) => stack.extend(items),
228            _ => {}
229        }
230    }
231    out
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    fn asset(name: &str, asset_type: &str, args: serde_json::Value) -> WorldJsonlAsset {
239        WorldJsonlAsset {
240            name: name.to_string(),
241            asset_type: asset_type.to_string(),
242            args,
243        }
244    }
245
246    fn layer_errors(assets: &[WorldJsonlAsset]) -> Vec<String> {
247        let mut errors = Vec::new();
248        check_layers(assets, &mut errors);
249        errors
250    }
251
252    #[test]
253    fn known_shapes_pass_and_unknown_shapes_fail() {
254        let ok = serde_json::json!({"collider": {"shape": "capsule"}});
255        assert!(check_collider_shape("p", &ok).is_ok());
256        // No collider or no shape field: nothing to judge.
257        assert!(check_collider_shape("p", &serde_json::json!({})).is_ok());
258        let err = check_collider_shape("p", &serde_json::json!({"collider": {"shape": "mesh"}}))
259            .unwrap_err();
260        assert!(err.contains("unknown collider shape 'mesh'"), "got: {err}");
261    }
262
263    #[test]
264    fn declared_layers_resolve_everywhere() {
265        let assets = [
266            asset(
267                "physics",
268                "PhysicsConfig",
269                serde_json::json!({"layers": ["debris"], "no_collide": [["debris", "character"]]}),
270            ),
271            asset(
272                "rock",
273                "Prop",
274                serde_json::json!({"mesh": "m", "collider": {"shape": "ball", "layer": "debris"}}),
275            ),
276        ];
277        assert!(layer_errors(&assets).is_empty());
278    }
279
280    #[test]
281    fn unknown_layer_references_fail() {
282        let assets = [
283            asset(
284                "physics",
285                "PhysicsConfig",
286                serde_json::json!({"no_collide": [["ghost", "world"]]}),
287            ),
288            asset(
289                "rock",
290                "Prop",
291                serde_json::json!({"mesh": "m", "collider": {"shape": "ball", "layer": "ghost"}}),
292            ),
293        ];
294        let errors = layer_errors(&assets);
295        assert_eq!(errors.len(), 2, "{errors:?}");
296        assert!(errors[0].contains("no_collide names unknown layer 'ghost'"));
297        assert!(errors[1].contains("collider layer 'ghost'"));
298    }
299
300    #[test]
301    fn prop_layers_work_without_a_physics_config() {
302        // Built-ins resolve with no config declared; unknown names still fail.
303        let ok = [asset(
304            "rock",
305            "Prop",
306            serde_json::json!({"mesh": "m", "collider": {"shape": "ball", "layer": "prop"}}),
307        )];
308        assert!(layer_errors(&ok).is_empty());
309        let bad = [asset(
310            "rock",
311            "Prop",
312            serde_json::json!({"mesh": "m", "collider": {"shape": "ball", "layer": "ghost"}}),
313        )];
314        assert_eq!(layer_errors(&bad).len(), 1);
315    }
316
317    #[test]
318    fn duplicate_and_overflowing_layer_declarations_fail() {
319        let dup = [asset(
320            "physics",
321            "PhysicsConfig",
322            serde_json::json!({"layers": ["debris", "debris", "world"]}),
323        )];
324        let errors = layer_errors(&dup);
325        assert_eq!(errors.len(), 2, "{errors:?}");
326        assert!(errors.iter().all(|e| e.contains("already defined")));
327
328        let many: Vec<String> = (0..29).map(|i| format!("layer{i}")).collect();
329        let over = [asset(
330            "physics",
331            "PhysicsConfig",
332            serde_json::json!({"layers": many}),
333        )];
334        let errors = layer_errors(&over);
335        assert_eq!(errors.len(), 1, "{errors:?}");
336        assert!(errors[0].contains("32-bit group space"));
337    }
338
339    #[test]
340    fn a_spawner_naming_a_collider_prop_can_spawn_bodies() {
341        let crate_ = [
342            asset(
343                "crate",
344                "Prop",
345                serde_json::json!({"mesh": "m", "collider": {"shape": "ball"}}),
346            ),
347            asset(
348                "drop",
349                "Spawner",
350                serde_json::json!({"template": "crate", "interval": 2.0, "lifetime": 4.0}),
351            ),
352        ];
353        let sources = collider_spawn_sources(&crate_);
354        assert_eq!(
355            sources.spawners,
356            vec![ColliderSpawner {
357                name: "drop",
358                interval: 2.0,
359                lifetime: 4.0,
360            }]
361        );
362        assert!(sources.behaviors.is_empty());
363
364        // The same spawner over a prop with no collider spawns nothing the
365        // simulation has to hold.
366        let bare = [
367            asset("banner", "Prop", serde_json::json!({"mesh": "m"})),
368            asset("drop", "Spawner", serde_json::json!({"template": "banner"})),
369        ];
370        assert_eq!(
371            collider_spawn_sources(&bare),
372            ColliderSpawnSources::default()
373        );
374    }
375
376    // An unauthored cadence is the authored Spawner default: one a second, and
377    // copies that are never removed.
378    #[test]
379    fn an_unauthored_cadence_reads_back_as_the_spawner_default() {
380        let assets = [
381            asset(
382                "crate",
383                "Prop",
384                serde_json::json!({"mesh": "m", "collider": {"shape": "ball"}}),
385            ),
386            asset("drop", "Spawner", serde_json::json!({"template": "crate"})),
387        ];
388        let defaults = concinnity_core::components::cook::Spawner::default();
389        let spawner = &collider_spawn_sources(&assets).spawners[0];
390        assert_eq!(spawner.interval, defaults.interval);
391        assert_eq!(spawner.lifetime, defaults.lifetime);
392    }
393
394    // An explicit null collider is not a collider: the load-time decomposition
395    // gives such a prop no Collider component, so no spawn of it needs a body.
396    #[test]
397    fn an_explicitly_null_collider_is_not_a_source() {
398        let assets = [
399            asset(
400                "banner",
401                "Prop",
402                serde_json::json!({"mesh": "m", "collider": null}),
403            ),
404            asset("drop", "Spawner", serde_json::json!({"template": "banner"})),
405        ];
406        assert_eq!(
407            collider_spawn_sources(&assets),
408            ColliderSpawnSources::default()
409        );
410    }
411
412    #[test]
413    fn a_nested_behavior_spawn_node_counts_too() {
414        let assets = [
415            asset(
416                "crate",
417                "Prop",
418                serde_json::json!({"mesh": "m", "collider": {"shape": "ball"}}),
419            ),
420            asset(
421                "thrower",
422                "Behavior",
423                serde_json::json!({"on": "tick", "do": [
424                    {"if": {"cond": "ready", "then": [{"spawn": {"template": "crate"}}]}}
425                ]}),
426            ),
427        ];
428        let sources = collider_spawn_sources(&assets);
429        assert_eq!(sources.behaviors, vec!["thrower"], "a nested node spawns");
430        assert!(sources.spawners.is_empty());
431
432        // No spawn source at all: the world only ever holds what it declares.
433        let quiet = [asset(
434            "crate",
435            "Prop",
436            serde_json::json!({"mesh": "m", "collider": {"shape": "ball"}}),
437        )];
438        assert_eq!(
439            collider_spawn_sources(&quiet),
440            ColliderSpawnSources::default()
441        );
442    }
443
444    #[test]
445    fn malformed_no_collide_and_negative_impulse_fail() {
446        let assets = [asset(
447            "physics",
448            "PhysicsConfig",
449            serde_json::json!({"no_collide": [["world"]], "contact_min_impulse": -1.0}),
450        )];
451        let errors = layer_errors(&assets);
452        assert_eq!(errors.len(), 2, "{errors:?}");
453        assert!(errors[0].contains("pairs"));
454        assert!(errors[1].contains("must not be negative"));
455    }
456}