//! Actor kind inference (migration only) and save-time validation. use std::collections::HashSet; use std::path::Path; use bevy::ecs::world::EntityRef; use bevy::prelude::*; use crate::{ animation_clip_source_index, animation_skeleton_source_index, brush_math::validate_brush, ActorKind, AnimationControllerDesc, AudioListenerDesc, AudioSourceDesc, BrushDesc, LevelObject, LightDesc, ModelRef, NavigationArea, NavigationBounds, NavigationLink, NavigationObstacle, ObjectiveMarker, PlayerSpawn, PostProcessVolumeDesc, PrefabInstance, PrefabRef, Primitive, SkinnedMeshRenderer, StaticMeshRenderer, TeamSpawn, TriggerVolume, WeaponSpawn, AUDIO_CLIP_SUB_ASSET_ID, }; /// Deterministic presentation/compatibility hint derived from authoring components. /// /// Validation is component-driven; this priority only chooses an icon/default /// label when an actor composes several compatible behaviors (for example a /// rendered mesh with a light). pub fn infer_actor_kind(entity: EntityRef<'_>) -> Option { entity.get::()?; if entity.get::().is_some() || entity.get::().is_some() { return Some(ActorKind::PrefabAnchor); } if entity.get::().is_some() { return Some(ActorKind::SkinnedMesh); } if entity.get::().is_some() { return Some(ActorKind::Brush); } if entity.get::().is_some() || entity.get::().is_some() { return Some(ActorKind::StaticMesh); } if entity.get::().is_some() { return Some(ActorKind::ImportedModel); } if entity.get::().is_some() { return Some(ActorKind::Light); } if entity.get::().is_some() { return Some(ActorKind::PlayerSpawn); } if entity.get::().is_some() { return Some(ActorKind::WeaponSpawn); } if entity.get::().is_some() { return Some(ActorKind::TriggerVolume); } if entity.get::().is_some() { return Some(ActorKind::PostProcessVolume); } if entity.get::().is_some() { return Some(ActorKind::TeamSpawn); } if entity.get::().is_some() { return Some(ActorKind::Objective); } if entity.get::().is_some() { return Some(ActorKind::AudioSource); } if entity.get::().is_some() { return Some(ActorKind::AudioListener); } if entity.get::().is_some() || entity.get::().is_some() || entity.get::().is_some() || entity.get::().is_some() { return Some(ActorKind::Navigation); } Some(ActorKind::Empty) } #[derive(Debug, Clone, PartialEq, Eq)] pub enum ActorValidationError { MissingActorKind, MissingTransform, BrushMissingDesc, BrushHasPrimitive, BrushHasStaticMeshRenderer, BrushHasLight, BrushHasModelRef, InvalidBrushGeometry(String), StaticMeshMissingPrimitive, StaticMeshHasLight, StaticMeshHasModelRef, ImportedModelMissingModelRef, ImportedModelHasPrimitive, ImportedModelHasStaticMeshRenderer, SkinnedMeshMissingRenderer, SkinnedMeshInvalidRenderer, SkinnedMeshHasPrimitive, SkinnedMeshHasStaticMeshRenderer, SkinnedMeshHasModelRef, SkinnedMeshRendererActorKindMismatch, ConflictingGeometrySources, LightMissingLightDesc, LightHasPrimitive, LightHasModelRef, LightHasStaticMeshRenderer, PostProcessVolumeInvalidHalfExtents, PostProcessVolumeInvalidBlendDistance, PostProcessVolumeInvalidOverrideScalar, PostProcessVolumeMissingDesc, AudioSourceMissingDesc, AudioSourceMissingClip, AudioSourceInvalidClipReference, AudioSourceInvalidGain, AudioSourceInvalidPitch, AudioSourceInvalidSpatialBlend, AudioSourceInvalidAttenuation, AudioSourceMissingBus, AudioListenerMissingDesc, AudioListenerInvalidEarGap, AnimationControllerMissingSkinnedMeshRenderer, AnimationControllerMissingSkeleton, AnimationControllerInvalidSkeletonReference, AnimationControllerEmptyStateId, AnimationControllerDuplicateStateId, AnimationControllerInvalidClipReference, AnimationControllerInvalidStateSpeed, AnimationControllerInvalidStateRange, AnimationControllerInvalidCrossfade, AnimationControllerMissingDefaultState, AnimationControllerUnknownDefaultState, InvalidNavigation(String), } fn is_finite_positive(v: f32) -> bool { v.is_finite() && v > 0.0 } fn is_finite_non_negative(v: f32) -> bool { v.is_finite() && v >= 0.0 } /// Validates authoring component sets for a level object before save or play. pub fn validate_actor(entity: EntityRef<'_>) -> Result<(), ActorValidationError> { if entity.get::().is_none() { return Ok(()); } let Some(_stored_kind) = entity.get::() else { return Err(ActorValidationError::MissingActorKind); }; let kind = infer_actor_kind(entity).unwrap_or(ActorKind::Empty); if entity.get::().is_none() { return Err(ActorValidationError::MissingTransform); } if let Some(source) = entity.get::() { validate_audio_source(source)?; } if let Some(listener) = entity.get::() { validate_audio_listener(listener)?; } if let Some(controller) = entity.get::() { if entity.get::().is_none() { return Err(ActorValidationError::AnimationControllerMissingSkinnedMeshRenderer); } validate_animation_controller(controller)?; } let geometry_source_count = usize::from(entity.get::().is_some()) + usize::from(entity.get::().is_some()) + usize::from(entity.get::().is_some()) + usize::from(entity.get::().is_some()) + usize::from(entity.get::().is_some()); if geometry_source_count > 1 { return Err(ActorValidationError::ConflictingGeometrySources); } if let Some(brush) = entity.get::() { let report = validate_brush(brush); if !report.is_valid() { let message = report .diagnostics .into_iter() .find(|diagnostic| { diagnostic.severity == crate::brush_math::BrushDiagnosticSeverity::Error }) .map(|diagnostic| diagnostic.message) .unwrap_or_else(|| "Brush geometry is invalid.".to_string()); return Err(ActorValidationError::InvalidBrushGeometry(message)); } } if let Some(renderer) = entity.get::() { if renderer.path.trim().is_empty() { return Err(ActorValidationError::SkinnedMeshInvalidRenderer); } } if let Some(desc) = entity.get::() { validate_post_process_volume(desc)?; } if entity.get::().is_some() || entity.get::().is_some() || entity.get::().is_some() || entity.get::().is_some() { validate_navigation_actor(entity)?; } match &kind { ActorKind::Brush => { let Some(brush) = entity.get::() else { return Err(ActorValidationError::BrushMissingDesc); }; if entity.get::().is_some() { return Err(ActorValidationError::BrushHasPrimitive); } if entity.get::().is_some() { return Err(ActorValidationError::BrushHasStaticMeshRenderer); } if entity.get::().is_some() { return Err(ActorValidationError::BrushHasModelRef); } let _ = brush; } ActorKind::StaticMesh => { let has_static_mesh_renderer = entity .get::() .is_some_and(|renderer| renderer.slots.iter().any(|slot| slot.mesh.is_resolved())); if entity.get::().is_none() && !has_static_mesh_renderer { return Err(ActorValidationError::StaticMeshMissingPrimitive); } if entity.get::().is_some() { return Err(ActorValidationError::StaticMeshHasModelRef); } } ActorKind::SkinnedMesh => { let Some(_renderer) = entity.get::() else { return Err(ActorValidationError::SkinnedMeshMissingRenderer); }; if entity.get::().is_some() { return Err(ActorValidationError::SkinnedMeshHasPrimitive); } if entity.get::().is_some() { return Err(ActorValidationError::SkinnedMeshHasStaticMeshRenderer); } if entity.get::().is_some() { return Err(ActorValidationError::SkinnedMeshHasModelRef); } } ActorKind::ImportedModel => { if entity.get::().is_none() { return Err(ActorValidationError::ImportedModelMissingModelRef); } if entity.get::().is_some() { return Err(ActorValidationError::ImportedModelHasPrimitive); } if entity.get::().is_some() { return Err(ActorValidationError::ImportedModelHasStaticMeshRenderer); } } ActorKind::Light => { if entity.get::().is_none() { return Err(ActorValidationError::LightMissingLightDesc); } // Other compatible authoring components may be composed with a light. } ActorKind::PostProcessVolume => { let Some(desc) = entity.get::() else { return Err(ActorValidationError::PostProcessVolumeMissingDesc); }; let _ = desc; } ActorKind::AudioSource => { if entity.get::().is_none() { return Err(ActorValidationError::AudioSourceMissingDesc); } } ActorKind::AudioListener => { if entity.get::().is_none() { return Err(ActorValidationError::AudioListenerMissingDesc); } } ActorKind::Navigation => {} ActorKind::Empty | ActorKind::PrefabAnchor | ActorKind::PlayerSpawn | ActorKind::WeaponSpawn | ActorKind::TriggerVolume | ActorKind::TeamSpawn | ActorKind::Objective => {} } Ok(()) } fn validate_post_process_volume(desc: &PostProcessVolumeDesc) -> Result<(), ActorValidationError> { if !is_finite_positive(desc.half_extents.x) || !is_finite_positive(desc.half_extents.y) || !is_finite_positive(desc.half_extents.z) { return Err(ActorValidationError::PostProcessVolumeInvalidHalfExtents); } if !is_finite_non_negative(desc.blend_distance) { return Err(ActorValidationError::PostProcessVolumeInvalidBlendDistance); } let overrides = &desc.overrides; if overrides .exposure_ev100 .is_some_and(|value| !value.is_finite()) || overrides .fog_density .is_some_and(|value| !value.is_finite() || value < 0.0) { return Err(ActorValidationError::PostProcessVolumeInvalidOverrideScalar); } Ok(()) } fn validate_navigation_actor(entity: EntityRef<'_>) -> Result<(), ActorValidationError> { let mut component_count = 0; if let Some(bounds) = entity.get::() { component_count += 1; validate_vec3_positive(bounds.half_extents, "navigation bounds")?; bounds .agent .validate() .map_err(|error| ActorValidationError::InvalidNavigation(error.into()))?; crate::navigation_generated_artifact_path(&bounds.artifact_path) .map_err(|error| ActorValidationError::InvalidNavigation(error.into()))?; let mut sample_ids = HashSet::new(); for sample in &bounds.validation_samples { let sample_id = sample.id.trim(); if sample_id.is_empty() { return Err(ActorValidationError::InvalidNavigation( "navigation validation sample ID must not be empty".into(), )); } if !sample_ids.insert(sample_id) { return Err(ActorValidationError::InvalidNavigation(format!( "navigation validation sample ID `{sample_id}` must be unique within its bounds" ))); } if !sample.start.is_finite() || !sample.end.is_finite() || sample.start == sample.end { return Err(ActorValidationError::InvalidNavigation(format!( "navigation validation sample `{sample_id}` endpoints must be finite and distinct" ))); } } } if let Some(obstacle) = entity.get::() { component_count += 1; validate_vec3_positive(obstacle.half_extents, "navigation obstacle")?; } if let Some(area) = entity.get::() { component_count += 1; validate_vec3_positive(area.half_extents, "navigation area")?; if area.id.trim().is_empty() { return Err(ActorValidationError::InvalidNavigation( "navigation area ID must not be empty".into(), )); } if !is_finite_positive(area.cost) { return Err(ActorValidationError::InvalidNavigation( "navigation area cost must be finite and greater than zero".into(), )); } } if let Some(link) = entity.get::() { component_count += 1; if !link.start.is_finite() || !link.end.is_finite() || link.start == link.end { return Err(ActorValidationError::InvalidNavigation( "navigation link endpoints must be finite and distinct".into(), )); } if !is_finite_positive(link.cost) { return Err(ActorValidationError::InvalidNavigation( "navigation link cost must be finite and greater than zero".into(), )); } } if component_count != 1 { return Err(ActorValidationError::InvalidNavigation( "navigation actors must own exactly one bounds, obstacle, area, or link component" .into(), )); } Ok(()) } fn validate_vec3_positive(value: Vec3, label: &str) -> Result<(), ActorValidationError> { if !value.is_finite() || value.min_element() <= 0.0 { return Err(ActorValidationError::InvalidNavigation(format!( "{label} half extents must be finite and greater than zero" ))); } Ok(()) } fn validate_audio_source(source: &AudioSourceDesc) -> Result<(), ActorValidationError> { let Some(clip) = source.clip.as_ref() else { return Err(ActorValidationError::AudioSourceMissingClip); }; if !clip.is_resolved() || clip.sub_asset_id != AUDIO_CLIP_SUB_ASSET_ID { return Err(ActorValidationError::AudioSourceInvalidClipReference); } if !source.gain_db.is_finite() || !(settings::AUDIO_GAIN_DB_MIN..=settings::AUDIO_GAIN_DB_MAX).contains(&source.gain_db) { return Err(ActorValidationError::AudioSourceInvalidGain); } if !is_finite_positive(source.pitch) { return Err(ActorValidationError::AudioSourceInvalidPitch); } if !source.spatial_blend.is_finite() || !(0.0..=1.0).contains(&source.spatial_blend) { return Err(ActorValidationError::AudioSourceInvalidSpatialBlend); } let attenuation = source.attenuation; if !is_finite_non_negative(attenuation.min_distance) || !attenuation.max_distance.is_finite() || attenuation.max_distance <= attenuation.min_distance || !is_finite_non_negative(attenuation.rolloff_factor) { return Err(ActorValidationError::AudioSourceInvalidAttenuation); } if source.bus.trim().is_empty() { return Err(ActorValidationError::AudioSourceMissingBus); } Ok(()) } fn validate_audio_listener(listener: &AudioListenerDesc) -> Result<(), ActorValidationError> { if !is_finite_positive(listener.ear_gap) { return Err(ActorValidationError::AudioListenerInvalidEarGap); } Ok(()) } fn validate_animation_controller( controller: &AnimationControllerDesc, ) -> Result<(), ActorValidationError> { let Some(skeleton) = controller.skeleton.as_ref() else { return Err(ActorValidationError::AnimationControllerMissingSkeleton); }; if !skeleton.is_resolved() || animation_skeleton_source_index(&skeleton.sub_asset_id).is_none() { return Err(ActorValidationError::AnimationControllerInvalidSkeletonReference); } let mut state_ids = HashSet::with_capacity(controller.states.len()); for state in &controller.states { if state.id.trim().is_empty() { return Err(ActorValidationError::AnimationControllerEmptyStateId); } if !state_ids.insert(state.id.as_str()) { return Err(ActorValidationError::AnimationControllerDuplicateStateId); } let clip_source_is_gltf = state .clip .source_path .as_deref() .and_then(|path| Path::new(path).extension()) .and_then(|extension| extension.to_str()) .is_some_and(|extension| { extension.eq_ignore_ascii_case("gltf") || extension.eq_ignore_ascii_case("glb") }); if !state.clip.is_resolved() || animation_clip_source_index(&state.clip.sub_asset_id).is_none() || !clip_source_is_gltf { return Err(ActorValidationError::AnimationControllerInvalidClipReference); } if !state.speed.is_finite() || state.speed <= 0.0 { return Err(ActorValidationError::AnimationControllerInvalidStateSpeed); } if !is_finite_non_negative(state.range.start_seconds) || state .range .end_seconds .is_some_and(|end| !end.is_finite() || end < 0.0 || end < state.range.start_seconds) { return Err(ActorValidationError::AnimationControllerInvalidStateRange); } } if !is_finite_non_negative(controller.default_crossfade_seconds) { return Err(ActorValidationError::AnimationControllerInvalidCrossfade); } if controller.default_state.trim().is_empty() { return Err(ActorValidationError::AnimationControllerMissingDefaultState); } if !state_ids.contains(controller.default_state.as_str()) { return Err(ActorValidationError::AnimationControllerUnknownDefaultState); } Ok(()) } #[cfg(test)] mod tests { use super::*; use crate::{ animation_clip_sub_asset_id, animation_skeleton_sub_asset_id, AnimationPlaybackRange, AnimationStateDesc, EditorAssetRef, PostProcessVolumeDesc, }; fn level_entity(world: &mut World, bundle: impl Bundle) -> Entity { world .spawn((LevelObject, Transform::default(), bundle)) .id() } fn audio_source() -> AudioSourceDesc { AudioSourceDesc { clip: Some( crate::EditorAssetRef::new("audio-id", AUDIO_CLIP_SUB_ASSET_ID, "Tone") .with_source_path("assets/audio/tone.ogg"), ), ..Default::default() } } fn animation_controller() -> AnimationControllerDesc { AnimationControllerDesc { skeleton: Some( EditorAssetRef::new("model-id", animation_skeleton_sub_asset_id(0, "Rig"), "Rig") .with_source_path("assets/models/animated.glb"), ), states: vec![AnimationStateDesc { id: "idle".into(), label: "Idle".into(), clip: EditorAssetRef::new( "model-id", animation_clip_sub_asset_id(0, "Idle"), "Idle", ) .with_source_path("assets/models/animated.glb"), looping: true, speed: 1.0, range: AnimationPlaybackRange::default(), }], default_state: "idle".into(), default_crossfade_seconds: 0.2, } } #[test] fn validate_static_mesh_ok() { let mut world = World::new(); let e = level_entity(&mut world, (ActorKind::StaticMesh, Primitive::default())); assert!(validate_actor(world.entity(e)).is_ok()); } #[test] fn validate_static_mesh_renderer_ok() { let mut world = World::new(); let mut renderer = StaticMeshRenderer::default(); renderer.slots.push(crate::StaticMeshRendererEntry { mesh: crate::StaticMeshAssetRef::new("asset-id", "Mesh0/Primitive0", "Crate"), ..Default::default() }); let e = level_entity(&mut world, (ActorKind::StaticMesh, renderer)); assert!(validate_actor(world.entity(e)).is_ok()); } #[test] fn validate_static_mesh_composes_with_light_and_uses_derived_hint() { let mut world = World::new(); let e = level_entity( &mut world, ( ActorKind::StaticMesh, Primitive::default(), LightDesc::default(), ), ); assert!(validate_actor(world.entity(e)).is_ok()); assert_eq!( infer_actor_kind(world.entity(e)), Some(ActorKind::StaticMesh) ); } #[test] fn validate_brush_ok() { let mut world = World::new(); let e = level_entity(&mut world, (ActorKind::Brush, BrushDesc::default())); assert!(validate_actor(world.entity(e)).is_ok()); } #[test] fn validate_brush_rejects_static_mesh_renderer() { let mut world = World::new(); let e = level_entity( &mut world, ( ActorKind::Brush, BrushDesc::default(), StaticMeshRenderer::default(), ), ); assert_eq!( validate_actor(world.entity(e)), Err(ActorValidationError::ConflictingGeometrySources) ); } #[test] fn validate_brush_rejects_invalid_geometry() { let mut world = World::new(); let mut brush = BrushDesc::default(); brush.faces[0].vertices.clear(); let e = level_entity(&mut world, (ActorKind::Brush, brush)); assert_eq!( validate_actor(world.entity(e)), Err(ActorValidationError::InvalidBrushGeometry( "Face has fewer than three vertices.".into() )) ); } #[test] fn validate_post_process_volume_ok() { let mut world = World::new(); let e = level_entity( &mut world, ( ActorKind::PostProcessVolume, PostProcessVolumeDesc::default(), ), ); assert!(validate_actor(world.entity(e)).is_ok()); } #[test] fn validate_post_process_volume_rejects_bad_extents() { let mut world = World::new(); let e = level_entity( &mut world, ( ActorKind::PostProcessVolume, PostProcessVolumeDesc { half_extents: Vec3::ZERO, ..Default::default() }, ), ); assert_eq!( validate_actor(world.entity(e)), Err(ActorValidationError::PostProcessVolumeInvalidHalfExtents) ); } #[test] fn infers_and_validates_audio_source_and_listener_actors() { let mut world = World::new(); let source = level_entity(&mut world, audio_source()); let listener = level_entity(&mut world, AudioListenerDesc::default()); assert_eq!( infer_actor_kind(world.entity(source)), Some(ActorKind::AudioSource) ); assert_eq!( infer_actor_kind(world.entity(listener)), Some(ActorKind::AudioListener) ); world.entity_mut(source).insert(ActorKind::AudioSource); world.entity_mut(listener).insert(ActorKind::AudioListener); assert!(validate_actor(world.entity(source)).is_ok()); assert!(validate_actor(world.entity(listener)).is_ok()); } #[test] fn audio_source_requires_a_stable_clip_reference() { let mut world = World::new(); let entity = level_entity( &mut world, (ActorKind::AudioSource, AudioSourceDesc::default()), ); assert_eq!( validate_actor(world.entity(entity)), Err(ActorValidationError::AudioSourceMissingClip) ); world.entity_mut(entity).insert(AudioSourceDesc { clip: Some(crate::EditorAssetRef::new("audio-id", "mesh:0", "Wrong")), ..Default::default() }); assert_eq!( validate_actor(world.entity(entity)), Err(ActorValidationError::AudioSourceInvalidClipReference) ); } #[test] fn audio_scalar_and_attenuation_ranges_are_validated() { let mut world = World::new(); let entity = level_entity( &mut world, ( ActorKind::AudioSource, AudioSourceDesc { spatial_blend: 1.5, ..audio_source() }, ), ); assert_eq!( validate_actor(world.entity(entity)), Err(ActorValidationError::AudioSourceInvalidSpatialBlend) ); let listener = level_entity( &mut world, ( ActorKind::AudioListener, AudioListenerDesc { ear_gap: 0.0, ..Default::default() }, ), ); assert_eq!( validate_actor(world.entity(listener)), Err(ActorValidationError::AudioListenerInvalidEarGap) ); } #[test] fn animation_controller_requires_skinned_renderer_and_resolved_skeleton() { let mut world = World::new(); let entity = level_entity(&mut world, (ActorKind::SkinnedMesh, animation_controller())); assert_eq!( validate_actor(world.entity(entity)), Err(ActorValidationError::AnimationControllerMissingSkinnedMeshRenderer) ); world .entity_mut(entity) .insert(SkinnedMeshRenderer::new("assets/models/animated.glb")); let mut controller = animation_controller(); controller.skeleton = None; world.entity_mut(entity).insert(controller); assert_eq!( validate_actor(world.entity(entity)), Err(ActorValidationError::AnimationControllerMissingSkeleton) ); let mut controller = animation_controller(); controller.skeleton = Some(EditorAssetRef::new("model-id", "mesh:0", "Wrong")); world.entity_mut(entity).insert(controller); assert_eq!( validate_actor(world.entity(entity)), Err(ActorValidationError::AnimationControllerInvalidSkeletonReference) ); world.entity_mut(entity).insert(animation_controller()); assert!(validate_actor(world.entity(entity)).is_ok()); } #[test] fn animation_controller_validates_state_identity_and_clip_reference() { let mut world = World::new(); let entity = level_entity( &mut world, ( ActorKind::SkinnedMesh, SkinnedMeshRenderer::new("assets/models/animated.glb"), animation_controller(), ), ); let mut controller = animation_controller(); controller.states[0].id = " ".into(); world.entity_mut(entity).insert(controller); assert_eq!( validate_actor(world.entity(entity)), Err(ActorValidationError::AnimationControllerEmptyStateId) ); let mut controller = animation_controller(); controller.states.push(controller.states[0].clone()); world.entity_mut(entity).insert(controller); assert_eq!( validate_actor(world.entity(entity)), Err(ActorValidationError::AnimationControllerDuplicateStateId) ); let mut controller = animation_controller(); controller.states[0].clip = EditorAssetRef::new("model-id", animation_clip_sub_asset_id(0, "Idle"), "Idle") .with_source_path("assets/models/animated.fbx"); world.entity_mut(entity).insert(controller); assert_eq!( validate_actor(world.entity(entity)), Err(ActorValidationError::AnimationControllerInvalidClipReference) ); } #[test] fn animation_controller_validates_playback_ranges_and_default_state() { let mut world = World::new(); let entity = level_entity( &mut world, ( ActorKind::SkinnedMesh, SkinnedMeshRenderer::new("assets/models/animated.glb"), animation_controller(), ), ); let mut controller = animation_controller(); controller.states[0].speed = 0.0; world.entity_mut(entity).insert(controller); assert_eq!( validate_actor(world.entity(entity)), Err(ActorValidationError::AnimationControllerInvalidStateSpeed) ); let mut controller = animation_controller(); controller.states[0].range = AnimationPlaybackRange { start_seconds: 1.0, end_seconds: Some(0.5), }; world.entity_mut(entity).insert(controller); assert_eq!( validate_actor(world.entity(entity)), Err(ActorValidationError::AnimationControllerInvalidStateRange) ); let mut controller = animation_controller(); controller.default_crossfade_seconds = f32::NAN; world.entity_mut(entity).insert(controller); assert_eq!( validate_actor(world.entity(entity)), Err(ActorValidationError::AnimationControllerInvalidCrossfade) ); let mut controller = animation_controller(); controller.default_state.clear(); world.entity_mut(entity).insert(controller); assert_eq!( validate_actor(world.entity(entity)), Err(ActorValidationError::AnimationControllerMissingDefaultState) ); let mut controller = animation_controller(); controller.default_state = "missing".into(); world.entity_mut(entity).insert(controller); assert_eq!( validate_actor(world.entity(entity)), Err(ActorValidationError::AnimationControllerUnknownDefaultState) ); } #[test] fn navigation_bounds_validate_generated_path_and_unique_samples() { let mut world = World::new(); let mut bounds = NavigationBounds::for_actor("bounds-main"); bounds.validation_samples.push(crate::NavigationPathSample { id: "entry-to-exit".into(), start: Vec3::new(-2.0, 0.0, 0.0), end: Vec3::new(2.0, 0.0, 0.0), enabled: true, }); let entity = level_entity(&mut world, (ActorKind::Navigation, bounds.clone())); assert!(validate_actor(world.entity(entity)).is_ok()); bounds .validation_samples .push(bounds.validation_samples[0].clone()); world.entity_mut(entity).insert(bounds.clone()); assert!(matches!( validate_actor(world.entity(entity)), Err(ActorValidationError::InvalidNavigation(message)) if message.contains("must be unique") )); bounds.validation_samples.pop(); bounds.artifact_path = "assets/navigation/manual.nav.ron".into(); world.entity_mut(entity).insert(bounds); assert!(matches!( validate_actor(world.entity(entity)), Err(ActorValidationError::InvalidNavigation(message)) if message.contains("assets/navigation/generated/") )); } }