using System; using System.Collections.Generic; using UnityEngine; using UnityEngine.AI; namespace ProjectI.Enemies { [DisallowMultipleComponent] public sealed class EnemyPositioning : MonoBehaviour, IEnemyCapability { public EnemyNavigationDefinition definition; public EnemyMovementExecutor movement; public EnemyPerception perception; public EnemyInvestigationDefinition investigationDefinition; public EnemyInvestigationEpisode ActiveInvestigation { get; private set; } public EnemyInvestigationSettings InvestigationSettings { get; private set; } private EnemyInvestigationPlanner investigationPlanner; private EnemyQueryBudget searchBudget; private readonly Dictionary investigations = new Dictionary(4); private readonly List removedInvestigations = new List(4); private int lastInvestigationTick = -1; public Transform muzzleTransform; public EnemyPlanStatus LastResult { get; private set; } public EnemyObservation? CapturedObservation { get; private set; } public EnemyApproachSector? CapturedSector { get; private set; } public int CandidatesChecked { get; private set; } public int PathQueries { get; private set; } #if UNITY_EDITOR || DEVELOPMENT_BUILD [Tooltip("Opt-in development measurement; captured at spawn. Reports managed query-window costs, not whole-frame or hardware performance.")] public bool profileInvestigationQueries; public double LastResultAt { get; private set; } = double.NaN; public ulong PlanningCalls { get; private set; } public int InvestigationPathQueries => investigationPlanner != null ? investigationPlanner.PathCount : 0; public int InvestigationVisibilityProbes => investigationPlanner != null ? investigationPlanner.ProbeCount : 0; public double InvestigationMaximumFrameMilliseconds => investigationPlanner != null ? investigationPlanner.MaximumFrameMilliseconds : 0; public double InvestigationCurrentFrameMilliseconds => investigationPlanner != null ? investigationPlanner.CurrentFrameMilliseconds : 0; public long InvestigationAllocatedBytes => investigationPlanner != null ? investigationPlanner.AllocatedBytes : 0; public bool InvestigationAllocationCounterAvailable => investigationPlanner != null && investigationPlanner.AllocationCounterAvailable; public bool TryInspectInvestigation(EnemyTargetKey target, out EnemyInvestigationEpisode episode) => investigations.TryGetValue(target, out episode); public string CapturedPlanningIntent => CapturedObservation.HasValue ? CapturedObservation.Value.Kind == EnemyObservationKind.PersonalVisual ? visibleHold ? "Seeking firing lane from sampled sight" : "Investigating remembered sight" : "Coarse evidence investigation" : CapturedSector.HasValue ? "Finite coarse sector" : "None"; #endif private EnemyActorContext actor; private EnemyPositionClaims claims; private EnemyQueryBudget budget; private EnemyActionToken token; private EnemyMoveLimits limits; private Vector3 muzzle, requestOrigin; private float range, clearance, preferredMin, preferredMax; private double claimSeconds, expiresAt; private int candidateLimit, index, legalCount; private Vector3 evidencePosition; private bool visibleHold; private bool firingFallback; private EnemyObservation? failedFiringObservation; private Vector3 failedFiringOrigin; private double firingRetryAt; private Vector3[] candidates; private EnemyApproachSector? approachSector; public double PlanningInterval { get; private set; } public bool HasAcceptedTravel => movement != null && (movement.AcceptedPlan != null || movement.HasObservation); public bool SetApproachSectorServer(EnemyApproachSector sector) { if (actor == null || !actor.IsValid || !sector.Valid(actor.Now)) return false; CancelPending(); approachSector = sector; return true; } private readonly List routes = new List(); private sealed class Candidate { internal Vector3 Point; internal NavMeshPath Path; internal EnemyPositionScore Score; } public void ConfigureServices(EnemyPositionClaims claimService, EnemyQueryBudget queryService, EnemyQueryBudget searchProbeService = null) { if (actor != null) throw new InvalidOperationException("Cannot replace active positioning services."); claims = claimService ?? throw new ArgumentNullException(nameof(claimService)); budget = queryService ?? throw new ArgumentNullException(nameof(queryService)); searchBudget = searchProbeService; } public void InitializeServer(EnemyActorContext context) { ShutdownServer(EnemyShutdownReason.InitializationFailure); if (context == null || !context.IsValid || context.Owner.gameObject != gameObject || definition == null || !definition.Valid || movement == null || movement.gameObject != gameObject || perception == null || perception.gameObject != gameObject || muzzleTransform != null && !muzzleTransform.IsChildOf(transform) || claims == null || budget == null || movement.Safety == null) throw new InvalidOperationException("Positioning needs local initialized movement/perception, finite tuning and explicit services."); actor = context; limits = new EnemyMoveLimits(definition.movementSpeed, definition.maximumTravelDistance, definition.maximumTravelSeconds); muzzle = definition.localMuzzle; range = definition.laneWeapon != null ? definition.laneWeapon.attackRange : 0; clearance = definition.laneWeapon != null ? Mathf.Clamp(definition.laneWeapon.clearanceRadius, .01f, .5f) : 0; preferredMin = definition.preferredRangeMinimum; preferredMax = definition.preferredRangeMaximum; candidateLimit = definition.positionCandidateLimit; claimSeconds = definition.destinationClaimSeconds; PlanningInterval = definition.planningInterval; if (investigationDefinition != null) { if (searchBudget == null || ReferenceEquals(searchBudget, budget)) throw new InvalidOperationException("Investigation needs a separate shared visibility budget."); InvestigationSettings = investigationDefinition.Capture(); investigationPlanner = new EnemyInvestigationPlanner(actor, movement, perception, claims, budget, searchBudget, limits, claimSeconds); #if UNITY_EDITOR || DEVELOPMENT_BUILD investigationPlanner.MeasureQueries = profileInvestigationQueries; #endif movement.Stopped += InvestigationMovementStopped; } } public void CancelPending() { CancelInvestigationAction(); investigationPlanner?.Cancel(); budget?.Cancel(token.Sequence); claims?.Release(token); movement?.Cancel(token); token = default; candidates = null; routes.Clear(); index = legalCount = 0; firingFallback = false; } public void ShutdownServer(EnemyShutdownReason reason) { if (movement != null) movement.Stopped -= InvestigationMovementStopped; CancelPending(); investigationPlanner?.Clear(); investigationPlanner = null; investigations.Clear(); ActiveInvestigation = null; InvestigationSettings = null; lastInvestigationTick = -1; actor = null; approachSector = null; CapturedObservation = null; CapturedSector = null; LastResult = EnemyPlanStatus.InvalidContext; failedFiringObservation = null; firingRetryAt = 0; } private void InvestigationMovementStopped(EnemyMovementOutcome outcome) { var episode = ActiveInvestigation; if (episode == null || !episode.Action.Equals(outcome.Token)) return; episode.ChargeTravel(outcome.Token, outcome.Travelled); searchBudget.Cancel(outcome.Token.Sequence); // Stun can pause the graph before its next owner tick. } private void CancelInvestigationAction() { var episode = ActiveInvestigation; if (episode == null || episode.Action.Sequence == 0) return; if (movement.AcceptedPlan != null && movement.AcceptedPlan.Token.Equals(episode.Action)) episode.ChargeTravel(episode.Action, movement.Travelled); movement.Cancel(episode.Action); searchBudget?.Cancel(episode.Action.Sequence); if (movement.TryConsumeOutcome(episode.Action, out var outcome)) episode.ChargeTravel(outcome.Token, outcome.Travelled); episode.EndAction(false, 0, actor != null ? actor.Now : episode.Deadline, investigationPlanner != null ? investigationPlanner.GeometrySignature : 0); } public void TickInvestigation(EnemyBehaviorContext context, double now) { if (!Valid(context, now) || investigationPlanner == null || lastInvestigationTick == Time.frameCount) return; lastInvestigationTick = Time.frameCount; removedInvestigations.Clear(); foreach (var pair in investigations) if (!perception.TryGetSnapshot(pair.Key, now, out var remembered) || now >= pair.Value.EvidenceExpiresAt) removedInvestigations.Add(pair.Key); foreach (var key in removedInvestigations) { var removed = investigations[key]; removed.Refresh(now); if (ReferenceEquals(removed, ActiveInvestigation)) { CancelInvestigationAction(); investigationPlanner.Cancel(); ActiveInvestigation = null; } investigations.Remove(key); } var episode = ActiveInvestigation; if (episode == null) return; episode.Refresh(now); if (!perception.TryGetSnapshot(episode.Target, now, out var snapshot)) episode.Finish(EnemyInvestigationReason.InvalidTarget); else if (snapshot.VisibleAtLastSample || snapshot.Personal.HasValue && snapshot.Personal.Value.ObservedAt > episode.SightAt) episode.Finish(EnemyInvestigationReason.Reacquired); if (episode.Terminal) { CancelInvestigationAction(); investigationPlanner.Cancel(); return; } if (perception.TryGetSelectedTarget(now, out var selected) && !selected.Equals(episode.Target)) { CancelInvestigationAction(); investigationPlanner.Cancel(); ActiveInvestigation = null; return; } if (episode.Phase == EnemyInvestigationPhase.Travelling) { if (movement.AcceptedPlan != null && movement.AcceptedPlan.Token.Equals(episode.Action)) episode.ChargeTravel(episode.Action, movement.Travelled); if (movement.TryConsumeOutcome(episode.Action, out var outcome)) { episode.ChargeTravel(outcome.Token, outcome.Travelled); if (outcome.Reason == EnemyMovementStopReason.Completed) episode.awaitingObservation = true; else episode.EndAction(false, 0, now, investigationPlanner.GeometrySignature); } episode.Refresh(now); if (episode.Terminal) { CancelInvestigationAction(); return; } if (episode.awaitingObservation && !actor.Status.Rooted && !actor.Status.Stunned) { var observe = movement.IssueTokenServer(); if (movement.TryObserve(observe, episode.LookPoint, episode.Deadline)) episode.ObserveAfterArrival(observe); else movement.Cancel(observe); } } if (episode.Phase != EnemyInvestigationPhase.Inspecting) return; if (!movement.HasObservation || !movement.ObservationToken.Equals(episode.Action)) { searchBudget.Cancel(episode.Action.Sequence); if (movement.TryConsumeOutcome(episode.Action, out var outcome)) episode.ChargeTravel(outcome.Token, outcome.Travelled); episode.EndAction(false, 0, now, investigationPlanner.GeometrySignature); return; } if (actor.Status.Rooted || actor.Status.Stunned || !Facing(transform.rotation * perception.LocalEyeRotation * Vector3.forward, episode.LookPoint - (transform.position + transform.rotation * perception.LocalEye), episode.Settings.FacingTolerance)) { episode.ResetInspection(); return; } if (double.IsNaN(episode.facingSince)) episode.facingSince = now; if (!investigationPlanner.TryGeometrySignature(episode, out int geometry)) return; if (episode.hasInspectionSnapshot && geometry != episode.inspectionGeometry) { episode.ResetInspection(); return; } if (!episode.hasInspectionSnapshot) { if (snapshot.SampledAt <= episode.SightAt || snapshot.SampledAt < episode.facingSince + episode.Settings.InspectionSeconds || !Facing(snapshot.SampleForward, episode.LookPoint - snapshot.SampleEyePosition, episode.Settings.FacingTolerance)) return; episode.inspectionSnapshot = snapshot; episode.hasInspectionSnapshot = true; episode.inspectionGeometry = geometry; } while (episode.inspectionProbe < episode.hypothesisCount) { int index = episode.inspectionProbe; if ((episode.inspectedMask & (1u << index)) != 0) { episode.inspectionProbe++; continue; } var visibility = investigationPlanner.Probe(episode.inspectionSnapshot.SampleEyePosition, episode.inspectionSnapshot.SampleForward, episode.hypotheses[index].Point + Vector3.up * EnemyTargetingRules.TorsoHeight, episode.Action.Sequence, now); if (visibility == EnemySearchVisibility.Unknown) return; if (visibility == EnemySearchVisibility.Visible) episode.actualCoverage |= 1u << index; episode.inspectionProbe++; } uint coverage = episode.actualCoverage; movement.Cancel(episode.Action); movement.TryConsumeOutcome(episode.Action, out _); searchBudget.Cancel(episode.Action.Sequence); episode.EndAction(coverage != 0, coverage, now, geometry); } private static bool Facing(Vector3 forward, Vector3 direction, float tolerance) { forward.y = direction.y = 0; return direction.sqrMagnitude < .000001f || forward.sqrMagnitude > .000001f && Vector3.Angle(forward, direction) <= tolerance; } internal void AcceptedInvestigationPlan(EnemyMovePlan plan) { if (plan == null || ActiveInvestigation == null || actor == null || !plan.Token.IsValidFor(actor) || movement.AcceptedPlan != plan || ActiveInvestigation.Terminal) return; ActiveInvestigation.Admit(ActiveInvestigation.PendingCandidate, plan.Token, false, actor.Now); } private EnemyPlanStatus PlanInvestigation(EnemyBehaviorContext context, EnemyPerceptionSnapshot snapshot, double now, out EnemyMovePlan plan) { plan = null; if (InvestigationSettings == null) return EnemyPlanStatus.NoLegalPosition; if (!investigations.TryGetValue(snapshot.Target, out var episode) || !episode.Matches(snapshot)) { if (episode != null) { if (snapshot.Personal.Value.ObservedAt <= episode.SightAt) return EnemyPlanStatus.NoLegalPosition; if (ReferenceEquals(episode, ActiveInvestigation)) CancelInvestigationAction(); investigations.Remove(snapshot.Target); } if (investigations.Count >= 4 || !snapshot.Personal.Value.Source.Equals(actor.Owner.Identity) || snapshot.Personal.Value.SourceSpawnIdentity != actor.SpawnIdentity || !EnemyInvestigationEpisode.TryBegin(snapshot, InvestigationSettings, transform.position, transform.forward, now, out episode)) return EnemyPlanStatus.NoLegalPosition; investigations.Add(snapshot.Target, episode); } ActiveInvestigation = episode; episode.Refresh(now); if (episode.Terminal) return EnemyPlanStatus.NoLegalPosition; if (episode.Phase != EnemyInvestigationPhase.Planning) return EnemyPlanStatus.Deferred; var status = investigationPlanner.TryPlan(episode, context, now, out plan); if (status == EnemyPlanStatus.Ready && Vector3.Distance(plan.Destination, transform.position) < .3f) { claims.Release(plan.Token); if (movement.TryObserve(plan.Token, episode.LookPoint, episode.Deadline)) episode.Admit(episode.PendingCandidate, plan.Token, true, now); else movement.Cancel(plan.Token); plan = null; return EnemyPlanStatus.Deferred; } return status; } private bool Valid(EnemyBehaviorContext context, double now) => actor != null && actor.IsValid && isActiveAndEnabled && context != null && ReferenceEquals(context.Actor, actor) && EnemyObservationRules.Finite(now) && now >= 0 && now == actor.Now && movement.Safety.Native; private EnemyPlanStatus InvalidRequest() { // Malformed/foreign calls must not erase a live owner's evidence or FIFO age. if (actor == null || !actor.IsValid || !isActiveAndEnabled || candidates != null && (!token.IsValidFor(actor) || actor.Now >= expiresAt)) CancelPending(); return Result(EnemyPlanStatus.InvalidContext); } private EnemyPlanStatus Result(EnemyPlanStatus status) { LastResult = status; #if UNITY_EDITOR || DEVELOPMENT_BUILD LastResultAt = actor != null ? actor.Now : double.NaN; #endif return status; } public EnemyPlanStatus TryPlan(EnemyBehaviorContext context, double now, out EnemyMovePlan plan) { plan = null; if (!Valid(context, now)) return InvalidRequest(); TickInvestigation(context, now); #if UNITY_EDITOR || DEVELOPMENT_BUILD PlanningCalls++; #endif if (candidates == null) { if (!perception.TryGetSelectedTarget(now, out var target) || !perception.TryGetSnapshot(target, now, out var snapshot)) return approachSector.HasValue ? TryPlanApproach(context, approachSector.Value, now, out plan) : Result(EnemyPlanStatus.NoLegalPosition); EnemyObservation? observation = Fresh(snapshot.Personal, snapshot.EvidenceLifetimeSeconds, now) ?? Fresh(snapshot.Report, snapshot.EvidenceLifetimeSeconds, now) ?? Fresh(snapshot.Heard, snapshot.EvidenceLifetimeSeconds, now); if (!observation.HasValue) return Result(EnemyPlanStatus.NoLegalPosition); CapturedObservation = observation; CapturedSector = null; evidencePosition = observation.Value.Position; expiresAt = observation.Value.ObservedAt + snapshot.EvidenceLifetimeSeconds; // A clear lane to an old sighting is not a useful stationary firing hold. // Capture sampled visibility with this request; never follow an unseen live transform. visibleHold = observation.Value.Kind == EnemyObservationKind.PersonalVisual && snapshot.VisibleAtLastSample; if (observation.Value.Kind == EnemyObservationKind.PersonalVisual && !visibleHold) { var investigationResult = PlanInvestigation(context, snapshot, now, out plan); if (investigationResult == EnemyPlanStatus.Ready || investigationResult == EnemyPlanStatus.Deferred) return Result(investigationResult); // Exhausted personal sight cannot reconstruct its episode from another evidence source. observation = Fresh(snapshot.Report, snapshot.EvidenceLifetimeSeconds, now) ?? Fresh(snapshot.Heard, snapshot.EvidenceLifetimeSeconds, now); if (!observation.HasValue) return Result(investigationResult); CapturedObservation = observation; evidencePosition = observation.Value.Position; expiresAt = observation.Value.ObservedAt + snapshot.EvidenceLifetimeSeconds; } if (observation.Value.Kind == EnemyObservationKind.HeardRegion) { // Investigation stays in the captured coarse region; it is never a firing point. Vector3 anchor = observation.Value.Position; anchor.y = Mathf.Clamp(transform.position.y, anchor.y - observation.Value.RegionRadius, anchor.y + observation.Value.RegionRadius); StartRequest(RegionCandidates(anchor, observation.Value.RegionRadius)); } else if (observation.Value.Kind == EnemyObservationKind.SquadVisualReport) StartRequest(RegionCandidates(observation.Value.Position - Vector3.up * EnemyTargetingRules.TorsoHeight, 3)); else StartRequest(PointCandidates(observation.Value.Position)); } return Continue(context, now, out plan); } private static EnemyObservation? Fresh(EnemyObservation? value, double lifetime, double now) => value.HasValue && now >= value.Value.ObservedAt && now - value.Value.ObservedAt < lifetime ? value : null; public EnemyPlanStatus TryPlanApproach(EnemyBehaviorContext context, EnemyApproachSector sector, double now, out EnemyMovePlan plan) { plan = null; if (!Valid(context, now) || !sector.Valid(now)) return InvalidRequest(); if (candidates == null) { CapturedObservation = null; CapturedSector = sector; evidencePosition = sector.Center; expiresAt = sector.ExpiresAt; visibleHold = false; StartRequest(RegionCandidates(sector.Center, sector.Radius)); } return Continue(context, now, out plan); } private Vector3[] RegionCandidates(Vector3 center, float radius) { float offset = Mathf.Min(radius * .5f, 4); return new[] { center, center + Vector3.right * offset, center - Vector3.right * offset, center + Vector3.forward * offset, center - Vector3.forward * offset, center + (Vector3.right + Vector3.forward).normalized * offset }; } private Vector3[] PointCandidates(Vector3 point) { Vector3 from = transform.position, feet = point - Vector3.up * EnemyTargetingRules.TorsoHeight; Vector3 forward = feet - from; forward.y = 0; float distance = forward.magnitude; forward = distance > .01f ? forward / distance : transform.forward; Vector3 right = Vector3.Cross(Vector3.up, forward); Vector3 approach = distance > preferredMax ? feet - forward * ((preferredMin + preferredMax) * .5f) : feet; return new[] { from, approach, from + forward * 3, from + right * 4, from - right * 4, from - forward * 3 }; } private void StartRequest(Vector3[] points) { if (muzzleTransform != null) muzzle = transform.InverseTransformPoint(muzzleTransform.position); token = movement.IssueTokenServer(); requestOrigin = transform.position; candidates = points; routes.Clear(); index = legalCount = 0; CandidatesChecked = PathQueries = 0; firingFallback = false; } private bool TryStartFiringFallback(double now) { if (firingFallback || !visibleHold || !CapturedObservation.HasValue || CapturedObservation.Value.Kind != EnemyObservationKind.PersonalVisual || !perception.TryGetSnapshot(CapturedObservation.Value.Target, now, out var snapshot) || !snapshot.VisibleAtLastSample || movement.Safety.CurrentLane(muzzleTransform != null ? muzzleTransform.position : transform.TransformPoint(muzzle), CapturedObservation.Value, clearance, range)) return false; var observation = CapturedObservation.Value; // Temporary occupancy/claims may clear; unchanged failed geometry gets a bounded retry rate. if (failedFiringObservation.HasValue && now < firingRetryAt && failedFiringObservation.Value.Target.Equals(observation.Target) && Vector3.Distance(failedFiringObservation.Value.Position, observation.Position) < .5f && Vector3.Distance(failedFiringOrigin, requestOrigin) < .3f) return false; // Separate fixed fallback ceiling: at most 24 endpoints, after the authored local candidate limit. candidates = new Vector3[24]; Vector3 feet = evidencePosition - Vector3.up * EnemyTargetingRules.TorsoHeight; for (int ring = 0; ring < 3; ring++) for (int direction = 0; direction < 8; direction++) { float angle = direction * Mathf.PI * .25f; candidates[ring * 8 + direction] = feet + new Vector3(Mathf.Cos(angle), 0, Mathf.Sin(angle)) * (2 << ring); } routes.Clear(); index = legalCount = 0; firingFallback = true; return true; } private EnemyPlanStatus Continue(EnemyBehaviorContext context, double now, out EnemyMovePlan plan) { plan = null; if (!Valid(context, now) || !token.IsValidFor(actor) || now >= expiresAt || !EnemyObservationRules.Finite(muzzle) || muzzle.magnitude >= 5 || CapturedObservation.HasValue && !perception.TryGetSnapshot(CapturedObservation.Value.Target, now, out _)) { CancelPending(); return Result(EnemyPlanStatus.InvalidContext); } var safety = movement.Safety; while (index < (firingFallback ? candidates.Length : candidateLimit)) { if (!safety.TryProject(candidates[index], out var point) || CapturedObservation.HasValue && CapturedObservation.Value.Kind == EnemyObservationKind.HeardRegion && Vector3.Distance(point, CapturedObservation.Value.Position) > CapturedObservation.Value.RegionRadius || !claims.Available(token, point, actor.Owner.Navigation.radius, now)) { index++; CandidatesChecked++; continue; } bool personal = CapturedObservation.HasValue && CapturedObservation.Value.Kind == EnemyObservationKind.PersonalVisual; float progress = Vector3.Distance(requestOrigin, evidencePosition) - Vector3.Distance(point, evidencePosition); bool current = Vector3.Distance(requestOrigin, point) < .3f; bool lane = personal && safety.Lane(point, evidencePosition, muzzle, clearance, range); // Fallback travel must end at a firing opportunity, never another arbitrary blocked waypoint. if (firingFallback && !lane) { index++; CandidatesChecked++; continue; } // A moved target leaving its old sample is not an actor blocking a currently visible muzzle. if (personal && current && visibleHold && perception.TryVerifyPersonalSightServer(CapturedObservation.Value.Target, now, out var currentSight)) lane = safety.CurrentLane(muzzleTransform != null ? muzzleTransform.position : transform.TransformPoint(muzzle), currentSight, clearance, range); // Remembered sight may justify a supported lateral angle, even if straight-line // distance increases. Only current sampled sight may justify holding in place. if (personal && !lane && progress < .5f) { index++; CandidatesChecked++; continue; } if (!budget.TrySpend(token.Sequence, Time.frameCount, now)) return Result(EnemyPlanStatus.Deferred); legalCount++; PathQueries++; index++; CandidatesChecked++; var path = new NavMeshPath(); if (!NavMesh.CalculatePath(requestOrigin, point, safety.Filter, path) || path.status != NavMeshPathStatus.PathComplete) continue; var preview = new EnemyMovePlan(EnemyPlanStatus.Ready, token, default, actor.Owner.AgentType, actor.Owner.Navigation.areaMask, requestOrigin, point, path, limits, now); if (!safety.Route(preview, false)) continue; float travel = Vector3.Distance(requestOrigin, point); if (firingFallback) { travel = 0; for (int corner = 1; corner < preview.CornerCount; corner++) travel += Vector3.Distance(preview.Corner(corner - 1), preview.Corner(corner)); } float distance = Vector3.Distance(point, evidencePosition); float penalty = Mathf.Max(0, preferredMin - distance) + Mathf.Max(0, distance - preferredMax); routes.Add(new Candidate { Point = point, Path = path, Score = new EnemyPositionScore(lane, visibleHold && lane && current, lane ? 0 : progress, penalty, travel) }); } routes.Sort((a, b) => b.Score.CompareTo(a.Score)); foreach (var route in routes) { if (!safety.Legal(route.Point) || !claims.TryAcquire(token, route.Point, actor.Owner.Navigation.radius, now, claimSeconds, out var claim)) continue; var bounded = new EnemyMoveLimits(limits.Speed, limits.TravelDistance, Math.Min(limits.Seconds, expiresAt - now)); plan = new EnemyMovePlan(EnemyPlanStatus.Ready, token, claim, actor.Owner.AgentType, actor.Owner.Navigation.areaMask, requestOrigin, route.Point, route.Path, bounded, now, CapturedObservation.HasValue && CapturedObservation.Value.Kind == EnemyObservationKind.PersonalVisual ? evidencePosition : (Vector3?)null); budget.Cancel(token.Sequence); token = default; candidates = null; routes.Clear(); firingFallback = false; failedFiringObservation = null; return Result(EnemyPlanStatus.Ready); } if (TryStartFiringFallback(now)) return Continue(context, now, out plan); var result = legalCount == 0 || routes.Count > 0 ? EnemyPlanStatus.NoLegalPosition : EnemyPlanStatus.NoPath; if (firingFallback) { failedFiringObservation = CapturedObservation; failedFiringOrigin = requestOrigin; firingRetryAt = now + Math.Max(1, PlanningInterval); } CancelPending(); return Result(result); } private void OnDisable() => ShutdownServer(EnemyShutdownReason.Disabled); private void OnDestroy() => ShutdownServer(EnemyShutdownReason.Despawn); } }