using System; using UnityEngine; namespace ProjectI.Enemies { [CreateAssetMenu(menuName = "ProjectI/Enemies/Investigation")] public sealed class EnemyInvestigationDefinition : ScriptableObject { [Header("Search strength (captured at spawn)")] [Tooltip("Horizontal metres around the last genuinely observed feet; never follows a hidden target.")] public float searchRadius = 6; [Tooltip("Maximum elapsed seconds from last personal sight, also capped by perception evidence retention.")] public float maximumSearchSeconds = 6; [Tooltip("Cumulative actual voluntary travel in metres, across all routes and interruptions.")] public float maximumSearchTravelDistance = 18; [Range(1, 16), Tooltip("Admitted destinations, including failed travel. Deferral consumes no attempt.")] public int maximumViewpointAttempts = 4; [Range(1, 16), Tooltip("Maximum completed viewpoint inspections; cannot exceed attempts.")] public int maximumViewpointsToInspect = 3; [Tooltip("Seconds of oriented dwell, plus a new real perception pass; zero still requires a new pass.")] public float inspectionSeconds = .45f; [Header("Sampling and scoring")] [Tooltip("Yaw error in degrees permitted throughout inspection.")] public float observationFacingToleranceDegrees = 10; [Tooltip("Maximum projected height difference from last-seen feet, in metres.")] public float maximumVerticalOffset = 2; [Tooltip("Additional viewpoint extent outside the belief radius, in metres.")] public float viewpointStandOff = 1.5f; [Tooltip("Projected viewpoints closer than this many metres share one identity.")] public float viewpointMergeDistance = .75f; [InspectorName("Maximum prediction locations"), Range(1, 16), Tooltip("Maximum total ground hypotheses: remembered anchor, motion extrapolation, ring guesses and cover samples. These are assumptions, never hidden-player positions.")] public int maximumHypotheses = 8; [InspectorName("Non-cover radial sample count"), Range(0, 16), Tooltip("Maximum extra radial guesses attempted around the remembered anchor, independent of cover cone rays. Uses half/full-radius cardinal samples, then diagonals. Zero disables radial guesses only; anchor and observed-motion predictions remain. Valid ground, merging, planning time and the shared Maximum prediction locations ceiling can admit fewer guesses. Captured at spawn; reset/respawn to apply.")] public int radialSampleCount = 16; [InspectorName("Cover cone ray count"), Range(0, 16), Tooltip("Maximum deterministic ray directions attempted inside the cone from the captured enemy eye toward the frozen prediction centre; fewer guesses may be admitted. Only wall hits with a valid far side and downward floor hit can add guesses, limited to half Maximum prediction locations, merging, shared budgets and remaining time. Zero disables. Reset/respawn to apply.")] public int coverFanSampleCount = 4; [InspectorName("Cover probe reach (m)"), Min(0), Tooltip("Horizontal radius around the frozen prediction centre allowed for accepted far-side ground, capped by Search Radius. Rays may hit walls before entering this region. Exit scanning remains limited to 32 quarter-metre steps per ray. Zero disables. Reset/respawn to apply.")] public float coverFanRadius = 3; [InspectorName("Cover cone half-angle (°)"), Range(0, 180), Tooltip("Redistributes rays inside a 3D cone aimed at the frozen predicted torso centre, separate from sight FOV. 15 means a 30-degree opening; 45 means 90; 180 covers all directions. Includes a centre ray. Wide cones need more rays and rays missing the local probe region add nothing. Zero disables. Reset/respawn to apply.")] public float coverFanHalfAngleDegrees = 15; [Range(1, 24), Tooltip("Maximum viewing positions considered, including the initial position. More candidates cost planning time; attempt and inspection limits still apply. Captured at spawn.")] public int maximumViewpointCandidates = 16; [Tooltip("Maximum seconds between contiguous successful feet samples used for prediction.")] public float directionSampleMaximumGap = .5f; [Tooltip("Minimum horizontal displacement in metres used for motion prediction.")] public float directionMinimumDisplacement = .2f; [Tooltip("Seconds of observed-motion extrapolation, clipped to the belief radius; zero disables it.")] public float directionPredictionSeconds = .75f; [Tooltip("Reject inferred motion above this many metres per second, including teleports.")] public float maximumInferredSpeed = 12; [Tooltip("Nonnegative weight for near-anchor space occluded by world geometry.")] public float nearCoverWeight = 2; [Tooltip("Nonnegative weight for alignment with verified observed motion.")] public float observedDirectionWeight = 1; [Tooltip("Cost of full route length divided by the episode travel allowance.")] public float travelCostWeight = .25f; [Tooltip("Minimum new coverage divided by INITIAL hypothesis weight, not residual weight.")] public float minimumInformationGain = .1f; [Tooltip("Minimum seconds before a failed destination may be retried; changed feasibility is also required.")] public float blockedViewpointRetrySeconds = 1; public bool Valid => Positive(searchRadius) && Positive(maximumSearchSeconds) && Positive(maximumSearchTravelDistance) && maximumViewpointAttempts >= 1 && maximumViewpointAttempts <= 16 && maximumViewpointsToInspect >= 1 && maximumViewpointsToInspect <= maximumViewpointAttempts && Nonnegative(inspectionSeconds) && Positive(observationFacingToleranceDegrees) && observationFacingToleranceDegrees <= 90 && Nonnegative(maximumVerticalOffset) && Nonnegative(viewpointStandOff) && Positive(viewpointMergeDistance) && viewpointMergeDistance < searchRadius && maximumHypotheses >= 1 && maximumHypotheses <= 16 && radialSampleCount >= 0 && radialSampleCount <= 16 && maximumViewpointCandidates >= 1 && maximumViewpointCandidates <= 24 && coverFanSampleCount >= 0 && coverFanSampleCount <= 16 && Nonnegative(coverFanRadius) && coverFanRadius <= 100 && Nonnegative(coverFanHalfAngleDegrees) && coverFanHalfAngleDegrees <= 180 && Positive(directionSampleMaximumGap) && Nonnegative(directionMinimumDisplacement) && Nonnegative(directionPredictionSeconds) && Positive(maximumInferredSpeed) && Nonnegative(nearCoverWeight) && Nonnegative(observedDirectionWeight) && Nonnegative(travelCostWeight) && Positive(minimumInformationGain) && minimumInformationGain <= 1 && Positive(blockedViewpointRetrySeconds) // Structural bounds keep derived points, weights and clocks finite; these are not balance defaults. && searchRadius <= 100 && maximumVerticalOffset <= 100 && viewpointStandOff <= 100 && maximumSearchTravelDistance <= 1000 && maximumSearchSeconds <= 300 && inspectionSeconds <= 300 && nearCoverWeight <= 1000 && observedDirectionWeight <= 1000 && travelCostWeight <= 1000 && directionPredictionSeconds <= 300 && maximumInferredSpeed <= 1000; private static bool Positive(float value) => EnemyObservationRules.Finite(value) && value > 0; private static bool Nonnegative(float value) => EnemyObservationRules.Finite(value) && value >= 0; public EnemyInvestigationSettings Capture() { if (!Valid) throw new InvalidOperationException("Investigation requires finite, consistent strength limits."); return new EnemyInvestigationSettings(this); } } public sealed class EnemyInvestigationSettings { public readonly string Name; public readonly float SearchRadius, MaximumSearchSeconds, MaximumSearchTravelDistance, InspectionSeconds, FacingTolerance, MaximumVerticalOffset, ViewpointStandOff, MergeDistance, CoverFanRadius, CoverFanHalfAngleDegrees, DirectionMaximumGap, DirectionMinimumDisplacement, PredictionSeconds, MaximumInferredSpeed, NearCoverWeight, DirectionWeight, TravelCostWeight, MinimumInformationGain, BlockedRetrySeconds; public readonly int MaximumAttempts, MaximumInspections, MaximumHypotheses, RadialSampleCount, MaximumCandidates, CoverFanSampleCount; internal EnemyInvestigationSettings(EnemyInvestigationDefinition value) { Name = value.name; SearchRadius = value.searchRadius; MaximumSearchSeconds = value.maximumSearchSeconds; MaximumSearchTravelDistance = value.maximumSearchTravelDistance; InspectionSeconds = value.inspectionSeconds; FacingTolerance = value.observationFacingToleranceDegrees; MaximumVerticalOffset = value.maximumVerticalOffset; ViewpointStandOff = value.viewpointStandOff; MergeDistance = value.viewpointMergeDistance; DirectionMaximumGap = value.directionSampleMaximumGap; DirectionMinimumDisplacement = value.directionMinimumDisplacement; PredictionSeconds = value.directionPredictionSeconds; MaximumInferredSpeed = value.maximumInferredSpeed; NearCoverWeight = value.nearCoverWeight; DirectionWeight = value.observedDirectionWeight; TravelCostWeight = value.travelCostWeight; MinimumInformationGain = value.minimumInformationGain; BlockedRetrySeconds = value.blockedViewpointRetrySeconds; MaximumAttempts = value.maximumViewpointAttempts; MaximumInspections = value.maximumViewpointsToInspect; MaximumHypotheses = value.maximumHypotheses; RadialSampleCount = value.radialSampleCount; MaximumCandidates = value.maximumViewpointCandidates; CoverFanSampleCount = value.coverFanSampleCount; CoverFanRadius = Mathf.Min(value.coverFanRadius, value.searchRadius); CoverFanHalfAngleDegrees = value.coverFanHalfAngleDegrees; } } }