Project_WL/AgentScripts/WL813i_Probe.cs

492 lines
33 KiB
C#
Raw Normal View History

// WL813i_Probe.cs — #813i(무적 해제 게이트 · 물약 · 사망/부활) 에디트 모드 검증 프로브
// 에디트 모드 전용 · Play 불필요 · 로그인 0 · 에셋/씬/TimeManager 무수정(런타임 클론 SO 를 리플렉션으로 주입 · 임시 오브젝트 즉시 파괴)
//
// 상주 에디터: unity command run_script --project-path <wt> --file AgentScripts/WL813i_Probe.cs --entry WL813i_Probe.RunAll
// 결과: Console + AgentScripts/WL813i_PROBE.txt(커밋) + Screenshots_WL/WL813i/probe_<HHmmss>.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]"; }
/// <summary>원본 Heal(float rate) 은 0.4f 를 double 로 넓혀 곱하므로(400.000006) 1e-3 허용.</summary>
static bool Near(double a, double b) { return Math.Abs(a - b) < 1e-3; }
/// <summary>Unity Mono 의 GC.GetAllocatedBytesForCurrentThread 는 상수를 돌려준다(실측 · 대조군 0 B) → GetTotalMemory(4 KB 페이지 단위) + 대조군으로 감도를 보인다(813k 선례).</summary>
static long Alloc() { return GC.GetTotalMemory(false); }
static long Mono() { return UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong(); }
/// <summary>디스크 에셋을 건드리지 않는 런타임 클론을 WLSurvivalSettings 캐시에 주입한다(값 변경 실험용).</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;
}
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<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, enemy);
return pc;
}
static Actor MakeBeater(string name, eSubRol sub)
{
var go = new GameObject("__WL813i_" + name);
var a = go.AddComponent<Actor>();
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<DamageInfo> 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<DamageInfo>[N];
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
long b0 = Alloc(), q0 = Mono();
for (int i = 0; i < N; i++) keepArr[i] = new Action<DamageInfo>(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<PotionEvent> onChanged = (in PotionEvent e) => { changed++; if (e.change == PotionChange.CooldownEnded) cdEnded++; if (e.change == PotionChange.Refused) { refused++; lastRefuse = e.result; } };
CombatHandler<PotionEvent> 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<DeathEvent> onDied = (in DeathEvent e) => { died++; };
CombatHandler<ReviveRequestEvent> onReq = (in ReviveRequestEvent e) => { requested++; lastHeld = e.worldHeld; };
CombatHandler<RevivedEvent> 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<PotionEvent> 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<GameObject>(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;
}
}