[WL-813x] 핫픽스: 보스 스킬2 에러 · 자동전투 기본 ON (#813)

근거 = 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 <noreply@anthropic.com>
This commit is contained in:
깃 관리자 2026-09-09 02:50:40 +09:00
parent e255055800
commit b3a43ed18b
10 changed files with 727 additions and 1 deletions

View File

@ -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<string> s_log = new System.Collections.Generic.List<string>();
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<UnityEngine.AI.NavMeshAgent>();
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.GameObject>(
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 { }
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 2deda96b331d854418a5c8ed206a6fdd
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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
{
/// <summary>맵 진입 시 자동전투 1회 ON. 정적 · 새 Manager/Singleton 0(런너는 숨김 GameObject 1개).</summary>
public static class AutoCombatOnEnter
{
static WLCombatCoreSettings Cfg { get { return WLCombatCoreSettings.Instance; } }
/// <summary>기능이 살아 있는가. 에셋이 없거나 플래그가 꺼져 있으면 false = 원본 동작 100%.</summary>
public static bool Active
{
get { var c = Cfg; return c != null && c.autoCombatOnEnter && !RuntimeDisabled; }
}
/// <summary>진단·A/B 전용 런타임 스위치. Play 종료 시 도메인 리로드로 자동 false.</summary>
public static bool RuntimeDisabled;
// ───────────────────────────────────────── 진단 카운터 (프로브가 읽는다)
public static int Boundaries, Applied, AlreadyOn, SkippedLobby, IconFixed;
public static string LastReason = "", LastLog = "";
/// <summary>현재 자동전투 값(서버 데이터 캐시). 프로브가 「진입 후 true」를 확인하는 지점.</summary>
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;
// ─────────────────────────────────────────────────────────────────────
// 틱
// ─────────────────────────────────────────────────────────────────────
/// <summary>런너가 매 프레임 부른다(에디트 모드에서는 프로브가 직접 부를 수 있다).</summary>
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;
}
/// <summary>전투 패드 자동 버튼 아이콘을 현재 값에 맞춘다. UI 파일 수정 0 — public 필드만 읽고 쓴다.</summary>
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<BattleUI>(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);
}
// ─────────────────────────────────────────────────────────────────────
// 진단 · 초기화
// ─────────────────────────────────────────────────────────────────────
/// <summary>프로브용: 카운터·경계 상태 초기화.</summary>
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;
}
/// <summary>프로브용: 러너 없이 1회 폴링(강제 틱).</summary>
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<AutoCombatOnEnterRunner>();
}
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
static void Boot()
{
if (Active) EnsureRunner(); // 꺼져 있으면 오브젝트 0(C8)
}
}
/// <summary>AutoCombatOnEnter 의 시간 축. 숨김 GameObject 1개 · 코루틴 0.</summary>
internal sealed class AutoCombatOnEnterRunner : MonoBehaviour
{
void Update() { AutoCombatOnEnter.Tick(); }
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2fa8f592a4729ce45916d5458f8882ce

View File

@ -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<MobActor>) 주기(초). 그 사이에는 캐시된 목록만 검사한다.")]
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;
}
}

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b34af33dcf64cd549a2c2f4dab4fedd9
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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
{
/// <summary>몬스터 NavMeshAgent 이탈 복구. 정적 · 새 Manager/Singleton 0(런너는 숨김 GameObject 1개).</summary>
public static class MobNavRecovery
{
static WLCombatCoreSettings Cfg { get { return WLCombatCoreSettings.Instance; } }
/// <summary>기능이 살아 있는가. 에셋이 없거나 플래그가 꺼져 있으면 false = 원본 동작 100%.</summary>
public static bool Active
{
get { var c = Cfg; return c != null && c.mobNavRecoveryEnabled && !RuntimeDisabled; }
}
/// <summary>진단·A/B 전용 런타임 스위치(WLHitFeel.RuntimeDisabled 선례). Play 종료 시 도메인 리로드로 자동 false.</summary>
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<int, float> s_anomalySince = new Dictionary<int, float>();
static float s_nextRescan, s_nextCheck;
static bool s_faulted;
// ─────────────────────────────────────────────────────────────────────
// 틱
// ─────────────────────────────────────────────────────────────────────
/// <summary>런너가 매 프레임 부른다(에디트 모드에서는 프로브가 직접 부를 수 있다).</summary>
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<MobActor>(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);
}
// ─────────────────────────────────────────────────────────────────────
// 진단 · 초기화
// ─────────────────────────────────────────────────────────────────────
/// <summary>프로브용: 카운터·유예 창 초기화.</summary>
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;
}
/// <summary>프로브용: 러너 없이 1회 검사(에디트 모드 · 강제 틱).</summary>
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<MobNavRecoveryRunner>();
}
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
static void Boot()
{
if (Active) EnsureRunner(); // 꺼져 있으면 오브젝트 0(C8)
}
}
/// <summary>MobNavRecovery 의 시간 축. 숨김 GameObject 1개 · 코루틴 0.</summary>
internal sealed class MobNavRecoveryRunner : MonoBehaviour
{
void Update() { MobNavRecovery.Tick(); }
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 862de09b7a5752e4283ac14655136423

View File

@ -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