// ───────────────────────────────────────────────────────────────────────────── // 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); } internal static bool ReadBoundarySources(out PCActor pc, out int mapId, out bool inLobby) { // 813p 공용 — 원천 값만 읽는다(판정·소비 0 · 동작 변경 0) pc = DSUtil.CheckNull(MyValue.MyPC) ? null : MyValue.MyPC; var info = InGameInfo.Ins; mapId = MyValue.MyChoiceMapData != null ? MyValue.MyChoiceMapData.n_MapID : int.MinValue; inLobby = !DSUtil.CheckNull(info) && info.Get_GameMode() == eGameMode.Lobby; return !DSUtil.CheckNull(info); } 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(); } } }