Project_WL/AgentScripts/WL813q2_Probe.cs

701 lines
38 KiB
C#
Raw Permalink Normal View History

// WL-813q2 프로브 — 결과 화면 핫픽스 실측(에디트 모드 · Play 0 · #813).
// run_script --file AgentScripts/WL813q2_Probe.cs --entry WL813q2_Probe.<Entry>
// Overlap · Watchdog · Reset · Rows · Gc · All
// 로그는 <worktree>/Logs/WL813q2_*.txt (Assets 밖 · 미추적).
//
// 🔴 에디트 모드에서 `Time.timeScale` 에 쓰지 않는다(ProjectSettings/TimeManager.asset 오염 · 811b-fix).
// DeathFlow.HoldWorld 도 `Application.isPlaying` 가드가 있어 에디트 모드에선 대입하지 않는다.
// 🔴 씬·프리팹을 저장하지 않는다. 만든 노드는 finally 에서 전부 DestroyImmediate.
// 🔴 SO 값은 메모리에서만 바꾸고 finally 에서 원복한다(AssetDatabase.SaveAssets 호출 0).
// 🔴 Gameplay 는 읽기·구독·공개 API 호출만 한다(파일 수정 0). `RunDirector.s_phase` 는 리플렉션 **읽기/쓰기**로
// "런 중/런 밖"만 흉내 낸다(런 시작·종료 부작용을 피하려고 StartRun/EndRun 을 쓰지 않는다).
using System;
using System.IO;
using System.Reflection;
using System.Text;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using UnityEditor;
using WL.Combat.Core;
using WL.Combat.Run;
using WL.Combat.Survival;
using WL.UI;
public static class WL813q2_Probe
{
const BindingFlags NP = BindingFlags.Instance | BindingFlags.NonPublic;
const BindingFlags SP = BindingFlags.Static | BindingFlags.NonPublic;
static int s_pass, s_fail;
// ─────────────────────────────────────────────────────────── 공통
static string LogDir
{
get
{
string d = Path.Combine(Directory.GetParent(Application.dataPath).FullName, "Logs");
if (!Directory.Exists(d)) Directory.CreateDirectory(d);
return d;
}
}
static string Write(string name, string body)
{
string p = Path.Combine(LogDir, name);
File.WriteAllText(p, body, new UTF8Encoding(false));
return p;
}
static string Chk(StringBuilder sb, bool ok, string what)
{
if (ok) s_pass++; else s_fail++;
sb.AppendLine((ok ? " PASS " : " **FAIL** ") + what);
return what;
}
/// <summary>세로 1080×1920 기준 합성 캔버스 + `IngameUIs/WL_HUD` 대역(SO 경로와 같게).</summary>
static Transform BuildUiRoot(out GameObject root)
{
root = new GameObject("__WL813q2_Canvas", typeof(RectTransform));
root.hideFlags = HideFlags.DontSave;
var canvas = root.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
var scaler = root.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920f, 1080f);
scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.Expand;
root.AddComponent<GraphicRaycaster>();
var ing = new GameObject("IngameUIs", typeof(RectTransform)).GetComponent<RectTransform>();
ing.gameObject.hideFlags = HideFlags.DontSave;
ing.SetParent(root.transform, false);
var hud = new GameObject("WL_HUD", typeof(RectTransform)).GetComponent<RectTransform>();
hud.gameObject.hideFlags = HideFlags.DontSave;
hud.SetParent(ing, false);
hud.anchorMin = hud.anchorMax = new Vector2(0.5f, 0.5f);
hud.pivot = new Vector2(0.5f, 0.5f);
hud.sizeDelta = new Vector2(1080f, 1920f);
// 결과 패널이 형제 맨 뒤/맨 앞으로 움직이는 것을 볼 수 있게 미끼 형제 2개
for (int i = 0; i < 2; i++)
{
var d = new GameObject("Decoy" + i, typeof(RectTransform)).GetComponent<RectTransform>();
d.gameObject.hideFlags = HideFlags.DontSave;
d.SetParent(hud, false);
}
return root.transform;
}
/// <summary>813z 프로브와 같은 가짜 메인 PC(사망 상태).</summary>
static PCActor MakePC()
{
var go = new GameObject("__WL813q2_PC");
go.hideFlags = HideFlags.DontSave;
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, 1000d); stat.Set_Stat(eStat.HP, 0d);
typeof(Actor).GetField("m_Stat", NP).SetValue(pc, stat);
typeof(Actor).GetField("m_Enemy", NP).SetValue(pc, false);
typeof(Actor).GetField("DeadStatus", NP).SetValue(pc, true);
return pc;
}
static void SetPhase(RunPhase p)
{
typeof(RunDirector).GetField("s_phase", SP).SetValue(null, p);
}
static RunPhase GetPhase()
{
return (RunPhase)typeof(RunDirector).GetField("s_phase", SP).GetValue(null);
}
static void OnReq(in ReviveRequestEvent e) { s_reqSeen++; }
static int s_reqSeen;
static void Kill(GameObject go) { if (go != null) UnityEngine.Object.DestroyImmediate(go); }
static RunResult FakeResult(RunOutcome outcome, float total, float bossClear, int kills, int bossKills)
{
return new RunResult
{
outcome = outcome, runIndex = 1, totalSec = total, runSeconds = 240f,
zoneClearSec = new float[] { 40f, -1f }, zoneKills = new int[] { 24, 11 },
zoneCount = 2, zonesCleared = 1,
bossGateSec = -1f, bossStartSec = -1f, bossClearSec = bossClear,
kills = kills, bossKills = bossKills, maxChain = 7,
maxDamage = 1234.4, maxDamageBurst = 8888.5,
levelFrom = 11, levelTo = 12,
lootByGrade = new int[] { 0, 5, 2, 0, 0, 0, 0, 0, 0, 0 }, lootTotal = 7, gold = 777,
deaths = 1, potionsUsed = 2, skillCasts = 9,
time = Time.unscaledTime, frame = Time.frameCount,
};
}
// ────────────────────────────────────────── ① D-18 결과 위 부활 겹침
public static string Overlap()
{
s_pass = s_fail = 0;
var sb = new StringBuilder();
sb.AppendLine("=== WL-813q2 ① D-18 결과 화면 위 부활 팝업 겹침 (에디트 모드 · Play 0) ===");
var ui = WLRunUiSettings.Instance;
var sv = WLSurvivalSettings.Instance;
if (ui == null || sv == null) { string b = sb.AppendLine("**FAIL** 설정 에셋 없음").ToString(); return b + "\n→ " + Write("WL813q2_overlap.txt", b); }
GameObject root = null; PCActor pc = null;
var phase0 = GetPhase();
int ov0 = WLSurvivalSettings.RuntimeOverride;
var ovr0 = DeathFlow.ReviveOverride;
bool skip0 = ui.skipRevivePopupAfterRunEnd, dying0 = ui.resultDeferWhileDying, lower0 = ui.resultLowerWhileRevive;
sb.AppendLine("근거: Q5 캡처 PD2_07 — 제목 \"시간 종료\"(= 결과가 **먼저** 떴다) 위에 부활 팝업이 겹쳐 4행이 가려졌다.");
sb.AppendLine("실측 전제: 813z 부활 팝업 sortingOrder = " + sv.reviveSortingOrder +
" · 결과 화면 SO sortingOrder = " + ui.resultSortingOrder + "(부활 중 " + ui.resultSortingOrderWhileRevive + ")");
sb.AppendLine();
try
{
WLSurvivalSettings.RuntimeOverride = 1;
DeathFlow.ReviveOverride = (p, r) => { };
DeathFlow.ReviveRequested.Add(OnReq);
s_reqSeen = 0;
var uiRoot = BuildUiRoot(out root);
sb.AppendLine("Bind: " + RunResultUI.Bind(uiRoot));
RunResultUI.Subscribe(true);
RunResultUI.ResetState();
DeathFlow.ResetDiagnostics();
pc = MakePC();
Chk(sb, RunResultUI.Bound, "결과 화면 노드 조립됨");
Chk(sb, RunResultUI.SortingOrder == ui.resultSortingOrder,
"전용 Canvas sortingOrder = " + RunResultUI.SortingOrder + " (SO " + ui.resultSortingOrder +
") < 부활 " + sv.reviveSortingOrder);
// ── A. 캡처와 같은 순서: RunEnded(결과 표시) → 그 뒤 사망 ────────
sb.AppendLine();
sb.AppendLine("A. 캡처 순서 재현 — RunEnded → 결과 표시 → **그 뒤** 사망(런 밖)");
SetPhase(RunPhase.Zones);
var r = FakeResult(RunOutcome.Timeout, 241f, -1f, 29, 0);
SetPhase(RunPhase.Ended); // EndRun 과 같은 상태(IsRunning=false)
WLRunUiProbeHooks.RaiseRunEnded(in r);
Chk(sb, RunResultUI.IsOpen, "RunEnded 로 결과 화면이 떴다(open=" + RunResultUI.IsOpen + " 제목 \"" + RunResultUI.TitleText + "\")");
Chk(sb, !RunDirector.IsRunning, "런 밖 상태(IsRunning=" + RunDirector.IsRunning + ")");
float t = 1000f;
DeathFlow.Tick(t);
DeathFlow.OnPCDied(pc); // ← 결과가 떠 있는 상태에서 사망
sb.AppendLine(" 사망 직후: DeathFlow=" + DeathFlow.State + " · 즉시부활 호출 " + RunResultUI.SkipPopupReviveCount +
"회 · " + RunResultUI.LastDeathLog);
Chk(sb, RunResultUI.SkipPopupReviveCount >= 1, "런 밖 사망 → 팝업 생략 즉시 부활을 불렀다(skipRevivePopupAfterRunEnd=1)");
int reqBefore = s_reqSeen;
DeathFlow.Tick(t + sv.deathDisplaySeconds + 0.01f); // 연출 끝 → acceptEarly 로 바로 부활
sb.AppendLine(" 연출 끝: DeathFlow=" + DeathFlow.State + " · ReviveRequested " + (s_reqSeen - reqBefore) +
"회(팝업이 1프레임 안에 닫힌다) · Revived=" + RunResultUI.RevivedSeen);
Chk(sb, DeathFlow.State == DeathState.None, "부활 완료 — DeathFlow.State=None(부활 대기 0 = 팝업이 남지 않는다)");
Chk(sb, DeathFlow.ReviveCount >= 1, "DeathFlow.ReviveCount=" + DeathFlow.ReviveCount);
Chk(sb, RunResultUI.IsOpen && !RunResultUI.Lowered,
"결과 화면은 그대로 · 재정렬 없음(open=" + RunResultUI.IsOpen + " lowered=" + RunResultUI.Lowered + ")");
// ── B. 스위치를 끄면 813q 동작(팝업이 뜬다) → 재정렬로 막는다 ──
sb.AppendLine();
sb.AppendLine("B. skipRevivePopupAfterRunEnd=0 (C8 롤백) — 팝업이 뜨는 경우 결과를 부활 **뒤로 재정렬**");
ui.skipRevivePopupAfterRunEnd = false;
DeathFlow.ResetDiagnostics();
RunResultUI.ResetState();
SetPhase(RunPhase.Ended);
WLRunUiProbeHooks.RaiseRunEnded(in r);
Chk(sb, RunResultUI.IsOpen, "결과 화면 재표시(open=" + RunResultUI.IsOpen + ")");
int sib0 = RunResultUI.SiblingIndex;
t = 2000f; DeathFlow.Tick(t); DeathFlow.OnPCDied(pc);
sb.AppendLine(" 사망 후: " + RunResultUI.LastDeathLog);
Chk(sb, RunResultUI.SkipPopupReviveCount == 0, "즉시 부활 호출 0(스위치 off = 813q 동작)");
Chk(sb, RunResultUI.Lowered && RunResultUI.SortingOrder == ui.resultSortingOrderWhileRevive,
"결과를 부활 뒤로 재정렬 sortingOrder " + ui.resultSortingOrder + " → " + RunResultUI.SortingOrder +
" (부활 " + sv.reviveSortingOrder + " 아래)");
Chk(sb, RunResultUI.SiblingIndex == 0 && sib0 != 0,
"형제 순서 " + sib0 + " → " + RunResultUI.SiblingIndex + " (SetAsFirstSibling)");
DeathFlow.Tick(t + sv.deathDisplaySeconds + 0.01f); // AwaitingRevive
Chk(sb, DeathFlow.State == DeathState.AwaitingRevive, "부활 대기 상태(DeathFlow=" + DeathFlow.State + ")");
Chk(sb, RunResultUI.ReviveBusy, "「부활 우선」 판정 ON — 이유 = " + RunResultUI.ReviveBusyReason);
DeathFlow.Revive();
Chk(sb, !RunResultUI.Lowered && RunResultUI.SortingOrder == ui.resultSortingOrder,
"부활 완료 → 결과 화면 복귀 sortingOrder=" + RunResultUI.SortingOrder + " 형제=" + RunResultUI.SiblingIndex);
// ── C. 813q 원래 판정(ReviveDialog.Busy)만으로는 못 잡는다는 증명 ──
sb.AppendLine();
sb.AppendLine("C. 왜 813q 판정이 못 잡았나 — RunEnded 시점에는 아무도 죽지 않아 Busy=false 였다");
DeathFlow.ResetDiagnostics();
RunResultUI.ResetState();
SetPhase(RunPhase.Ended);
Chk(sb, !ReviveDialog.Busy && DeathFlow.State == DeathState.None,
"RunEnded 직전 실측: ReviveDialog.Busy=" + ReviveDialog.Busy + " · DeathFlow=" + DeathFlow.State +
" → 813q 의 미룸 조건은 어느 쪽으로도 걸리지 않는다");
ui.skipRevivePopupAfterRunEnd = true;
// ── D. 반대 순서(사망 먼저 → RunEnded)는 813q2 에서도 미뤄진다 ──
sb.AppendLine();
sb.AppendLine("D. 반대 순서 — 사망(런 중) → RunEnded → 부활 뒤에 결과가 뜬다");
RunResultUI.ResetState();
DeathFlow.ResetDiagnostics();
SetPhase(RunPhase.Zones);
t = 3000f; DeathFlow.Tick(t); DeathFlow.OnPCDied(pc);
Chk(sb, DeathFlow.State == DeathState.Dying, "사망 연출 중(DeathFlow=" + DeathFlow.State + ")");
Chk(sb, RunResultUI.ReviveBusy, "「부활 우선」 ON — " + RunResultUI.ReviveBusyReason);
SetPhase(RunPhase.Ended);
WLRunUiProbeHooks.RaiseRunEnded(in r);
Chk(sb, !RunResultUI.IsOpen && RunResultUI.Pending,
"결과 화면 미룸(open=" + RunResultUI.IsOpen + " pending=" + RunResultUI.Pending +
" defer=" + RunResultUI.DeferredCount + ")");
DeathFlow.Tick(t + sv.deathDisplaySeconds + 0.01f);
DeathFlow.Revive();
RunResultUI.Tick(Time.unscaledTime);
Chk(sb, RunResultUI.IsOpen && !RunResultUI.Pending,
"부활 뒤 결과 화면 표시(open=" + RunResultUI.IsOpen + " shows=" + RunResultUI.ShownCount + ")");
sb.AppendLine();
sb.AppendLine(RunResultUI.Dump());
}
catch (Exception ex) { s_fail++; sb.AppendLine("**FAIL** 예외 " + ex.GetType().Name + " — " + ex.Message + "\n" + ex.StackTrace); }
finally
{
ui.skipRevivePopupAfterRunEnd = skip0; ui.resultDeferWhileDying = dying0; ui.resultLowerWhileRevive = lower0;
DeathFlow.ReviveRequested.Remove(OnReq);
DeathFlow.ReviveOverride = ovr0;
DeathFlow.Abort("probe cleanup");
WLSurvivalSettings.RuntimeOverride = ov0;
RunResultUI.Teardown();
Kill(pc != null ? pc.gameObject : null);
Kill(root);
SetPhase(phase0);
}
sb.AppendLine();
sb.AppendLine("① 결과 = " + s_pass + " PASS / " + s_fail + " FAIL");
string body = sb.ToString();
return body + "\n→ " + Write("WL813q2_overlap.txt", body);
}
// ────────────────────────────────────── ② 워치독 자동 부활 경로(813z)
public static string Watchdog()
{
s_pass = s_fail = 0;
var sb = new StringBuilder();
sb.AppendLine("=== WL-813q2 ② 워치독 자동 부활 경로에서도 「부활 우선」이 걸리는가 (813z §1) ===");
var ui = WLRunUiSettings.Instance;
var sv = WLSurvivalSettings.Instance;
if (ui == null || sv == null) { string b = sb.AppendLine("**FAIL** 설정 에셋 없음").ToString(); return b + "\n→ " + Write("WL813q2_watchdog.txt", b); }
GameObject root = null; PCActor pc = null;
var phase0 = GetPhase();
int ov0 = WLSurvivalSettings.RuntimeOverride;
var ovr0 = DeathFlow.ReviveOverride;
bool dying0 = ui.resultDeferWhileDying;
try
{
WLSurvivalSettings.RuntimeOverride = 1;
DeathFlow.ReviveOverride = (p, r) => { };
DeathFlow.ReviveRequested.Add(OnReq);
s_reqSeen = 0;
var uiRoot = BuildUiRoot(out root);
RunResultUI.Bind(uiRoot);
RunResultUI.Subscribe(true);
RunResultUI.ResetState();
DeathFlow.ResetDiagnostics();
pc = MakePC();
sb.AppendLine("워치독 SO 실측: reviveWatchdogSeconds=" + sv.reviveWatchdogSeconds.ToString("F1") + "s");
SetPhase(RunPhase.Zones);
float t = 5000f;
DeathFlow.Tick(t); DeathFlow.OnPCDied(pc);
float req = t + sv.deathDisplaySeconds + 0.01f;
DeathFlow.Tick(req);
Chk(sb, DeathFlow.State == DeathState.AwaitingRevive, "부활 대기(DeathFlow=" + DeathFlow.State + ")");
// 런 종료가 부활 대기 중에 온다 → 결과는 미뤄진다(813q 는 Busy 로만 봤다)
SetPhase(RunPhase.Ended);
var r = FakeResult(RunOutcome.Timeout, 240f, -1f, 31, 0);
WLRunUiProbeHooks.RaiseRunEnded(in r);
Chk(sb, !RunResultUI.IsOpen && RunResultUI.Pending,
"결과 미룸 — 이유 = " + RunResultUI.ReviveBusyReason + " (ReviveDialog.Busy=" + ReviveDialog.Busy +
" 이라 813q 판정만으로는 못 막는다)");
// 워치독 시각 직전/직후
DeathFlow.Tick(req + sv.reviveWatchdogSeconds - 0.1f);
RunResultUI.Tick(Time.unscaledTime);
Chk(sb, !RunResultUI.IsOpen, "워치독 직전까지 계속 미룸(defer=" + RunResultUI.DeferredCount + ")");
DeathFlow.Tick(req + sv.reviveWatchdogSeconds + 0.02f);
sb.AppendLine(" 워치독: WatchdogCount=" + DeathFlow.WatchdogCount + " 대기 " + DeathFlow.LastWatchdogSeconds.ToString("F2") +
"s · auto 부활=" + DeathFlow.AutoReviveCount + " · RunResultUI.Revived=" + RunResultUI.RevivedSeen);
Chk(sb, DeathFlow.WatchdogCount == 1 && DeathFlow.State == DeathState.None, "워치독 자동 부활 1회 · 상태 None");
Chk(sb, RunResultUI.RevivedSeen >= 1, "RunResultUI 가 Revived 를 받았다(워치독 경로 포함)");
RunResultUI.Tick(Time.unscaledTime);
Chk(sb, RunResultUI.IsOpen && !RunResultUI.Pending, "워치독 부활 뒤 결과 화면 표시(shows=" + RunResultUI.ShownCount + ")");
// C8 — resultDeferWhileDying=0 이면 813q 동작(미룸 없음)
sb.AppendLine();
sb.AppendLine("C8 롤백 — resultDeferWhileDying=0 (813q 동작 복귀)");
ui.resultDeferWhileDying = false;
RunResultUI.ResetState(); DeathFlow.ResetDiagnostics();
SetPhase(RunPhase.Zones);
t = 6000f; DeathFlow.Tick(t); DeathFlow.OnPCDied(pc);
DeathFlow.Tick(t + sv.deathDisplaySeconds + 0.01f);
Chk(sb, DeathFlow.State == DeathState.AwaitingRevive && !RunResultUI.ReviveBusy,
"DeathFlow=" + DeathFlow.State + " 인데 부활우선=" + RunResultUI.ReviveBusy + " (스위치 off = 813q 판정만)");
DeathFlow.Revive();
}
catch (Exception ex) { s_fail++; sb.AppendLine("**FAIL** 예외 " + ex.GetType().Name + " — " + ex.Message + "\n" + ex.StackTrace); }
finally
{
ui.resultDeferWhileDying = dying0;
DeathFlow.ReviveRequested.Remove(OnReq);
DeathFlow.ReviveOverride = ovr0;
DeathFlow.Abort("probe cleanup");
WLSurvivalSettings.RuntimeOverride = ov0;
RunResultUI.Teardown();
Kill(pc != null ? pc.gameObject : null);
Kill(root);
SetPhase(phase0);
}
sb.AppendLine();
sb.AppendLine("② 결과 = " + s_pass + " PASS / " + s_fail + " FAIL");
string body2 = sb.ToString();
return body2 + "\n→ " + Write("WL813q2_watchdog.txt", body2);
}
// ─────────────────────────────── ③ D-23 「다음 런」 뒤 상태 리셋
public static string Reset()
{
s_pass = s_fail = 0;
var sb = new StringBuilder();
sb.AppendLine("=== WL-813q2 ③ D-23 「다음 런」 뒤 타이머 색 · 보스 바 · 배너 리셋 ===");
var ui = WLRunUiSettings.Instance;
if (ui == null) { string b = sb.AppendLine("**FAIL** WLRunUiSettings 없음").ToString(); return b + "\n→ " + Write("WL813q2_reset.txt", b); }
GameObject root = null, barGo = null, bannerGo = null;
bool reset0 = ui.timerResetColorOnRunStart;
var phase0 = GetPhase();
try
{
var uiRoot = BuildUiRoot(out root);
sb.AppendLine("RunHud Bind: " + RunHud.Bind(uiRoot));
RunHud.Subscribe(true);
RunHud.ResetState();
string warnHex = ColorUtility.ToHtmlStringRGB(ui.timerWarnColor);
string normHex = ColorUtility.ToHtmlStringRGB(ui.timerColor);
sb.AppendLine("SO 색 실측: 평상 #" + normHex + " · 경고 #" + warnHex + " (경고 구간 " + ui.timerWarnSeconds + "s)");
// 런 1 — 시작 → 마지막 30 s 경고색
WLRunUiProbeHooks.RaiseRunStarted(1, 240f, 2, "probe");
Chk(sb, ColorUtility.ToHtmlStringRGB(RunHud.TimerColor) == normHex,
"런 1 시작 타이머 색 #" + ColorUtility.ToHtmlStringRGB(RunHud.TimerColor) + " = 평상색 · 표시 \"" + RunHud.TimerText + "\"");
WLRunUiProbeHooks.RaiseRunTick(215f, 25f, 1, 3, 14, 1, 2, RunPhase.Zones, 1);
Chk(sb, ColorUtility.ToHtmlStringRGB(RunHud.TimerColor) == warnHex && RunHud.TimerWarn,
"남은 25 s → 경고색 #" + ColorUtility.ToHtmlStringRGB(RunHud.TimerColor) + " · 표시 \"" + RunHud.TimerText + "\"");
var r = FakeResult(RunOutcome.Timeout, 240f, -1f, 29, 0);
WLRunUiProbeHooks.RaiseRunEnded(in r);
// 런 2 (= 「다음 런」) — 색이 평상색으로 돌아와야 한다
WLRunUiProbeHooks.RaiseRunStarted(2, 240f, 2, "restart");
Chk(sb, ColorUtility.ToHtmlStringRGB(RunHud.TimerColor) == normHex,
"🔴 D-23 — 다음 런 시작 타이머 색 #" + ColorUtility.ToHtmlStringRGB(RunHud.TimerColor) +
" = 평상색 · 표시 \"" + RunHud.TimerText + "\" (색 쓰기 " + RunHud.TimerColorWrites + "회)");
bool zonesZero = true;
for (int i = 0; i < RunHud.ZoneCells; i++) if (RunHud.ZoneFill(i) > 0.0005f) zonesZero = false;
Chk(sb, zonesZero && RunHud.ZoneCells == 2, "존 게이지 리셋 — 칸 " + RunHud.ZoneCells + "개 전부 fill 0");
// C8 — 스위치를 끄면 813q 버그가 그대로 재현된다(= 이 스위치가 원인 지점이다)
sb.AppendLine();
sb.AppendLine("C8 롤백 — timerResetColorOnRunStart=0 (813q 동작 = D-23 재현)");
ui.timerResetColorOnRunStart = false;
WLRunUiProbeHooks.RaiseRunTick(215f, 25f, 1, 3, 14, 1, 2, RunPhase.Zones, 2);
WLRunUiProbeHooks.RaiseRunStarted(3, 240f, 2, "restart");
Chk(sb, ColorUtility.ToHtmlStringRGB(RunHud.TimerColor) == warnHex,
"스위치 off → 이전 런의 경고색 #" + ColorUtility.ToHtmlStringRGB(RunHud.TimerColor) +
" 이 그대로 남는다 = D-23 원인 지점 확정");
ui.timerResetColorOnRunStart = true;
WLRunUiProbeHooks.RaiseRunStarted(4, 240f, 2, "restart");
Chk(sb, ColorUtility.ToHtmlStringRGB(RunHud.TimerColor) == normHex, "스위치 on 복귀 → 평상색");
// ── 보스 HP 바
sb.AppendLine();
sb.AppendLine("보스 HP 바 — 이전 런의 바가 남는가");
barGo = new GameObject("__WL813q2_BossBar", typeof(RectTransform));
barGo.hideFlags = HideFlags.DontSave;
barGo.transform.SetParent(root.transform, false);
var bar = barGo.AddComponent<BossHpBar>();
sb.AppendLine(" " + bar.Initialize());
sb.AppendLine(" " + bar.Bind(null, "테스트 보스", 200f)); // 합성(actor=null) 경로
Chk(sb, bar.Visible && bar.LastRatio > 0.99f, "바 표시 · ratio=" + bar.LastRatio.ToString("F3"));
bar.SetRatio(0.37f);
Chk(sb, Mathf.Abs(BossHpBar.RunBossHpRatio - 0.37f) < 0.001f,
"런 단위 보스 HP 실측값 = " + (BossHpBar.RunBossHpRatio * 100f).ToString("F1") + "% (결과 화면이 읽는 값)");
int reset1 = BossHpBar.RunResetCount;
WLRunUiProbeHooks.RaiseRunStarted(5, 240f, 2, "restart");
sb.AppendLine(" RunStarted 뒤: 리셋 " + (BossHpBar.RunResetCount - reset1) + "회 · visible=" + bar.Visible +
" · 런 HP=" + BossHpBar.RunBossHpRatio.ToString("F2") + " · 늦은구독 스캔=" + bar.LateScanCount);
Chk(sb, BossHpBar.RunResetCount == reset1 + 1, "RunStarted 를 받아 리셋했다");
Chk(sb, !bar.Visible, "🔴 D-23 — 이전 런의 보스 바가 사라졌다(visible=" + bar.Visible + ")");
Chk(sb, BossHpBar.RunBossHpRatio < 0f, "런 단위 보스 HP 값도 비워졌다(" + BossHpBar.RunBossHpRatio.ToString("F2") + ")");
ui.bossResetOnRunStart = false;
bar.Bind(null, "테스트 보스", 200f);
WLRunUiProbeHooks.RaiseRunStarted(6, 240f, 2, "restart");
Chk(sb, bar.Visible, "C8 롤백 bossResetOnRunStart=0 → 813q 동작(바가 남는다 · visible=" + bar.Visible + ")");
ui.bossResetOnRunStart = true;
// ── 보스 배너(재트리거 가드)
sb.AppendLine();
sb.AppendLine("보스 등장 배너 — 재트리거 가드가 다음 런에서 풀리는가");
bannerGo = new GameObject("__WL813q2_BossBanner", typeof(RectTransform));
bannerGo.hideFlags = HideFlags.DontSave;
bannerGo.transform.SetParent(root.transform, false);
var banner = bannerGo.AddComponent<BossBanner>();
sb.AppendLine(" " + banner.Initialize());
sb.AppendLine(" " + banner.Trigger("테스트 보스", true));
int show1 = banner.ShowCount;
sb.AppendLine(" 같은 런에서 재트리거: " + banner.Trigger("테스트 보스", true));
Chk(sb, banner.ShowCount == show1, "같은 런에서는 가드가 막는다(shows=" + banner.ShowCount + ")");
WLRunUiProbeHooks.RaiseRunStarted(7, 240f, 2, "restart");
Chk(sb, !banner.Visible, "다음 런 시작 → 배너 숨김(alpha=" + banner.BannerAlpha.ToString("F2") + ")");
sb.AppendLine(" 다음 런에서 재트리거: " + banner.Trigger("테스트 보스", true));
Chk(sb, banner.ShowCount == show1 + 1, "다음 런에서는 다시 뜬다 = 가드 해제됨(shows=" + banner.ShowCount + ")");
sb.AppendLine();
sb.AppendLine(RunHud.Dump());
}
catch (Exception ex) { s_fail++; sb.AppendLine("**FAIL** 예외 " + ex.GetType().Name + " — " + ex.Message + "\n" + ex.StackTrace); }
finally
{
ui.timerResetColorOnRunStart = reset0;
ui.bossResetOnRunStart = true;
BossHpBar.RunBossHpRatio = -1f; BossHpBar.RunBossSeen = false;
RunHud.Teardown();
Kill(barGo); Kill(bannerGo); Kill(root);
SetPhase(phase0);
}
sb.AppendLine();
sb.AppendLine("③ 결과 = " + s_pass + " PASS / " + s_fail + " FAIL");
string body3 = sb.ToString();
return body3 + "\n→ " + Write("WL813q2_reset.txt", body3);
}
// ─────────────────────────────── ④ 결과 화면 행 보강(보스 미처치 · 엘리트)
public static string Rows()
{
s_pass = s_fail = 0;
var sb = new StringBuilder();
sb.AppendLine("=== WL-813q2 ④ 결과 화면 행 — 보스 미처치 문구 · 엘리트 처치 수 ===");
var ui = WLRunUiSettings.Instance;
if (ui == null) { string b = sb.AppendLine("**FAIL** WLRunUiSettings 없음").ToString(); return b + "\n→ " + Write("WL813q2_rows.txt", b); }
GameObject root = null, barGo = null;
var phase0 = GetPhase();
try
{
var uiRoot = BuildUiRoot(out root);
RunResultUI.Bind(uiRoot);
RunResultUI.Subscribe(true);
RunResultUI.ResetState();
BossHpBar.RunBossHpRatio = -1f; BossHpBar.RunBossSeen = false; // 앞 항목의 잔값 제거
sb.AppendLine("SO 행 배열 = " + ui.resultRows.Length + "개 · 엘리트 행 포함=" + ui.HasRow(WLRunResultRow.EliteKills));
Chk(sb, ui.HasRow(WLRunResultRow.EliteKills), "엘리트 처치 행이 SO 배열에 있다");
// 엘리트 집계 — 런 중에만 센다
SetPhase(RunPhase.Zones);
WLRunUiProbeHooks.RaiseRunStarted(1, 240f, 2, "probe");
for (int i = 0; i < 3; i++) WLRunUiProbeHooks.RaiseKilled(eSubRol.Elite);
for (int i = 0; i < 5; i++) WLRunUiProbeHooks.RaiseKilled(eSubRol.None);
WLRunUiProbeHooks.RaiseKilled(eSubRol.Boss);
Chk(sb, RunResultUI.EliteKills == 3, "런 중 엘리트 처치 " + RunResultUI.EliteKills + "건(일반 5 · 보스 1 은 세지 않는다)");
SetPhase(RunPhase.Ended);
WLRunUiProbeHooks.RaiseKilled(eSubRol.Elite); // 런 밖 처치는 세지 않는다
Chk(sb, RunResultUI.EliteKills == 3, "런 밖 엘리트 처치는 무시(" + RunResultUI.EliteKills + ")");
// 보스 미처치 + HP 를 모르는 경우
var r = FakeResult(RunOutcome.Timeout, 240f, -1f, 29, 0);
WLRunUiProbeHooks.RaiseRunEnded(in r);
string boss1 = RunResultUI.RowValueText(WLRunResultRow.BossClear);
string elite1 = RunResultUI.RowValueText(WLRunResultRow.EliteKills);
sb.AppendLine(" 보스 처치 행 = \"" + boss1 + "\" · 엘리트 처치 행 = \"" + elite1 + "\"");
Chk(sb, boss1 == ui.resultBossNotClearedText, "보스 미처치(HP 모름) → \"" + boss1 + "\"");
Chk(sb, elite1 == "3", "엘리트 처치 행 = \"" + elite1 + "\" (RunEnded 순간 스냅샷)");
// 보스 미처치 + 813c 바가 남긴 HP 실측값
barGo = new GameObject("__WL813q2_BossBar2", typeof(RectTransform));
barGo.hideFlags = HideFlags.DontSave;
barGo.transform.SetParent(root.transform, false);
var bar = barGo.AddComponent<BossHpBar>();
bar.Initialize();
bar.Bind(null, "테스트 보스", 200f);
bar.SetRatio(0.436f);
RunResultUI.ResetState();
WLRunUiProbeHooks.RaiseRunEnded(in r);
string boss2 = RunResultUI.RowValueText(WLRunResultRow.BossClear);
sb.AppendLine(" 바 실측 HP 43.6% 일 때 보스 처치 행 = \"" + boss2 + "\"");
Chk(sb, boss2.Contains("44"), "보스 미처치 + HP 남음 44 % 표기 — \"" + boss2 + "\"");
// 보스 처치한 런은 시간이 그대로 나온다
var r2 = FakeResult(RunOutcome.Win, 154f, 154f, 99, 1);
RunResultUI.ResetState();
WLRunUiProbeHooks.RaiseRunEnded(in r2);
string boss3 = RunResultUI.RowValueText(WLRunResultRow.BossClear);
Chk(sb, boss3 == "02:34", "보스 처치 런 = \"" + boss3 + "\" (mm:ss 그대로 · 813q 동작 불변)");
// C8 — UI 집계·UI HP 를 끄면 이전 동작
ui.resultBossHpFromUi = false; ui.resultCountElitesInUi = false;
RunResultUI.ResetState();
WLRunUiProbeHooks.RaiseRunEnded(in r);
Chk(sb, RunResultUI.RowValueText(WLRunResultRow.BossClear) == ui.resultBossNotClearedText &&
RunResultUI.RowValueText(WLRunResultRow.EliteKills) == "0",
"C8 롤백 — 보스 행 \"" + RunResultUI.RowValueText(WLRunResultRow.BossClear) + "\" · 엘리트 행 \"" +
RunResultUI.RowValueText(WLRunResultRow.EliteKills) + "\"");
ui.resultBossHpFromUi = true; ui.resultCountElitesInUi = true;
sb.AppendLine();
sb.AppendLine(RunResultUI.Dump());
}
catch (Exception ex) { s_fail++; sb.AppendLine("**FAIL** 예외 " + ex.GetType().Name + " — " + ex.Message + "\n" + ex.StackTrace); }
finally
{
ui.resultBossHpFromUi = true; ui.resultCountElitesInUi = true;
RunResultUI.Teardown();
Kill(barGo); Kill(root);
SetPhase(phase0);
}
sb.AppendLine();
sb.AppendLine("④ 결과 = " + s_pass + " PASS / " + s_fail + " FAIL");
string body4 = sb.ToString();
return body4 + "\n→ " + Write("WL813q2_rows.txt", body4);
}
// ────────────────────────────────────────────────── ⑤ GC 0 · C8
public static string Gc()
{
s_pass = s_fail = 0;
var sb = new StringBuilder();
sb.AppendLine("=== WL-813q2 ⑤ GC 0 · C8 ===");
var ui = WLRunUiSettings.Instance;
if (ui == null) { string b = sb.AppendLine("**FAIL** WLRunUiSettings 없음").ToString(); return b + "\n→ " + Write("WL813q2_gc.txt", b); }
GameObject root = null;
var phase0 = GetPhase();
try
{
var uiRoot = BuildUiRoot(out root);
RunHud.Bind(uiRoot); RunHud.Subscribe(true); RunHud.ResetState();
RunResultUI.Bind(uiRoot); RunResultUI.Subscribe(true); RunResultUI.ResetState();
SetPhase(RunPhase.Zones);
WLRunUiProbeHooks.RaiseRunStarted(1, 240f, 2, "probe");
WLRunUiProbeHooks.RaiseRunTick(60f, 180f, 0, 5, 24, 0, 2, RunPhase.Zones, 1);
// 같은 초 100틱 = 표시 쓰기 0회 · 델타 0 B
GC.Collect(); GC.WaitForPendingFinalizers();
long m0 = GC.GetTotalMemory(true);
int tw0 = RunHud.TimerWrites, cw0 = RunHud.TimerColorWrites;
for (int i = 0; i < 100; i++) WLRunUiProbeHooks.RaiseRunTick(60f, 180f, 0, 5, 24, 0, 2, RunPhase.Zones, 1);
long m1 = GC.GetTotalMemory(false);
Chk(sb, RunHud.TimerWrites == tw0 && RunHud.TimerColorWrites == cw0,
"같은 초 100틱 — 타이머 쓰기 " + (RunHud.TimerWrites - tw0) + "회 · 색 쓰기 " + (RunHud.TimerColorWrites - cw0) + "회");
Chk(sb, m1 - m0 == 0, "Δ GetTotalMemory = " + (m1 - m0) + " B");
// Killed 100건(엘리트 집계 경로) — 문자열 0
GC.Collect(); GC.WaitForPendingFinalizers();
long k0 = GC.GetTotalMemory(true);
for (int i = 0; i < 100; i++) WLRunUiProbeHooks.RaiseKilled(i % 2 == 0 ? eSubRol.Elite : eSubRol.None);
long k1 = GC.GetTotalMemory(false);
Chk(sb, k1 - k0 == 0, "Killed 100건(엘리트 50) Δ = " + (k1 - k0) + " B · 집계=" + RunResultUI.EliteKills);
// 재정렬 100회 왕복 — 노드 재생성 0(Canvas·Raycaster 는 1개씩 재사용)
// 🔴 재정렬 함수는 진단 문자열을 돌려주므로 그 자체는 할당이 있다. 다만 **매 프레임 경로가 아니다**
// (사망 1회 · 부활 1회). 여기서는 "노드를 다시 만들지 않는다"만 PASS 기준으로 둔다.
long l0 = GC.GetTotalMemory(true);
for (int i = 0; i < 100; i++) { RunResultUI.LowerBehindRevive(); RunResultUI.RaiseToFront(); }
long l1 = GC.GetTotalMemory(false);
var resRoot = root.transform.Find("IngameUIs/WL_HUD/WL_RunResult");
int canvasN = resRoot != null ? resRoot.GetComponents<Canvas>().Length : -1;
int rayN = resRoot != null ? resRoot.GetComponents<GraphicRaycaster>().Length : -1;
Chk(sb, RunResultUI.LowerCount == 100 && RunResultUI.RaiseCount == 100, "재정렬 왕복 100회(내림 " +
RunResultUI.LowerCount + " / 복귀 " + RunResultUI.RaiseCount + ") · Δ = " + (l1 - l0) + " B(진단 문자열분)");
Chk(sb, canvasN == 1 && rayN == 1, "노드 재생성 0 — Canvas " + canvasN + "개 · GraphicRaycaster " + rayN + "개");
// C8 — 전체 off
sb.AppendLine();
WLRunUiSettings.RuntimeDisabled = true;
RunResultUI.ResetState();
var r = FakeResult(RunOutcome.Timeout, 240f, -1f, 29, 0);
SetPhase(RunPhase.Ended);
WLRunUiProbeHooks.RaiseRunEnded(in r);
RunResultUI.Tick(Time.unscaledTime);
Chk(sb, !RunResultUI.IsOpen, "C8 RuntimeDisabled — 결과 화면 표시 0(open=" + RunResultUI.IsOpen + ")");
Chk(sb, !RunResultUI.ReviveBusy, "C8 중 부활우선 판정도 무동작(" + RunResultUI.ReviveBusyReason + ")");
WLRunUiSettings.RuntimeDisabled = false;
Chk(sb, WLRunUiSettings.Enabled, "C8 복구 — Enabled=" + WLRunUiSettings.Enabled);
}
catch (Exception ex) { s_fail++; sb.AppendLine("**FAIL** 예외 " + ex.GetType().Name + " — " + ex.Message + "\n" + ex.StackTrace); }
finally
{
WLRunUiSettings.RuntimeDisabled = false;
RunHud.Teardown(); RunResultUI.Teardown();
Kill(root);
SetPhase(phase0);
}
sb.AppendLine();
sb.AppendLine("⑤ 결과 = " + s_pass + " PASS / " + s_fail + " FAIL");
string body5 = sb.ToString();
return body5 + "\n→ " + Write("WL813q2_gc.txt", body5);
}
// ───────────────────────────────────────────────────────── 전체
public static string All()
{
var sb = new StringBuilder();
int pass = 0, fail = 0;
string[] parts = new string[5];
parts[0] = Overlap(); pass += s_pass; fail += s_fail;
parts[1] = Watchdog(); pass += s_pass; fail += s_fail;
parts[2] = Reset(); pass += s_pass; fail += s_fail;
parts[3] = Rows(); pass += s_pass; fail += s_fail;
parts[4] = Gc(); pass += s_pass; fail += s_fail;
for (int i = 0; i < parts.Length; i++) sb.AppendLine(parts[i]).AppendLine();
sb.AppendLine("======================================================");
sb.AppendLine("WL-813q2 전체 = " + pass + " PASS / " + fail + " FAIL");
string body = sb.ToString();
return "WL-813q2 전체 = " + pass + " PASS / " + fail + " FAIL\n→ " + Write("WL813q2_all.txt", body);
}
}