From b3a43ed18b217ec8221fece8dfbe5b7187a04fdd Mon Sep 17 00:00:00 2001 From: swrring Date: Wed, 9 Sep 2026 02:50:40 +0900 Subject: [PATCH] =?UTF-8?q?[WL-813x]=20=ED=95=AB=ED=94=BD=EC=8A=A4:=20?= =?UTF-8?q?=EB=B3=B4=EC=8A=A4=20=EC=8A=A4=ED=82=AC2=20=EC=97=90=EB=9F=AC?= =?UTF-8?q?=20=C2=B7=20=EC=9E=90=EB=8F=99=EC=A0=84=ED=88=AC=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=20ON=20(#813)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 근거 = QA Q3 qa/W2-3_바퀴2.md D-1·D-2 · Q2 qa/W2-2_스모크.md D-4 D-1/D-4 (에러 팝업) 원인 사슬 — 실측 Boss_Anubis.cs:114 Co_Skill2 → :140 JumpToPosition(onNavMesh:false) → Actor.cs:1867 Co_JumpToPosition 이 점프 동안 에이전트를 끄고 착지 보정은 Snap_toNavMesh(맵 mobNavSnapRadius = 2 m)뿐 → 2 m 초과 이탈 시 NavMesh 밖으로 남음 → 다음 Co_Skill2 의 Change_MobStatus(eMobStatus.Skill) = MobActor.cs:339 이 가드 없이 isStopped=false(Resume) 대입 → 예외 → Util/ErrorLogHookManager.cs:26-35 가 모든 Error 를 플레이어 모달 팝업으로 표시 변경 · MobActor.cs:339 — 원본 1줄(≤2줄 예외 · 보고). 같은 switch :304·:309 및 StopMove_Imm(Actor.cs:2149)과 동일한 isOnNavMesh 가드. 예외만 차단. · Assets/WL/Combat/Nav/MobNavRecovery.cs (신규) — 가드만으로는 보스가 NavMesh 밖에 굳는 상태가 남는다. 에이전트 off/NavMesh 밖이 유예(1.5 s) 넘게 지속되면 Set_Warp 으로 가장 가까운 NavMesh 위로 복구. 원본 훅 0 · 폴링. · Assets/WL/Combat/Auto/AutoCombatOnEnter.cs (신규) — D-2. 맵(런) 진입 경계에서 자동전투가 꺼져 있으면 Actor.Set_Auto() 1회. 로비 제외 · 진입 뒤 수동 OFF 는 존중. 전투 패드 아이콘(BattleUI.images[0])만 동기화(UI 파일 수정 0). · WLCombatCoreSettings(.cs/.asset) — 값 10개 전부 SO(C45). 각 플래그 0 = 롤백(C8). 코어 스위치에는 의존하지 않는다(코어 A/B 중에도 팝업이 되살아나면 안 됨). · AgentScripts/WL813x_Probe.cs — 에디트 모드 검증 프로브(Play 0 · 로그인 0) Core 수정 0 · Boss_*.cs 수정 0 · Actor.cs 수정 0 · UI 파일 수정 0 Co-Authored-By: Claude Fable 5.1 --- AgentScripts/WL813x_Probe.cs | 221 ++++++++++++++++++ Assets/Script/Character/Mob/MobActor.cs | 2 +- Assets/WL/Combat/Auto.meta | 8 + Assets/WL/Combat/Auto/AutoCombatOnEnter.cs | 217 +++++++++++++++++ .../WL/Combat/Auto/AutoCombatOnEnter.cs.meta | 2 + Assets/WL/Combat/Core/WLCombatCoreSettings.cs | 39 ++++ Assets/WL/Combat/Nav.meta | 8 + Assets/WL/Combat/Nav/MobNavRecovery.cs | 219 +++++++++++++++++ Assets/WL/Combat/Nav/MobNavRecovery.cs.meta | 2 + .../Resources/WL/WLCombatCoreSettings.asset | 10 + 10 files changed, 727 insertions(+), 1 deletion(-) create mode 100644 AgentScripts/WL813x_Probe.cs create mode 100644 Assets/WL/Combat/Auto.meta create mode 100644 Assets/WL/Combat/Auto/AutoCombatOnEnter.cs create mode 100644 Assets/WL/Combat/Auto/AutoCombatOnEnter.cs.meta create mode 100644 Assets/WL/Combat/Nav.meta create mode 100644 Assets/WL/Combat/Nav/MobNavRecovery.cs create mode 100644 Assets/WL/Combat/Nav/MobNavRecovery.cs.meta diff --git a/AgentScripts/WL813x_Probe.cs b/AgentScripts/WL813x_Probe.cs new file mode 100644 index 000000000..9d9219f91 --- /dev/null +++ b/AgentScripts/WL813x_Probe.cs @@ -0,0 +1,221 @@ +// 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 { } + } +} diff --git a/Assets/Script/Character/Mob/MobActor.cs b/Assets/Script/Character/Mob/MobActor.cs index 9a2cbfe16..e2aa2e5f6 100644 --- a/Assets/Script/Character/Mob/MobActor.cs +++ b/Assets/Script/Character/Mob/MobActor.cs @@ -336,7 +336,7 @@ public class MobActor : Actor break; case eMobStatus.Skill: StopMove_Imm(false); - m_NavMeshAgent.isStopped = false; + if (m_NavMeshAgent.isOnNavMesh) m_NavMeshAgent.isStopped = false; // #813x — 위 PatrolWait/PatrolRot(:304·:309)·StopMove_Imm 과 같은 가드. NavMesh 밖/비활성 에이전트면 "Resume" 예외 → 에러 팝업(Q3 D-1 · Q2 D-4) break; } } diff --git a/Assets/WL/Combat/Auto.meta b/Assets/WL/Combat/Auto.meta new file mode 100644 index 000000000..f5ab4a3e5 --- /dev/null +++ b/Assets/WL/Combat/Auto.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2deda96b331d854418a5c8ed206a6fdd +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Auto/AutoCombatOnEnter.cs b/Assets/WL/Combat/Auto/AutoCombatOnEnter.cs new file mode 100644 index 000000000..b9bbb44ac --- /dev/null +++ b/Assets/WL/Combat/Auto/AutoCombatOnEnter.cs @@ -0,0 +1,217 @@ +// ───────────────────────────────────────────────────────────────────────────── +// AutoCombatOnEnter.cs — 맵 진입 시 자동전투 기본 ON (Q3 D-2) +// +// PD 지시 #813 · 발주서 WL-813x §1-2 (2026-09-09) · 근거 = qa/W2-3_바퀴2.md D-2 +// +// ■ 관측된 결함 (QA 실측) +// 신규 계정 기본값이 `eCommon.Auto = 0` 이라 인게임에 들어가도 자동전투가 꺼져 있다. +// 존1 60 s 방치 = **0킬 · skillCast 0**. `Actor.Set_Auto()` 1회 뒤 같은 조건 60 s = **23킬**. +// (W2-1 의 「스킬 0발」 미확인 항목이 여기서 해소됐다.) 이 값이 0인 채로 PD 가 Play 하면 +// "스킬도 안 나가고 적도 안 죽는 게임"을 보게 된다. +// +// ■ 원인 (코드 실측) +// PCActor.cs:185 `m_Auto = m_ServerData.Get_Common(eCommon.Auto) == 1;` — ReSet() 마다 서버 값에서 다시 읽는다. +// PCActor.cs:93 의 자동 스킬 루프가 `m_Auto` 로 게이트된다 → false 면 스킬을 한 발도 쏘지 않는다. +// Actor.cs:2260 `Set_Auto()` 는 m_Auto 를 토글하고 같은 값을 eCommon.Auto 에 넣는다(둘은 항상 동기). +// +// ■ 이 파일이 하는 것 — 원본 훅 0 · 폴링만(Survival.PollRunBoundary 선례) +// 런(맵) 진입 경계를 관측해서 그 시점에 자동전투가 꺼져 있으면 `MyValue.MyPC.Set_Auto()` 를 **1회** 부른다. +// · 경계 = 메인 PC 교체(PCActor.ReSet 이 MyValue.MyPC 를 갈아 끼운다) 또는 맵 ID 변경. +// · 진입 뒤 플레이어가 직접 끈 것은 다시 켜지 않는다(경계당 1회이므로). +// · 로비는 기본 제외(적이 없어 자동 타겟팅이 의미가 없다) — autoCombatInLobby 로 켤 수 있다. +// · Set_Auto() 는 m_Auto 와 eCommon.Auto 를 함께 세우므로, 뒤늦게 도는 PCActor.ReSet() 이 +// 다시 읽어도 ON 이 유지된다. +// +// ■ 계정 영구 변경 아님 +// ServerClass.Set_Common(ServerClass.cs:1469) 은 로컬 캐시 배열만 쓴다(서버 저장 호출 없음). +// QA 도 "슬롯 2에서 켠 값이 슬롯 3 로그인에 안 남았다 = 세션 간 비영속"을 실측했다. +// → 이 핫픽스는 매 진입마다 다시 켜 주는 방식이지 계정 기본값을 바꾸는 것이 아니다. +// 계정 기본값(신규 계정 Auto=1)은 서버/테이블 소관 = S 트랙 · PD 결정. +// +// ■ 전투 패드 아이콘 +// BattleUI.Set_Auto()(private)가 `Get_Common(eCommon.Auto)` 로 스프라이트를 고른다. +// UI 가 먼저 떴다면 아이콘이 OFF 로 굳으므로, 적용 뒤 잠시 동안 public 필드 +// `BattleUI.images[0]` 의 스프라이트만 맞춘다(UI 파일 수정 0 · 813c 패드 배치와 무관). +// +// ■ 값(C45) 전부 WLCombatCoreSettings. autoCombatOnEnter = 0 이면 러너 자체가 뜨지 않는다(C8 롤백). +// 🔴 코어 스위치(WLCombatCoreSettings.Enabled)에는 의존하지 않는다 — 코어 A/B 가 킬 수를 0으로 만들면 안 된다. +// +// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것(Actor/PCActor 가 Assembly-CSharp 에 있다). +// ───────────────────────────────────────────────────────────────────────────── + +using UnityEngine; +using UnityEngine.UI; +using WL.Combat.Core; + +namespace WL.Combat.Auto +{ + /// 맵 진입 시 자동전투 1회 ON. 정적 · 새 Manager/Singleton 0(런너는 숨김 GameObject 1개). + public static class AutoCombatOnEnter + { + static WLCombatCoreSettings Cfg { get { return WLCombatCoreSettings.Instance; } } + + /// 기능이 살아 있는가. 에셋이 없거나 플래그가 꺼져 있으면 false = 원본 동작 100%. + public static bool Active + { + get { var c = Cfg; return c != null && c.autoCombatOnEnter && !RuntimeDisabled; } + } + + /// 진단·A/B 전용 런타임 스위치. Play 종료 시 도메인 리로드로 자동 false. + public static bool RuntimeDisabled; + + // ───────────────────────────────────────── 진단 카운터 (프로브가 읽는다) + public static int Boundaries, Applied, AlreadyOn, SkippedLobby, IconFixed; + public static string LastReason = "", LastLog = ""; + + /// 현재 자동전투 값(서버 데이터 캐시). 프로브가 「진입 후 true」를 확인하는 지점. + public static bool IsAutoOn + { + get + { + // 🔴 ServerInfo.Ins 는 없으면 GameObject 를 만든다(ServerInfo.cs:19) — 에디트 모드를 오염시키지 않는다. + if (!Application.isPlaying) return false; + var si = ServerInfo.Ins; + if (DSUtil.CheckNull(si) || si.m_ServerData == null) return false; + return si.m_ServerData.Get_Common(eCommon.Auto) == 1; + } + } + + // ───────────────────────────────────────── 내부 상태 + static PCActor s_lastPc; + static int s_lastMapId = int.MinValue; + static float s_nextPoll, s_iconDeadline; + static bool s_faulted; + + // ───────────────────────────────────────────────────────────────────── + // 틱 + // ───────────────────────────────────────────────────────────────────── + + /// 런너가 매 프레임 부른다(에디트 모드에서는 프로브가 직접 부를 수 있다). + internal static void Tick() + { + if (s_faulted || !Active) return; + var cfg = Cfg; + float now = Time.unscaledTime; + if (now < s_nextPoll) return; + s_nextPoll = now + Mathf.Max(0.02f, cfg.autoCombatPollSeconds); + + // 🔴 Debug.LogException 을 쓰지 않는다 — ErrorLogHookManager 가 그것을 플레이어 팝업으로 띄운다(같은 커밋 §D-1). + try + { + PollBoundary(cfg); + if (now < s_iconDeadline) SyncPadIcon(); + } + catch (System.Exception ex) { s_faulted = true; Log("중단(예외) — " + ex.Message); } + } + + static void PollBoundary(WLCombatCoreSettings cfg) + { + var pc = MyValue.MyPC; // 파괴된 참조는 Unity != 로 걸러진다 + if (DSUtil.CheckNull(pc)) { s_lastPc = null; return; } // PC 없음(타이틀·로딩) — 다음 등장을 경계로 본다 + + // InGameInfo 가 아직 없으면 경계를 「소비」하지 않는다 — 다음 폴링에서 같은 경계를 다시 본다. + var info = InGameInfo.Ins; + if (DSUtil.CheckNull(info)) return; + + int map = MyValue.MyChoiceMapData != null ? MyValue.MyChoiceMapData.n_MapID : int.MinValue; + bool pcChanged = !ReferenceEquals(pc, s_lastPc); + bool mapChanged = map != s_lastMapId; + if (!pcChanged && !mapChanged) return; + + s_lastPc = pc; s_lastMapId = map; + Boundaries++; + LastReason = pcChanged ? "pc" : ("map " + map); + + // WL 인게임 맵(WL_Nature)은 BattleMapConfig n_MapID −1 · e_MapType = Stage 로 실측 — 로비가 아니다. + if (!cfg.autoCombatInLobby && info.Get_GameMode() == eGameMode.Lobby) + { + SkippedLobby++; + Log("로비 — 건너뜀(" + LastReason + ")"); + return; + } + + if (IsAutoOn) { AlreadyOn++; Log("이미 ON — 무동작(" + LastReason + ")"); ArmIcon(cfg); return; } + + pc.Set_Auto(); // m_Auto = true + eCommon.Auto = 1 (Actor.cs:2260) + Applied++; + Log("자동전투 ON 적용 #" + Applied + " (" + LastReason + ") → auto=" + (IsAutoOn ? "1" : "0")); + ArmIcon(cfg); + } + + static void ArmIcon(WLCombatCoreSettings cfg) + { + if (cfg.autoCombatIconRetrySeconds > 0f) + s_iconDeadline = Time.unscaledTime + cfg.autoCombatIconRetrySeconds; + } + + /// 전투 패드 자동 버튼 아이콘을 현재 값에 맞춘다. UI 파일 수정 0 — public 필드만 읽고 쓴다. + static void SyncPadIcon() + { + var atlas = UIAtlasMgr.Ins; + if (DSUtil.CheckNull(atlas)) return; + var want = atlas.Get_Sprite(IsAutoOn ? "autotarget_on" : "autotarget_off"); + if (want == null) return; + + var uis = Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); + for (int i = 0; i < uis.Length; i++) + { + var imgs = uis[i].images; + if (imgs == null || imgs.Length == 0) continue; + Image img = imgs[0]; + if (img == null || img.sprite == want) continue; + img.sprite = want; + IconFixed++; + s_iconDeadline = 0f; // 한 번 맞췄으면 폴링을 멈춘다 + } + } + + static void Log(string msg) + { + LastLog = msg; + var c = Cfg; + if (c != null && c.verboseLog) Debug.Log("[AutoCombatOnEnter] " + msg); + } + + // ───────────────────────────────────────────────────────────────────── + // 진단 · 초기화 + // ───────────────────────────────────────────────────────────────────── + + /// 프로브용: 카운터·경계 상태 초기화. + public static void ResetDiagnostics() + { + Boundaries = Applied = AlreadyOn = SkippedLobby = IconFixed = 0; + LastReason = LastLog = ""; + s_lastPc = null; s_lastMapId = int.MinValue; + s_nextPoll = s_iconDeadline = 0f; s_faulted = false; + } + + /// 프로브용: 러너 없이 1회 폴링(강제 틱). + public static void ForceTick() { s_nextPoll = 0f; Tick(); } + + // ───────────────────────────────────────────────────────────────────── + // 런너 (숨김 GameObject 1개 · 플레이 모드 전용) + // ───────────────────────────────────────────────────────────────────── + + static AutoCombatOnEnterRunner s_runner; + + internal static void EnsureRunner() + { + if (!Application.isPlaying || !DSUtil.CheckNull(s_runner)) return; + var go = new GameObject("[WL813x] AutoCombatOnEnterRunner"); + go.hideFlags = HideFlags.HideAndDontSave; + s_runner = go.AddComponent(); + } + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] + static void Boot() + { + if (Active) EnsureRunner(); // 꺼져 있으면 오브젝트 0(C8) + } + } + + /// AutoCombatOnEnter 의 시간 축. 숨김 GameObject 1개 · 코루틴 0. + internal sealed class AutoCombatOnEnterRunner : MonoBehaviour + { + void Update() { AutoCombatOnEnter.Tick(); } + } +} diff --git a/Assets/WL/Combat/Auto/AutoCombatOnEnter.cs.meta b/Assets/WL/Combat/Auto/AutoCombatOnEnter.cs.meta new file mode 100644 index 000000000..a5b9bc39f --- /dev/null +++ b/Assets/WL/Combat/Auto/AutoCombatOnEnter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2fa8f592a4729ce45916d5458f8882ce \ No newline at end of file diff --git a/Assets/WL/Combat/Core/WLCombatCoreSettings.cs b/Assets/WL/Combat/Core/WLCombatCoreSettings.cs index 2b55df5d2..c23964cd0 100644 --- a/Assets/WL/Combat/Core/WLCombatCoreSettings.cs +++ b/Assets/WL/Combat/Core/WLCombatCoreSettings.cs @@ -107,5 +107,44 @@ namespace WL.Combat.Core [Tooltip("화면 밖 판정 여유(뷰포트 비율). IsInView 는 [−margin, 1+margin] 안이면 화면 안으로 본다.")] public float viewMargin = 0.1f; + + // ─────────────────────────────────────────── 핫픽스 (WL-813x · PD 지시 #813) + // 🔴 아래 두 기능은 위 `enabled`(코어 스위치)·RuntimeDisabled 와 무관하게 각자의 플래그만 본다. + // 이유 = 코어를 끈 A/B 중에도 에러 팝업이 되살아나면 안 된다(Q3 D-1 은 PD 시연 차단 등급). + // 롤백은 이 플래그 각각을 0 으로(에셋만 수정 · 코드 무수정 · C8). + [Header("몹 NavMesh 복구 (MobNavRecovery · Q3 D-1 / Q2 D-4 근본 수정)")] + [Tooltip("몬스터가 점프·넉백으로 NavMesh 밖에 남거나 에이전트가 꺼진 채 굳으면 가장 가까운 NavMesh 위로 되돌린다. " + + "끄면 원본 동작 100%(= Actor.Snap_toNavMesh 의 맵 반경 2 m 스냅만 남는다).")] + public bool mobNavRecoveryEnabled = true; + + [Tooltip("이상 상태(에이전트 off 또는 NavMesh 밖)를 이 시간 이상 유지할 때만 손댄다(초). " + + "점프(Co_JumpToPosition 0.25 s · Co_Jump)·넉백처럼 원본이 스스로 복구하는 구간을 건드리지 않기 위한 유예. 실측 근거 = Anubis Skill2 점프 0.25 s.")] + public float mobNavRecoveryGraceSeconds = 1.5f; + + [Tooltip("전수 재수집(FindObjectsByType) 주기(초). 그 사이에는 캐시된 목록만 검사한다.")] + public float mobNavRecoveryRescanSeconds = 3f; + + [Tooltip("캐시된 목록을 검사하는 주기(초).")] + public float mobNavRecoveryCheckSeconds = 0.25f; + + [Tooltip("복귀 지점을 찾을 1차 NavMesh 표집 반경(m). 실패하면 아래 원거리 반경으로 한 번 더 시도한다.")] + public float mobNavRecoveryNearRadius = 4f; + + [Tooltip("1차 실패 시의 원거리 표집 반경(m). 보스가 아레나 밖 지형으로 크게 튄 경우를 위한 값.")] + public float mobNavRecoveryFarRadius = 30f; + + [Header("자동전투 기본 ON (AutoCombatOnEnter · Q3 D-2)")] + [Tooltip("맵(런) 진입 시 자동전투가 꺼져 있으면 1회 켠다. 진입 뒤 플레이어가 직접 끈 것은 다시 켜지 않는다. " + + "근거 = Q3 실측 자동 OFF 60 s 0킬 / ON 60 s 23킬.")] + public bool autoCombatOnEnter = true; + + [Tooltip("로비에서도 켤지. 기본 off — 로비는 적이 없어 자동 타겟팅이 의미가 없다.")] + public bool autoCombatInLobby = false; + + [Tooltip("진입 경계 폴링 주기(초).")] + public float autoCombatPollSeconds = 0.25f; + + [Tooltip("적용 뒤 전투 패드 자동 버튼 아이콘(BattleUI.images[0])을 이 시간 동안 재시도로 맞춘다(초). UI 가 늦게 뜨는 경우 대비 · 0 이면 아이콘을 건드리지 않는다.")] + public float autoCombatIconRetrySeconds = 8f; } } diff --git a/Assets/WL/Combat/Nav.meta b/Assets/WL/Combat/Nav.meta new file mode 100644 index 000000000..724f659ed --- /dev/null +++ b/Assets/WL/Combat/Nav.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b34af33dcf64cd549a2c2f4dab4fedd9 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Nav/MobNavRecovery.cs b/Assets/WL/Combat/Nav/MobNavRecovery.cs new file mode 100644 index 000000000..187b56e56 --- /dev/null +++ b/Assets/WL/Combat/Nav/MobNavRecovery.cs @@ -0,0 +1,219 @@ +// ───────────────────────────────────────────────────────────────────────────── +// MobNavRecovery.cs — 몬스터 NavMeshAgent 이탈 복구 (Q3 D-1 / Q2 D-4 근본 수정) +// +// PD 지시 #813 · 발주서 WL-813x §1-1 (2026-09-09) · 근거 = qa/W2-3_바퀴2.md D-1 · qa/W2-2_스모크.md D-4 +// +// ■ 관측된 결함 (QA 실측) +// 플레이 중 화면 중앙에 모달 에러 팝업 — +// `"Resume" can only be called on an active agent that has been placed on a NavMesh.` +// 보스(Anubis)가 Skill2 를 쓰는 순간 발생하고, 한 번 뜬 뒤로는 세션 끝까지 반복 출력된다. +// +// ■ 원인 사슬 (코드 실측) +// ① Boss_Anubis.Co_Skill2 → Co_ShootSkill2 가 JumpToPosition(Get_PositionBack/Right/Foward(5f), onNavMesh:false) +// 로 3회 점프한다(Boss_Anubis.cs:140). +// ② Actor.Co_JumpToPosition(Actor.cs:1867) 은 점프 동안 `m_NavMeshAgent.enabled = false` 로 끄고 +// 트랜스폼을 직접 옮긴 뒤 다시 켠다. 착지 보정은 Snap_toNavMesh() = 맵 데이터 mobNavSnapRadius(WL_Nature = 2 m) +// 뿐이라, 착지 지점이 NavMesh 에서 2 m 넘게 벗어나면 스냅이 실패하고 에이전트는 **켜졌지만 NavMesh 밖**으로 남는다. +// (Q3 D-4 실측 — 이 보스는 아레나(139,−20) 를 벗어나 (128,−9) · 존2(147,30) 까지 나가 있었다.) +// ③ 다음 Co_Skill2 첫 줄의 Change_MobStatus(eMobStatus.Skill) → MobActor.cs:339 이 가드 없이 +// `m_NavMeshAgent.isStopped = false`(= Resume) 를 대입 → 예외. +// ④ Util/ErrorLogHookManager.cs:26-35 가 모든 Error/Exception 을 Popup.Ins.Set(ePopupType.One, 174, …) 로 +// 플레이어 팝업으로 띄운다(#if !FGB_LIVE) → 화면 중앙 모달. +// +// ■ 이 파일이 고치는 것 = ② +// MobActor.cs:339 의 1줄 가드(같은 커밋)는 **예외만** 막는다. 그 가드만으로는 보스가 NavMesh 밖에 굳은 채 +// 영영 못 움직이는 상태(추격·순찰 정지)가 남는다. 여기서 그 상태 자체를 되돌린다 — +// 에이전트가 꺼졌거나 NavMesh 밖인 몬스터를 유예 시간 뒤 가장 가까운 NavMesh 위로 Set_Warp 한다. +// +// ■ 원본 훅 0 — 폴링만 쓴다(Survival.PollRunBoundary · BossArenaRunner 선례). +// 유예(graceSeconds)를 두는 이유: 점프·넉백 중에는 원본이 의도적으로 에이전트를 끈다. +// 그 구간을 건드리면 점프가 깨진다. 실측 = Anubis 점프 0.25 s · 기본 유예 1.5 s. +// +// ■ 값(C45) 전부 WLCombatCoreSettings(에셋 Assets/WL/Combat/Settings/Resources/WL/WLCombatCoreSettings.asset). +// 게임 수치 상수 0. mobNavRecoveryEnabled = 0 이면 러너 자체가 뜨지 않는다(C8 롤백). +// 🔴 코어 스위치(WLCombatCoreSettings.Enabled)에는 의존하지 않는다 — 코어를 끈 A/B 중에도 팝업이 되살아나면 안 된다. +// +// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것(Actor/MobActor 가 Assembly-CSharp 에 있다). +// ───────────────────────────────────────────────────────────────────────────── + +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.AI; +using WL.Combat.Core; + +namespace WL.Combat.Nav +{ + /// 몬스터 NavMeshAgent 이탈 복구. 정적 · 새 Manager/Singleton 0(런너는 숨김 GameObject 1개). + public static class MobNavRecovery + { + static WLCombatCoreSettings Cfg { get { return WLCombatCoreSettings.Instance; } } + + /// 기능이 살아 있는가. 에셋이 없거나 플래그가 꺼져 있으면 false = 원본 동작 100%. + public static bool Active + { + get { var c = Cfg; return c != null && c.mobNavRecoveryEnabled && !RuntimeDisabled; } + } + + /// 진단·A/B 전용 런타임 스위치(WLHitFeel.RuntimeDisabled 선례). Play 종료 시 도메인 리로드로 자동 false. + public static bool RuntimeDisabled; + + // ───────────────────────────────────────── 진단 카운터 (프로브가 읽는다) + public static int Repaired, RepairFailed, Scans, AnomaliesSeen; + public static string LastLog = ""; + public static Vector3 LastRepairFrom, LastRepairTo; + + // ───────────────────────────────────────── 내부 상태 + static MobActor[] s_mobs = new MobActor[0]; + static readonly Dictionary s_anomalySince = new Dictionary(); + static float s_nextRescan, s_nextCheck; + static bool s_faulted; + + // ───────────────────────────────────────────────────────────────────── + // 틱 + // ───────────────────────────────────────────────────────────────────── + + /// 런너가 매 프레임 부른다(에디트 모드에서는 프로브가 직접 부를 수 있다). + internal static void Tick() + { + if (s_faulted || !Active) return; + var cfg = Cfg; + float now = Time.unscaledTime; + + if (now >= s_nextRescan) + { + s_nextRescan = now + Mathf.Max(0.1f, cfg.mobNavRecoveryRescanSeconds); + Rescan(); + } + if (now < s_nextCheck) return; + s_nextCheck = now + Mathf.Max(0.02f, cfg.mobNavRecoveryCheckSeconds); + + // 🔴 Debug.LogException 을 쓰지 않는다 — ErrorLogHookManager 가 그것을 다시 플레이어 팝업으로 띄운다. + // 이 파일의 목적이 그 팝업을 없애는 것이므로, 사고가 나면 조용히 자기 자신만 끈다(원본 동작으로 복귀). + try { CheckAll(cfg, now); } + catch (System.Exception ex) { s_faulted = true; Log("중단(예외) — " + ex.Message); } + } + + static void Rescan() + { + // FindObjectsInactive.Exclude — 비활성 몹은 풀에 들어간 사체다(Actor.cs:633 에서 에이전트를 끈다). + s_mobs = Object.FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None); + Scans++; + if (s_anomalySince.Count > 64) s_anomalySince.Clear(); // 풀 재사용으로 쌓인 죽은 키 정리 + } + + static void CheckAll(WLCombatCoreSettings cfg, float now) + { + // 보스는 목록 갱신(3 s)을 기다리지 않고 매 검사마다 본다 — 결함의 실제 피해자다. + var boss = WL.Combat.Boss.BossArena.Boss; + if (!DSUtil.CheckNull(boss)) CheckOne(cfg, boss, now); + + var mobs = s_mobs; + for (int i = 0; i < mobs.Length; i++) + { + var m = mobs[i]; + if (DSUtil.CheckNull(m) || ReferenceEquals(m, boss)) continue; + CheckOne(cfg, m, now); + } + } + + static void CheckOne(WLCombatCoreSettings cfg, MobActor mob, float now) + { + if (mob.IsDead()) { s_anomalySince.Remove(mob.GetInstanceID()); return; } + + var agent = mob.Get_Agent(); + if (DSUtil.CheckNull(agent)) return; + + int key = mob.GetInstanceID(); + + // 정상 = 켜져 있고 NavMesh 위. isOnNavMesh 는 꺼진 에이전트에도 false 를 준다. + if (agent.enabled && agent.isOnNavMesh) { s_anomalySince.Remove(key); return; } + + float since; + if (!s_anomalySince.TryGetValue(key, out since)) + { + s_anomalySince[key] = now; // 점프·넉백일 수 있다 — 유예를 준다 + AnomaliesSeen++; + return; + } + if (now - since < Mathf.Max(0f, cfg.mobNavRecoveryGraceSeconds)) return; + + Repair(cfg, mob, agent, key); + } + + static void Repair(WLCombatCoreSettings cfg, MobActor mob, NavMeshAgent agent, int key) + { + Vector3 from = mob.Get_position(); + NavMeshHit hit; + bool found = NavMesh.SamplePosition(from, out hit, Mathf.Max(0.1f, cfg.mobNavRecoveryNearRadius), NavMesh.AllAreas) + || NavMesh.SamplePosition(from, out hit, Mathf.Max(0.1f, cfg.mobNavRecoveryFarRadius), NavMesh.AllAreas); + + s_anomalySince.Remove(key); // 성공이든 실패든 창을 다시 연다(실패 시 다음 유예 뒤 재시도) + + if (!found) + { + RepairFailed++; + Log("복구 실패 — 반경 " + cfg.mobNavRecoveryFarRadius + " m 안에 NavMesh 없음 " + from); + return; + } + + // Set_Warp 이 에이전트를 다시 켜고(Actor.cs:2116) 맵 스냅 반경을 한 번 더 적용한 뒤 Warp 한다 — 이동 로직 복제 0. + mob.Set_Warp(hit.position); + Repaired++; + LastRepairFrom = from; LastRepairTo = hit.position; + Log("복구 #" + Repaired + " " + mob.name + " " + from + " → " + hit.position); + } + + static void Log(string msg) + { + LastLog = msg; + var c = Cfg; + if (c != null && c.verboseLog) Debug.Log("[MobNavRecovery] " + msg); + } + + // ───────────────────────────────────────────────────────────────────── + // 진단 · 초기화 + // ───────────────────────────────────────────────────────────────────── + + /// 프로브용: 카운터·유예 창 초기화. + public static void ResetDiagnostics() + { + Repaired = RepairFailed = Scans = AnomaliesSeen = 0; + LastLog = ""; LastRepairFrom = LastRepairTo = Vector3.zero; + s_anomalySince.Clear(); s_mobs = new MobActor[0]; + s_nextRescan = s_nextCheck = 0f; s_faulted = false; + } + + /// 프로브용: 러너 없이 1회 검사(에디트 모드 · 강제 틱). + public static void ForceTick() + { + s_nextRescan = s_nextCheck = 0f; + Tick(); + } + + // ───────────────────────────────────────────────────────────────────── + // 런너 (숨김 GameObject 1개 · 플레이 모드 전용) + // ───────────────────────────────────────────────────────────────────── + + static MobNavRecoveryRunner s_runner; + + internal static void EnsureRunner() + { + if (!Application.isPlaying || !DSUtil.CheckNull(s_runner)) return; + var go = new GameObject("[WL813x] MobNavRecoveryRunner"); + go.hideFlags = HideFlags.HideAndDontSave; // 씬 전환에도 살아남고 씬·프리팹에 저장되지 않는다 + s_runner = go.AddComponent(); + } + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] + static void Boot() + { + if (Active) EnsureRunner(); // 꺼져 있으면 오브젝트 0(C8) + } + } + + /// MobNavRecovery 의 시간 축. 숨김 GameObject 1개 · 코루틴 0. + internal sealed class MobNavRecoveryRunner : MonoBehaviour + { + void Update() { MobNavRecovery.Tick(); } + } +} diff --git a/Assets/WL/Combat/Nav/MobNavRecovery.cs.meta b/Assets/WL/Combat/Nav/MobNavRecovery.cs.meta new file mode 100644 index 000000000..9beef16d3 --- /dev/null +++ b/Assets/WL/Combat/Nav/MobNavRecovery.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 862de09b7a5752e4283ac14655136423 \ No newline at end of file diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLCombatCoreSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLCombatCoreSettings.asset index e3338f005..ea195aeb9 100644 --- a/Assets/WL/Combat/Settings/Resources/WL/WLCombatCoreSettings.asset +++ b/Assets/WL/Combat/Settings/Resources/WL/WLCombatCoreSettings.asset @@ -28,3 +28,13 @@ MonoBehaviour: clusterBurstMax: 1 killCamCooldownSeconds: 8 viewMargin: 0.1 + mobNavRecoveryEnabled: 1 + mobNavRecoveryGraceSeconds: 1.5 + mobNavRecoveryRescanSeconds: 3 + mobNavRecoveryCheckSeconds: 0.25 + mobNavRecoveryNearRadius: 4 + mobNavRecoveryFarRadius: 30 + autoCombatOnEnter: 1 + autoCombatInLobby: 0 + autoCombatPollSeconds: 0.25 + autoCombatIconRetrySeconds: 8