#if UNITY_EDITOR || DEVELOPMENT_BUILD using System; using System.Collections; using System.Collections.Generic; using System.Linq; using ProjectI.Combat; using ProjectI.Enemies; using ProjectI.Player; using ProjectI.Runs; using ProjectI.Sessions; using ProjectI.Settings; using Unity.Netcode; using UnityEngine; using UnityEngine.AI; using UnityEngine.InputSystem; using UnityEngine.UIElements; namespace ProjectI.Debugging { // Editor-native fixture only. Owns setup/events, never enemy decisions or execution ticks. public sealed class EnemyBehaviorScenarioDriver : MonoBehaviour { public enum Situation { WallAlternateExit, StairsCloseTransit, BottleneckInvalidation, PursuitExpiryStub, DisconnectedGroundStub, WallSearchStopped, WallSearchLeft, WallSearchRight, WallSearchDoubleBack, WallSearchEscape } public enum SearchPreset { Weak, Ordinary, Persistent } public SearchPreset SelectedSearchPreset { get; private set; } = SearchPreset.Ordinary; public SearchPreset ActiveSearchPreset { get; private set; } = SearchPreset.Ordinary; public void SelectSearchPreset(SearchPreset value) { if (!Enum.IsDefined(typeof(SearchPreset), value)) throw new ArgumentOutOfRangeException(nameof(value)); SelectedSearchPreset = value; // Current actors retain their startup snapshots until owned reset. } public static readonly Vector3 Origin = new Vector3(400, 0, 400); public const string Controls = "WASD/mouse · LMB fire · RMB aim · Space jump · Shift sprint · R reload · 1/2 weapons · Q/F/C actives · Z ultimate · V melee · Esc menu · Tab inspect · F5 reset · F6 director debug"; public IReadOnlyList Enemies => enemies; public EnemyPositionClaims Claims { get; private set; } public EnemyQueryBudget Budget { get; private set; } public EnemyQueryBudget SearchBudget { get; private set; } [Header("Shared planning budgets (captured on reset)")] [InspectorName("Shared path queries per frame"), Range(1, 32), Tooltip("Total path-query grants shared by every sandbox shooter. F5/manual reset captures both budgets together for replacement actors.")] public int decisionPathQueriesPerFrame = 8; [InspectorName("Shared investigation probes per frame"), Range(1, 32), Tooltip("Total investigation geometry-probe grants shared by every sandbox shooter, separate from path queries. F5/manual reset captures both budgets together for replacement actors.")] public int searchVisibilityProbesPerFrame = 16; public string Status { get; private set; } = "Not initialized"; public string LastScenarioResult { get; private set; } = "No scripted case run; human acceptance pending"; public bool Ready { get; private set; } public bool Busy => operation != null; public bool Stopped => disposed; public bool Scripted { get; private set; } public uint Generation { get; private set; } public int ScriptedPlacements { get; private set; } public int ManualCount { get; private set; } = 1; public bool DirectorMode { get; private set; } public bool BossDirectorMode { get; private set; } public EnemyDirectorDefinition DirectorDefinition { get; set; } public EnemyAdmissionLedger DirectorAdmissions => controller != null ? controller.encounter?.DirectorAdmissions : null; public bool ShowDirectorOverlay { get; set; } = true; private EnemyDirectorDebugPanel directorOverlay; private double nextDirectorDebugRefresh; public EnemyDirectorDebugView.Snapshot CaptureDirectorDebug() { var encounter = controller != null ? controller.encounter : null; if (!Ready || Busy || !DirectorMode || encounter == null || !encounter.CreditDirectorEnabled || !encounter.IsSpawned || !encounter.IsServer || session == null || encounter.NetworkManager != session.Network || encounter.gameObject.scene != gameObject.scene) return EnemyDirectorDebugView.Inactive; return EnemyDirectorDebugView.Capture(encounter.DirectorAdmissions, encounter.CreditScheduler, encounter.Snapshot.Phase, session.Network.ServerTime.Time); } public int LastResetActiveFlights { get; private set; } public int LastResetPendingPresentations { get; private set; } public PrototypePlayerCombat Player => session != null && session.Network.IsHost && session.Network.ConnectedClients.TryGetValue(session.Network.LocalClientId, out var client) && client.PlayerObject != null ? client.PlayerObject.GetComponent() : null; private PlaytestSceneController controller; private PrototypeSessionController session; private GameObject prefab, terrain; private EnemyInvestigationFixtureAssets fixtureAssets; private PrototypePlayerCombat scriptedTarget; private bool occludeSettledTarget; private Action restoreOwnedPlacement; public bool profileInvestigationQueries; public PrototypePlayerCombat PlacementTarget { get; private set; } public string LastScenarioTracePath { get; private set; } private EnemyNavigationDefinition navigation; private StageSpatialContext stage; private StageSpatialDefinition originalStage, ownedStage; private bool registered, originalSpawning, configured, disposed; private bool directorTerrain; private Coroutine operation; private readonly List enemies = new List(); private readonly List events = new List(); private readonly List meshes = new List(); private readonly List meshData = new List(); public static string Label(Situation value) => value == Situation.WallAlternateExit ? "Wall and alternate exit" : value == Situation.StairsCloseTransit ? "Stairs and close transit" : value == Situation.BottleneckInvalidation ? "Bottleneck and destination invalidation" : value == Situation.PursuitExpiryStub ? "Pursuit expiry — PARTIAL (M6 pending)" : value == Situation.DisconnectedGroundStub ? "Disconnected-ground recovery — PARTIAL (M7 pending)" : "Bounded nearby-cover search — " + value.ToString().Replace("WallSearch", ""); // Called in sceneLoaded before the baseline controller starts its local host. public void Configure(PlaytestSceneController owner, GameObject shooter, EnemyInvestigationFixtureAssets assets = null) { if (controller != null || owner == null || shooter == null || owner.gameObject.scene != gameObject.scene) throw new InvalidOperationException("Fixture requires one local controller and the authored shooter child."); var candidate = PrototypeSessionController.Instance; if (candidate == null || candidate.Network.IsListening || candidate.Busy) throw new InvalidOperationException("Launch requires a fresh owned local fixture session."); originalSpawning = owner.spawningEnabled; controller = owner; prefab = shooter; fixtureAssets = assets; session = candidate; configured = true; owner.spawningEnabled = false; registered = !session.Network.NetworkConfig.Prefabs.Contains(prefab); if (registered) session.Network.AddNetworkPrefab(prefab); } public void BeginManual(int count) { DirectorMode = false; BossDirectorMode = false; scriptedTarget = null; occludeSettledTarget = false; if (count != 1 && count != 3) throw new ArgumentOutOfRangeException(nameof(count)); if (operation != null && LastScenarioResult.StartsWith("Running:")) LastScenarioResult = "Cancelled by manual/reset: " + LastScenarioResult.Substring(9); ManualCount = count; StartOperation(InitializeAndReset(count)); } public void BeginDirector(bool? bossPhase = null) { if (DirectorDefinition == null) throw new InvalidOperationException("Create the selected director fixture definitions first."); BossDirectorMode = bossPhase ?? BossDirectorMode; DirectorMode = true; scriptedTarget = null; occludeSettledTarget = false; StartOperation(InitializeDirector()); } private IEnumerator InitializeDirector() { yield return InitializeAndReset(0); ClearEncounter(); yield return null; Box("S2 broad temporary arrival ground", new Vector3(0, -.65f, 0), new Vector3(150, 1.3f, 150), true); foreach (var instance in meshes) instance.Remove(); meshes.Clear(); foreach (var data in meshData) if (data != null) Destroy(data); meshData.Clear(); BuildNavigation(true); directorTerrain = true; ownedStage.spawnMode = StageSpawnMode.DynamicCircles; ownedStage.minimumSpawnDistance = 20; ownedStage.maximumSpawnDistance = 60; ownedStage.spawnCircleRadius = 6; ownedStage.bossPosition = BossDirectorMode ? Origin + Vector3.right * 30 : originalStage.bossPosition; controller.encounter.DirectorActorPreparing -= PreparingDirectorActor; controller.encounter.DirectorActorPreparing += PreparingDirectorActor; Require(controller.encounter.TryEnableDirectorFixtureServer(DirectorDefinition, "exploration", 20261015, out var reason), reason); Ready = true; Status = BossDirectorMode ? "S3 ready · passive boss · provisional tuning" : "S2 director ready. Arriving enemies can be damaged; they become active when the brackets disappear. Provisional tuning; no boss reinforcements or splitting."; } private void PreparingDirectorActor(GameObject instance) { var enemy = instance.GetComponent(); if (instance.GetComponent() == null) return; ConfigureShooterBeforeBirth(enemy); enemies.Add(enemy); } public void BeginScenario(Situation situation) { DirectorMode = false; BossDirectorMode = false; scriptedTarget = null; occludeSettledTarget = false; if (!Ready) throw new InvalidOperationException("Launch the manual sandbox first."); LastScenarioResult = "Running: " + Label(situation); StartOperation(RunScenario(situation)); } public void BeginNetworkScenario(Situation situation, PrototypePlayerCombat target, bool occludeSettled = false) { if (!Ready || target == null || !target.IsServer || !target.IsSpawned || target.NetworkManager != session.Network || !session.Network.ConnectedClientsList.Any(client => client.PlayerObject == target.NetworkObject)) throw new InvalidOperationException("Network fixture target must be a current participant in the owned host."); scriptedTarget = target; occludeSettledTarget = occludeSettled; DirectorMode = false; LastScenarioResult = "Running: " + Label(situation); StartOperation(RunScenario(situation)); } public void BeginStop() => StartOperation(StopOwnedSession()); private IEnumerator StopOwnedSession() { Ready = false; Status = "Stopping owned sandbox…"; ClearEncounter(); if (configured && session != null) { var leaving = session.LeaveAsync(); yield return Wait(() => leaving.IsCompleted, 30, "owned leave completion"); leaving.GetAwaiter().GetResult(); yield return Wait(() => !session.Network.IsListening && !session.Network.ShutdownInProgress, 15, "confirmed owned network shutdown"); } DisposeSandbox(); } private void StartOperation(IEnumerator routine) { if (disposed) throw new ObjectDisposedException(nameof(EnemyBehaviorScenarioDriver)); if (operation != null) StopCoroutine(operation); RestoreOwnedPlacement(); Scripted = false; operation = StartCoroutine(Guard(routine)); } // Unity does not route exceptions from nested iterators into an outer try/finally. private IEnumerator Guard(IEnumerator routine) { var stack = new Stack(); stack.Push(routine); Exception failure = null; while (stack.Count > 0) { object current = null; bool more = false; try { more = stack.Peek().MoveNext(); if (more) current = stack.Peek().Current; } catch (Exception error) { failure = error; } if (failure != null) break; if (!more) { (stack.Pop() as IDisposable)?.Dispose(); continue; } if (current is IEnumerator nested) { stack.Push(nested); continue; } yield return current; } foreach (var remaining in stack) (remaining as IDisposable)?.Dispose(); Scripted = false; if (failure != null) { Ready = false; string failed = "Failed: " + failure.Message; try { ClearEncounter(); } catch (Exception cleanup) { failed += " | cleanup: " + cleanup.Message; } if (configured && session != null && session.IsLocalDebugSession) { var leaving = session.LeaveAsync(); double end = Time.realtimeSinceStartupAsDouble + 30; while (!leaving.IsCompleted && Time.realtimeSinceStartupAsDouble < end) yield return null; try { Require(leaving.IsCompleted, "owned session cleanup timed out"); leaving.GetAwaiter().GetResult(); } catch (Exception cleanup) { failed += " | cleanup: " + cleanup.Message; } } double shutdownEnd = Time.realtimeSinceStartupAsDouble + 15; while (configured && session != null && (session.Network.IsListening || session.Network.ShutdownInProgress) && Time.realtimeSinceStartupAsDouble < shutdownEnd) yield return null; bool stopped = !configured || session == null || !session.Network.IsListening && !session.Network.ShutdownInProgress; operation = null; if (stopped) { try { DisposeSandbox(); } catch (Exception cleanup) { failed += " | cleanup: " + cleanup.Message; } } else failed += " | cleanup pending: session still owns resources; use Stop sandbox to retry"; Status = failed + ". Stop and relaunch the sandbox."; LastScenarioResult = Status; Debug.LogError("M5 fixture " + Status); } operation = null; } private IEnumerator InitializeAndReset(int count) { Ready = false; Status = "Starting owned local sandbox…"; if (terrain == null) { yield return Wait(() => controller != null && controller.Ready, 30, "local practice host"); yield return null; // Let baseline Start finish before taking its reset/spawn ownership. Require(controller.AcquireManualFixture(this, () => { if (DirectorMode) BeginDirector(); else BeginManual(ManualCount); }), "manual fixture ownership"); navigation = ScriptableObject.CreateInstance(); navigation.laneWeapon = prefab.GetComponent().definition; Claims = new EnemyPositionClaims(); BuildTerrain(); stage = StageSpatialContext.Active; originalStage = stage.definition; ownedStage = Instantiate(originalStage); ownedStage.movementEnvelope = new Bounds(new Vector3(200, 3, 200), new Vector3(600, 30, 600)); stage.definition = ownedStage; } yield return Reset(count, true); Ready = true; Status = "Manual ready: " + count + " shooter(s). Human combat/readability acceptance pending."; } private IEnumerator Reset(int count, bool cover) { Status = "Resetting fixture…"; ClearEncounter(); if (directorTerrain && !DirectorMode) { ownedStage.bossPosition = originalStage.bossPosition; yield return null; foreach (var instance in meshes) instance.Remove(); meshes.Clear(); foreach (var data in meshData) if (data != null) Destroy(data); meshData.Clear(); BuildNavigation(); ownedStage.spawnMode = originalStage.spawnMode; directorTerrain = false; } decisionPathQueriesPerFrame = Mathf.Clamp(decisionPathQueriesPerFrame, 1, 32); searchVisibilityProbesPerFrame = Mathf.Clamp(searchVisibilityProbesPerFrame, 1, 32); navigation.decisionPathQueriesPerFrame = decisionPathQueriesPerFrame; Budget = new EnemyQueryBudget(decisionPathQueriesPerFrame); SearchBudget = new EnemyQueryBudget(searchVisibilityProbesPerFrame); ActiveSearchPreset = SelectedSearchPreset; foreach (var client in session.Network.ConnectedClientsList) client.PlayerObject?.GetComponent()?.ResetPracticeServer(); yield return PlacePlayer(new Vector3(0, 0, 3), false); if (cover) AddEventBox("Manual cover — exits on both sides", new Vector3(0, 1.5f, -2), new Vector3(4, 3, .5f)); for (int i = 0; i < count; i++) Spawn(new Vector3((i - (count - 1) * .5f) * 5, 0, -8)); yield return null; } private void ClearEncounter() { directorOverlay?.RemoveFromHierarchy(); directorOverlay = null; if (!configured) return; if (controller != null && controller.encounter != null) { controller.encounter.DirectorActorPreparing -= PreparingDirectorActor; controller.encounter.EndDirectorFixtureServer(); } Generation++; foreach (var enemy in enemies) { if (enemy == null) continue; enemy.GetComponent().ShutdownServer(EnemyShutdownReason.Reset); if (enemy.IsSpawned) enemy.NetworkObject.Despawn(); else Destroy(enemy.gameObject); } enemies.Clear(); // Only encounter reset owns launched flight; ordinary action cancellation never comes here. if (controller != null && controller.encounter != null) controller.encounter.Projectiles?.CancelAllServer(); LastResetActiveFlights = controller != null && controller.encounter != null ? controller.encounter.Projectiles.ActiveCount : 0; LastResetPendingPresentations = controller != null && controller.encounter != null ? controller.encounter.Projectiles.PendingClientPresentationCount : 0; foreach (var item in events) if (item != null) Destroy(item); events.Clear(); Physics.SyncTransforms(); Require(Claims == null || Claims.InspectionEntryCount == 0, "old claims survived reset"); Require(Budget == null || Budget.InspectionPendingCount == 0, "old planning work survived reset"); Require(SearchBudget == null || SearchBudget.InspectionPendingCount == 0, "old search work survived reset"); } private PrototypeEnemy Spawn(Vector3 local) { var enemy = Instantiate(prefab, Origin + local, Quaternion.identity).GetComponent(); enemies.Add(enemy); ConfigureShooterBeforeBirth(enemy); enemy.ConfigureSpawn(false, controller.encounter); enemy.NetworkObject.Spawn(); enemy.name = $"Sandbox host shooter {enemy.NetworkObjectId} (spawn {enemy.SpawnIdentity})"; Require(enemy.GetComponent().IsRunning, enemy.GetComponent().LastError); return enemy; } private void ConfigureShooterBeforeBirth(PrototypeEnemy enemy) { var movement = enemy.GetComponent(); movement.definition = navigation; movement.ConfigureServices(Claims); var positioning = enemy.GetComponent(); positioning.definition = navigation; positioning.ConfigureServices(Claims, Budget, SearchBudget); positioning.profileInvestigationQueries = profileInvestigationQueries; if (fixtureAssets != null) { positioning.investigationDefinition = fixtureAssets.Investigation[(int)ActiveSearchPreset]; enemy.GetComponent().definition = fixtureAssets.Perception[(int)ActiveSearchPreset]; } #if UNITY_EDITOR else { positioning.investigationDefinition = UnityEditor.AssetDatabase.LoadAssetAtPath("Assets/_Project/Debug/EnemyBehavior/" + ActiveSearchPreset + "Investigation.asset"); enemy.GetComponent().definition = UnityEditor.AssetDatabase.LoadAssetAtPath("Assets/_Project/Debug/EnemyBehavior/" + ActiveSearchPreset + "ShooterPerception.asset"); } #endif } private IEnumerator PlacePlayer(Vector3 local, bool scripted = true) { if (scripted) ScriptedPlacements++; Vector3 point = Origin + local + Vector3.up * .1f; var target = scripted && scriptedTarget != null ? scriptedTarget : Player; PlacementTarget = target; PrototypeFirstPersonPlayer replica = null; bool previousMotor = false, previousController = false; bool restored = false; Action restore = null; restore = () => { if (restored) return; restored = true; if (replica != null) { replica.enabled = previousMotor; replica.GetComponent().enabled = previousController; } if (ReferenceEquals(restoreOwnedPlacement, restore)) { restoreOwnedPlacement = null; PlacementTarget = null; } }; restoreOwnedPlacement = restore; try { if (target.OwnerClientId != session.Network.LocalClientId) { replica = FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None) .FirstOrDefault(value => value.IsOwner && !value.IsServer && value.NetworkObjectId == target.NetworkObjectId && value.OwnerClientId == target.OwnerClientId); Require(replica != null, "matching loopback owner for fixture correction"); previousMotor = replica.enabled; previousController = replica.GetComponent().enabled; replica.enabled = true; replica.GetComponent().enabled = true; } target.GetComponent().ServerTeleport(point); yield return Wait(() => target != null && target.Vitals.SpatiallySettled && Vector3.Distance(target.Vitals.AcceptedPosition, point) < .3f, 5, "accepted fixture placement"); } finally { restore(); } Physics.SyncTransforms(); } private void RestoreOwnedPlacement() { var restore = restoreOwnedPlacement; restore?.Invoke(); } private IEnumerator Sight(PrototypeEnemy enemy) { yield return Wait(() => enemy.GetComponent().TryGetSnapshot(Key, enemy.ServerNow, out var snapshot) && snapshot.VisibleAtLastSample && snapshot.ReactionReady, 3, "genuine earned personal sight"); } private EnemyTargetKey Key => scriptedTarget != null ? new EnemyTargetKey(scriptedTarget.NetworkObjectId, scriptedTarget.SpawnLifetime) : new EnemyTargetKey(Player.NetworkObjectId, Player.SpawnLifetime); private static ulong Releases(PrototypeEnemy enemy) => enemy.GetComponent().ReleasedShots; private static void Require(bool condition, string reason) { if (!condition) throw new InvalidOperationException(reason); } public static IEnumerator Wait(Func condition, double seconds, string reason) { double end = Time.realtimeSinceStartupAsDouble + seconds; while (!condition()) { Require(Time.realtimeSinceStartupAsDouble < end, "Timed out: " + reason); yield return null; } } private IEnumerator RunScenario(Situation situation) { yield return Reset(0, false); Scripted = true; Status = "Scripted: " + Label(situation); if (scriptedTarget != null && scriptedTarget != Player) yield return PlacePlayer(new Vector3(45, 0, 0), false); if (situation == Situation.WallAlternateExit) yield return WallAlternateExit(); else if (situation == Situation.StairsCloseTransit) yield return StairsCloseTransit(); else if (situation == Situation.BottleneckInvalidation) yield return BottleneckInvalidation(); else if (situation == Situation.PursuitExpiryStub) yield return PursuitExpiry(); else if (situation == Situation.DisconnectedGroundStub) yield return DisconnectedGround(); else yield return BoundedWallSearch(situation); LastScenarioResult = (situation == Situation.PursuitExpiryStub || situation == Situation.DisconnectedGroundStub ? "Partial — implemented stub assertions satisfied: " : "Automated assertions satisfied: ") + Label(situation); Status = LastScenarioResult + ". Return to Manual to fight; human acceptance pending."; } private IEnumerator WallAlternateExit() { yield return PlacePlayer(Vector3.zero); var enemy = Spawn(new Vector3(0, 0, -8)); yield return Sight(enemy); var perception = enemy.GetComponent(); Require(perception.TryGetPersonalObservation(Key, enemy.ServerNow, out var old), "initial personal observation"); var wall = AddEventBox("Scripted unseen exit cover", new Vector3(0, 1.5f, -3), new Vector3(20, 3, .3f)); ulong count = Releases(enemy); yield return PlacePlayer(new Vector3(8, 0, 0)); yield return new WaitForSeconds(.3f); Require(perception.TryGetPersonalObservation(Key, enemy.ServerNow, out var stored) && stored.Position == old.Position && stored.ObservedAt == old.ObservedAt, "unseen exit changed personal evidence"); Require(Releases(enemy) == count, "new release through occluding wall"); Destroy(wall); yield return null; yield return Sight(enemy); Require(perception.TryGetPersonalObservation(Key, enemy.ServerNow, out var fresh) && fresh.Position.x > old.Position.x + 7, "exit correction requires new sight"); yield return Wait(() => Releases(enemy) > count, 4, "reacquired graph release"); } private IEnumerator BoundedWallSearch(Situation situation) { yield return PlacePlayer(new Vector3(-3.84f, 0, -.695f)); var cover = AddEventBox("M5B retained four-metre cover", new Vector3(0, 1.5f, -2), new Vector3(4, 3, .5f)); var enemy = Spawn(new Vector3(4.28f, 0, -.87f)); yield return Sight(enemy); yield return Wait(() => Releases(enemy) > 0, 4, "ordinary visible attack before disappearance"); Vector3 hidden = new Vector3(-3.84f, 0, -3.57f); if (situation == Situation.WallSearchLeft) hidden.x = -5; if (situation == Situation.WallSearchRight) hidden.x = 0; if (situation == Situation.WallSearchEscape) hidden.z = -9; if (occludeSettledTarget) { Require(scriptedTarget != null && scriptedTarget.Vitals.SpatiallySettled && scriptedTarget.Vitals.IsAlive, "settled observed remote before owned-cover occlusion"); // Exercise normal status cancellation of the prior visible action before // this controlled disconnect trial. Evidence/search clocks continue normally. Require(enemy.ApplyStatusServer(new ProjectI.Combat.Statuses.StatusApplication(985, ProjectI.Combat.Statuses.EnemyStatusId.Stun, 1, .3, 985), enemy.ServerNow), "controlled lifecycle stun accepted by normal status owner"); cover.transform.position = Origin + new Vector3(0, 1.5f, -.695f); Physics.SyncTransforms(); } else yield return PlacePlayer(hidden); var perception = enemy.GetComponent(); var positioning = enemy.GetComponent(); yield return Wait(() => perception.TryGetSnapshot(Key, enemy.ServerNow, out var snapshot) && !snapshot.VisibleAtLastSample, 1, "real occlusion in retained cover"); var trace = new System.Text.StringBuilder("Scripted accepted player placements; not human acceptance.\n" + (occludeSettledTarget ? "Stationary settled remote target occluded by moved 4x3x0.5m fixture cover after controlled 0.3s stun; normal status cancellation, original evidence/search clocks; disconnect lifecycle proof only.\n" : "") + "Preset " + ActiveSearchPreset + "\n"); double end = enemy.ServerNow + positioning.InvestigationSettings.MaximumSearchSeconds + 1; bool doubledBack = false; double began = enemy.ServerNow, nextCapture = 0; while (enemy.ServerNow < end) { if (situation == Situation.WallSearchDoubleBack && !doubledBack && enemy.ServerNow - began > .7) { doubledBack = true; yield return PlacePlayer(new Vector3(-6, 0, -3.5f)); } if (enemy.ServerNow >= nextCapture) { trace.AppendLine($"Time {enemy.ServerNow:F3}; actor {enemy.transform.position:F3}\n" + EnemyBehaviorDiagnostics.Capture(enemy)); nextCapture = enemy.ServerNow + .1; } var episode = positioning.ActiveInvestigation; if (episode != null) { Require(episode.Travelled <= episode.Settings.MaximumSearchTravelDistance + .01f && episode.Attempts <= episode.Settings.MaximumAttempts && episode.Inspections <= episode.Settings.MaximumInspections, "episode exceeded captured strength"); if (episode.Terminal) break; } yield return null; } string directory = System.IO.Path.Combine("TestResults", "m5b-sandbox-" + Guid.NewGuid().ToString("N")); System.IO.Directory.CreateDirectory(directory); LastScenarioTracePath = System.IO.Path.GetFullPath(System.IO.Path.Combine(directory, situation + ".txt")); System.IO.File.WriteAllText(LastScenarioTracePath, trace.ToString()); Debug.Log("M5B scripted comparison trace: " + System.IO.Path.GetFullPath(directory)); } private IEnumerator StairsCloseTransit() { yield return PlacePlayer(new Vector3(0, 0, -7)); var enemy = Spawn(new Vector3(0, 0, 7)); Require(enemy.GetComponent().SetApproachSectorServer(new EnemyApproachSector(Origin + new Vector3(0, 3, 19), 3, enemy.ServerNow + 10)), "finite coarse stair assignment"); yield return Wait(() => enemy.transform.position.y > 2.8f && enemy.transform.position.z > Origin.z + 15, 8, "twelve actual stair risers"); yield return PlacePlayer(new Vector3(0, 3, 23)); yield return Sight(enemy); ulong before = Releases(enemy); yield return Wait(() => Releases(enemy) > before, 4, "legal close landing shot"); Require(Vector3.Distance(enemy.transform.position, Player.Vitals.AcceptedPosition) < 8, "close transit must not become a hard range prohibition"); } private IEnumerator BottleneckInvalidation() { yield return PlacePlayer(new Vector3(45, 0, 0)); var front = Spawn(new Vector3(8, 0, 0)); var rear = Spawn(new Vector3(5, 0, 0)); foreach (var enemy in enemies) Require(enemy.GetComponent().SetApproachSectorServer(new EnemyApproachSector(Origin + new Vector3(24, 0, 0), 3, enemy.ServerNow + 12)), "finite bridge assignment"); var movement = front.GetComponent(); yield return Wait(() => movement.AcceptedPlan != null, 3, "front accepted claim"); var old = movement.AcceptedPlan; var obstruction = AddEventBox("Invalidated destination", old.Destination - Origin + Vector3.up, new Vector3(1.2f, 2, 1.2f)); yield return Wait(() => !ReferenceEquals(movement.AcceptedPlan, old), 3, "unsafe old destination released"); Require(!Claims.TryInspect(old.Claim, out _), "obsolete destination claim retained"); Destroy(obstruction); yield return null; float minimumSeparation = float.PositiveInfinity; double end = front.ServerNow + 10; while (rear.transform.position.x < Origin.x + 20 || front.transform.position.x < Origin.x + 20) { minimumSeparation = Mathf.Min(minimumSeparation, Vector3.Distance(front.transform.position, rear.transform.position)); Require(front.ServerNow < end, "bounded bridge throughput"); yield return null; } Require(minimumSeparation > .7f, "bridge actors overlap"); Debug.Log($"M5 bridge: front {front.transform.position:F2}, rear {rear.transform.position:F2}, min separation {minimumSeparation:F2}m"); } private IEnumerator PursuitExpiry() { yield return PlacePlayer(new Vector3(45, 0, 0)); var enemy = Spawn(new Vector3(-7, 0, 0)); var positioning = enemy.GetComponent(); double expiry = enemy.ServerNow + .8; Require(enemy.DamageServer(5) > 0, "stub prior damage"); float health = enemy.CurrentHealth; Require(positioning.SetApproachSectorServer(new EnemyApproachSector(Origin, 3, expiry)), "finite assignment"); yield return new WaitForSeconds(1.5f); Require(positioning.CapturedSector.HasValue && positioning.CapturedSector.Value.ExpiresAt == expiry && !positioning.HasAcceptedTravel, "expired sector silently renewed or kept travel"); Require(Releases(enemy) == 0 && enemy.CurrentHealth == health, "stub fabricated fire or health reset"); } private IEnumerator DisconnectedGround() { yield return PlacePlayer(new Vector3(45, 0, 0)); var enemy = Spawn(new Vector3(25, 0, 0)); var positioning = enemy.GetComponent(); Require(enemy.DamageServer(5) > 0, "stub prior damage"); float health = enemy.CurrentHealth; Vector3 start = enemy.transform.position; // Coarse disconnected region is fixture setup, not target knowledge or a recovery policy. Require(positioning.SetApproachSectorServer(new EnemyApproachSector(Origin + new Vector3(45, 0, 0), 3, enemy.ServerNow + 3)), "disconnected coarse assignment"); yield return Wait(() => positioning.LastResult == EnemyPlanStatus.NoPath, 3, "native disconnected NoPath"); yield return new WaitForSeconds(1); Require(enemy.IsAlive && enemy.CurrentHealth == health && Vector3.Distance(start, enemy.transform.position) < .5f, "stub warped, healed or retired actor"); } private GameObject Box(string name, Vector3 local, Vector3 size, bool scenario) { var box = GameObject.CreatePrimitive(PrimitiveType.Cube); box.name = name; box.transform.position = Origin + local; box.transform.localScale = size; box.transform.SetParent(terrain.transform, true); if (scenario) events.Add(box); return box; } private GameObject AddEventBox(string name, Vector3 local, Vector3 size) { var box = Box(name, local, size, true); Physics.SyncTransforms(); return box; } private void BuildTerrain() { terrain = new GameObject("M5 temporary shooter sandbox (no saved navigation)"); Box("Base island", new Vector3(0, -.5f, 0), new Vector3(20, 1, 20), false); for (int step = 0; step < 12; step++) Box("Actual step " + step, new Vector3(0, (step + 1) * .125f, 10.25f + step * .5f), new Vector3(4, (step + 1) * .25f, .5f), false); Box("Upper landing", new Vector3(0, 2.5f, 21), new Vector3(16, 1, 10), false); var ramp = Box("Alternate supported ramp", new Vector3(-6, 1.5f, 13), new Vector3(3, .2f, Mathf.Sqrt(73)), false); ramp.transform.rotation = Quaternion.Euler(-Mathf.Atan2(3, 8) * Mathf.Rad2Deg, 0, 0); Box("Narrow bridge", new Vector3(15, -.5f, 0), new Vector3(10, 1, 1.4f), false); Box("Bridge island", new Vector3(25, -.5f, 0), new Vector3(10, 1, 10), false); Box("Disconnected island", new Vector3(45, -.5f, 0), new Vector3(10, 1, 10), false); BuildNavigation(); } private void BuildNavigation(bool wide = false) { Physics.SyncTransforms(); var sources = new List(); NavMeshBuilder.CollectSources(terrain.transform, ~0, NavMeshCollectGeometry.PhysicsColliders, 0, new List(), sources); var definition = prefab.GetComponent().definition; foreach (int type in new[] { definition.normalAgentType, definition.largeAgentType }.Distinct()) { var settings = NavMesh.GetSettingsByID(type); settings.overrideVoxelSize = true; settings.voxelSize = .05f; settings.buildHeightMesh = true; var bounds = wide ? new Bounds(Origin + Vector3.up * 4, new Vector3(160, 20, 160)) : new Bounds(Origin + new Vector3(20, 3, 8), new Vector3(70, 14, 40)); var data = NavMeshBuilder.BuildNavMeshData(settings, sources, bounds, Vector3.zero, Quaternion.identity); Require(data != null, "fixture navigation build failed"); meshData.Add(data); meshes.Add(NavMesh.AddNavMeshData(data)); } } private void Update() { if (Ready && DirectorMode && Application.isFocused && !LocalPlayerSettings.IsInputBlocked && Keyboard.current != null && Keyboard.current.f6Key.wasPressedThisFrame) ShowDirectorOverlay = !ShowDirectorOverlay; RefreshDirectorOverlay(); if (Ready && DirectorMode && BossDirectorMode && controller != null && controller.encounter != null && DirectorAdmissions != null) { var snapshot = controller.encounter.Snapshot; var ledger = DirectorAdmissions; Status = snapshot.Phase == EncounterPhase.Waves ? $"Exploration · eligible kills {snapshot.Kills}/{snapshot.KillGoal}" : !ledger.Active ? $"{snapshot.Phase} · hostile work stopped" : snapshot.AwaitingBossEntry ? "Awaiting safe boss entry · reinforcements paused" : $"Passive boss alive · nonboss live {ledger.LiveCount}/{ledger.Profile.AdmissionLimit} · pending {ledger.PendingCount}" + (ledger.NonbossCount > ledger.Profile.AdmissionLimit ? " · Existing survivors retained; reinforcements waiting for space" : ""); } if (Ready && Application.isFocused && Keyboard.current != null && Keyboard.current.f5Key.wasPressedThisFrame) { if (DirectorMode) BeginDirector(); else BeginManual(ManualCount); } } private void RefreshDirectorOverlay() { if (!Ready || Busy || !DirectorMode || controller == null || controller.document == null || controller.encounter == null || !controller.encounter.CreditDirectorEnabled) { directorOverlay?.RemoveFromHierarchy(); directorOverlay = null; return; } var root = controller.document.rootVisualElement; if (directorOverlay == null) directorOverlay = new EnemyDirectorDebugPanel(); if (directorOverlay.parent != root) root.Add(directorOverlay); directorOverlay.style.display = ShowDirectorOverlay && !LocalPlayerSettings.IsInputBlocked ? DisplayStyle.Flex : DisplayStyle.None; double now = session.Network.ServerTime.Time; if (now < nextDirectorDebugRefresh) return; nextDirectorDebugRefresh = now + .15; directorOverlay.Refresh(CaptureDirectorDebug()); } public void DisposeSandbox() { RestoreOwnedPlacement(); if (disposed) return; if (Application.isPlaying && configured && session != null && (session.Network.IsListening || session.Network.ShutdownInProgress)) throw new InvalidOperationException("Confirm owned network shutdown before restoring fixture resources; use BeginStop."); disposed = true; Ready = false; Scripted = false; try { ClearEncounter(); } finally { if (stage != null && stage.definition == ownedStage) stage.definition = originalStage; foreach (var mesh in meshes) mesh.Remove(); meshes.Clear(); foreach (var data in meshData) if (data != null) Destroy(data); meshData.Clear(); if (terrain != null) Destroy(terrain); if (navigation != null) Destroy(navigation); if (ownedStage != null) Destroy(ownedStage); if (configured && controller != null) { controller.ReleaseManualFixture(this); controller.spawningEnabled = originalSpawning; } if (registered && session != null && !session.Network.IsListening && prefab != null) session.Network.RemoveNetworkPrefab(prefab); Status = "Stopped; temporary sandbox removed"; } } private void OnDestroy() { // Editor Stop destroys the entire owned Play session; ordinary in-play stop awaits NGO first. if (!Application.isPlaying) DisposeSandbox(); } } } #endif