// WL813m2_Probe.cs — #813m2(자동 물약) 에디트 모드 검증 프로브 // 에디트 모드 전용 · Play 불필요 · 로그인 0 · 디스크 에셋/씬 무수정(런타임 클론 SO 를 리플렉션으로 주입 · 임시 오브젝트 즉시 파괴) // // 상주 에디터: unity command run_script --project-path --file AgentScripts/WL813m2_Probe.cs --entry WL813m2_Probe.RunAll // 결과: Console + AgentScripts/WL813m2_PROBE.txt(커밋) + Screenshots_WL/WL813m2/probe_.txt (RESULT PASS / RESULT FAIL n) // // 검사(발주서 WL-813m2 §1-3) // ① 에셋 실측: 디스크 autoPotion* 5값 · 코드 기본값 // ② 임계 이하 자동 사용 1회(HP 30 % · MaxHP 310 실측값 · +124 → 217) · 임계 위(50 %)는 0 // ③ 쿨 중 재사용 0(거절 이벤트도 0 — IsReady 로 먼저 거른다) // ④ 잔량 0 이면 0 // ⑤ 자동전투 OFF 면 0(옵션 requireAutoCombat=0 이면 다시 쓴다) // ⑥ 사망·부활 중 0 // ⑦ GC 0(폴링 200회 · 대조군 대비) // ⑧ C8: autoPotionEnabled=0 → Active=false · 사용 0 · 이벤트 0 // ⑨ 813y 버튼 표시 왕복: WLSurvivalUiBridge.Install() 뒤 PotionButton.CountProvider/CooldownRemainProvider 가 자동 사용을 그대로 읽는가 // ⑩ 정리(MyPC 원복 · 클론 파괴 · 캐시 복구 · 러너 0 · 씬 dirty 0) // // 에디트 모드 한계: ServerInfo(eCommon.Auto) 가 없다 → AutoPotion.AutoStateOverride 로 자동전투 상태를 준다. // 실제 플래그 경로(813x AutoCombatOnEnter.IsAutoOn)는 Play 필요 = 병합 후 QA. using System; using System.IO; using System.Reflection; using System.Text; using UnityEngine; using WL.Combat.Survival; public static class WL813m2_Probe { const string OutDir = "Screenshots_WL/WL813m2"; const string OutCommit = "AgentScripts/WL813m2_PROBE.txt"; const BindingFlags NP = BindingFlags.NonPublic | BindingFlags.Instance; // PC MaxHP 실측 310(813u §표 B ④ · Q3·Q4-a 실측) — 자동 회복량 0.4 × 310 = 124 const double MaxHp = 310d; static WLSurvivalSettings s_clone; public static object RunAll() { var sb = new StringBuilder(); sb.AppendLine("# WL813m2 Probe " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " (edit mode · Play 0)"); int fail = 0; float ts0 = Time.timeScale; var pc0 = MyValue.MyPC; bool dirty0 = UnityEngine.SceneManagement.SceneManager.GetActiveScene().isDirty; try { fail += Asset(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ① 예외 " + ex); } try { fail += Basic(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ② 예외 " + ex); } try { fail += Cooldown(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ③ 예외 " + ex); } try { fail += Empty(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ④ 예외 " + ex); } try { fail += Manual(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⑤ 예외 " + ex); } try { fail += Death(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⑥ 예외 " + ex); } try { fail += Gc(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⑦ 예외 " + ex); } try { fail += C8(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⑧ 예외 " + ex); } try { fail += Button(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⑨ 예외 " + ex); } try { fail += Cleanup(sb, ts0, pc0, dirty0); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⑩ 예외 " + ex); } sb.AppendLine(fail == 0 ? "RESULT PASS" : "RESULT FAIL " + fail); var s = sb.ToString(); Debug.Log(s); try { Directory.CreateDirectory(OutDir); File.WriteAllText(Path.Combine(OutDir, "probe_" + DateTime.Now.ToString("HHmmss") + ".txt"), s); File.WriteAllText(OutCommit, s); } catch { /* 출력 저장 실패는 판정에 영향 없음 */ } return s; } // ───────────────────────────────────────── 도구 static string P(bool ok) { return ok ? "[PASS]" : "[FAIL]"; } static bool Near(double a, double b) { return Math.Abs(a - b) < 1e-3; } static long Alloc() { return GC.GetTotalMemory(false); } static long Mono() { return UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong(); } /// 디스크 에셋을 건드리지 않는 런타임 클론을 WLSurvivalSettings 캐시에 주입한다(813i 프로브와 같은 방식). static WLSurvivalSettings UseClone() { var orig = WLSurvivalSettings.Instance; if (orig == null) return null; if (s_clone == null) { s_clone = UnityEngine.Object.Instantiate(orig); s_clone.hideFlags = HideFlags.HideAndDontSave; s_clone.name = "WLSurvivalSettings(probe clone)"; } var fc = typeof(WLSurvivalSettings).GetField("s_cached", BindingFlags.NonPublic | BindingFlags.Static); var fd = typeof(WLSurvivalSettings).GetField("s_lookupDone", BindingFlags.NonPublic | BindingFlags.Static); if (fc == null || fd == null) return null; fc.SetValue(null, s_clone); fd.SetValue(null, true); return s_clone; } /// 기본 실험 조건(생존 축 on · 물약 3/40 %/쿨 5 s · 자동 물약 on · HUD 회복 숫자 off). static WLSurvivalSettings Arm() { var st = UseClone(); if (st == null) return null; st.enabled = true; st.verboseLog = false; st.potionCountPerRun = 3; st.potionHealRate = 0.4f; st.potionCooldownSeconds = 5f; st.potionShowHealNumber = false; st.potionRefuseAtFullHp = true; st.autoPotionEnabled = true; st.autoPotionHpRatio = 0.35f; st.autoPotionRequireAutoCombat = true; st.autoPotionCheckSeconds = 0.25f; st.autoPotionLog = false; PotionUse.ResetDiagnostics(); AutoPotion.ResetDiagnostics(); DeathFlow.ResetDiagnostics(); AutoPotion.AutoStateOverride = 1; // 에디트 모드엔 ServerInfo 가 없다 = 자동전투 ON 대역 return st; } static PCActor MakePC(string name, double maxHp, double hp) { var go = new GameObject("__WL813m2_" + name); go.hideFlags = HideFlags.HideAndDontSave; // 씬에 저장되지 않는다 var pc = go.AddComponent(); pc.m_Role = eRole.PC; pc.m_SubRole = eSubRol.None; var stat = new ActorStatInfo(eRole.PC); stat.Set_Stat(eStat.MaxHP, maxHp); stat.Set_Stat(eStat.HP, hp); typeof(Actor).GetField("m_Stat", NP).SetValue(pc, stat); typeof(Actor).GetField("m_Enemy", NP).SetValue(pc, false); MyValue.MyPC = pc; return pc; } static void SetHp(Actor a, double hp) { a.Get_StatInfo().Set_Stat(eStat.HP, hp); } static void SetDead(Actor a, bool dead) { typeof(Actor).GetField("DeadStatus", NP).SetValue(a, dead); } static void Kill(params UnityEngine.Object[] objs) { foreach (var o in objs) { if (o == null) continue; var c = o as Component; UnityEngine.Object.DestroyImmediate(c != null ? c.gameObject : o); } } /// 러너 1프레임 대역 — SurvivalRunner.Update() 와 같은 순서(PotionUse 먼저 · AutoPotion 나중). static void Step(float t) { PotionUse.Tick(t); AutoPotion.ForceTick(t); } // ───────────────────────────────────────── ① 에셋 실측 static int Asset(StringBuilder sb) { WLSurvivalSettings.ClearCache(); var st = WLSurvivalSettings.Instance; if (st == null) { sb.AppendLine("[FAIL] ① 에셋 없음 " + WLSurvivalSettings.ResourcesPath); return 1; } var def = ScriptableObject.CreateInstance(); sb.AppendLine("[INFO] ① 디스크 = enabled " + st.enabled + " · autoPotionEnabled " + st.autoPotionEnabled + " · hpRatio " + st.autoPotionHpRatio.ToString("F2") + " · requireAutoCombat " + st.autoPotionRequireAutoCombat + " · checkSeconds " + st.autoPotionCheckSeconds.ToString("F2") + " · log " + st.autoPotionLog + " | 물약 " + st.potionCountPerRun + "개 · " + (st.potionHealRate * 100f).ToString("F0") + "% · 쿨 " + st.potionCooldownSeconds.ToString("F0") + "s"); sb.AppendLine("[INFO] ① 코드 기본 = autoPotionEnabled " + def.autoPotionEnabled + " · hpRatio " + def.autoPotionHpRatio.ToString("F2") + " · requireAutoCombat " + def.autoPotionRequireAutoCombat + " · checkSeconds " + def.autoPotionCheckSeconds.ToString("F2")); // 낭비 0 조건: 임계 + 회복률 ≤ 1 이면 회복이 MaxHP 를 넘지 않는다 bool noWaste = st.autoPotionHpRatio + st.potionHealRate <= 1.0f + 1e-4f; sb.AppendLine(P(noWaste) + " ① 회복 낭비 0 조건: hpRatio " + st.autoPotionHpRatio.ToString("F2") + " + healRate " + st.potionHealRate.ToString("F2") + " = " + (st.autoPotionHpRatio + st.potionHealRate).ToString("F2") + " ≤ 1.00"); Kill(def); return noWaste ? 0 : 1; } // ───────────────────────────────────────── ② 임계 이하 자동 사용 1회 · 임계 위 0 static int Basic(StringBuilder sb) { var st = Arm(); if (st == null) { sb.AppendLine("[FAIL] ② 클론 주입 실패"); return 1; } int used = 0, changed = 0; WL.Combat.Core.CombatHandler onUsed = (in PotionEvent e) => { used++; }; WL.Combat.Core.CombatHandler onChanged = (in PotionEvent e) => { changed++; }; PotionUse.Used.Add(onUsed); PotionUse.Changed.Add(onChanged); var pc = MakePC("basic", MaxHp, MaxHp * 0.5d); // 155 = 50 % int fails = 0; try { Step(0f); bool a = AutoPotion.Applied == 0 && AutoPotion.SkippedHpOk == 1 && used == 0 && Near(pc.Get_HP(), 155d) && PotionUse.Remaining == 3; sb.AppendLine(P(a) + " ②a 임계 위(50 % > 35 %) 사용 0 — applied=" + AutoPotion.Applied + " skipHpOk=" + AutoPotion.SkippedHpOk + " hp=" + pc.Get_HP().ToString("F0") + " remaining=" + PotionUse.Remaining); if (!a) fails++; SetHp(pc, MaxHp * 0.3d); // 93 = 30 % Step(1f); bool b = AutoPotion.Applied == 1 && AutoPotion.Attempts == 1 && AutoPotion.Refusals == 0 && Near(pc.Get_HP(), 217d) && Near(AutoPotion.LastHealed, 124d) && PotionUse.Remaining == 2 && used == 1 && AutoPotion.LastResult == PotionResult.Used; sb.AppendLine(P(b) + " ②b 임계 이하(30 % ≤ 35 %) 자동 사용 1회 — hp 93→" + pc.Get_HP().ToString("F0") + " (기대 217 · +" + AutoPotion.LastHealed.ToString("F0") + ") remaining=" + PotionUse.Remaining + " Used 이벤트=" + used + " lastHpRatio=" + AutoPotion.LastHpRatio.ToString("F2") + " result=" + AutoPotion.LastResult + " · 813i Changed 이벤트=" + changed + "(813y 버튼이 구독)"); if (!b) fails++; // 자동 사용 뒤 HP 가 임계 위로 올라갔다 = 다음 폴링에서 또 쓰지 않는다(쿨과 별개 이유) int hpOk0 = AutoPotion.SkippedHpOk; Step(20f); // 쿨(5 s) 완전히 지난 시점 bool c = AutoPotion.Applied == 1 && AutoPotion.SkippedHpOk == hpOk0 + 1 && PotionUse.Remaining == 2; sb.AppendLine(P(c) + " ②c 회복 뒤(70 %) 쿨이 끝나도 재사용 0 — applied=" + AutoPotion.Applied + " skipHpOk=" + AutoPotion.SkippedHpOk + " remaining=" + PotionUse.Remaining); if (!c) fails++; sb.AppendLine("[INFO] ② " + AutoPotion.Dump()); } finally { PotionUse.Used.Remove(onUsed); PotionUse.Changed.Remove(onChanged); MyValue.MyPC = null; Kill(pc); } return fails; } // ───────────────────────────────────────── ③ 쿨 중 재사용 0(거절 이벤트도 0) static int Cooldown(StringBuilder sb) { var st = Arm(); if (st == null) { sb.AppendLine("[FAIL] ③ 클론 주입 실패"); return 1; } int refused = 0; WL.Combat.Core.CombatHandler onChanged = (in PotionEvent e) => { if (e.change == PotionChange.Refused) refused++; }; PotionUse.Changed.Add(onChanged); var pc = MakePC("cd", MaxHp, MaxHp * 0.3d); int fails = 0; try { Step(0f); // 1회 사용 → 쿨 5 s SetHp(pc, MaxHp * 0.1d); // 다시 위험(31 HP) — 임계 조건은 만족하지만 쿨 중 for (int i = 1; i <= 19; i++) Step(i * 0.25f); // t = 0.25 ~ 4.75 s (쿨 안) bool a = AutoPotion.Applied == 1 && AutoPotion.Attempts == 1 && refused == 0 && AutoPotion.SkippedNotReady == 19 && PotionUse.Remaining == 2 && Near(pc.Get_HP(), 31d); sb.AppendLine(P(a) + " ③a 쿨 중 19회 폴링 재사용 0 — applied=" + AutoPotion.Applied + " attempts=" + AutoPotion.Attempts + " skipNotReady=" + AutoPotion.SkippedNotReady + " · 813i Refused 이벤트=" + refused + "(기대 0 · TryUse 를 부르지 않는다)" + " hp=" + pc.Get_HP().ToString("F0")); if (!a) fails++; Step(5f); // 쿨 종료 프레임 bool b = AutoPotion.Applied == 2 && PotionUse.Remaining == 1 && Near(pc.Get_HP(), 155d); sb.AppendLine(P(b) + " ③b 쿨 종료(t=5.0) 즉시 사용 — applied=" + AutoPotion.Applied + " hp 31→" + pc.Get_HP().ToString("F0") + " (기대 155) remaining=" + PotionUse.Remaining); if (!b) fails++; } finally { PotionUse.Changed.Remove(onChanged); MyValue.MyPC = null; Kill(pc); } return fails; } // ───────────────────────────────────────── ④ 잔량 0 이면 0 static int Empty(StringBuilder sb) { var st = Arm(); if (st == null) { sb.AppendLine("[FAIL] ④ 클론 주입 실패"); return 1; } int refused = 0; WL.Combat.Core.CombatHandler onChanged = (in PotionEvent e) => { if (e.change == PotionChange.Refused) refused++; }; PotionUse.Changed.Add(onChanged); var pc = MakePC("empty", MaxHp, MaxHp * 0.3d); int fails = 0; try { float t = 0f; for (int i = 0; i < 3; i++) { SetHp(pc, MaxHp * 0.3d); Step(t); t += 6f; } // 3개 전부 자동 사용 bool a = AutoPotion.Applied == 3 && PotionUse.Remaining == 0; SetHp(pc, MaxHp * 0.1d); int notReady0 = AutoPotion.SkippedNotReady; for (int i = 0; i < 8; i++) { Step(t); t += 1f; } // 잔량 0 인 채 8회 폴링 bool b = AutoPotion.Applied == 3 && AutoPotion.Attempts == 3 && refused == 0 && AutoPotion.SkippedNotReady == notReady0 + 8 && Near(pc.Get_HP(), 31d); sb.AppendLine(P(a && b) + " ④ 잔량 0 사용 0 — 3개 소진 applied=" + AutoPotion.Applied + " remaining=" + PotionUse.Remaining + " · 이후 8회 폴링 attempts=" + AutoPotion.Attempts + " skipNotReady(+8)=" + (AutoPotion.SkippedNotReady - notReady0) + " Refused 이벤트=" + refused + " hp=" + pc.Get_HP().ToString("F0")); if (!(a && b)) fails++; PotionUse.ResetRun("probe run 2"); // 다음 런 = 다시 3개 SetHp(pc, MaxHp * 0.3d); Step(t + 10f); bool c = PotionUse.Remaining == 2 && AutoPotion.Applied == 4; sb.AppendLine(P(c) + " ④b 런 리셋 뒤 다시 자동 사용 — remaining=" + PotionUse.Remaining + " applied=" + AutoPotion.Applied); if (!c) fails++; } finally { PotionUse.Changed.Remove(onChanged); MyValue.MyPC = null; Kill(pc); } return fails; } // ───────────────────────────────────────── ⑤ 자동전투 OFF 면 0 static int Manual(StringBuilder sb) { var st = Arm(); if (st == null) { sb.AppendLine("[FAIL] ⑤ 클론 주입 실패"); return 1; } var pc = MakePC("manual", MaxHp, MaxHp * 0.3d); int fails = 0; try { AutoPotion.AutoStateOverride = -1; // 수동 전투 for (int i = 0; i < 10; i++) Step(i * 0.3f); bool a = AutoPotion.Applied == 0 && AutoPotion.Attempts == 0 && AutoPotion.SkippedManual == 10 && PotionUse.Remaining == 3 && Near(pc.Get_HP(), 93d); sb.AppendLine(P(a) + " ⑤a 자동전투 OFF 10회 폴링 사용 0 — applied=" + AutoPotion.Applied + " skipManual=" + AutoPotion.SkippedManual + " remaining=" + PotionUse.Remaining + " hp=" + pc.Get_HP().ToString("F0") + " · 버튼 수동 사용은 그대로 가능"); if (!a) fails++; var manual = PotionUse.TryUse(pc); // 813j 버튼 경로는 자동전투와 무관 bool a2 = manual == PotionResult.Used && PotionUse.Remaining == 2; sb.AppendLine(P(a2) + " ⑤b 자동전투 OFF 에서도 버튼(수동) 사용은 정상 — " + manual + " remaining=" + PotionUse.Remaining); if (!a2) fails++; st.autoPotionRequireAutoCombat = false; // 옵션 SO 를 끄면 수동 전투에서도 자동 사용 SetHp(pc, MaxHp * 0.2d); Step(30f); bool b = AutoPotion.Applied == 1 && PotionUse.Remaining == 1; sb.AppendLine(P(b) + " ⑤c requireAutoCombat=0 이면 수동 전투에서도 자동 사용 — applied=" + AutoPotion.Applied + " remaining=" + PotionUse.Remaining + " hp=" + pc.Get_HP().ToString("F0")); if (!b) fails++; } finally { st.autoPotionRequireAutoCombat = true; AutoPotion.AutoStateOverride = 1; MyValue.MyPC = null; Kill(pc); } return fails; } // ───────────────────────────────────────── ⑥ 사망 · 부활 중 0 static int Death(StringBuilder sb) { var st = Arm(); if (st == null) { sb.AppendLine("[FAIL] ⑥ 클론 주입 실패"); return 1; } var pc = MakePC("death", MaxHp, MaxHp * 0.3d); int fails = 0; try { SetDead(pc, true); Step(0f); bool a = AutoPotion.Applied == 0 && AutoPotion.SkippedNoPc == 1 && PotionUse.Remaining == 3; SetDead(pc, false); DeathFlow.OnPCDied(pc); // state = Dying int noPc0 = AutoPotion.SkippedNoPc; for (int i = 0; i < 5; i++) Step(1f + i * 0.3f); bool b = DeathFlow.State != DeathState.None && AutoPotion.Applied == 0 && AutoPotion.SkippedDeath == 5 && AutoPotion.SkippedNoPc == noPc0 && PotionUse.Remaining == 3; sb.AppendLine(P(a && b) + " ⑥ 사망·부활 중 사용 0 — 사망 플래그 skipNoPc=" + AutoPotion.SkippedNoPc + " · DeathFlow state=" + DeathFlow.State + " 5회 폴링 skipDeath=" + AutoPotion.SkippedDeath + " applied=" + AutoPotion.Applied + " remaining=" + PotionUse.Remaining); if (!(a && b)) fails++; DeathFlow.Abort("probe"); SetHp(pc, MaxHp * 0.3d); Step(10f); bool c = AutoPotion.Applied == 1; sb.AppendLine(P(c) + " ⑥b 부활 흐름 종료 뒤에는 다시 자동 사용 — applied=" + AutoPotion.Applied + " state=" + DeathFlow.State + " hp=" + pc.Get_HP().ToString("F0")); if (!c) fails++; MyValue.MyPC = null; // PC 없음 int noPc1 = AutoPotion.SkippedNoPc; Step(20f); bool d = AutoPotion.SkippedNoPc == noPc1 + 1 && AutoPotion.Applied == 1; sb.AppendLine(P(d) + " ⑥c PC 없음 무동작 — skipNoPc=" + AutoPotion.SkippedNoPc + " applied=" + AutoPotion.Applied); if (!d) fails++; } finally { DeathFlow.Abort("probe cleanup"); MyValue.MyPC = null; Kill(pc); } return fails; } // ───────────────────────────────────────── ⑦ GC 0 static int Gc(StringBuilder sb) { var st = Arm(); if (st == null) { sb.AppendLine("[FAIL] ⑦ 클론 주입 실패"); return 1; } st.autoPotionLog = false; var pc = MakePC("gc", MaxHp, MaxHp * 0.6d); // 임계 위 = 매 폴링이 SkippedHpOk 경로 int fails = 0; try { for (int i = 0; i < 50; i++) Step(i * 0.1f); // 워밍업(지연 초기화 · 델리게이트 캐시) GC.Collect(); GC.WaitForPendingFinalizers(); long a0 = Alloc(); long m0 = Mono(); for (int i = 0; i < 200; i++) Step(100f + i * 0.1f); long a1 = Alloc(); long m1 = Mono(); // 대조군 — Mono 힙은 4 KB 페이지 단위로만 늘어난다(813i 실측) → 800 KB 를 할당해 감도를 증명한다 GC.Collect(); GC.WaitForPendingFinalizers(); long b0 = Alloc(); var sink = new byte[200][]; for (int i = 0; i < 200; i++) sink[i] = new byte[4096]; long b1 = Alloc(); bool ok = (a1 - a0) <= 0 && (b1 - b0) > 0; sb.AppendLine(P(ok) + " ⑦ GC 0 — 폴링 200회 Δ(GetTotalMemory)=" + (a1 - a0) + " B · Δ(MonoUsedSize)=" + (m1 - m0) + " B (기대 ≤ 0) · 대조군(byte[4096]×200 = 800 KB) Δ=" + (b1 - b0) + " B (감도 증명 · sink=" + sink.Length + ")" + " · applied=" + AutoPotion.Applied + "(기대 0) skipHpOk=" + AutoPotion.SkippedHpOk); if (!ok) fails++; } finally { MyValue.MyPC = null; Kill(pc); } return fails; } // ───────────────────────────────────────── ⑧ C8 롤백 static int C8(StringBuilder sb) { var st = Arm(); if (st == null) { sb.AppendLine("[FAIL] ⑧ 클론 주입 실패"); return 1; } int changed = 0; WL.Combat.Core.CombatHandler onChanged = (in PotionEvent e) => { changed++; }; PotionUse.Changed.Add(onChanged); var pc = MakePC("c8", MaxHp, MaxHp * 0.1d); // 10 % = 임계 한참 아래 int fails = 0; try { st.autoPotionEnabled = false; bool act0 = AutoPotion.Active; for (int i = 0; i < 20; i++) Step(i * 0.5f); bool a = !act0 && AutoPotion.Applied == 0 && AutoPotion.Attempts == 0 && changed == 0 && PotionUse.Remaining == 3 && Near(pc.Get_HP(), 31d) && AutoPotion.SkippedHpOk == 0 && AutoPotion.SkippedNotReady == 0; // 폴링 자체가 없다 sb.AppendLine(P(a) + " ⑧a autoPotionEnabled=0 → Active=" + act0 + " · 20회 폴링 applied=" + AutoPotion.Applied + " attempts=" + AutoPotion.Attempts + " skip 전부 0=" + (AutoPotion.SkippedHpOk == 0 && AutoPotion.SkippedNotReady == 0) + " · 813i 이벤트=" + changed + " remaining=" + PotionUse.Remaining + " hp=" + pc.Get_HP().ToString("F0")); if (!a) fails++; st.autoPotionEnabled = true; st.enabled = false; // 생존 축 전체 off bool act1 = AutoPotion.Active; for (int i = 0; i < 5; i++) Step(20f + i * 0.5f); bool b = !act1 && AutoPotion.Applied == 0 && changed == 0 && Near(pc.Get_HP(), 31d); sb.AppendLine(P(b) + " ⑧b 생존 축 enabled=0 → Active=" + act1 + " · applied=" + AutoPotion.Applied + " 이벤트=" + changed + " hp=" + pc.Get_HP().ToString("F0")); if (!b) fails++; st.enabled = true; AutoPotion.RuntimeDisabled = true; bool act2 = AutoPotion.Active; for (int i = 0; i < 5; i++) Step(40f + i * 0.5f); bool c = !act2 && AutoPotion.Applied == 0 && changed == 0; AutoPotion.RuntimeDisabled = false; Step(60f); bool d = AutoPotion.Active && AutoPotion.Applied == 1 && PotionUse.Remaining == 2; sb.AppendLine(P(c && d) + " ⑧c RuntimeDisabled=1 사용 0(Active=" + act2 + ") → 0 으로 되돌리면 applied=" + AutoPotion.Applied + " remaining=" + PotionUse.Remaining); if (!(c && d)) fails++; } finally { PotionUse.Changed.Remove(onChanged); AutoPotion.RuntimeDisabled = false; MyValue.MyPC = null; Kill(pc); } return fails; } // ───────────────────────────────────────── ⑨ 813y 물약 버튼 표시 왕복(UI 파일 수정 0 · public API 만) static int Button(StringBuilder sb) { var st = Arm(); if (st == null) { sb.AppendLine("[FAIL] ⑨ 클론 주입 실패"); return 1; } var pc = MakePC("btn", MaxHp, MaxHp * 0.3d); bool installedHere = false; int fails = 0; try { string inst = WL.UI.WLSurvivalUiBridge.Install(); installedHere = inst.StartsWith("연결"); var cp = WL.UI.PotionButton.CountProvider; var cr = WL.UI.PotionButton.CooldownRemainProvider; var ct = WL.UI.PotionButton.CooldownTotalProvider; if (cp == null || cr == null || ct == null) { sb.AppendLine("[FAIL] ⑨ 813y 연결 없음 — count=" + (cp != null) + " cdRemain=" + (cr != null) + " cdTotal=" + (ct != null) + " (" + inst + ")"); return 1; } int before = cp(); Step(0f); int after = cp(); float cdRemain = cr(), cdTotal = ct(); bool a = before == 3 && after == 2 && AutoPotion.Applied == 1 && Math.Abs(cdRemain - 5f) < 1e-3f && Math.Abs(cdTotal - 5f) < 1e-3f; sb.AppendLine(P(a) + " ⑨ 버튼 표시 = 자동 사용을 그대로 읽는다 — CountProvider " + before + " → " + after + " · CooldownRemainProvider " + cdRemain.ToString("F2") + "/" + cdTotal.ToString("F2") + "s" + " · PotionButton.Update()→Refresh() 가 매 프레임 이 provider 를 다시 읽는다(PotionButton.cs:108) → 표시 갱신에 UI 수정 0"); if (!a) fails++; } finally { if (installedHere) WL.UI.WLSurvivalUiBridge.Uninstall(); MyValue.MyPC = null; Kill(pc); } return fails; } // ───────────────────────────────────────── ⑩ 정리 static int Cleanup(StringBuilder sb, float ts0, PCActor pc0, bool dirty0) { AutoPotion.ResetDiagnostics(); PotionUse.ResetDiagnostics(); DeathFlow.ResetDiagnostics(); Kill(s_clone); s_clone = null; WLSurvivalSettings.ClearCache(); WLSurvivalSettings.RuntimeOverride = 0; MyValue.MyPC = pc0; var leftovers = UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); int n = 0; for (int i = 0; i < leftovers.Length; i++) if (leftovers[i] != null && leftovers[i].name.StartsWith("__WL813m2_")) { n++; Kill(leftovers[i]); } var st = WLSurvivalSettings.Instance; bool assetBack = st != null && st.autoPotionEnabled && Math.Abs(st.autoPotionHpRatio - 0.35f) < 1e-4f && st.enabled; var scene = UnityEngine.SceneManagement.SceneManager.GetActiveScene(); bool dirty = scene.isDirty; bool ok = assetBack && n == 0 && Time.timeScale == ts0 && !SurvivalRunner.Exists && dirty == dirty0; sb.AppendLine(P(ok) + " ⑩ 정리: 디스크 에셋 복구 " + assetBack + "(enabled=" + (st != null && st.enabled) + " autoPotion=" + (st != null && st.autoPotionEnabled) + ")" + " · 임시 오브젝트 잔여 " + n + " · timeScale " + Time.timeScale + "(시작 " + ts0 + ")" + " · 러너 생성 0 " + !SurvivalRunner.Exists + " · 씬 '" + scene.name + "' dirty=" + dirty + "(시작 " + dirty0 + ")" + " · MyPC 원복 " + (MyValue.MyPC == pc0)); return ok ? 0 : 1; } }