// ───────────────────────────────────────────────────────────────────────────── // AutoPotion.cs — 자동전투 중 HP 임계 이하가 되면 물약을 자동으로 쓴다 (Q5 D-21) // // PD 지시 #813 · 발주서 WL-813m2 §1-1 (2026-09-09) // 근거 = qa/W2-5_재채점_v3.md **D-21**: 4분 런당 사망 **3회** · 결과 화면 「사망 / 물약 = 3 / 0」 // = 런당 3개의 물약을 **한 번도 쓰지 않고** 죽는다. // // ■ 왜 안 썼나 (실측 사슬) // · 813x: 맵 진입 시 자동전투가 기본 ON 이 된다(`eCommon.Auto` · `PCActor.cs:185` 가 이 값으로 `m_Auto` 를 세우고 // `PCActor.cs:93` 자동 스킬 루프가 그 플래그로 게이트된다). 자동전투 런에서는 플레이어가 화면을 보고만 있다. // · 813j/813y: 물약은 **버튼을 눌러야** 나간다(`PotionButton.UseHandler` → `PotionUse.TryUse()`). // → 자동전투 + 버튼 입력 필요 = 물약 0개 사용. 813u 의 밸런스 산식(「물약 2~3개로 흡수」)이 전제부터 성립하지 않았다. // // ■ 이 파일이 하는 것 — 원본 훅 0 · UI 파일 0 · 폴링만(AutoCombatOnEnter · Survival.PollRunBoundary 선례) // 메인 PC 의 HP 비율이 `autoPotionHpRatio`(SO · 기본 0.35) 이하가 되면 813i 공개 API `PotionUse.TryUse(pc)` 를 부른다. // · 잔량·쿨은 813i 가 이미 관리한다 — 여기서는 `PotionUse.IsReady` 로 **먼저 걸러** 거절 이벤트를 매 프레임 쏘지 않는다(GC 0). // · 자동전투가 꺼져 있으면(수동 전투) 쓰지 않는다 — `autoPotionRequireAutoCombat`(SO · 기본 on). // 플레이어가 직접 조작 중이면 물약 타이밍도 플레이어 것이다. // · 사망/부활 중(`DeathFlow.State != None`)에는 쓰지 않는다. 부활은 전회복이라(reviveHealRate=1) 쓸 이유도 없다. // · 시간 축은 **게임 시간**(`Time.time`) — `holdWorldWhileReviving` 이 세운 timeScale=0 동안 폴링이 멈춘다. // // ■ 「자동 사용」 표시 — UI 파일 수정 0 // · 813y `PotionButton.Update()` 가 매 프레임 `CountProvider/CooldownRemainProvider` 를 다시 읽는다 // → 자동 사용이 **잔량 숫자 −1 + 쿨 링**으로 즉시 보인다(연결은 813y `WLSurvivalUiBridge` 가 이미 해 놓았다). // · `potionShowHealNumber=1` 이면 원본 `Actor.Heal(rate, showhud)` 이 HUDDMGUI 초록 회복 숫자를 띄운다. // · 결과 화면 「물약」 칸도 자동으로 오른다 — `RunDirector.OnPotionUsed` 가 `PotionUse.Used` 를 구독한다(`RunDirector.cs:170`). // · 별도 토스트는 만들지 않았다 — `LootToast` 에 외부에서 부를 수 있는 공개 표시 API 가 없어(실측) UI 파일을 고쳐야 한다. // 그래서 이 파일은 `autoPotionLog` 로 Console 1줄만 남긴다. // 🔴 `Debug.LogError/LogException` 을 쓰지 않는다 — `Util/ErrorLogHookManager.cs:33` 이 그것을 플레이어 모달로 띄운다(813x 교훈). // // ■ C8 롤백 = `WLSurvivalSettings.asset` 의 `autoPotionEnabled: 1 → 0`(코드 무수정) — 그러면 아래 Tick 은 첫 줄에서 돌아간다. // 생존 축 전체 스위치(`enabled`)가 꺼져도 당연히 동작 0(`PotionUse` 자체가 Disabled). // // 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것(PCActor 가 Assembly-CSharp 에 있다). // ───────────────────────────────────────────────────────────────────────────── using UnityEngine; namespace WL.Combat.Survival { /// 자동전투 중 HP 임계 이하면 813i 물약을 자동 사용. 정적 · 새 Manager/Singleton 0(틱은 SurvivalRunner 가 준다). public static class AutoPotion { static WLSurvivalSettings St { get { return WLSurvivalSettings.Instance; } } /// 기능이 살아 있는가. 생존 축 스위치 + 자동 물약 스위치 + 런타임 차단 없음. public static bool Active { get { if (RuntimeDisabled || !WLSurvivalSettings.Enabled) return false; var st = St; return st != null && st.autoPotionEnabled; } } /// 진단·A/B 전용 런타임 스위치(에셋 무수정 · Play 종료 시 도메인 리로드로 자동 false). public static bool RuntimeDisabled; /// /// 프로브·QA 전용 자동전투 상태 강제값. 0 = 실제 값(`eCommon.Auto`) · 1 = 강제 ON · −1 = 강제 OFF. /// 에디트 모드에는 `ServerInfo` 가 없어(만들면 에디트 모드를 오염시킨다) 실제 값을 읽을 수 없다. /// public static int AutoStateOverride; /// 지금 자동전투인가. 실제 값은 813x 의 가드된 읽기(`eCommon.Auto` · 플레이 모드에서만)를 재사용한다. public static bool IsAutoCombatOn { get { if (AutoStateOverride != 0) return AutoStateOverride > 0; return WL.Combat.Auto.AutoCombatOnEnter.IsAutoOn; } } // ───────────────────────────────────────── 진단(프로브가 읽는다) /// 임계 아래로 내려가 사용을 시도한 횟수. public static int Attempts; /// 실제로 자동 사용된 횟수(PotionResult.Used). public static int Applied; /// 시도했는데 813i 가 거절한 횟수(만충·사망 등). public static int Refusals; /// 자동전투 OFF 라 건너뛴 폴링 수. public static int SkippedManual; /// 잔량 0 또는 쿨 중이라 건너뛴 폴링 수(= TryUse 를 부르지 않아 거절 이벤트도 없다). public static int SkippedNotReady; /// HP 가 임계 위라 건너뛴 폴링 수. public static int SkippedHpOk; /// 사망·부활 중이라 건너뛴 폴링 수. public static int SkippedDeath; /// PC 가 없거나 MaxHP 가 0 이라 건너뛴 폴링 수. public static int SkippedNoPc; /// 마지막 폴링에서 본 HP 비율(0~1 · 진단용). public static float LastHpRatio; /// 마지막 TryUse 결과. public static PotionResult LastResult; /// 마지막 자동 사용 시각(게임 시간). public static float LastUseTime = -1f; /// 마지막 자동 사용 회복량(HP). public static double LastHealed; /// 마지막 로그 문장(Console 이 꺼져 있어도 프로브가 읽는다). public static string LastLog = ""; static float s_nextPoll; static bool s_faulted; static int s_faultCount; const int FaultLimit = 5; // 연속 예외 5회면 이 세션 동안 중단(원본 동작으로 남는다) // ───────────────────────────────────────────────────────────────────── // 틱 — SurvivalRunner.Update() 가 PotionUse.Tick 뒤에 부른다(쿨 계산이 먼저 갱신돼야 한다) // ───────────────────────────────────────────────────────────────────── /// 게임 시간(Time.time). 폴링 간격은 SO. 에디트 모드 프로브는 ForceTick 으로 직접 부른다. internal static void Tick(float gameNow) { if (s_faulted || !Active) return; var st = St; if (gameNow < s_nextPoll) return; s_nextPoll = gameNow + Mathf.Max(0.02f, st.autoPotionCheckSeconds); // 🔴 예외를 삼키되 LogError 로 올리지 않는다(ErrorLogHookManager 모달 회피 · 813x 교훈). try { Poll(st, gameNow); s_faultCount = 0; } catch (System.Exception ex) { LastLog = "예외 — " + ex.Message; if (++s_faultCount >= FaultLimit) { s_faulted = true; LastLog = "중단(연속 예외 " + s_faultCount + ") — " + ex.Message; } } } static void Poll(WLSurvivalSettings st, float gameNow) { if (DeathFlow.State != DeathState.None) { SkippedDeath++; return; } var pc = MyValue.MyPC; // 파괴된 참조는 Unity != 로 걸러진다 if (pc == null || pc.IsDead()) { SkippedNoPc++; return; } if (st.autoPotionRequireAutoCombat && !IsAutoCombatOn) { SkippedManual++; return; } // 잔량·쿨·전체 스위치는 813i 가 판정한다. 여기서 먼저 걸러야 매 프레임 Refused 이벤트가 나가지 않는다. if (!PotionUse.IsReady) { SkippedNotReady++; return; } double maxHp = pc.Get_MaxHP(); if (maxHp <= 0d) { SkippedNoPc++; return; } float ratio = (float)(pc.Get_HP() / maxHp); LastHpRatio = ratio; if (ratio > st.autoPotionHpRatio) { SkippedHpOk++; return; } Attempts++; double before = pc.Get_HP(); var r = PotionUse.TryUse(pc); LastResult = r; if (r != PotionResult.Used) { Refusals++; return; } Applied++; LastUseTime = gameNow; LastHealed = pc.Get_HP() - before; if (st.autoPotionLog || st.verboseLog) { // 런당 최대 potionCountPerRun 회(기본 3) — 매 프레임 경로가 아니다. LastLog = "자동 사용 #" + Applied + " — HP " + (ratio * 100f).ToString("F0") + "% ≤ " + (st.autoPotionHpRatio * 100f).ToString("F0") + "% · +" + LastHealed.ToString("F0") + " → " + pc.Get_HP().ToString("F0") + "/" + maxHp.ToString("F0") + " · 남은 물약 " + PotionUse.Remaining + "/" + PotionUse.Max; Debug.Log("[AutoPotion] " + LastLog); } } // ───────────────────────────────────────────────────────────────────── // 진단 · 초기화 // ───────────────────────────────────────────────────────────────────── /// 프로브용: 러너 없이 1회 폴링(간격 무시). public static void ForceTick(float gameNow) { s_nextPoll = float.MinValue; s_faulted = false; s_faultCount = 0; Tick(gameNow); } /// 프로브용: 카운터·폴링 상태 초기화. public static void ResetDiagnostics() { Attempts = Applied = Refusals = 0; SkippedManual = SkippedNotReady = SkippedHpOk = SkippedDeath = SkippedNoPc = 0; LastHpRatio = 0f; LastResult = PotionResult.Used; LastUseTime = -1f; LastHealed = 0d; LastLog = ""; s_nextPoll = float.MinValue; s_faulted = false; s_faultCount = 0; AutoStateOverride = 0; RuntimeDisabled = false; } /// 진단 한 줄(프로브·QA 로그). public static string Dump() { var st = St; return "[AutoPotion] active=" + Active + " ratio<=" + (st != null ? st.autoPotionHpRatio.ToString("F2") : "?") + " requireAuto=" + (st != null && st.autoPotionRequireAutoCombat) + " autoOn=" + IsAutoCombatOn + " · attempts=" + Attempts + " applied=" + Applied + " refused=" + Refusals + " · skip(manual/notReady/hpOk/death/noPc)=" + SkippedManual + "/" + SkippedNotReady + "/" + SkippedHpOk + "/" + SkippedDeath + "/" + SkippedNoPc + " · lastHp=" + LastHpRatio.ToString("F2") + " last=" + LastResult + " potion=" + PotionUse.Remaining + "/" + PotionUse.Max; } } }