using System; using System.Collections.Generic; using System.Linq; using ProjectI.Combat; using ProjectI.Player; using ProjectI.Runs; using UnityEngine; using UnityEngine.AI; namespace ProjectI.Enemies { public sealed class EnemyRetirementService { private sealed class Entry { internal EnemyActorContext Actor; internal EnemyPositioning Positioning; internal EnemyMovementExecutor Movement; internal IEnemyRecoveryContribution[] Contributions; internal readonly EnemyRecoveryPolicy Policy = new EnemyRecoveryPolicy(); internal EnemyActionToken Query, Execution; internal EnemyTargetKey[] Targets; internal Vector3[] Positions; internal Bounds[] TargetVolumes; internal Vector3 Origin; internal Matrix4x4 ActorPose; internal Vector3? ProgressGoal; internal float ProgressDistance; internal int AreaMask, AgentType, NativeAgentType; internal float Radius, Height; internal NavMeshBuildSettings Settings; internal ulong Revision, Geometry; internal double PreviousAt, IdleSeconds, NextVisibilityCheck; internal string State = "Waiting for completed recovery"; internal int PendingFrame = -1, CoarseAttempt, ExecutionAlternative, UnsupportedAlternative; internal bool Useful, Executed, PhysicalOpportunity, VisibilityRetained; internal EnemyTargetKey? RecoveringTarget; } private readonly PrototypeEncounterDirector encounter; private readonly StageSpatialContext stage; private readonly StageSpatialDefinition definition; private readonly PrototypeRunDirector run; private readonly ulong runId, stageId; private readonly Dictionary entries = new Dictionary(); private Dictionary geometry = new Dictionary(); private Dictionary meshContents = new Dictionary(); private ulong geometryRevision; private NavMeshTriangulation navigation; private float[] areaCosts; private double nextGeometry; private bool closed, geometryReady; public string LastOutcome { get; private set; } = "No retirement"; public string GeometryStatus { get; private set; } = "Not sampled"; private bool Valid => !closed && encounter != null && encounter.IsSpawned && encounter.IsServer && encounter.NetworkManager != null && encounter.NetworkManager.IsListening && !encounter.NetworkManager.ShutdownInProgress && StageSpatialContext.Active == stage && stage != null && StageSpatialContext.Bound && stage.definition == definition && stage.gameObject.scene == encounter.gameObject.scene && PrototypeRunDirector.Instance == run && (run == null || run.IsSpawned && run.RunId == runId && run.StageId == stageId); internal EnemyRetirementService(PrototypeEncounterDirector encounter) { this.encounter = encounter; stage = StageSpatialContext.Active; definition = stage != null ? stage.definition : null; run = PrototypeRunDirector.Instance; runId = run != null ? run.RunId : 0; stageId = run != null ? run.StageId : 0; if (!Valid) throw new ArgumentException("Recovery needs a live authoritative stage."); } private PrototypePlayerCombat[] Targets() { return PrototypePlayerVitals.ActivePlayers.Where(p => p != null && p.IsSpawned && p.IsServer && p.IsAlive && p.NetworkManager == encounter.NetworkManager && p.gameObject.scene == encounter.gameObject.scene) .Select(p => p.GetComponent()).OrderBy(p => p != null ? p.OwnerClientId : ulong.MaxValue).ToArray(); } private bool Eligible(PrototypeEnemy enemy) => Valid && enemy != null && enemy.IsSpawned && enemy.IsServer && enemy.IsAlive && enemy.Encounter == encounter && enemy.NetworkManager == encounter.NetworkManager && !enemy.IsBoss && enemy.definition != null && enemy.definition.allowOrdinaryRetirement; private static EnemyTargetKey Key(PrototypePlayerCombat player) => new EnemyTargetKey(player.NetworkObjectId, player.SpawnLifetime); private bool ValidTargets(PrototypePlayerCombat[] targets) => targets.Length > 0 && targets.Length <= 4 && targets.All(p => p != null && EnemyTargetRules.Eligible(p, Key(p), encounter.NetworkManager, stage, runId, stageId)); private bool RefreshGeometry(bool force = false) { double now = encounter.NetworkManager.ServerTime.Time; if (!force && now < nextGeometry) return geometryReady; nextGeometry = now + .25; geometryReady = false; GeometryStatus = "Geometry sampling incomplete"; var captured = new Dictionary(); var capturedMeshes = new Dictionary(); int vertexCount = 0, indexCount = 0; foreach (var collider in UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None)) { if (collider.gameObject.scene != encounter.gameObject.scene || !collider.enabled || collider.isTrigger || collider.GetComponentInParent() != null || collider.GetComponentInParent() != null) continue; if (captured.Count >= 2048 || !EnemyObservationRules.Finite(collider.bounds.center) || !EnemyObservationRules.Finite(collider.bounds.size)) return false; if (!(collider is BoxCollider) && !(collider is SphereCollider) && !(collider is CapsuleCollider) && !(collider is MeshCollider)) { GeometryStatus = "Unsupported " + collider.GetType().Name + ": " + collider.name; return false; } Mesh mesh = collider is MeshCollider meshCollider ? meshCollider.sharedMesh : null; if (collider is MeshCollider meshShape) { if (mesh == null || !mesh.isReadable || mesh.vertexCount > 65536 || mesh.subMeshCount > 32) { GeometryStatus = "Unreadable or oversized mesh: " + collider.name; return false; } if (!capturedMeshes.ContainsKey(mesh)) { ulong indices = 0; for (int sub = 0; sub < mesh.subMeshCount; sub++) { if (mesh.GetTopology(sub) != MeshTopology.Triangles) return false; indices += mesh.GetIndexCount(sub); } if (indices > 196608 || capturedMeshes.Count >= 256 || vertexCount + mesh.vertexCount > 262144 || indexCount + (long)indices > 786432) { GeometryStatus = "Mesh snapshot ceiling exceeded"; return false; } var vertices = mesh.vertices; var triangles = mesh.triangles; if (vertices.Any(point => !EnemyObservationRules.Finite(point))) return false; vertexCount += vertices.Length; indexCount += triangles.Length; capturedMeshes.Add(mesh, (vertices, triangles)); } } Vector4 shape = collider is BoxCollider box ? new Vector4(box.size.x, box.size.y, box.size.z, 0) : collider is SphereCollider sphere ? new Vector4(sphere.radius, 0, 0, 1) : collider is CapsuleCollider capsule ? new Vector4(capsule.radius, capsule.height, capsule.direction, 2) : collider is MeshCollider configured ? new Vector4(configured.convex ? 1 : 0, (int)configured.cookingOptions, 0, 3) : Vector4.zero; captured.Add(collider, (collider.bounds, collider.transform.localToWorldMatrix, mesh, shape)); } bool changed = captured.Count != geometry.Count || captured.Any(p => !geometry.TryGetValue(p.Key, out var old) || !old.Equals(p.Value)); changed |= capturedMeshes.Count != meshContents.Count || capturedMeshes.Any(pair => !meshContents.TryGetValue(pair.Key, out var old) || !old.Item1.SequenceEqual(pair.Value.Item1) || !old.Item2.SequenceEqual(pair.Value.Item2)); var currentNavigation = NavMesh.CalculateTriangulation(); if (currentNavigation.vertices.Length > 65536 || currentNavigation.indices.Length > 196608) { GeometryStatus = "Navigation snapshot ceiling exceeded"; return false; } if (UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None).Any(link => link.isActiveAndEnabled) || UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None).Any(link => link.isActiveAndEnabled)) { GeometryStatus = "Unversioned navigation links"; return false; } var currentCosts = Enumerable.Range(0, 32).Select(NavMesh.GetAreaCost).ToArray(); changed |= navigation.vertices == null || !navigation.vertices.SequenceEqual(currentNavigation.vertices) || !navigation.indices.SequenceEqual(currentNavigation.indices) || !navigation.areas.SequenceEqual(currentNavigation.areas) || areaCosts == null || !areaCosts.SequenceEqual(currentCosts); if (changed) { if (geometryRevision == ulong.MaxValue) return false; geometryRevision++; geometry = captured; meshContents = capturedMeshes; navigation = currentNavigation; areaCosts = currentCosts; } GeometryStatus = "Supported geometry"; geometryReady = true; return true; } private static bool TargetVolumes(PrototypePlayerCombat[] targets, out Bounds[] volumes) { volumes = new Bounds[targets.Length]; for (int i = 0; i < targets.Length; i++) if (!EnemyRetirementVisibility.TryTargetVolume(targets[i], out volumes[i])) return false; return true; } private void Begin(Entry entry, PrototypePlayerCombat[] targets, Bounds[] volumes, double now) { CancelQueries(entry); if (entry.Revision == ulong.MaxValue) { entry.State = "Recovery identity exhausted"; return; } entry.Targets = targets.Select(Key).ToArray(); entry.Positions = targets.Select(p => p.Vitals.AcceptedPosition).ToArray(); entry.TargetVolumes = (Bounds[])volumes.Clone(); entry.Geometry = geometryRevision; entry.Origin = entry.Actor.Owner.transform.position; entry.ActorPose = entry.Actor.Owner.transform.localToWorldMatrix; var agent = entry.Actor.Owner.Navigation; entry.AreaMask = agent.areaMask; entry.NativeAgentType = agent.agentTypeID; entry.AgentType = entry.Actor.Owner.AgentType; entry.Radius = agent.radius; entry.Height = agent.height; entry.Settings = NavMesh.GetSettingsByID(entry.AgentType); entry.Policy.Begin(++entry.Revision, entry.Targets, now); entry.PreviousAt = now; entry.ExecutionAlternative = entry.UnsupportedAlternative = 0; entry.PendingFrame = -1; entry.RecoveringTarget = null; entry.CoarseAttempt = 0; entry.Useful = entry.PhysicalOpportunity = entry.VisibilityRetained = false; entry.Query = entry.Actor.Owner.GetComponent().IssueActionTokenServer(); entry.Execution = entry.Actor.Owner.GetComponent().IssueActionTokenServer(); } private bool Changed(Entry entry, PrototypePlayerCombat[] targets, Bounds[] volumes) => entry.Geometry != geometryRevision || entry.Targets == null || !entry.Targets.SequenceEqual(targets.Select(Key)) || !entry.Positions.SequenceEqual(targets.Select(p => p.Vitals.AcceptedPosition)) || !entry.TargetVolumes.SequenceEqual(volumes) || !entry.ActorPose.Equals(entry.Actor.Owner.transform.localToWorldMatrix) || entry.AreaMask != entry.Actor.Owner.Navigation.areaMask || entry.NativeAgentType != entry.Actor.Owner.Navigation.agentTypeID || entry.AgentType != entry.Actor.Owner.AgentType || entry.Radius != entry.Actor.Owner.Navigation.radius || entry.Height != entry.Actor.Owner.Navigation.height || !entry.Settings.Equals(NavMesh.GetSettingsByID(entry.Actor.Owner.AgentType)); private static void ObserveProgress(Entry entry) { var plan = entry.Movement.AcceptedPlan; if (plan == null) { entry.ProgressGoal = null; return; } float distance = Vector3.Distance(entry.Actor.Owner.transform.position, plan.Destination); if (!entry.ProgressGoal.HasValue || Vector3.Distance(entry.ProgressGoal.Value, plan.Destination) >= 1) { entry.ProgressGoal = plan.Destination; entry.ProgressDistance = distance; } else if (entry.ProgressDistance - distance >= 1) { entry.IdleSeconds = 0; entry.ProgressDistance = distance; } } private EnemyRecoveryAssessment Contributions(Entry entry, PrototypePlayerCombat[] targets) { if (entry.Contributions == null) return EnemyRecoveryAssessment.Unknown; bool useful = false, unknown = false, deferred = false; foreach (var target in targets) foreach (var contribution in entry.Contributions) { var result = contribution.AssessRecoveryServer(entry.Actor, Key(target)); useful |= result == EnemyRecoveryAssessment.Useful; unknown |= result == EnemyRecoveryAssessment.Unknown; deferred |= result == EnemyRecoveryAssessment.Deferred; } return useful ? EnemyRecoveryAssessment.Useful : unknown ? EnemyRecoveryAssessment.Unknown : deferred ? EnemyRecoveryAssessment.Deferred : EnemyRecoveryAssessment.Infeasible; } internal void TickServer() { if (!Valid) { Close(); return; } var targets = Targets(); if (targets.Length == 0) { Close(); return; } foreach (var client in encounter.NetworkManager.ConnectedClients.Values) client.PlayerObject?.GetComponent()?.SetRetirementObservationServer(true); double now = encounter.NetworkManager.ServerTime.Time; bool volumesReady = TargetVolumes(targets, out var volumes); bool ready = ValidTargets(targets) && volumesReady, geometryKnown = RefreshGeometry(); foreach (var old in entries.ToArray()) if (!Eligible(old.Value.Actor.Owner) || !old.Value.Actor.IsValid || old.Value.Actor.Owner.GetComponent()?.Actor != old.Value.Actor) { CancelQueries(old.Value); entries.Remove(old.Key); } foreach (var enemy in PrototypeEnemy.ActiveEnemies.ToArray()) { if (!Eligible(enemy)) continue; var context = enemy.GetComponent(); var actor = context != null ? context.Actor : null; if (actor == null || !actor.IsValid) continue; if (!entries.TryGetValue(enemy.SpawnIdentity, out var entry)) { entry = new Entry { Actor = actor, Positioning = enemy.GetComponent(), Movement = enemy.GetComponent(), PreviousAt = now }; context.TryCaptureRecoveryContributions(out entry.Contributions); entries.Add(enemy.SpawnIdentity, entry); } if (entry.Executed || entry.Positioning == null || entry.Movement == null) continue; double elapsed = now - entry.PreviousAt; entry.PreviousAt = now; if (!ready || !actor.ReadyForActionsServer || actor.Status.Rooted || actor.Status.Stunned) { entry.Policy.Advance(now, false); entry.State = ready ? "Temporarily controlled or arriving" : "Target or geometry coverage unknown"; continue; } ObserveProgress(entry); if (Changed(entry, targets, volumes)) Begin(entry, targets, volumes, now); var contribution = Contributions(entry, targets); if (contribution == EnemyRecoveryAssessment.Useful) { if (!entry.Useful) entry.Policy.RecordCompleted(entry.Revision, entry.Targets[0], 0, contribution); entry.Useful = true; entry.IdleSeconds = 0; entry.PendingFrame = -1; entry.State = "Useful legal contribution"; continue; } if (entry.Useful && !entry.PhysicalOpportunity) { Begin(entry, targets, volumes, now); entry.IdleSeconds = 0; } if (contribution != EnemyRecoveryAssessment.Infeasible) { entry.Policy.Advance(now, false); entry.State = contribution == EnemyRecoveryAssessment.Deferred ? "Waiting for owned action or query" : "Capability coverage unknown"; continue; } if (entry.PhysicalOpportunity) { entry.Policy.Advance(now, false); continue; } if (entry.VisibilityRetained) { entry.Policy.Advance(now, false); if (now < entry.NextVisibilityCheck) continue; entry.NextVisibilityCheck = now + .25; if (!Hidden(enemy, out _)) continue; entry.VisibilityRetained = false; } if (elapsed >= 0 && elapsed <= .25) entry.IdleSeconds += elapsed; if (entry.IdleSeconds < 3) { entry.Policy.Advance(now, false); entry.State = "Observing bounded lack of useful progress"; continue; } if (entry.RecoveringTarget.HasValue) { Recover(entry, targets, now); continue; } EnemyTargetKey targetKey; int alternative; bool next = entry.Policy.TryGetNext(out targetKey, out alternative); if (!geometryKnown) { if (entry.UnsupportedAlternative >= targets.Length * 6) { entry.Policy.Advance(now, false); entry.State = "Unsupported geometry; finite recovery exhausted, actor retained"; continue; } targetKey = entry.Targets[entry.UnsupportedAlternative / 6]; alternative = entry.UnsupportedAlternative % 6; next = true; } if (next) { var target = targets.First(p => Key(p).Equals(targetKey)); int targetIndex = Array.IndexOf(entry.Targets, targetKey); var status = entry.Positioning.CheckRecoveryOpportunityServer(actor, entry.Query, target.Vitals.AcceptedPosition, entry.TargetVolumes[targetIndex], alternative, out _); if (status == EnemyPlanStatus.Ready) { entry.Policy.RecordCompleted(entry.Revision, targetKey, alternative, EnemyRecoveryAssessment.Useful); entry.RecoveringTarget = targetKey; entry.State = "Legal alternative; requesting coarse recovery"; Recover(entry, targets, now); continue; } bool completed = status == EnemyPlanStatus.NoPath || status == EnemyPlanStatus.NoLegalPosition; if (completed && !geometryKnown) entry.UnsupportedAlternative++; entry.Policy.Advance(now, completed && geometryKnown); entry.Policy.RecordCompleted(entry.Revision, targetKey, alternative, completed && geometryKnown ? EnemyRecoveryAssessment.Infeasible : status == EnemyPlanStatus.Deferred ? EnemyRecoveryAssessment.Deferred : EnemyRecoveryAssessment.Unknown); entry.State = status == EnemyPlanStatus.Deferred ? "Waiting for query budget" : completed ? "Completed infeasible alternative" : "Native query incomplete"; } else entry.Policy.Advance(now, true); if (!entry.Policy.CanRetire) continue; if (entry.PendingFrame < 0) { entry.PendingFrame = Time.frameCount; entry.State = "Retirement pending; execution guards required"; } else if (Time.frameCount > entry.PendingFrame) { TryRetireServer(enemy, out _); } } } private void Recover(Entry entry, PrototypePlayerCombat[] targets, double now) { if (entry.Movement.AcceptedPlan != null || entry.Movement.HasObservation) { entry.State = "Recovery available; preserving existing movement owner"; return; } var target = targets.FirstOrDefault(p => Key(p).Equals(entry.RecoveringTarget.Value)); if (target == null) return; var area = EnemySquadAssignments.CaptureAreas(new[] { target.Vitals.AcceptedPosition }, new uint[] { 1 })[0]; var status = entry.Positioning.CheckPressureOpportunityServer(entry.Actor, entry.Query, area.Center, entry.CoarseAttempt, out var endpoint); if (status == EnemyPlanStatus.Deferred) { entry.State = "Waiting for coarse recovery query"; return; } bool assigned = false; if (status == EnemyPlanStatus.Ready) { if (encounter.PressureDirector != null) assigned = encounter.PressureDirector.TryAssignRecoveryServer(entry.Actor, Key(target), area.Center, endpoint); else assigned = entry.Positioning.SetRecoverySectorServer(new EnemyApproachSector(endpoint, 3, now + 6), area.Center); } if (assigned || ++entry.CoarseAttempt >= 6) { entry.RecoveringTarget = null; entry.PhysicalOpportunity = true; entry.State = assigned ? "Finite coarse recovery assigned; health and pursuit retained" : "Potential opportunity; encounter policy or coarse route blocked"; entry.Positioning.CancelPressureQuery(entry.Query); entry.Query.Invalidate(); } } private bool Hidden(PrototypeEnemy enemy, out string reason) { reason = "Observer coverage incomplete"; foreach (var client in encounter.NetworkManager.ConnectedClients.Values) { var observer = client.PlayerObject != null ? client.PlayerObject.GetComponent() : null; if (observer == null || !observer.IsSpawned || observer.gameObject.scene != encounter.gameObject.scene || !EnemyRetirementVisibility.HiddenFromObserver(enemy, observer, out reason)) return false; } return encounter.NetworkManager.ConnectedClients.Count > 0; } public bool TryRetireServer(PrototypeEnemy enemy, out string reason) { reason = "Retirement protected or recovery incomplete"; if (!Eligible(enemy) || !entries.TryGetValue(enemy.SpawnIdentity, out var entry) || entry.Actor.Owner != enemy || entry.Executed || !entry.Actor.ReadyForActionsServer || !entry.Actor.IsValid || entry.Actor.Status.Rooted || entry.Actor.Status.Stunned || !entry.Policy.CanRetire) return false; var targets = Targets(); if (!ValidTargets(targets) || !TargetVolumes(targets, out var volumes) || !RefreshGeometry(true) || Changed(entry, targets, volumes)) { entry.State = reason = "Opportunity changed; reassessment required"; return false; } if (Contributions(entry, targets) != EnemyRecoveryAssessment.Infeasible) { entry.State = reason = "Current role contribution available or unknown"; return false; } if (entry.Movement.AcceptedPlan != null || entry.Movement.HasObservation) { entry.State = reason = "Existing movement owner retained"; return false; } if (!Hidden(enemy, out reason)) { entry.VisibilityRetained = true; entry.ExecutionAlternative = 0; entry.NextVisibilityCheck = entry.Actor.Now + .25; entry.State = "Recovery exhausted; retained: " + reason; return false; } while (entry.ExecutionAlternative < targets.Length * 6) { int index = entry.ExecutionAlternative; var status = entry.Positioning.CheckRecoveryOpportunityServer(entry.Actor, entry.Execution, entry.Positions[index / 6], entry.TargetVolumes[index / 6], index % 6, out _); if (status != EnemyPlanStatus.NoPath && status != EnemyPlanStatus.NoLegalPosition) { if (status == EnemyPlanStatus.Ready) { entry.Policy.RecordCompleted(entry.Revision, entry.Targets[index / 6], index % 6, EnemyRecoveryAssessment.Useful); entry.RecoveringTarget = entry.Targets[index / 6]; } if (status != EnemyPlanStatus.Deferred) entry.ExecutionAlternative = 0; entry.Policy.Advance(entry.Actor.Now, false); entry.State = reason = status == EnemyPlanStatus.Deferred ? "Waiting for execution query budget" : "Execution found a useful or incomplete route"; return false; } entry.ExecutionAlternative++; } if (!Eligible(enemy) || !entry.Actor.IsValid || !TargetVolumes(targets, out volumes) || Changed(entry, targets, volumes) || Contributions(entry, targets) != EnemyRecoveryAssessment.Infeasible) { entry.State = "Execution authority or opportunity changed; retained"; return false; } if (!Hidden(enemy, out reason)) { entry.VisibilityRetained = true; entry.ExecutionAlternative = 0; entry.NextVisibilityCheck = entry.Actor.Now + .25; entry.State = "Execution visibility changed; retained"; return false; } entry.Executed = true; CancelQueries(entry); LastOutcome = entry.State = reason = "Retired spawn " + enemy.SpawnIdentity + " without death, credit, refund or replacement"; enemy.NetworkObject.Despawn(true); return true; } private static void CancelQueries(Entry entry) { entry.Positioning?.CancelPressureQuery(entry.Query); entry.Positioning?.CancelPressureQuery(entry.Execution); entry.Query.Invalidate(); entry.Execution.Invalidate(); } public string Capture(PrototypeEnemy enemy) { if (enemy == null) return LastOutcome; return entries.TryGetValue(enemy.SpawnIdentity, out var entry) ? entry.State + "; completed " + entry.Policy.CompletedAlternatives + "; eligible " + entry.Policy.EligibleSeconds.ToString("F2") + "s; revision " + entry.Revision + "; " + GeometryStatus : "Protected, unsupported or inactive recovery actor"; } internal void Close() { if (closed) return; closed = true; foreach (var entry in entries.Values) CancelQueries(entry); entries.Clear(); if (encounter == null || encounter.NetworkManager == null || !encounter.IsServer) return; foreach (var client in encounter.NetworkManager.ConnectedClients.Values) client.PlayerObject?.GetComponent()?.SetRetirementObservationServer(false); } } }