// PD 지시 #813 · 발주서 WL-813x — 핫픽스 검증 프로브 (에디트 모드 전용 · Play 0 · 로그인 0) // // 실행 (CLAUDE.md §1: using 금지 · 네임스페이스 풀어 쓰기) // unity command run_script --file AgentScripts/WL813x_Probe.cs --entry WL813x_Probe.Edit --timeout 300 // // 무엇을 재는가 // A. 결함 재현·수정 — NavMesh 밖 NavMeshAgent 에 대한 `isStopped = false`(= Resume) 가 // Q3 D-1 / Q2 D-4 의 문구를 실제로 뱉는지, 그리고 MobActor.cs:339 에 들어간 가드 // (`if (agent.isOnNavMesh)`)가 그 예외를 0으로 만드는지를 같은 에이전트로 A/B 측정한다. // B. MobNavRecovery — SO 값 로드 · Active · 에디트 모드 ForceTick 예외 0 · 러너 오브젝트 0 // C. AutoCombatOnEnter — SO 값 로드 · Active · 에디트 모드 ForceTick 예외 0 · ServerInfo 오염 0 // D. 원본 소스 가드 문자열 확인(MobActor.cs 스킬 케이스) // // 게임 수치는 전부 SO 에서 읽는다(C45). 프로브가 만드는 임시 오브젝트는 HideAndDontSave + DestroyImmediate. public static class WL813x_Probe { const string kOut = "AgentScripts/staging/WL813x/EDIT_PROBE.txt"; const string kMobActor = "Assets/Script/Character/Mob/MobActor.cs"; static System.Collections.Generic.List s_log = new System.Collections.Generic.List(); static bool s_hooked; static void OnLog(string cond, string stack, UnityEngine.LogType t) { if (t == UnityEngine.LogType.Error || t == UnityEngine.LogType.Exception || t == UnityEngine.LogType.Assert) s_log.Add(t + " | " + cond); } static void Hook() { if (!s_hooked) { UnityEngine.Application.logMessageReceived += OnLog; s_hooked = true; } } static void Unhook() { if (s_hooked) { UnityEngine.Application.logMessageReceived -= OnLog; s_hooked = false; } } static int Count() { return s_log.Count; } static void Clear() { s_log.Clear(); } public static string Edit() { var sb = new System.Text.StringBuilder(); sb.Append("# WL-813x 핫픽스 프로브 (에디트 모드)\n"); sb.Append("unity=").Append(UnityEngine.Application.unityVersion) .Append(" isPlaying=").Append(UnityEngine.Application.isPlaying) .Append(" utc=").Append(System.DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss")).Append("\n\n"); SectionA(sb); SectionB(sb); SectionC(sb); SectionD(sb); var dir = System.IO.Path.GetDirectoryName(kOut); if (!System.IO.Directory.Exists(dir)) System.IO.Directory.CreateDirectory(dir); System.IO.File.WriteAllText(kOut, sb.ToString(), new System.Text.UTF8Encoding(false)); return sb.ToString(); } // ───────────────────────────────────────────────────────────────────────── // A. 결함 재현 → 가드 효과 (같은 에이전트로 A/B) // ───────────────────────────────────────────────────────────────────────── static void SectionA(System.Text.StringBuilder sb) { sb.Append("## A. Resume 예외 재현·가드 (NavMesh 밖 에이전트)\n"); UnityEngine.GameObject go = null; try { go = new UnityEngine.GameObject("[WL813x] probe agent"); go.hideFlags = UnityEngine.HideFlags.HideAndDontSave; var agent = go.AddComponent(); sb.Append("agent.enabled=").Append(agent.enabled) .Append(" isOnNavMesh=").Append(agent.isOnNavMesh).Append("\n"); // A-1 원본 패턴(가드 없음) = MobActor.cs:339 수정 전 Hook(); Clear(); try { agent.isStopped = false; } catch (System.Exception e) { s_log.Add("THROW | " + e.Message); } int a1 = Count(); string a1msg = a1 > 0 ? s_log[0] : ""; Unhook(); // A-2 수정 후 패턴(가드) = 현재 MobActor.cs:339 Hook(); Clear(); try { if (agent.isOnNavMesh) agent.isStopped = false; } catch (System.Exception e) { s_log.Add("THROW | " + e.Message); } int a2 = Count(); Unhook(); sb.Append("A-1 가드 없음 : 에러 ").Append(a1).Append("건 ").Append(a1msg).Append("\n"); sb.Append("A-2 가드 있음 : 에러 ").Append(a2).Append("건\n"); bool qaMatch = a1msg.Contains("Resume") && a1msg.Contains("NavMesh"); sb.Append("판정 : ").Append(a1 >= 1 && a2 == 0 ? "PASS" : "FAIL") .Append(" (QA 문구 일치=").Append(qaMatch).Append(")\n\n"); } catch (System.Exception e) { Unhook(); sb.Append("A 실패 : ").Append(e.GetType().Name).Append(" ").Append(e.Message).Append("\n\n"); } finally { if (go != null) UnityEngine.Object.DestroyImmediate(go); TryClearConsole(); } } // ───────────────────────────────────────────────────────────────────────── // B. MobNavRecovery // ───────────────────────────────────────────────────────────────────────── static void SectionB(System.Text.StringBuilder sb) { sb.Append("## B. MobNavRecovery (SO · Active · 에디트 모드 안전)\n"); try { var cfg = WL.Combat.Core.WLCombatCoreSettings.Instance; sb.Append("SO=").Append(cfg == null ? "null" : cfg.name).Append("\n"); if (cfg != null) sb.Append(" mobNavRecoveryEnabled=").Append(cfg.mobNavRecoveryEnabled) .Append(" grace=").Append(cfg.mobNavRecoveryGraceSeconds) .Append(" rescan=").Append(cfg.mobNavRecoveryRescanSeconds) .Append(" check=").Append(cfg.mobNavRecoveryCheckSeconds) .Append(" near=").Append(cfg.mobNavRecoveryNearRadius) .Append(" far=").Append(cfg.mobNavRecoveryFarRadius).Append("\n"); sb.Append("Active=").Append(WL.Combat.Nav.MobNavRecovery.Active).Append("\n"); WL.Combat.Nav.MobNavRecovery.ResetDiagnostics(); Hook(); Clear(); WL.Combat.Nav.MobNavRecovery.ForceTick(); WL.Combat.Nav.MobNavRecovery.ForceTick(); int errs = Count(); Unhook(); sb.Append("ForceTick x2 : 에러 ").Append(errs) .Append("건 · scans=").Append(WL.Combat.Nav.MobNavRecovery.Scans) .Append(" anomalies=").Append(WL.Combat.Nav.MobNavRecovery.AnomaliesSeen) .Append(" repaired=").Append(WL.Combat.Nav.MobNavRecovery.Repaired) .Append(" failed=").Append(WL.Combat.Nav.MobNavRecovery.RepairFailed).Append("\n"); int runners = CountByName("[WL813x] MobNavRecoveryRunner"); sb.Append("에디트 모드 러너 오브젝트=").Append(runners).Append(" (기대 0)\n"); sb.Append("판정 : ").Append(errs == 0 && runners == 0 ? "PASS" : "FAIL").Append("\n\n"); } catch (System.Exception e) { Unhook(); sb.Append("B 실패 : ").Append(e.GetType().Name).Append(" ").Append(e.Message).Append("\n\n"); } } // ───────────────────────────────────────────────────────────────────────── // C. AutoCombatOnEnter // ───────────────────────────────────────────────────────────────────────── static void SectionC(System.Text.StringBuilder sb) { sb.Append("## C. AutoCombatOnEnter (SO · Active · 에디트 모드 안전)\n"); try { var cfg = WL.Combat.Core.WLCombatCoreSettings.Instance; if (cfg != null) sb.Append(" autoCombatOnEnter=").Append(cfg.autoCombatOnEnter) .Append(" inLobby=").Append(cfg.autoCombatInLobby) .Append(" poll=").Append(cfg.autoCombatPollSeconds) .Append(" iconRetry=").Append(cfg.autoCombatIconRetrySeconds).Append("\n"); sb.Append("Active=").Append(WL.Combat.Auto.AutoCombatOnEnter.Active).Append("\n"); int serverBefore = CountByName("ServerInfo"); WL.Combat.Auto.AutoCombatOnEnter.ResetDiagnostics(); Hook(); Clear(); bool autoRead = WL.Combat.Auto.AutoCombatOnEnter.IsAutoOn; // 에디트 모드에서는 무조건 false + 오염 0 WL.Combat.Auto.AutoCombatOnEnter.ForceTick(); WL.Combat.Auto.AutoCombatOnEnter.ForceTick(); int errs = Count(); Unhook(); int serverAfter = CountByName("ServerInfo"); sb.Append("IsAutoOn(에디트)=").Append(autoRead).Append(" (기대 false · ServerInfo 생성 금지)\n"); sb.Append("ForceTick x2 : 에러 ").Append(errs) .Append("건 · boundaries=").Append(WL.Combat.Auto.AutoCombatOnEnter.Boundaries) .Append(" applied=").Append(WL.Combat.Auto.AutoCombatOnEnter.Applied) .Append(" alreadyOn=").Append(WL.Combat.Auto.AutoCombatOnEnter.AlreadyOn) .Append(" skippedLobby=").Append(WL.Combat.Auto.AutoCombatOnEnter.SkippedLobby).Append("\n"); sb.Append("ServerInfo 오브젝트 ").Append(serverBefore).Append(" → ").Append(serverAfter).Append(" (기대 동일)\n"); int runners = CountByName("[WL813x] AutoCombatOnEnterRunner"); sb.Append("에디트 모드 러너 오브젝트=").Append(runners).Append(" (기대 0)\n"); sb.Append("판정 : ").Append(errs == 0 && !autoRead && serverBefore == serverAfter && runners == 0 ? "PASS" : "FAIL").Append("\n"); sb.Append("🔴 인게임 실값(진입 후 auto=1 · 킬수)은 Play 로그인이 필요해 이 프로브 범위 밖 = 「미확인」. QA 재현 절차는 완료보고 참조.\n\n"); } catch (System.Exception e) { Unhook(); sb.Append("C 실패 : ").Append(e.GetType().Name).Append(" ").Append(e.Message).Append("\n\n"); } } // ───────────────────────────────────────────────────────────────────────── // D. 원본 소스 가드 확인 // ───────────────────────────────────────────────────────────────────────── static void SectionD(System.Text.StringBuilder sb) { sb.Append("## D. 원본 가드 확인\n"); try { var lines = System.IO.File.ReadAllLines(kMobActor); int guarded = 0, bare = 0, skillLine = -1; for (int i = 0; i < lines.Length; i++) { if (lines[i].Contains("isStopped = false")) { if (lines[i].Contains("isOnNavMesh")) guarded++; else bare++; } if (lines[i].Contains("case eMobStatus.Skill")) skillLine = i + 1; } sb.Append(kMobActor).Append(" : isStopped=false 가드 ").Append(guarded) .Append("곳 · 무가드 ").Append(bare).Append("곳 · case eMobStatus.Skill = ").Append(skillLine).Append("줄\n"); sb.Append("판정 : ").Append(bare == 0 ? "PASS" : "FAIL").Append("\n"); } catch (System.Exception e) { sb.Append("D 실패 : ").Append(e.GetType().Name).Append(" ").Append(e.Message).Append("\n"); } } // ───────────────────────────────────────────────────────────────────────── static int CountByName(string name) { int n = 0; var all = UnityEngine.Object.FindObjectsByType( UnityEngine.FindObjectsInactive.Include, UnityEngine.FindObjectsSortMode.None); for (int i = 0; i < all.Length; i++) if (all[i].name == name) n++; return n; } static void TryClearConsole() { // A-1 은 의도적으로 에러 1건을 만든다 — 뒤따르는 콘솔 판정(조직 C39)을 오염시키지 않도록 지운다. try { var t = System.Type.GetType("UnityEditor.LogEntries,UnityEditor"); if (t == null) return; var m = t.GetMethod("Clear", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic); if (m != null) m.Invoke(null, null); } catch { } } }