Merge branch 'wl/gameplay/WL-813m2-auto-potion'

This commit is contained in:
깃 관리자 2026-09-09 14:03:09 +09:00
commit 4ab5bb3c2a
7 changed files with 752 additions and 1 deletions

View File

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

View File

@ -0,0 +1,489 @@
// WL813m2_Probe.cs — #813m2(자동 물약) 에디트 모드 검증 프로브
// 에디트 모드 전용 · Play 불필요 · 로그인 0 · 디스크 에셋/씬 무수정(런타임 클론 SO 를 리플렉션으로 주입 · 임시 오브젝트 즉시 파괴)
//
// 상주 에디터: unity command run_script --project-path <wt> --file AgentScripts/WL813m2_Probe.cs --entry WL813m2_Probe.RunAll
// 결과: Console + AgentScripts/WL813m2_PROBE.txt(커밋) + Screenshots_WL/WL813m2/probe_<HHmmss>.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(); }
/// <summary>디스크 에셋을 건드리지 않는 런타임 클론을 WLSurvivalSettings 캐시에 주입한다(813i 프로브와 같은 방식).</summary>
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;
}
/// <summary>기본 실험 조건(생존 축 on · 물약 3/40 %/쿨 5 s · 자동 물약 on · HUD 회복 숫자 off).</summary>
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<PCActor>();
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);
}
}
/// <summary>러너 1프레임 대역 — SurvivalRunner.Update() 와 같은 순서(PotionUse 먼저 · AutoPotion 나중).</summary>
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<WLSurvivalSettings>();
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<PotionEvent> onUsed = (in PotionEvent e) => { used++; };
WL.Combat.Core.CombatHandler<PotionEvent> 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<PotionEvent> 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<PotionEvent> 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<PotionEvent> 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<Transform>(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;
}
}

View File

@ -25,6 +25,11 @@ MonoBehaviour:
potionShowHealNumber: 1 potionShowHealNumber: 1
potionRefuseAtFullHp: 1 potionRefuseAtFullHp: 1
potionResetOnMapLoad: 1 potionResetOnMapLoad: 1
autoPotionEnabled: 1
autoPotionHpRatio: 0.35
autoPotionRequireAutoCombat: 1
autoPotionCheckSeconds: 0.25
autoPotionLog: 1
deathDisplaySeconds: 2 deathDisplaySeconds: 2
holdWorldWhileReviving: 1 holdWorldWhileReviving: 1
autoReviveWhenNoListener: 1 autoReviveWhenNoListener: 1

View File

