diff --git a/AgentScripts/WL813m2_PROBE.txt b/AgentScripts/WL813m2_PROBE.txt new file mode 100644 index 000000000..1768a9f72 --- /dev/null +++ b/AgentScripts/WL813m2_PROBE.txt @@ -0,0 +1,25 @@ +# WL813m2 Probe 2026-09-09 13:51:29 (edit mode · Play 0) +[INFO] ① 디스크 = enabled True · autoPotionEnabled True · hpRatio 0.35 · requireAutoCombat True · checkSeconds 0.25 · log True | 물약 3개 · 40% · 쿨 5s +[INFO] ① 코드 기본 = autoPotionEnabled True · hpRatio 0.35 · requireAutoCombat True · checkSeconds 0.25 +[PASS] ① 회복 낭비 0 조건: hpRatio 0.35 + healRate 0.40 = 0.75 ≤ 1.00 +[PASS] ②a 임계 위(50 % > 35 %) 사용 0 — applied=0 skipHpOk=1 hp=155 remaining=3 +[PASS] ②b 임계 이하(30 % ≤ 35 %) 자동 사용 1회 — hp 93→217 (기대 217 · +124) remaining=2 Used 이벤트=1 lastHpRatio=0.30 result=Used · 813i Changed 이벤트=1(813y 버튼이 구독) +[PASS] ②c 회복 뒤(70 %) 쿨이 끝나도 재사용 0 — applied=1 skipHpOk=2 remaining=2 +[INFO] ② [AutoPotion] active=True ratio<=0.35 requireAuto=True autoOn=True · attempts=1 applied=1 refused=0 · skip(manual/notReady/hpOk/death/noPc)=0/0/2/0/0 · lastHp=0.70 last=Used potion=2/3 +[PASS] ③a 쿨 중 19회 폴링 재사용 0 — applied=1 attempts=1 skipNotReady=19 · 813i Refused 이벤트=0(기대 0 · TryUse 를 부르지 않는다) hp=31 +[PASS] ③b 쿨 종료(t=5.0) 즉시 사용 — applied=2 hp 31→155 (기대 155) remaining=1 +[PASS] ④ 잔량 0 사용 0 — 3개 소진 applied=3 remaining=0 · 이후 8회 폴링 attempts=3 skipNotReady(+8)=8 Refused 이벤트=0 hp=31 +[PASS] ④b 런 리셋 뒤 다시 자동 사용 — remaining=2 applied=4 +[PASS] ⑤a 자동전투 OFF 10회 폴링 사용 0 — applied=0 skipManual=10 remaining=3 hp=93 · 버튼 수동 사용은 그대로 가능 +[PASS] ⑤b 자동전투 OFF 에서도 버튼(수동) 사용은 정상 — Used remaining=2 +[PASS] ⑤c requireAutoCombat=0 이면 수동 전투에서도 자동 사용 — applied=1 remaining=1 hp=186 +[PASS] ⑥ 사망·부활 중 사용 0 — 사망 플래그 skipNoPc=1 · DeathFlow state=Dying 5회 폴링 skipDeath=5 applied=0 remaining=3 +[PASS] ⑥b 부활 흐름 종료 뒤에는 다시 자동 사용 — applied=1 state=None hp=217 +[PASS] ⑥c PC 없음 무동작 — skipNoPc=2 applied=1 +[PASS] ⑦ GC 0 — 폴링 200회 Δ(GetTotalMemory)=0 B · Δ(MonoUsedSize)=0 B (기대 ≤ 0) · 대조군(byte[4096]×200 = 800 KB) Δ=1638400 B (감도 증명 · sink=200) · applied=0(기대 0) skipHpOk=250 +[PASS] ⑧a autoPotionEnabled=0 → Active=False · 20회 폴링 applied=0 attempts=0 skip 전부 0=True · 813i 이벤트=0 remaining=3 hp=31 +[PASS] ⑧b 생존 축 enabled=0 → Active=False · applied=0 이벤트=0 hp=31 +[PASS] ⑧c RuntimeDisabled=1 사용 0(Active=False) → 0 으로 되돌리면 applied=1 remaining=2 +[PASS] ⑨ 버튼 표시 = 자동 사용을 그대로 읽는다 — CountProvider 3 → 2 · CooldownRemainProvider 5.00/5.00s · PotionButton.Update()→Refresh() 가 매 프레임 이 provider 를 다시 읽는다(PotionButton.cs:108) → 표시 갱신에 UI 수정 0 +[PASS] ⑩ 정리: 디스크 에셋 복구 True(enabled=True autoPotion=True) · 임시 오브젝트 잔여 0 · timeScale 1(시작 1) · 러너 생성 0 True · 씬 '' dirty=False(시작 False) · MyPC 원복 True +RESULT PASS diff --git a/AgentScripts/WL813m2_Probe.cs b/AgentScripts/WL813m2_Probe.cs new file mode 100644 index 000000000..27f3e36ee --- /dev/null +++ b/AgentScripts/WL813m2_Probe.cs @@ -0,0 +1,489 @@ +// 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; + } +} diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLSurvivalSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLSurvivalSettings.asset index 29378af72..05f8133d6 100644 --- a/Assets/WL/Combat/Settings/Resources/WL/WLSurvivalSettings.asset +++ b/Assets/WL/Combat/Settings/Resources/WL/WLSurvivalSettings.asset @@ -25,6 +25,11 @@ MonoBehaviour: potionShowHealNumber: 1 potionRefuseAtFullHp: 1 potionResetOnMapLoad: 1 + autoPotionEnabled: 1 + autoPotionHpRatio: 0.35 + autoPotionRequireAutoCombat: 1 + autoPotionCheckSeconds: 0.25 + autoPotionLog: 1 deathDisplaySeconds: 2 holdWorldWhileReviving: 1 autoReviveWhenNoListener: 1 diff --git a/Assets/WL/Combat/Survival/AutoPotion.cs b/Assets/WL/Combat/Survival/AutoPotion.cs new file mode 100644 index 000000000..817cb2821 --- /dev/null +++ b/Assets/WL/Combat/Survival/AutoPotion.cs @@ -0,0 +1,200 @@ +// ───────────────────────────────────────────────────────────────────────────── +// 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; + } + } +} diff --git a/Assets/WL/Combat/Survival/AutoPotion.cs.meta b/Assets/WL/Combat/Survival/AutoPotion.cs.meta new file mode 100644 index 000000000..18e091c4a --- /dev/null +++ b/Assets/WL/Combat/Survival/AutoPotion.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ef4c52f074b544098b4bb57c97c361ab +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Survival/Survival.cs b/Assets/WL/Combat/Survival/Survival.cs index 31e59bd45..0236e530e 100644 --- a/Assets/WL/Combat/Survival/Survival.cs +++ b/Assets/WL/Combat/Survival/Survival.cs @@ -21,7 +21,7 @@ // "받는 피해 0.5" 의 체감은 같고, 고정 피해(ATK_FIXED) 항만 배율 밖이다. // // ■ 러너(SurvivalRunner · 숨은 오브젝트 1개 · DontDestroyOnLoad · 코루틴 없음)는 SO 가 켜져 있을 때만 만든다(C8: 꺼져 있으면 오브젝트 0). -// 틱: Survival(게임 시간) · PotionUse(게임 시간) · DeathFlow(unscaled · 세계를 세워도 흘러야 한다) · 런 경계 폴링(맵/모드/PC 변화). +// 틱: Survival(게임 시간) · PotionUse(게임 시간) · AutoPotion(게임 시간 · 813m2) · DeathFlow(unscaled · 세계를 세워도 흘러야 한다) · 런 경계 폴링(맵/모드/PC 변화). // // 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것. // ───────────────────────────────────────────────────────────────────────────── @@ -210,6 +210,7 @@ namespace WL.Combat.Survival { Survival.Tick(Time.time); PotionUse.Tick(Time.time); + AutoPotion.Tick(Time.time); // 813m2 · PotionUse.Tick 뒤(쿨 잔량이 먼저 갱신돼야 IsReady 가 맞다) DeathFlow.Tick(Time.unscaledTime); Survival.PollRunBoundary(); } diff --git a/Assets/WL/Combat/Survival/WLSurvivalSettings.cs b/Assets/WL/Combat/Survival/WLSurvivalSettings.cs index 0249854eb..858ac2150 100644 --- a/Assets/WL/Combat/Survival/WLSurvivalSettings.cs +++ b/Assets/WL/Combat/Survival/WLSurvivalSettings.cs @@ -106,6 +106,26 @@ namespace WL.Combat.Survival [Tooltip("Stage/Dungeon 맵에 들어갈 때(맵 ID·게임 모드 변화) 물약 개수를 런 시작값으로 되돌린다. 끄면 PotionUse.ResetRun() 호출(813m 런 시작)로만 리셋된다.")] public bool potionResetOnMapLoad = true; + // ─────────────────────────────────────────── 자동 물약 (Q5 D-21 · WL-813m2) + // 실측 근거: Q5 결과 화면 「사망 / 물약 = 3 / 0」 — 자동전투(813x)로 도는 런에서는 플레이어가 + // 물약 버튼(813j/813y)을 누르지 않아 런당 3개가 통째로 남은 채 3번 죽는다. + [Header("자동 물약 (Q5 D-21 · 자동전투 중 HP 임계 이하 자동 사용 · WL-813m2)")] + [Tooltip("자동전투 중 HP 가 임계 이하로 떨어지면 물약을 자동으로 쓴다. C8 롤백 = 0(코드 무수정 · 물약은 버튼으로만).")] + public bool autoPotionEnabled = true; + + [Tooltip("자동 사용 임계(최대 HP 대비 비율). 0.35 = 35 % 이하. 회복량(potionHealRate)과 합이 1 을 넘지 않아야 회복이 낭비되지 않는다(0.35 + 0.4 = 0.75 → 낭비 0).")] + [Range(0.05f, 0.9f)] + public float autoPotionHpRatio = 0.35f; + + [Tooltip("자동전투(eCommon.Auto)일 때만 자동 사용한다. 끄면 수동 전투에서도 자동으로 쓴다(플레이어의 물약 타이밍을 뺏는다).")] + public bool autoPotionRequireAutoCombat = true; + + [Tooltip("HP 를 확인하는 주기(초 · 게임 시간). 짧을수록 반응이 빠르고 폴링 비용이 는다. 0.02 미만은 0.02 로 잘린다.")] + public float autoPotionCheckSeconds = 0.25f; + + [Tooltip("자동 사용 때 Console 에 1줄 남긴다(런당 최대 potionCountPerRun 회 · verboseLog 와 별개로 켤 수 있다).")] + public bool autoPotionLog = true; + // ─────────────────────────────────────────── 사망 · 부활 [Header("사망 · 부활 (기준서 사망 연출 2 s → 부활 요청 → 존 시작점 On_Regen · 부활 후 무적 1.5 s)")] [Tooltip("사망 연출 길이(초 · unscaled). 이 시간이 지나면 ReviveRequested 를 낸다(813j 팝업).")]