// WL813i_Probe.cs — #813i(무적 해제 게이트 · 물약 · 사망/부활) 에디트 모드 검증 프로브 // 에디트 모드 전용 · Play 불필요 · 로그인 0 · 에셋/씬/TimeManager 무수정(런타임 클론 SO 를 리플렉션으로 주입 · 임시 오브젝트 즉시 파괴) // // 상주 에디터: unity command run_script --project-path --file AgentScripts/WL813i_Probe.cs --entry WL813i_Probe.RunAll // 결과: Console + AgentScripts/WL813i_PROBE.txt(커밋) + Screenshots_WL/WL813i/probe_.txt (RESULT PASS / RESULT FAIL n) // // 검사(발주서 WL-813i ⓒ · ⓓ) // ① 에셋: 디스크 enabled=0(C8 기본) · 값 덤프 · RuntimeOverride // ② C8: SO off 면 TakeDamage=base 그대로(피해·플래그 무변경) · TryUse=Disabled · OnPCDied 무시 // ③ 무적 해제 게이트: 잡몹 ×0.5 · 보스 ×배율 · 스위치 off 통과 · 정예 취급 · 적 PC 통과 · 호출 중 플래그 false → 뒤 true 복원 · 예외에도 복원 · 부활 무적 창 통과 · GC // ④ 물약: 3회/쿨 5 s/만충 거절/빈 통/사망/NoPC · 잔량·쿨 이벤트 · 런 리셋 // ⑤ 사망/부활: 연출 2 s → ReviveRequested → Revive() → Revived + 무적 1.5 s · 구독자 0 자동 부활 · 외부 부활 감지 · 조기 응답 · 타임아웃 · Abort · 중복 사망 · 적 PC // ⑥ 이벤트 GC 0 · ⑦ 정리(클론 파괴 · 캐시 복구 · 씬 dirty) // // 에디트 모드 한계: Time.timeScale 은 읽기만(대입하면 TimeManager.asset 에 적힌다 · 811b-fix 실측) — DeathFlow 가 Application.isPlaying 으로 스스로 막는다. // 부활 실행(On_Regen → LoadMapMgr/ActorInfo)은 에디트 모드에 없어 DeathFlow.ReviveOverride 로 바꿔치기한다(런타임 On_Regen 경로 = 병합 후 QA). using System; using System.IO; using System.Reflection; using System.Text; using UnityEngine; using WL.Combat.Core; using WL.Combat.Survival; public static class WL813i_Probe { const string OutDir = "Screenshots_WL/WL813i"; const string OutCommit = "AgentScripts/WL813i_PROBE.txt"; const BindingFlags NP = BindingFlags.NonPublic | BindingFlags.Instance; static WLSurvivalSettings s_clone; public static object RunAll() { var sb = new StringBuilder(); sb.AppendLine("# WL813i Probe " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " (edit mode · Play 0)"); int fail = 0; float ts0 = Time.timeScale; try { fail += Asset(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ① 예외 " + ex); } try { fail += C8(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ② 예외 " + ex); } try { fail += DamageGate(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ③ 예외 " + ex); } try { fail += Potion(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ④ 예외 " + ex); } try { fail += Death(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⑤ 예외 " + ex); } try { fail += EventGc(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⑥ 예외 " + ex); } try { fail += Cleanup(sb, ts0); } 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]"; } /// 원본 Heal(float rate) 은 0.4f 를 double 로 넓혀 곱하므로(400.000006) 1e-3 허용. static bool Near(double a, double b) { return Math.Abs(a - b) < 1e-3; } /// Unity Mono 의 GC.GetAllocatedBytesForCurrentThread 는 상수를 돌려준다(실측 · 대조군 0 B) → GetTotalMemory(4 KB 페이지 단위) + 대조군으로 감도를 보인다(813k 선례). static long Alloc() { return GC.GetTotalMemory(false); } static long Mono() { return UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong(); } /// 디스크 에셋을 건드리지 않는 런타임 클론을 WLSurvivalSettings 캐시에 주입한다(값 변경 실험용). 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; } static void RestoreAsset() { WLSurvivalSettings.ClearCache(); WLSurvivalSettings.RuntimeOverride = 0; } static PCActor MakePC(string name, double maxHp, double hp, bool enemy = false) { var go = new GameObject("__WL813i_" + name); 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, enemy); return pc; } static Actor MakeBeater(string name, eSubRol sub) { var go = new GameObject("__WL813i_" + name); var a = go.AddComponent(); a.m_Role = eRole.Mob; a.m_SubRole = sub; typeof(Actor).GetField("m_Enemy", NP).SetValue(a, true); return a; } static void SetDead(Actor a, bool dead) { typeof(Actor).GetField("DeadStatus", NP).SetValue(a, dead); } static bool IsDead(Actor a) { return (bool)typeof(Actor).GetField("DeadStatus", NP).GetValue(a); } static void SetHp(Actor a, double hp) { a.Get_StatInfo().Set_Stat(eStat.HP, hp); } 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); } } // ── 가짜 base.Get_Damage(MyActor 대역): 호출 수 · 호출 시점의 피해값 · 무적 플래그를 기록한다 static int s_baseCalls; static double s_baseDamage; static bool s_baseFlag; static bool s_baseThrow; static void FakeBase(DamageInfo d) { s_baseCalls++; s_baseDamage = d != null ? d.Damage : -1d; s_baseFlag = WL.Settings.WLGameplaySettings.PlayerInvincible; if (s_baseThrow) throw new InvalidOperationException("probe: base threw"); } static readonly Action s_fake = FakeBase; static DamageInfo Dmg(Actor beater, double dmg) { var d = new DamageInfo(); d.Beater = beater; d.Damage = dmg; return d; } // ───────────────────────────────────────── ① 에셋 static int Asset(StringBuilder sb) { RestoreAsset(); var st = WLSurvivalSettings.Instance; var gs = WL.Settings.WLGameplaySettings.Instance; sb.AppendLine("survival asset=" + (st != null ? "found" : "NULL") + " enabled(disk)=" + (st != null && st.enabled) + " Enabled=" + WLSurvivalSettings.Enabled + " · gameplay asset=" + (gs != null ? "found" : "NULL") + " playerInvincible(disk)=" + (gs != null && gs.playerInvincible) + " · core Enabled=" + WLCombatCoreSettings.Enabled); if (st == null) { sb.AppendLine("[FAIL] ① WLSurvivalSettings.asset 로드 실패"); return 1; } sb.AppendLine(" values: mob " + st.takeMobDamage + "×" + st.mobDamageMultiplier + " boss " + st.takeBossDamage + "×" + st.bossDamageMultiplier + " eliteAsBoss=" + st.eliteCountsAsBoss + " · potion " + st.potionCountPerRun + "/run " + st.potionHealRate + " cd " + st.potionCooldownSeconds + " showHeal=" + st.potionShowHealNumber + " refuseFull=" + st.potionRefuseAtFullHp + " resetOnMap=" + st.potionResetOnMapLoad + " · death display " + st.deathDisplaySeconds + " hold=" + st.holdWorldWhileReviving + " autoNoListener=" + st.autoReviveWhenNoListener + " timeout=" + st.autoReviveTimeoutSeconds + " invincible " + st.reviveInvincibleSeconds + " heal " + st.reviveHealRate + " companions=" + st.reviveCompanions); bool diskOff = !st.enabled && !WLSurvivalSettings.Enabled; WLSurvivalSettings.RuntimeOverride = 1; bool forceOn = WLSurvivalSettings.Enabled; WLSurvivalSettings.RuntimeOverride = -1; bool forceOff = !WLSurvivalSettings.Enabled; WLSurvivalSettings.RuntimeOverride = 0; bool ok = diskOff && forceOn && forceOff && gs != null && gs.playerInvincible; sb.AppendLine(P(ok) + " ① C8 기본: enabled(disk)=false✔ Enabled=false · RuntimeOverride 1→" + forceOn + " -1→off " + forceOff + " · playerInvincible 유지 " + (gs != null && gs.playerInvincible)); return ok ? 0 : 1; } // ───────────────────────────────────────── ② C8 통과 static int C8(StringBuilder sb) { RestoreAsset(); Survival.ResetDiagnostics(); PotionUse.ResetDiagnostics(); DeathFlow.ResetDiagnostics(); var pc = MakePC("pc", 1000, 300); var mob = MakeBeater("mob", eSubRol.None); try { s_baseCalls = 0; var d = Dmg(mob, 100); Survival.TakeDamage(pc, d, s_fake); bool a = s_baseCalls == 1 && Math.Abs(s_baseDamage - 100d) < 1e-9 && s_baseFlag && Survival.PassDisabled == 1 && Survival.Applied == 0; var r = PotionUse.TryUse(pc); bool b = r == PotionResult.Disabled && PotionUse.Changed.Raised == 0 && Math.Abs(pc.Get_HP() - 300d) < 1e-9; SetDead(pc, true); DeathFlow.OnPCDied(pc); bool c = DeathFlow.State == DeathState.None && DeathFlow.Died.Raised == 0 && DeathFlow.DeathCount == 0; SetDead(pc, false); bool ok = a && b && c; sb.AppendLine(P(ok) + " ② C8(SO off): TakeDamage→base 1회 dmg=" + s_baseDamage + " flag=" + s_baseFlag + " PassDisabled=" + Survival.PassDisabled + " · TryUse=" + r + " Changed=" + PotionUse.Changed.Raised + " hp=" + pc.Get_HP() + " · OnPCDied state=" + DeathFlow.State + " Died=" + DeathFlow.Died.Raised); return ok ? 0 : 1; } finally { Kill(pc, mob); } } // ───────────────────────────────────────── ③ 무적 해제 게이트 static int DamageGate(StringBuilder sb) { var st = UseClone(); if (st == null) { sb.AppendLine("[FAIL] ③ 클론 주입 실패"); return 1; } st.enabled = true; st.verboseLog = false; st.takeMobDamage = true; st.mobDamageMultiplier = 0.5f; st.takeBossDamage = true; st.bossDamageMultiplier = 0.25f; st.eliteCountsAsBoss = false; Survival.ResetDiagnostics(); var gs = WL.Settings.WLGameplaySettings.Instance; bool flagBefore = WL.Settings.WLGameplaySettings.PlayerInvincible; var pc = MakePC("pc", 1000, 500); var enemyPc = MakePC("enemyPc", 1000, 500, true); var mob = MakeBeater("mob", eSubRol.None); var boss = MakeBeater("boss", eSubRol.Boss); var elite = MakeBeater("elite", eSubRol.Elite); int fails = 0; try { // a) 잡몹 ×0.5 · 호출 중 플래그 false · 뒤 복원 s_baseCalls = 0; var d = Dmg(mob, 100); Survival.TakeDamage(pc, d, s_fake); bool a = s_baseCalls == 1 && Math.Abs(s_baseDamage - 50d) < 1e-9 && !s_baseFlag && WL.Settings.WLGameplaySettings.PlayerInvincible == flagBefore && Survival.Applied == 1 && Survival.Lifted == (flagBefore ? 1 : 0) && !Survival.LastWasBoss; sb.AppendLine(P(a) + " ③a 잡몹 100→base " + s_baseDamage + " (기대 50) flagDuring=" + s_baseFlag + " flagAfter=" + WL.Settings.WLGameplaySettings.PlayerInvincible + " (기대 " + flagBefore + ") Lifted=" + Survival.Lifted); if (!a) fails++; // b) 보스 ×0.25 s_baseCalls = 0; d = Dmg(boss, 100); Survival.TakeDamage(pc, d, s_fake); bool b = s_baseCalls == 1 && Math.Abs(s_baseDamage - 25d) < 1e-9 && !s_baseFlag && Survival.LastWasBoss && WL.Settings.WLGameplaySettings.PlayerInvincible == flagBefore; sb.AppendLine(P(b) + " ③b 보스 100→base " + s_baseDamage + " (기대 25) boss=" + Survival.LastWasBoss); if (!b) fails++; // c) 보스 스위치 off → 원본 그대로(피해 100 · 플래그 true 유지) st.takeBossDamage = false; s_baseCalls = 0; d = Dmg(boss, 100); Survival.TakeDamage(pc, d, s_fake); bool c = s_baseCalls == 1 && Math.Abs(s_baseDamage - 100d) < 1e-9 && s_baseFlag == flagBefore && Survival.SkippedBossSwitch == 1; sb.AppendLine(P(c) + " ③c 보스 스위치 off: base dmg=" + s_baseDamage + " (기대 100) flagDuring=" + s_baseFlag + " SkippedBossSwitch=" + Survival.SkippedBossSwitch); if (!c) fails++; st.takeBossDamage = true; // d) 정예: 기본 잡몹 취급(×0.5) · eliteCountsAsBoss → ×0.25 s_baseCalls = 0; d = Dmg(elite, 100); Survival.TakeDamage(pc, d, s_fake); double e1 = s_baseDamage; st.eliteCountsAsBoss = true; s_baseCalls = 0; d = Dmg(elite, 100); Survival.TakeDamage(pc, d, s_fake); double e2 = s_baseDamage; st.eliteCountsAsBoss = false; bool dd = Math.Abs(e1 - 50d) < 1e-9 && Math.Abs(e2 - 25d) < 1e-9; sb.AppendLine(P(dd) + " ③d 정예: 기본 " + e1 + " (기대 50) · eliteCountsAsBoss " + e2 + " (기대 25)"); if (!dd) fails++; // e) 잡몹 스위치 off + 적 PC 통과 st.takeMobDamage = false; s_baseCalls = 0; d = Dmg(mob, 100); Survival.TakeDamage(pc, d, s_fake); bool e_mob = s_baseDamage == 100d && Survival.SkippedMobSwitch == 1; st.takeMobDamage = true; s_baseCalls = 0; d = Dmg(mob, 100); Survival.TakeDamage(enemyPc, d, s_fake); bool e_pc = s_baseDamage == 100d && Survival.PassNotMainPC == 1 && s_baseFlag == flagBefore; bool e = e_mob && e_pc; sb.AppendLine(P(e) + " ③e 잡몹 스위치 off 통과=" + e_mob + " (SkippedMobSwitch=" + Survival.SkippedMobSwitch + ") · 적 PC 통과=" + e_pc + " (PassNotMainPC=" + Survival.PassNotMainPC + ")"); if (!e) fails++; // f) base 예외 → finally 복원 s_baseThrow = true; bool threw = false; try { Survival.TakeDamage(pc, Dmg(mob, 100), s_fake); } catch (InvalidOperationException) { threw = true; } s_baseThrow = false; bool f = threw && WL.Settings.WLGameplaySettings.PlayerInvincible == flagBefore; sb.AppendLine(P(f) + " ③f base 예외 후 플래그 복원: threw=" + threw + " flag=" + WL.Settings.WLGameplaySettings.PlayerInvincible + " (기대 " + flagBefore + ")"); if (!f) fails++; // g) 부활 무적 창(게임 시간 클록) Survival.Tick(10f); Survival.SetReviveInvincible(pc, 1.5f); bool g1 = Survival.IsReviveInvincible(pc) && Math.Abs(Survival.ReviveInvincibleRemaining - 1.5f) < 1e-4f; s_baseCalls = 0; Survival.TakeDamage(pc, Dmg(mob, 100), s_fake); bool g2 = s_baseCalls == 1 && s_baseDamage == 100d && s_baseFlag == flagBefore && Survival.BlockedReviveInvincible == 1; Survival.Tick(11.49f); bool g3 = Survival.IsReviveInvincible(pc); Survival.Tick(11.5f); bool g4 = !Survival.IsReviveInvincible(pc); s_baseCalls = 0; Survival.TakeDamage(pc, Dmg(mob, 100), s_fake); bool g5 = s_baseDamage == 50d; bool g6 = !Survival.IsReviveInvincible(enemyPc); bool g = g1 && g2 && g3 && g4 && g5 && g6; sb.AppendLine(P(g) + " ③g 부활 무적 1.5 s: 창 안 통과(dmg " + (g2 ? "100" : s_baseDamage.ToString()) + " Blocked=" + Survival.BlockedReviveInvincible + ") · t+1.49 " + g3 + " · t+1.5 해제 " + g4 + " → 배율 재적용 " + g5 + " · 다른 PC " + g6); if (!g) fails++; // h) GC: 힙 크기 델타는 페이지 단위·수집 시점에 좌우되므로 100만 회(할당 시 32 MB) + GC.CollectionCount 로 판정하고, // 델리게이트·대조군은 배열에 살려 둔 채(100k · 수집 불가) 힙 델타로 개당 비용을 잰다. var dd2 = Dmg(mob, 100); for (int i = 0; i < 20; i++) { dd2.Damage = 100; Survival.TakeDamage(pc, dd2, s_fake); } GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); int gc0 = GC.CollectionCount(0); long a0 = Alloc(), p0 = Mono(); for (int i = 0; i < 1000000; i++) { dd2.Damage = 100; Survival.TakeDamage(pc, dd2, s_fake); } long a1 = Alloc(), p1 = Mono(); int gc1 = GC.CollectionCount(0); const int N = 100000; var keepArr = new Action[N]; GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); long b0 = Alloc(), q0 = Mono(); for (int i = 0; i < N; i++) keepArr[i] = new Action(mob.Get_Damage); long b1 = Alloc(), q1 = Mono(); var objArr = new object[N]; GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); long c0 = Alloc(), r0 = Mono(); for (int i = 0; i < N; i++) objArr[i] = new object(); long c1 = Alloc(), r1 = Mono(); bool sensitive = (c1 - c0) > 0L || (r1 - r0) > 0L; bool h = a1 - a0 == 0L && p1 - p0 == 0L && gc1 == gc0 && sensitive && keepArr[N - 1] != null && objArr[N - 1] != null; sb.AppendLine(P(h) + " ③h GC: TakeDamage×1,000,000 = 힙 " + (a1 - a0) + " B / mono " + (p1 - p0) + " B / 수집 " + (gc1 - gc0) + "회 (기대 0/0/0)" + " · 훅 줄 델리게이트 ×100k(보존) = " + (b1 - b0) + " B / mono " + (q1 - q0) + " B (≈" + (Math.Max(b1 - b0, q1 - q0) / N) + " B/피격)" + " · 대조군 new object()×100k(보존) = " + (c1 - c0) + " B / mono " + (r1 - r0) + " B (감도 " + sensitive + ")"); keepArr = null; objArr = null; if (!h) fails++; } finally { Kill(pc, enemyPc, mob, boss, elite); if (gs != null) gs.playerInvincible = flagBefore; } return fails; } // ───────────────────────────────────────── ④ 물약 static int Potion(StringBuilder sb) { var st = UseClone(); if (st == null) { sb.AppendLine("[FAIL] ④ 클론 주입 실패"); return 1; } st.enabled = true; st.potionCountPerRun = 3; st.potionHealRate = 0.4f; st.potionCooldownSeconds = 5f; st.potionShowHealNumber = false; st.potionRefuseAtFullHp = true; PotionUse.ResetDiagnostics(); int changed = 0, used = 0, cdEnded = 0, refused = 0; PotionResult lastRefuse = PotionResult.Used; CombatHandler onChanged = (in PotionEvent e) => { changed++; if (e.change == PotionChange.CooldownEnded) cdEnded++; if (e.change == PotionChange.Refused) { refused++; lastRefuse = e.result; } }; CombatHandler onUsed = (in PotionEvent e) => { used++; }; PotionUse.Changed.Add(onChanged); PotionUse.Used.Add(onUsed); var pc = MakePC("pc", 1000, 300); int fails = 0; try { PotionUse.Tick(0f); bool a0 = PotionUse.Remaining == 3 && PotionUse.Max == 3 && PotionUse.IsReady; var r1 = PotionUse.TryUse(pc); bool a = a0 && r1 == PotionResult.Used && Near(pc.Get_HP(), 700d) && PotionUse.Remaining == 2 && Math.Abs(PotionUse.CooldownRemaining - 5f) < 1e-4f && !PotionUse.IsReady && used == 1 && changed == 1; sb.AppendLine(P(a) + " ④a 사용 1: " + r1 + " hp 300→" + pc.Get_HP() + " (기대 700) remaining=" + PotionUse.Remaining + " cd=" + PotionUse.CooldownRemaining.ToString("F2") + " IsReady=" + PotionUse.IsReady); if (!a) fails++; var r2 = PotionUse.TryUse(pc); PotionUse.Tick(4.99f); int cd1 = cdEnded; PotionUse.Tick(5f); int cd2 = cdEnded; bool b = r2 == PotionResult.Cooldown && lastRefuse == PotionResult.Cooldown && cd1 == 0 && cd2 == 1 && PotionUse.IsReady && Near(pc.Get_HP(), 700d); sb.AppendLine(P(b) + " ④b 쿨 중 거절=" + r2 + " · t=4.99 CooldownEnded=" + cd1 + " · t=5.0 CooldownEnded=" + cd2 + " IsReady=" + PotionUse.IsReady); if (!b) fails++; var r3 = PotionUse.TryUse(pc); // 700 + 400 → 1000(클램프) bool c = r3 == PotionResult.Used && Near(pc.Get_HP(), 1000d) && PotionUse.Remaining == 1; PotionUse.Tick(10f); var r4 = PotionUse.TryUse(pc); // 만충 거절 · 개수 유지 bool c2 = r4 == PotionResult.FullHp && PotionUse.Remaining == 1; sb.AppendLine(P(c && c2) + " ④c 사용 2: " + r3 + " hp=" + pc.Get_HP() + " (기대 1000 클램프) remaining=" + PotionUse.Remaining + " · 만충 거절=" + r4 + " remaining 유지=" + (PotionUse.Remaining == 1)); if (!(c && c2)) fails++; SetHp(pc, 100); var r5 = PotionUse.TryUse(pc); // 3회째 bool d = r5 == PotionResult.Used && Near(pc.Get_HP(), 500d) && PotionUse.Remaining == 0 && !PotionUse.IsReady; PotionUse.Tick(20f); var r6 = PotionUse.TryUse(pc); bool d2 = r6 == PotionResult.Empty && PotionUse.Remaining == 0 && used == 3; sb.AppendLine(P(d && d2) + " ④d 사용 3: " + r5 + " hp=" + pc.Get_HP() + " (기대 500) remaining=" + PotionUse.Remaining + " · 빈 통=" + r6 + " Used 이벤트=" + used + " (기대 3)"); if (!(d && d2)) fails++; int chBefore = changed; PotionUse.ResetRun("probe"); int chReset = changed - chBefore; bool e = PotionUse.Remaining == 3 && PotionUse.IsReady && chReset == 1 && PotionUse.RunResets == 1; SetDead(pc, true); var r7 = PotionUse.TryUse(pc); SetDead(pc, false); var r8 = PotionUse.TryUse(null); bool e2 = r7 == PotionResult.Dead && r8 == PotionResult.NoPC && PotionUse.Remaining == 3; sb.AppendLine(P(e && e2) + " ④e 런 리셋: remaining=" + PotionUse.Remaining + " Changed(RunReset)=" + chReset + " · 사망=" + r7 + " · NoPC=" + r8 + " · Changed 총=" + changed + " refused=" + refused); if (!(e && e2)) fails++; } finally { PotionUse.Changed.Remove(onChanged); PotionUse.Used.Remove(onUsed); Kill(pc); } return fails; } // ───────────────────────────────────────── ⑤ 사망/부활 static int Death(StringBuilder sb) { var st = UseClone(); if (st == null) { sb.AppendLine("[FAIL] ⑤ 클론 주입 실패"); return 1; } st.enabled = true; st.deathDisplaySeconds = 2f; st.holdWorldWhileReviving = true; st.autoReviveWhenNoListener = true; st.autoReviveTimeoutSeconds = 0f; st.reviveInvincibleSeconds = 1.5f; st.reviveHealRate = 1f; st.reviveCompanions = true; DeathFlow.ResetDiagnostics(); Survival.ResetDiagnostics(); int died = 0, requested = 0, revived = 0, revivedExternal = 0, revivedAuto = 0, overrideCalls = 0; bool lastHeld = true; float lastInv = -1f; CombatHandler onDied = (in DeathEvent e) => { died++; }; CombatHandler onReq = (in ReviveRequestEvent e) => { requested++; lastHeld = e.worldHeld; }; CombatHandler onRev = (in RevivedEvent e) => { revived++; if (e.external) revivedExternal++; if (e.auto) revivedAuto++; lastInv = e.invincibleSeconds; }; DeathFlow.Died.Add(onDied); DeathFlow.ReviveRequested.Add(onReq); DeathFlow.Revived.Add(onRev); DeathFlow.ReviveOverride = (pc, rate) => { overrideCalls++; SetDead(pc, false); SetHp(pc, pc.Get_MaxHP() * rate); }; var pc = MakePC("pc", 1000, 0); var enemyPc = MakePC("enemyPc", 1000, 0, true); float ts = Time.timeScale; int fails = 0; try { // a) 정상 사이클: 사망 → 2 s → 요청 → Revive() → 부활 + 무적 1.5 s Survival.Tick(50f); DeathFlow.Tick(100f); SetDead(pc, true); DeathFlow.OnPCDied(pc); bool a1 = DeathFlow.State == DeathState.Dying && died == 1 && DeathFlow.DeathCount == 1 && DeathFlow.Current == pc; DeathFlow.Tick(101f); bool a2 = DeathFlow.State == DeathState.Dying && requested == 0 && Math.Abs(DeathFlow.DisplayRemaining - 1f) < 1e-4f; DeathFlow.Tick(101.99f); bool a3 = DeathFlow.State == DeathState.Dying && requested == 0; DeathFlow.Tick(102f); bool a4 = DeathFlow.State == DeathState.AwaitingRevive && requested == 1 && !lastHeld && !DeathFlow.WorldHeld && IsDead(pc); bool a5 = DeathFlow.Revive(); bool a6 = DeathFlow.State == DeathState.None && revived == 1 && revivedExternal == 0 && revivedAuto == 0 && overrideCalls == 1 && !IsDead(pc) && pc.Get_HP() == 1000d && Math.Abs(lastInv - 1.5f) < 1e-4f; bool a7 = Survival.IsReviveInvincible(pc) && Math.Abs(Survival.ReviveInvincibleRemaining - 1.5f) < 1e-4f; Survival.Tick(51.5f); bool a8 = !Survival.IsReviveInvincible(pc); bool a = a1 && a2 && a3 && a4 && a5 && a6 && a7 && a8; sb.AppendLine(P(a) + " ⑤a 사이클: Dying " + a1 + " · t+1 Dying(remain " + (a2 ? "1.00" : DeathFlow.DisplayRemaining.ToString("F2")) + ") · t+1.99 " + a3 + " · t+2 Awaiting+ReviveRequested(held=" + lastHeld + " · 에디트 모드 기대 false) " + a4 + " · Revive()=" + a5 + " → None/Revived/무적 1.5 " + a6 + " · 창 " + a7 + " → 1.5 s 뒤 해제 " + a8 + " · LastDeathToRequest=" + DeathFlow.LastDeathToRequest.ToString("F2")); if (!a) fails++; // b) 구독자 0 → 자동 부활 DeathFlow.ReviveRequested.Remove(onReq); SetDead(pc, true); DeathFlow.Tick(200f); DeathFlow.OnPCDied(pc); DeathFlow.Tick(202f); bool b = DeathFlow.State == DeathState.None && revived == 2 && revivedAuto == 1 && !IsDead(pc) && DeathFlow.AutoReviveCount == 1; sb.AppendLine(P(b) + " ⑤b 구독자 0 자동 부활: state=" + DeathFlow.State + " revived=" + revived + " auto=" + revivedAuto); if (!b) fails++; DeathFlow.ReviveRequested.Add(onReq); // c) 외부(원본 After_Die)가 먼저 살림 → external SetDead(pc, true); DeathFlow.Tick(300f); DeathFlow.OnPCDied(pc); DeathFlow.Tick(302f); bool c1 = DeathFlow.State == DeathState.AwaitingRevive && requested == 2; SetDead(pc, false); int ov = overrideCalls; DeathFlow.Tick(302.1f); bool c = c1 && DeathFlow.State == DeathState.None && revived == 3 && revivedExternal == 1 && overrideCalls == ov && Survival.IsReviveInvincible(pc); sb.AppendLine(P(c) + " ⑤c 외부 부활 감지: Awaiting " + c1 + " → external=" + revivedExternal + " override 미호출 " + (overrideCalls == ov) + " 무적 " + Survival.IsReviveInvincible(pc)); if (!c) fails++; // d) 연출 중 조기 응답 → 연출 끝에 부활 SetDead(pc, true); DeathFlow.Tick(400f); DeathFlow.OnPCDied(pc); DeathFlow.Tick(400.5f); bool d1 = DeathFlow.Revive() && DeathFlow.State == DeathState.Dying && IsDead(pc); DeathFlow.Tick(402f); bool d = d1 && DeathFlow.State == DeathState.None && revived == 4 && !IsDead(pc) && requested == 3; sb.AppendLine(P(d) + " ⑤d 조기 응답: Dying 유지 " + d1 + " → 2 s 뒤 부활 " + (DeathFlow.State == DeathState.None) + " revived=" + revived); if (!d) fails++; // e) 타임아웃 자동 부활 st.autoReviveTimeoutSeconds = 3f; SetDead(pc, true); DeathFlow.Tick(500f); DeathFlow.OnPCDied(pc); DeathFlow.Tick(502f); bool e1 = DeathFlow.State == DeathState.AwaitingRevive; DeathFlow.Tick(504.9f); bool e2 = DeathFlow.State == DeathState.AwaitingRevive; DeathFlow.Tick(505f); bool e3 = DeathFlow.State == DeathState.None && revived == 5 && revivedAuto == 2; st.autoReviveTimeoutSeconds = 0f; bool e = e1 && e2 && e3; sb.AppendLine(P(e) + " ⑤e 타임아웃 3 s: +2.9 대기 " + e2 + " · +3.0 자동 부활 " + e3 + " · LastRequestToRevive=" + DeathFlow.LastRequestToRevive.ToString("F2")); if (!e) fails++; // f) Abort · 중복 사망 · 적 PC SetDead(pc, true); DeathFlow.Tick(600f); DeathFlow.OnPCDied(pc); int dc = DeathFlow.DeathCount; DeathFlow.OnPCDied(pc); bool f1 = DeathFlow.DeathCount == dc; DeathFlow.Abort("probe"); bool f2 = DeathFlow.State == DeathState.None && DeathFlow.AbortCount == 1 && revived == 5 && IsDead(pc); SetDead(pc, false); SetDead(enemyPc, true); DeathFlow.OnPCDied(enemyPc); bool f3 = DeathFlow.State == DeathState.None && DeathFlow.DeathCount == dc; SetDead(enemyPc, false); bool f4 = Time.timeScale == ts; bool f = f1 && f2 && f3 && f4; sb.AppendLine(P(f) + " ⑤f 중복 사망 무시 " + f1 + " · Abort " + f2 + " · 적 PC 무시 " + f3 + " · timeScale 무변경 " + f4 + " (" + Time.timeScale + ")"); if (!f) fails++; // g) Revive() with nothing pending → false bool g = !DeathFlow.Revive(); sb.AppendLine(P(g) + " ⑤g 대기 없는 Revive()=false " + g); if (!g) fails++; } finally { DeathFlow.Died.Remove(onDied); DeathFlow.ReviveRequested.Remove(onReq); DeathFlow.Revived.Remove(onRev); DeathFlow.ReviveOverride = null; DeathFlow.ResetDiagnostics(); Survival.ResetDiagnostics(); Kill(pc, enemyPc); } return fails; } // ───────────────────────────────────────── ⑥ 이벤트 GC static int EventGc(StringBuilder sb) { var st = UseClone(); if (st == null) { sb.AppendLine("[FAIL] ⑥ 클론 주입 실패"); return 1; } st.enabled = true; st.verboseLog = false; int n = 0; CombatHandler h = (in PotionEvent e) => { n++; }; PotionUse.Changed.Add(h); try { for (int i = 0; i < 10; i++) PotionUse.ResetRun(""); GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); int g0 = GC.CollectionCount(0); long a0 = Alloc(), p0 = Mono(); for (int i = 0; i < 1000000; i++) PotionUse.ResetRun(""); long a1 = Alloc(), p1 = Mono(); int g1 = GC.CollectionCount(0); bool ok = a1 - a0 == 0L && p1 - p0 == 0L && g1 == g0 && n == 1000010; sb.AppendLine(P(ok) + " ⑥ PotionUse.Changed 디스패치×1,000,000 = 힙 " + (a1 - a0) + " B / mono " + (p1 - p0) + " B / 수집 " + (g1 - g0) + "회 (기대 0) handler=" + n); return ok ? 0 : 1; } finally { PotionUse.Changed.Remove(h); PotionUse.ResetDiagnostics(); } } // ───────────────────────────────────────── ⑦ 정리 static int Cleanup(StringBuilder sb, float ts0) { RestoreAsset(); if (s_clone != null) { UnityEngine.Object.DestroyImmediate(s_clone); s_clone = null; } Survival.ResetDiagnostics(); PotionUse.ResetDiagnostics(); DeathFlow.ResetDiagnostics(); var st = WLSurvivalSettings.Instance; bool assetBack = st != null && !st.enabled && !WLSurvivalSettings.Enabled; bool flag = WL.Settings.WLGameplaySettings.PlayerInvincible; int leftovers = 0; foreach (var go in UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None)) if (go != null && go.name.StartsWith("__WL813i_")) { leftovers++; UnityEngine.Object.DestroyImmediate(go); } bool dirty = false; string scene = "?"; #if UNITY_EDITOR var sc = UnityEditor.SceneManagement.EditorSceneManager.GetActiveScene(); dirty = sc.isDirty; scene = sc.name; #endif bool ok = assetBack && flag && leftovers == 0 && Time.timeScale == ts0 && !SurvivalRunner.Exists && MyValue.MyPC == null; sb.AppendLine(P(ok) + " ⑦ 정리: 디스크 에셋 enabled=false 복귀 " + assetBack + " · playerInvincible=" + flag + " · 잔존 오브젝트 " + leftovers + " · timeScale " + Time.timeScale + "(시작 " + ts0 + ") · 러너 생성 0 " + !SurvivalRunner.Exists + " · MyPC=null " + (MyValue.MyPC == null) + " · 씬 '" + scene + "' dirty=" + dirty); return ok ? 0 : 1; } }