@ -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
{
/// <summary>자동전투 중 HP 임계 이하면 813i 물약을 자동 사용. 정적 · 새 Manager/Singleton 0(틱은 SurvivalRunner 가 준다).</summary>
public static class AutoPotion
{
static WLSurvivalSettings St { get { return WLSurvivalSettings.Instance; } }
/// <summary>기능이 살아 있는가. 생존 축 스위치 + 자동 물약 스위치 + 런타임 차단 없음.</summary>
public static bool Active
{
get
{
if (RuntimeDisabled || !WLSurvivalSettings.Enabled) return false;
var st = St;
return st != null && st.autoPotionEnabled;
}
}
/// <summary>진단·A/B 전용 런타임 스위치(에셋 무수정 · Play 종료 시 도메인 리로드로 자동 false).</summary>
public static bool RuntimeDisabled;
/// <summary>
/// 프로브·QA 전용 자동전투 상태 강제값. 0 = 실제 값(`eCommon.Auto`) · 1 = 강제 ON · 1 = 강제 OFF.
/// 에디트 모드에는 `ServerInfo` 가 없어(만들면 에디트 모드를 오염시킨다) 실제 값을 읽을 수 없다.
/// </summary>
public static int AutoStateOverride;
/// <summary>지금 자동전투인가. 실제 값은 813x 의 가드된 읽기(`eCommon.Auto` · 플레이 모드에서만)를 재사용한다.</summary>
public static bool IsAutoCombatOn
{
get
{
if (AutoStateOverride != 0) return AutoStateOverride > 0;
return WL.Combat.Auto.AutoCombatOnEnter.IsAutoOn;
}
}
// ───────────────────────────────────────── 진단(프로브가 읽는다)
/// <summary>임계 아래로 내려가 사용을 시도한 횟수.</summary>
public static int Attempts;
/// <summary>실제로 자동 사용된 횟수(PotionResult.Used).</summary>
public static int Applied;
/// <summary>시도했는데 813i 가 거절한 횟수(만충·사망 등).</summary>
public static int Refusals;
/// <summary>자동전투 OFF 라 건너뛴 폴링 수.</summary>
public static int SkippedManual;
/// <summary>잔량 0 또는 쿨 중이라 건너뛴 폴링 수(= TryUse 를 부르지 않아 거절 이벤트도 없다).</summary>
public static int SkippedNotReady;
/// <summary>HP 가 임계 위라 건너뛴 폴링 수.</summary>
public static int SkippedHpOk;
/// <summary>사망·부활 중이라 건너뛴 폴링 수.</summary>
public static int SkippedDeath;
/// <summary>PC 가 없거나 MaxHP 가 0 이라 건너뛴 폴링 수.</summary>
public static int SkippedNoPc;
/// <summary>마지막 폴링에서 본 HP 비율(0~1 · 진단용).</summary>
public static float LastHpRatio;
/// <summary>마지막 TryUse 결과.</summary>
public static PotionResult LastResult;
/// <summary>마지막 자동 사용 시각(게임 시간).</summary>
public static float LastUseTime = -1f;
/// <summary>마지막 자동 사용 회복량(HP).</summary>
public static double LastHealed;
/// <summary>마지막 로그 문장(Console 이 꺼져 있어도 프로브가 읽는다).</summary>
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 뒤에 부른다(쿨 계산이 먼저 갱신돼야 한다)
// ─────────────────────────────────────────────────────────────────────
/// <summary>게임 시간(Time.time). 폴링 간격은 SO. 에디트 모드 프로브는 ForceTick 으로 직접 부른다.</summary>
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);
}
}
// ─────────────────────────────────────────────────────────────────────
// 진단 · 초기화
// ─────────────────────────────────────────────────────────────────────
/// <summary>프로브용: 러너 없이 1회 폴링(간격 무시).</summary>
public static void ForceTick(float gameNow) { s_nextPoll = float.MinValue; s_faulted = false; s_faultCount = 0; Tick(gameNow); }
/// <summary>프로브용: 카운터·폴링 상태 초기화.</summary>
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;
}
/// <summary>진단 한 줄(프로브·QA 로그).</summary>
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;
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ef4c52f074b544098b4bb57c97c361ab
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -21,7 +21,7 @@
// "받는 피해 0.5" 의 체감은 같고, 고정 피해(ATK_FIXED) 항만 배율 밖이다. // "받는 피해 0.5" 의 체감은 같고, 고정 피해(ATK_FIXED) 항만 배율 밖이다.
// //
// ■ 러너(SurvivalRunner · 숨은 오브젝트 1개 · DontDestroyOnLoad · 코루틴 없음)는 SO 가 켜져 있을 때만 만든다(C8: 꺼져 있으면 오브젝트 0). // ■ 러너(SurvivalRunner · 숨은 오브젝트 1개 · DontDestroyOnLoad · 코루틴 없음)는 SO 가 켜져 있을 때만 만든다(C8: 꺼져 있으면 오브젝트 0).
// 틱: Survival(게임 시간) · PotionUse(게임 시간) · DeathFlow(unscaled · 세계를 세워도 흘러야 한다) · 런 경계 폴링(맵/모드/PC 변화). // 틱: Survival(게임 시간) · PotionUse(게임 시간) · AutoPotion(게임 시간 · 813m2) · DeathFlow(unscaled · 세계를 세워도 흘러야 한다) · 런 경계 폴링(맵/모드/PC 변화).
// //
// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것. // 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것.
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@ -210,6 +210,7 @@ namespace WL.Combat.Survival
{ {
Survival.Tick(Time.time); Survival.Tick(Time.time);
PotionUse.Tick(Time.time); PotionUse.Tick(Time.time);
AutoPotion.Tick(Time.time); // 813m2 · PotionUse.Tick 뒤(쿨 잔량이 먼저 갱신돼야 IsReady 가 맞다)
DeathFlow.Tick(Time.unscaledTime); DeathFlow.Tick(Time.unscaledTime);
Survival.PollRunBoundary(); Survival.PollRunBoundary();
} }

View File

@ -106,6 +106,26 @@ namespace WL.Combat.Survival
[Tooltip("Stage/Dungeon 맵에 들어갈 때(맵 ID·게임 모드 변화) 물약 개수를 런 시작값으로 되돌린다. 끄면 PotionUse.ResetRun() 호출(813m 런 시작)로만 리셋된다.")] [Tooltip("Stage/Dungeon 맵에 들어갈 때(맵 ID·게임 모드 변화) 물약 개수를 런 시작값으로 되돌린다. 끄면 PotionUse.ResetRun() 호출(813m 런 시작)로만 리셋된다.")]
public bool potionResetOnMapLoad = true; 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)")] [Header("사망 · 부활 (기준서 사망 연출 2 s → 부활 요청 → 존 시작점 On_Regen · 부활 후 무적 1.5 s)")]
[Tooltip("사망 연출 길이(초 · unscaled). 이 시간이 지나면 ReviveRequested 를 낸다(813j 팝업).")] [Tooltip("사망 연출 길이(초 · unscaled). 이 시간이 지나면 ReviveRequested 를 낸다(813j 팝업).")]