using System; using System.Collections.Generic; using System.Linq; using System.Text; using ProjectI.Debugging; using ProjectI.Enemies; using UnityEditor; using UnityEditor.SceneManagement; using UnityEngine; using UnityEngine.SceneManagement; using UnityEngine.UIElements; namespace ProjectI.Editor { public sealed class EnemyBehaviorSandboxWindow : EditorWindow { private const string ScenePath = "Assets/_Project/Scenes/PlayTestScene.unity"; private const string LaunchKey = "ProjectI.M5.OwnedManualLaunch"; private const string DirectorLaunchKey = "ProjectI.S2.DirectorLaunch"; private const string BossLaunchKey = "ProjectI.S3.BossLaunch"; private const string RestoreKey = "ProjectI.M5.RestorePlaySettings"; private const string PresetKey = "ProjectI.M5B.SearchPreset"; private const string ProbeKey = "ProjectI.M5B.ProbesPerFrame"; private const string PathKey = "ProjectI.M5B.PathsPerFrame"; private const string ProfileKey = "ProjectI.M5B.ProfileQueries"; private Label status, diagnostics; private IntegerField pathQueries, investigationProbes; private EnumField situation; private DropdownField graphTarget; private bool inspect; private string error = ""; private static EnemyBehaviorScenarioDriver Driver => UnityEngine.Object.FindAnyObjectByType(); [MenuItem("ProjectI/Debug/Enemy AI/Shooter sandbox")] public static void Open() => GetWindow("Shooter sandbox"); [InitializeOnLoadMethod] private static void Subscribe() { EditorApplication.playModeStateChanged -= PlayState; EditorApplication.playModeStateChanged += PlayState; if (SessionState.GetInt(LaunchKey, 0) > 0) { SceneManager.sceneLoaded -= Loaded; SceneManager.sceneLoaded += Loaded; } } public static void Launch(int count) { if (FoundationTestRunner.ActiveRequestStatus != "idle" || EditorApplication.isCompiling || EditorApplication.isUpdating || EditorUtility.scriptCompilationFailed) throw new InvalidOperationException("Require a compiled/imported Editor with an idle Test Runner."); if (EditorApplication.isPlaying) { if (Driver == null) throw new InvalidOperationException("Stop the current Play session before launching the sandbox."); Driver.BeginManual(count); return; } if (EditorApplication.isPlayingOrWillChangePlaymode) throw new InvalidOperationException("Play transition in progress."); if (count != 1 && count != 3) throw new ArgumentOutOfRangeException(nameof(count)); SessionState.SetBool(RestoreKey, true); SessionState.SetBool(RestoreKey + ".enabled", EditorSettings.enterPlayModeOptionsEnabled); SessionState.SetInt(RestoreKey + ".options", (int)EditorSettings.enterPlayModeOptions); SessionState.SetString(RestoreKey + ".scene", AssetDatabase.GetAssetPath(EditorSceneManager.playModeStartScene)); // A fresh stopped-session reload avoids retained NGO test-provider state. Restore on exit. EditorSettings.enterPlayModeOptionsEnabled = true; EditorSettings.enterPlayModeOptions = EnterPlayModeOptions.None; EditorSceneManager.playModeStartScene = AssetDatabase.LoadAssetAtPath(ScenePath); SessionState.SetInt(LaunchKey, count); SceneManager.sceneLoaded -= Loaded; SceneManager.sceneLoaded += Loaded; EditorApplication.isPlaying = true; } public static void LaunchDirector() => LaunchDirector(false); public static void LaunchBossDirector() => LaunchDirector(true); private static void LaunchDirector(bool boss) { var definition = AssetDatabase.LoadAssetAtPath(boss ? EnemyDirectorFixtureAuthoring.BossDefinitionPath : EnemyDirectorFixtureAuthoring.ArrivalDefinitionPath); if (definition == null) throw new InvalidOperationException(boss ? "Use Create S3 boss fixtures first." : "Use Create S2 arrival fixtures first."); if (EditorApplication.isPlaying && Driver != null) { Driver.DirectorDefinition = definition; Driver.BeginDirector(boss); return; } SessionState.SetBool(DirectorLaunchKey, true); SessionState.SetBool(BossLaunchKey, boss); try { Launch(1); } catch { SessionState.EraseBool(DirectorLaunchKey); SessionState.EraseBool(BossLaunchKey); throw; } } private static void PlayState(PlayModeStateChange state) { if (state != PlayModeStateChange.EnteredEditMode) return; SessionState.EraseInt(LaunchKey); SceneManager.sceneLoaded -= Loaded; if (!SessionState.GetBool(RestoreKey, false)) return; EditorSettings.enterPlayModeOptionsEnabled = SessionState.GetBool(RestoreKey + ".enabled", true); EditorSettings.enterPlayModeOptions = (EnterPlayModeOptions)SessionState.GetInt(RestoreKey + ".options", 3); EditorSceneManager.playModeStartScene = AssetDatabase.LoadAssetAtPath(SessionState.GetString(RestoreKey + ".scene", "")); SessionState.EraseBool(RestoreKey); } private static void Loaded(Scene scene, LoadSceneMode mode) { if (scene.path != ScenePath) return; SceneManager.sceneLoaded -= Loaded; int count = SessionState.GetInt(LaunchKey, 1); SessionState.EraseInt(LaunchKey); var obj = new GameObject("M5 opt-in shooter sandbox"); SceneManager.MoveGameObjectToScene(obj, scene); var driver = obj.AddComponent(); try { var prefab = AssetDatabase.LoadAssetAtPath(EnemyShooterFixtureBuilder.PrefabPath).GetComponentInChildren(true).gameObject; driver.Configure(UnityEngine.Object.FindAnyObjectByType(), prefab); bool boss = SessionState.GetBool(BossLaunchKey, false); SessionState.EraseBool(BossLaunchKey); driver.DirectorDefinition = AssetDatabase.LoadAssetAtPath(boss ? EnemyDirectorFixtureAuthoring.BossDefinitionPath : EnemyDirectorFixtureAuthoring.ArrivalDefinitionPath); driver.SelectSearchPreset((EnemyBehaviorScenarioDriver.SearchPreset)SessionState.GetInt(PresetKey, 1)); driver.decisionPathQueriesPerFrame = SessionState.GetInt(PathKey, 8); driver.searchVisibilityProbesPerFrame = SessionState.GetInt(ProbeKey, 16); driver.profileInvestigationQueries = SessionState.GetBool(ProfileKey, false); if (SessionState.GetBool(DirectorLaunchKey, false)) { SessionState.EraseBool(DirectorLaunchKey); driver.BeginDirector(boss); } else driver.BeginManual(count); } catch (Exception exception) { Debug.LogException(exception); EditorApplication.isPlaying = false; } } public static void StopSandbox() { if (Driver == null) throw new InvalidOperationException("No owned sandbox is running."); SessionState.SetInt(PathKey, Mathf.Clamp(Driver.decisionPathQueriesPerFrame, 1, 32)); SessionState.SetInt(ProbeKey, Mathf.Clamp(Driver.searchVisibilityProbesPerFrame, 1, 32)); if (Driver.Stopped) { EditorApplication.isPlaying = false; return; } Driver.BeginStop(); EditorApplication.update -= FinishStop; EditorApplication.update += FinishStop; } private static void FinishStop() { if (Driver != null && !Driver.Stopped) return; EditorApplication.update -= FinishStop; EditorApplication.isPlaying = false; } private static PrototypeEnemy InspectionTarget(int index) { if (EditorApplication.isCompiling || EditorApplication.isUpdating || EditorUtility.scriptCompilationFailed || FoundationTestRunner.ActiveRequestStatus != "idle") throw new InvalidOperationException("Require a ready Editor and idle Test Runner."); var driver = Driver; if (!EditorApplication.isPlaying || driver == null || !driver.Ready || index < 0 || index >= driver.Enemies.Count) throw new InvalidOperationException("Launch a manual or scripted shooter fixture first."); var enemy = driver.Enemies[index]; if (enemy == null || !enemy.IsServer || !enemy.IsAlive || !enemy.GetComponent().IsRunning) throw new InvalidOperationException("Select a living authoritative shooter; reset if needed."); return enemy; } public static void OpenObservations(int index = 0) => EnemyPerceptionWindow.Inspect(InspectionTarget(index).GetComponent()); public static void OpenLiveGraph(int index = 0) { var enemy = InspectionTarget(index); var authoring = AssetDatabase.LoadMainAssetAtPath(EnemyShooterFixtureBuilder.GraphPath); if (authoring == null || authoring.GetType().FullName != "Unity.Behavior.BehaviorAuthoringGraph" || !AssetDatabase.OpenAsset(authoring)) throw new InvalidOperationException("Native shooter authoring graph is unavailable."); Selection.activeGameObject = enemy.gameObject; EditorGUIUtility.PingObject(enemy); } public void CreateGUI() { rootVisualElement.style.paddingLeft = rootVisualElement.style.paddingRight = 8; rootVisualElement.Add(new Label("M5 isolated fixture · Editor only · normal-stage defaults unchanged") { style = { whiteSpace = WhiteSpace.Normal } }); var preset = new EnumField("Search preset (next reset)", (EnemyBehaviorScenarioDriver.SearchPreset)SessionState.GetInt(PresetKey, 1)); preset.RegisterValueChangedCallback(evt => { var value = (EnemyBehaviorScenarioDriver.SearchPreset)evt.newValue; SessionState.SetInt(PresetKey, (int)value); Driver?.SelectSearchPreset(value); }); rootVisualElement.Add(preset); rootVisualElement.Add(new Label("Preset and asset edits apply on reset/respawn. Default comparison values — Weak: 3 m / 3 s / 1 inspection. Ordinary: 6 m / 6 s / 3. Persistent: 9 m / 9 s / 4. Fair wrong guesses remain possible.") { style = { whiteSpace = WhiteSpace.Normal } }); pathQueries = new IntegerField("Shared path queries per frame (next reset)") { value = Driver != null ? Driver.decisionPathQueriesPerFrame : SessionState.GetInt(PathKey, 8) }; pathQueries.RegisterValueChangedCallback(evt => { int value = Mathf.Clamp(evt.newValue, 1, 32); pathQueries.SetValueWithoutNotify(value); SessionState.SetInt(PathKey, value); if (Driver != null) Driver.decisionPathQueriesPerFrame = value; }); rootVisualElement.Add(pathQueries); investigationProbes = new IntegerField("Shared investigation probes per frame (next reset)") { value = Driver != null ? Driver.searchVisibilityProbesPerFrame : SessionState.GetInt(ProbeKey, 16) }; investigationProbes.RegisterValueChangedCallback(evt => { int value = Mathf.Clamp(evt.newValue, 1, 32); investigationProbes.SetValueWithoutNotify(value); SessionState.SetInt(ProbeKey, value); if (Driver != null) Driver.searchVisibilityProbesPerFrame = value; }); rootVisualElement.Add(investigationProbes); rootVisualElement.Add(new Button(() => Act(() => { if (Driver == null) throw new InvalidOperationException("Launch the manual sandbox first."); Selection.activeGameObject = Driver.gameObject; })) { text = "Inspect shared planning budgets" }); var profileQueries = new Toggle("Profile search queries (next reset)") { value = SessionState.GetBool(ProfileKey, false) }; profileQueries.RegisterValueChangedCallback(evt => { SessionState.SetBool(ProfileKey, evt.newValue); if (Driver != null) Driver.profileInvestigationQueries = evt.newValue; }); rootVisualElement.Add(profileQueries); rootVisualElement.Add(new Button(() => { Selection.activeObject = AssetDatabase.LoadAssetAtPath("Assets/_Project/Debug/EnemyBehavior/" + (EnemyBehaviorScenarioDriver.SearchPreset)SessionState.GetInt(PresetKey, 1) + "Investigation.asset"); }) { text = "Inspect selected search strength asset" }); rootVisualElement.Add(new Button(() => Act(() => Launch(1))) { text = "Manual: one shooter" }); rootVisualElement.Add(new Button(() => Act(() => Launch(3))) { text = "Manual: small group (3)" }); rootVisualElement.Add(new Button(() => Act(LaunchDirector)) { text = "S2: reserved groups and readable arrivals" }); rootVisualElement.Add(new Button(() => Act(LaunchBossDirector)) { text = "S3: boss phase and reinforcements (passive boss)" }); rootVisualElement.Add(new Button(EnemyDirectorDebugWindow.Open) { text = "Director debug: bank, saving and recent births" }); rootVisualElement.Add(new Label("Arriving enemies can be damaged; they become active when the brackets disappear. S2 uses 20–60 m placement on temporary ground; no production activation.") { style = { whiteSpace = WhiteSpace.Normal } }); rootVisualElement.Add(new Button(() => Act(() => { if (Driver.DirectorMode) Driver.BeginDirector(); else Driver.BeginManual(Driver.ManualCount); })) { text = "Reset current fixture (F5)" }); rootVisualElement.Add(new Button(() => Act(StopSandbox)) { text = "Stop sandbox / restore Editor scenes" }); rootVisualElement.Add(new Label(EnemyBehaviorScenarioDriver.Controls) { style = { whiteSpace = WhiteSpace.Normal } }); rootVisualElement.Add(new Label("Cover: exit left/right. Stairs and alternate ramp: behind you from the starting spawn. Bridge: right. Manual buttons cancel scripted automation.") { style = { whiteSpace = WhiteSpace.Normal } }); situation = new EnumField("Scripted case", EnemyBehaviorScenarioDriver.Situation.WallAlternateExit); rootVisualElement.Add(situation); rootVisualElement.Add(new Label("Pursuit expiry and disconnected recovery are PARTIAL stubs; M6/M7 policies are absent.") { style = { whiteSpace = WhiteSpace.Normal } }); rootVisualElement.Add(new Button(() => Act(() => Driver.BeginScenario((EnemyBehaviorScenarioDriver.Situation)situation.value))) { text = "Run selected scripted case" }); graphTarget = new DropdownField("Inspected shooter"); rootVisualElement.Add(graphTarget); rootVisualElement.Add(new Button(() => Act(() => OpenLiveGraph(graphTarget.index))) { text = "Open live Behavior graph" }); rootVisualElement.Add(new Button(() => Act(() => OpenObservations(graphTarget.index))) { text = "Show memory and move destination (Scene view)" }); rootVisualElement.Add(new Label("In the graph, enable Auto-Connect Debugger or choose the selected host shooter in Debug. Node icons show live status. Reset creates new actors: select and open again.") { style = { whiteSpace = WhiteSpace.Normal } }); var toggle = new Toggle("Observe live owners (opt-in)"); toggle.RegisterValueChangedCallback(evt => inspect = evt.newValue); rootVisualElement.Add(toggle); status = new Label { style = { whiteSpace = WhiteSpace.Normal } }; rootVisualElement.Add(status); var scroll = new ScrollView(); scroll.style.flexGrow = 1; diagnostics = new Label { style = { whiteSpace = WhiteSpace.Normal } }; scroll.Add(diagnostics); rootVisualElement.Add(scroll); rootVisualElement.schedule.Execute(Refresh).Every(150); } private void Act(Action action) { try { error = ""; action(); } catch (Exception exception) { error = exception.Message; } } public void Refresh() { var driver = Driver; if (driver != null) { pathQueries?.SetValueWithoutNotify(driver.decisionPathQueriesPerFrame); investigationProbes?.SetValueWithoutNotify(driver.searchVisibilityProbesPerFrame); } if (graphTarget != null) { var choices = driver != null ? driver.Enemies.Select(enemy => enemy != null ? enemy.name : "Inactive shooter").ToList() : new List(); if (!graphTarget.choices.SequenceEqual(choices)) { int oldIndex = graphTarget.index; graphTarget.choices = choices; graphTarget.index = choices.Count > 0 ? Mathf.Clamp(oldIndex, 0, choices.Count - 1) : -1; } } status.text = error.Length > 0 ? error : driver != null ? $"Generation {driver.Generation} · {(driver.Scripted ? "SCRIPTED" : "MANUAL")} · {driver.Status}\nSelected preset {driver.SelectedSearchPreset}; active {driver.ActiveSearchPreset}. Active shared path/investigation caps {driver.Budget?.QueriesPerFrame}/{driver.SearchBudget?.QueriesPerFrame}.\n{driver.LastScenarioResult}" : "Stopped. Choose Manual to explicitly launch. Play alone keeps the passive baseline. Human combat/readability, hardware and separate-network acceptance pending."; if (!inspect || driver == null) { diagnostics.text = "Diagnostics closed; owner state is unaffected."; return; } var text = new StringBuilder(); text.AppendLine($"Claim entries (unpurged): {driver.Claims?.InspectionEntryCount}; pending path/visibility requests: {driver.Budget?.InspectionPendingCount}/{driver.SearchBudget?.InspectionPendingCount}"); foreach (var enemy in driver.Enemies) { text.AppendLine(EnemyBehaviorDiagnostics.Capture(enemy)); var plan = enemy != null ? enemy.GetComponent().AcceptedPlan : null; if (plan != null && driver.Claims.TryInspect(plan.Claim, out double until)) text.AppendLine($"Claim lease (unpurged) expires at {until:F2}; remaining {Math.Max(0, until - enemy.ServerNow):F2}s"); text.AppendLine(); } diagnostics.text = text.ToString(); } } }