Project_WL/AgentScripts/WL813i_Probe.cs

492 lines
33 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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;
}
}