// WL813p_Probe.cs — #813p(3분 런 구조 · RunEvents · 집계 · 다음 런) 에디트 모드 검증 프로브 // 에디트 모드 전용 · Play 불필요 · 로그인 0 · 씬/에셋 무수정(임시 GameObject 는 즉시 파괴) // // 상주 에디터: unity command run_script --project-path --file AgentScripts/WL813p_Probe.cs --entry WL813p_Probe.RunAll --timeout 300 // 결과: Console + AgentScripts/WL813p_PROBE.txt (RESULT PASS / RESULT FAIL n) // // 검사 (발주서 WL-813p §2 ⓒ ⓓ) // ① 에셋·SOT: WLRunSettings 로드 · 값 전수 덤프 · zoneSpawnerIds/zoneClearKills 를 MonsterAppear.json 실측과 대조 // ② 계약: RunEvents 6종 구독자 목록 · RunDirector 가 무는 11개 소스 이벤트(EnsureSubscribed 전/후 Count +1) // ③ 런 1회(승리): 존 4 클리어 → 게이트(ZonesCleared) → 보스 등장 → 보스 처치 → RunEnded(Win) // · 가짜 Killed/Spawned/SkillCast/LootPicked + Note* 로 넣은 값과 RunResult 덤프가 일치하는지 전수 비교 // · 런 시작 전 존 누적 처치(스냅샷 차감)가 진행률에 섞이지 않는지 // ④ 시간 종료: openGateAtSec 경과 → BossGateOpened(TimeLimit) · runSeconds 경과 → RunEnded(Timeout) // ⑤ 다음 런: RunDirector.Restart() → runIndex+1 · 집계 0 · 존 클리어 0 · BossArena 게이트 재무장(누적 카운터는 보존) // ⑥ RunTick 1 Hz: 같은 프레임 100회 강제 틱 = +0 · 0.5 s 씩 두 번 = +1 · SO tickIntervalSeconds 반영 // ⑦ GC: 틱·집계 100회 반복 0 B(대조군 = 의도적 할당) // ⑧ C8: RuntimeDisabled / enabled=0 이면 StartRun·Tick 이 아무 일도 하지 않는다 // // 에디트 모드 한계: 실제 스폰·PC·서버 데이터가 없다 → Restart() 의 On_Regen(존 시작점 워프)·levelFrom(ServerInfo) // 은 여기서 측정할 수 없다(플레이 모드 · 병합 후 QA). RunDirector 가 Application.isPlaying 으로 스스로 막는다. using System; using System.IO; using System.Reflection; using System.Text; using UnityEngine; using WL.Combat.Boss; using WL.Combat.Core; using WL.Combat.Loot; using WL.Combat.Run; using WL.Combat.Survival; public static class WL813p_Probe { const string OutCommit = "AgentScripts/WL813p_PROBE.txt"; const string MonsterAppear = "Assets/ResWork/Table/Export/MonsterAppear.json"; const BindingFlags NP = BindingFlags.NonPublic | BindingFlags.Instance; static StringBuilder sb; static int s_fail; static GameObject s_fbdGo, s_pcGo, s_bossGo; static GameObject[] s_zoneGo; static MobActor[] s_zoneMob; static MobActor s_bossMob; static Actor s_pc; static int s_resume; // ── 마지막으로 받은 RunEvents 페이로드(구독 검증용) static int s_evStart, s_evZone, s_evGate, s_evBoss, s_evEnd, s_evTick; static RunResult s_lastEnd; static RunGateReason s_lastGateReason; static int s_lastZoneIndex = -1; public static object RunAll() { sb = new StringBuilder(); s_fail = 0; sb.AppendLine("# WL813p Probe " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " (edit mode · Play 0 · 로그인 0)"); sb.AppendLine("playMode=" + Application.isPlaying); try { Setup(); Sec1_Asset(); Sec2_Contract(); Sec3_WinRun(); Sec4_Timeout(); Sec5_Restart(); Sec6_TickRate(); Sec7_Gc(); Sec8_Rollback(); } catch (Exception ex) { s_fail++; L("[FAIL] 예외 " + ex); } finally { Teardown(); } sb.AppendLine(s_fail == 0 ? "RESULT PASS" : "RESULT FAIL " + s_fail); string s = sb.ToString(); Debug.Log(s); try { File.WriteAllText(OutCommit, s, new UTF8Encoding(false)); } catch { } return s; } // ───────────────────────────────────────── 도구 static void L(string m) { sb.AppendLine(m); } static string P(bool ok) { if (!ok) s_fail++; return ok ? "[PASS]" : "[FAIL]"; } static bool Near(double a, double b) { return Math.Abs(a - b) < 1e-3; } static long Alloc() { return GC.GetTotalMemory(false); } static long Mono() { return UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong(); } static MobActor MakeMob(string name, int spawnerId, eSubRol sub) { var go = new GameObject("__WL813p_" + name); var m = go.AddComponent(); m.m_Role = eRole.Mob; m.m_SubRole = sub; var f = typeof(MobActor).GetField("m_SpawnerId", BindingFlags.NonPublic | BindingFlags.Instance); if (f != null) f.SetValue(m, spawnerId); return m; } static void Kill(params UnityEngine.Object[] objs) { for (int i = 0; i < objs.Length; i++) if (objs[i] != null) UnityEngine.Object.DestroyImmediate(objs[i]); } /// MonsterAppear.json 의 한 행에서 정수 필드를 읽는다(코드 상수 0 · 실측 대조용). static int ReadAppear(int spawnerId, string field, int fallback) { try { string txt = File.ReadAllText(MonsterAppear); int at = txt.IndexOf("\"n_SpawnerID\": \"" + spawnerId + "\"", StringComparison.Ordinal); if (at < 0) return fallback; int end = txt.IndexOf('}', at); int k = txt.IndexOf("\"" + field + "\": \"", at, StringComparison.Ordinal); if (k < 0 || k > end) return fallback; k += field.Length + 5; int q = txt.IndexOf('"', k); int v; return int.TryParse(txt.Substring(k, q - k), out v) ? v : fallback; } catch { return fallback; } } // ───────────────────────────────────────── ⓪ 준비 static void Setup() { WLCombatCoreSettings.RuntimeDisabled = false; WLBossArenaSettings.RuntimeDisabled = false; WLRunSettings.RuntimeDisabled = false; WLRunSettings.ClearCache(); WLBossArenaSettings.ClearCache(); BossArena.ResetAll(); RunDirector.ResetAll(); RunDirector.Unsubscribe(); RunEvents.ResetDiagnostics(); CombatEvents.ResetDiagnostics(); // 프로브가 RunEvents 를 실제로 구독한다(813q 가 할 일과 같은 계약). RunEvents.RunStarted.Add(OnRunStarted); RunEvents.ZoneCleared.Add(OnZoneCleared); RunEvents.BossGateOpened.Add(OnGateOpened); RunEvents.BossStarted.Add(OnBossStarted); RunEvents.RunEnded.Add(OnRunEnded); RunEvents.RunTick.Add(OnRunTick); var cfg = WLRunSettings.Instance; var bcfg = WLBossArenaSettings.Instance; // 가짜 FieldBossData 로 813d 게이트를 무장시킨다(존 카운터 KillsOf 가 살아야 스냅샷 차감을 측정할 수 있다). s_fbdGo = new GameObject("__WL813p_FieldBoss"); s_fbdGo.transform.position = bcfg != null ? bcfg.arenaCenter : Vector3.zero; var fbd = s_fbdGo.AddComponent(); fbd.isChapterBoss = false; fbd.n_MonsterID = bcfg != null ? bcfg.bossMonsterId : 10006; fbd.n_SpawnerId = bcfg != null ? bcfg.bossSpawnerId : 813010; fbd.RegenTime = 60f; BossArena.EnsureSubscribed(); bool held = BossArena.HoldSpawn(fbd, delegate { s_resume++; }); // 가짜 존 몹 · 보스 · 메인 PC int zc = cfg != null && cfg.zoneSpawnerIds != null ? cfg.zoneSpawnerIds.Length : 0; s_zoneGo = new GameObject[zc]; s_zoneMob = new MobActor[zc]; for (int i = 0; i < zc; i++) { s_zoneMob[i] = MakeMob("zone" + i, cfg.zoneSpawnerIds[i], eSubRol.None); s_zoneGo[i] = s_zoneMob[i].gameObject; } s_bossMob = MakeMob("boss", cfg != null ? cfg.bossSpawnerId : 813010, eSubRol.Boss); s_bossGo = s_bossMob.gameObject; s_pcGo = new GameObject("__WL813p_PC"); s_pc = s_pcGo.AddComponent(); s_pc.m_Role = eRole.PC; s_pc.m_SubRole = eSubRol.None; // m_Enemy 기본 false → IsMainPC() true L(""); L("── ⓪ 준비"); L(" BossArena.HoldSpawn=" + held + " GateArmed=" + BossArena.GateArmed + " 존 " + zc + "개 · 보스 " + (cfg != null ? cfg.bossSpawnerId : 0)); L(" " + P(BossArena.GateArmed) + " 813d 게이트 무장(존 누적 카운터 사용 가능)"); L(" " + P(s_pc.IsMainPC()) + " 가짜 메인 PC IsMainPC()=true"); } static void OnRunStarted(in RunStartedEvent e) { s_evStart++; } static void OnZoneCleared(in ZoneClearedEvent e) { s_evZone++; s_lastZoneIndex = e.zoneIndex; } static void OnGateOpened(in BossGateOpenedEvent e) { s_evGate++; s_lastGateReason = e.reason; } static void OnBossStarted(in BossStartedEvent e) { s_evBoss++; } static void OnRunEnded(in RunResult e) { s_evEnd++; s_lastEnd = e; } static void OnRunTick(in RunTickEvent e) { s_evTick++; } // ───────────────────────────────────────── ① 에셋 · SOT static void Sec1_Asset() { L(""); L("── ① 에셋 · SOT (WLRunSettings)"); var c = WLRunSettings.Instance; L(" " + P(c != null) + " Resources.Load(\"" + WLRunSettings.ResourcesPath + "\")"); if (c == null) return; L(" " + P(WLRunSettings.Enabled) + " Enabled(에셋·enabled·RuntimeDisabled·코어) = " + WLRunSettings.Enabled + " (core=" + WLCombatCoreSettings.Enabled + ")"); L(" 값: enabled=" + c.enabled + " verboseLog=" + c.verboseLog + " runSeconds=" + c.runSeconds + " tickIntervalSeconds=" + c.tickIntervalSeconds + " pollSeconds=" + c.pollSeconds + " pauseTimerWhileWorldHeld=" + c.pauseTimerWhileWorldHeld); L(" 값: bossSpawnerId=" + c.bossSpawnerId + " openGateOnZonesCleared=" + c.openGateOnZonesCleared + " openGateAtSec=" + c.openGateAtSec + " autoStartOnMapEnter=" + c.autoStartOnMapEnter + " skipLobby=" + c.skipLobby + " endOnMapLeave=" + c.endOnMapLeave); L(" 값: restartWarpsPlayer=" + c.restartWarpsPlayer + " restartHealRate=" + c.restartHealRate + " restartRearmsBossGate=" + c.restartRearmsBossGate + " restartResetsPotions=" + c.restartResetsPotions); bool lenOk = c.zoneSpawnerIds != null && c.zoneClearKills != null && c.zoneSpawnerIds.Length == c.zoneClearKills.Length; L(" " + P(lenOk) + " zoneSpawnerIds/zoneClearKills 길이 일치 = " + (c.zoneSpawnerIds != null ? c.zoneSpawnerIds.Length : -1) + " / " + (c.zoneClearKills != null ? c.zoneClearKills.Length : -1)); if (!lenOk) return; for (int i = 0; i < c.zoneSpawnerIds.Length; i++) { int max = ReadAppear(c.zoneSpawnerIds[i], "n_MaxMonsterCount", -1); bool ok = max > 0 && c.zoneClearKills[i] == max * 2; L(" " + P(ok) + " 존" + i + " id=" + c.zoneSpawnerIds[i] + " zoneClearKills=" + c.zoneClearKills[i] + " MonsterAppear n_MaxMonsterCount=" + max + " ×2=" + (max * 2)); } int bossMax = ReadAppear(c.bossSpawnerId, "n_MaxMonsterCount", -1); L(" " + P(bossMax == 1) + " 보스 스포너 " + c.bossSpawnerId + " n_MaxMonsterCount=" + bossMax + " (MonsterAppear 실측)"); L(" lootByGrade 길이 = WLLootSettings.GradeSlots(" + WLLootSettings.GradeSlots + ") + 1 = " + (WLLootSettings.GradeSlots + 1) + " ※ 발주서 §1 은 [6] 이라 적혀 있으나 실측 등급 슬롯은 " + WLLootSettings.GradeSlots + " 이라 그쪽에 맞췄다"); } // ───────────────────────────────────────── ② 계약(구독) static void Sec2_Contract() { L(""); L("── ② 계약 — RunEvents 6종 · RunDirector 가 무는 소스 11종"); L(" RunEvents 구독자(프로브 6개): RunStarted=" + RunEvents.RunStarted.Count + " ZoneCleared=" + RunEvents.ZoneCleared.Count + " BossGateOpened=" + RunEvents.BossGateOpened.Count + " BossStarted=" + RunEvents.BossStarted.Count + " RunEnded=" + RunEvents.RunEnded.Count + " RunTick=" + RunEvents.RunTick.Count); bool six = RunEvents.RunStarted.Count == 1 && RunEvents.ZoneCleared.Count == 1 && RunEvents.BossGateOpened.Count == 1 && RunEvents.BossStarted.Count == 1 && RunEvents.RunEnded.Count == 1 && RunEvents.RunTick.Count == 1; L(" " + P(six) + " 813q 가 구독할 6종이 전부 살아 있다"); int k0 = CombatEvents.Killed.Count, s0 = CombatEvents.Spawned.Count, c0 = CombatEvents.SkillCast.Count, h0 = CombatEvents.HitConfirmed.Count, d0 = WL.Combat.Reaction.DamageAggregator.Flushed.Count, t0 = WL.Combat.Reaction.KillChain.TierReached.Count, e0 = WL.Combat.Reaction.KillChain.Ended.Count, g0 = WL.Combat.Growth.GrowthEvents.LevelUp.Count, l0 = LootBurst.Picked.Count, x0 = DeathFlow.Died.Count, p0 = PotionUse.Used.Count; RunDirector.EnsureSubscribed(); RunDirector.EnsureSubscribed(); // 중복 구독 방어 확인 bool add = CombatEvents.Killed.Count == k0 + 1 && CombatEvents.Spawned.Count == s0 + 1 && CombatEvents.SkillCast.Count == c0 + 1 && CombatEvents.HitConfirmed.Count == h0 + 1 && WL.Combat.Reaction.DamageAggregator.Flushed.Count == d0 + 1 && WL.Combat.Reaction.KillChain.TierReached.Count == t0 + 1 && WL.Combat.Reaction.KillChain.Ended.Count == e0 + 1 && WL.Combat.Growth.GrowthEvents.LevelUp.Count == g0 + 1 && LootBurst.Picked.Count == l0 + 1 && DeathFlow.Died.Count == x0 + 1 && PotionUse.Used.Count == p0 + 1; L(" " + P(add) + " EnsureSubscribed ×2 → 소스 11종 각각 +1 (중복 구독 0) · Subscribed=" + RunDirector.Subscribed); } // ───────────────────────────────────────── ③ 런 1회(승리) static void Sec3_WinRun() { L(""); L("── ③ 런 1회(승리) — 존 4 클리어 → 게이트 → 보스 등장 → 처치"); var c = WLRunSettings.Instance; if (c == null) { s_fail++; L(" [FAIL] 설정 없음"); return; } // 런 시작 「전」 누적 처치 — 스냅샷 차감이 먹는지 보려고 존0 에 미리 5킬을 쌓는다. const int kPre = 5; for (int i = 0; i < kPre; i++) { BossArena.NoteSpawn(c.zoneSpawnerIds[0], false); BossArena.NoteKill(c.zoneSpawnerIds[0], false); } int preArena = BossArena.KillsOf(c.zoneSpawnerIds[0]); s_evStart = s_evZone = s_evGate = s_evBoss = s_evEnd = s_evTick = 0; RunDirector.StartRun("probe-win"); L(" " + P(s_evStart == 1 && RunDirector.Phase == RunPhase.Zones) + " RunStarted 1회 · phase=" + RunDirector.Phase + " runIndex=" + RunDirector.RunIndex + " · 시작 즉시 RunTick " + s_evTick + "회"); L(" " + P(RunDirector.ZoneProgress(0) == 0) + " 스냅샷 차감 — BossArena.KillsOf(" + c.zoneSpawnerIds[0] + ")=" + preArena + " 인데 런 진행률=" + RunDirector.ZoneProgress(0) + " (0 이어야 한다)"); // 존별 처치 — 진짜 CombatEvents.Killed 로 넣는다(BossArena 카운터 + RunDirector 자체 카운터 둘 다 탄다). int totalZoneKills = 0; for (int z = 0; z < c.zoneSpawnerIds.Length; z++) { int need = c.zoneClearKills[z]; for (int i = 0; i < need; i++) { CombatEvents.RaiseSpawned(s_zoneMob[z]); CombatEvents.RaiseKilled(s_zoneMob[z]); totalZoneKills++; } } L(" " + P(s_evZone == c.zoneSpawnerIds.Length) + " ZoneCleared " + s_evZone + "회 / 존 " + c.zoneSpawnerIds.Length + "개 · 마지막 zoneIndex=" + s_lastZoneIndex + " · ZonesCleared=" + RunDirector.ZonesCleared); L(" " + P(s_evGate == 1 && s_lastGateReason == RunGateReason.ZonesCleared) + " BossGateOpened " + s_evGate + "회 reason=" + s_lastGateReason + " · BossArena.GateOpen=" + BossArena.GateOpen + " resume 호출=" + s_resume); // 보스 등장 CombatEvents.RaiseSpawned(s_bossMob); L(" " + P(s_evBoss == 1 && RunDirector.Phase == RunPhase.Boss) + " BossStarted " + s_evBoss + "회 · phase=" + RunDirector.Phase); // 나머지 집계 — 스킬(진짜 이벤트) · 연쇄 · 피해 · 레벨 · 전리품 · 사망 · 물약 const int kSkill = 7, kChain = 9, kDeath = 2, kPotion = 3; for (int i = 0; i < kSkill; i++) CombatEvents.RaiseSkillCast(s_pc, null); RunDirector.NoteChain(4); RunDirector.NoteChain(kChain); RunDirector.NoteChain(3); // 최대만 남아야 한다 RunDirector.NoteDamage(1234.5d, 0d); RunDirector.NoteDamage(999d, 8888.5d); RunDirector.NoteDamage(50d, 100d); RunDirector.NoteLevel(11, 12); RunDirector.NoteLevel(12, 14); for (int i = 0; i < kDeath; i++) RunDirector.NoteDeath(); for (int i = 0; i < kPotion; i++) RunDirector.NotePotion(); // 전리품 — 등급 1 은 진짜 LootBurst 경로(구독 배선 증명) · 나머지 등급은 Note 이음매 int gradeSlots = WLLootSettings.GradeSlots; LootBurst.OnDropPicked(null, 1001, 1, null); // table_itemlist 없음 → ResolveGrade = 1 int lootTotal = 1, expect1 = 1; for (int g = 2; g <= gradeSlots; g++) { RunDirector.NoteLoot(g, g, false); lootTotal += g; } RunDirector.NoteLoot(LootBurst.ResolveGrade(LootBurst.GoldItemId), 777, true); // 골드는 lootTotal 에 들어가지 않는다 // 보스 처치 = 승리 CombatEvents.RaiseKilled(s_bossMob); var r = s_lastEnd; L(" " + P(s_evEnd == 1 && r.outcome == RunOutcome.Win) + " RunEnded " + s_evEnd + "회 outcome=" + r.outcome + " phase=" + RunDirector.Phase); int expectKills = totalZoneKills + 1; // 존 처치 + 보스 1 L(" 집계 대조 (입력 → RunResult)"); L(" " + P(r.kills == expectKills) + " kills " + r.kills + " / 기대 " + expectKills); L(" " + P(r.bossKills == 1) + " bossKills " + r.bossKills + " / 기대 1"); L(" " + P(r.maxChain == kChain) + " maxChain " + r.maxChain + " / 기대 " + kChain); L(" " + P(Near(r.maxDamage, 1234.5d)) + " maxDamage " + r.maxDamage + " / 기대 1234.5"); L(" " + P(Near(r.maxDamageBurst, 8888.5d)) + " maxDamageBurst " + r.maxDamageBurst + " / 기대 8888.5"); L(" " + P(r.levelFrom == 11 && r.levelTo == 14) + " level " + r.levelFrom + "→" + r.levelTo + " / 기대 11→14"); L(" " + P(r.deaths == kDeath) + " deaths " + r.deaths + " / 기대 " + kDeath); L(" " + P(r.potionsUsed == kPotion) + " potionsUsed " + r.potionsUsed + " / 기대 " + kPotion); L(" " + P(r.skillCasts == kSkill) + " skillCasts " + r.skillCasts + " / 기대 " + kSkill); L(" " + P(r.lootTotal == lootTotal) + " lootTotal " + r.lootTotal + " / 기대 " + lootTotal); L(" " + P(r.gold == 777) + " gold " + r.gold + " / 기대 777"); L(" " + P(r.lootByGrade != null && r.lootByGrade.Length == gradeSlots + 1) + " lootByGrade 길이 " + (r.lootByGrade != null ? r.lootByGrade.Length : -1) + " / 기대 " + (gradeSlots + 1)); if (r.lootByGrade != null && r.lootByGrade.Length == gradeSlots + 1) { bool gradeOk = r.lootByGrade[0] == 0 && r.lootByGrade[1] == expect1; for (int g = 2; g <= gradeSlots; g++) if (r.lootByGrade[g] != g) gradeOk = false; var line = new StringBuilder(); for (int g = 0; g <= gradeSlots; g++) { if (g > 0) line.Append(','); line.Append(r.lootByGrade[g]); } L(" " + P(gradeOk) + " lootByGrade[0.." + gradeSlots + "] = " + line + " / 기대 0,1,2,3,…," + gradeSlots); } L(" " + P(r.zonesCleared == r.zoneCount && r.zoneCount == c.zoneSpawnerIds.Length) + " zonesCleared " + r.zonesCleared + "/" + r.zoneCount); bool zsecOk = r.zoneClearSec != null && r.zoneClearSec.Length == r.zoneCount; if (zsecOk) for (int i = 0; i < r.zoneCount; i++) if (r.zoneClearSec[i] < 0f) zsecOk = false; L(" " + P(zsecOk) + " zoneClearSec 전 존 기록됨"); bool zkOk = r.zoneKills != null && r.zoneKills.Length == r.zoneCount; if (zkOk) for (int i = 0; i < r.zoneCount; i++) if (r.zoneKills[i] != c.zoneClearKills[i]) zkOk = false; L(" " + P(zkOk) + " zoneKills = zoneClearKills(스냅샷 차감 후) · 존0 = " + (r.zoneKills != null && r.zoneKills.Length > 0 ? r.zoneKills[0] : -1)); L(" " + P(r.bossGateSec >= 0f && r.bossStartSec >= 0f && r.bossClearSec >= 0f) + " bossGateSec=" + r.bossGateSec.ToString("F2") + " bossStartSec=" + r.bossStartSec.ToString("F2") + " bossClearSec=" + r.bossClearSec.ToString("F2")); L(" " + P(r.runIndex == 1 && Near(r.runSeconds, c.runSeconds)) + " runIndex=" + r.runIndex + " runSeconds=" + r.runSeconds); L(" BossArena 누적 보존 — KillsOf(" + c.zoneSpawnerIds[0] + ")=" + BossArena.KillsOf(c.zoneSpawnerIds[0]) + " (런 전 " + preArena + " + 이번 런 " + c.zoneClearKills[0] + ")"); L(" " + P(BossArena.KillsOf(c.zoneSpawnerIds[0]) == preArena + c.zoneClearKills[0]) + " BossArena 값 리셋 0(발주서 §1)"); // 종료 뒤에는 아무것도 세지 않는다 int killsAfter = r.kills; CombatEvents.RaiseKilled(s_zoneMob[0]); L(" " + P(RunDirector.LastResult.kills == killsAfter) + " 종료 후 처치는 집계에 안 들어간다(phase=" + RunDirector.Phase + ")"); } // ───────────────────────────────────────── ④ 시간 종료 static void Sec4_Timeout() { L(""); L("── ④ 시간 종료 — openGateAtSec → 게이트 · runSeconds → Timeout"); var c = WLRunSettings.Instance; BossArena.ResetGate(); s_evStart = s_evZone = s_evGate = s_evBoss = s_evEnd = s_evTick = 0; RunDirector.StartRun("probe-timeout"); // 존은 일부만 밀어 둔다(클리어 미달) int part = Math.Max(1, c.zoneClearKills[0] / 2); for (int i = 0; i < part; i++) CombatEvents.RaiseKilled(s_zoneMob[0]); L(" " + P(s_evZone == 0 && s_evGate == 0) + " 존 미달(" + RunDirector.ZoneProgress(0) + "/" + c.zoneClearKills[0] + ") → ZoneCleared " + s_evZone + " · BossGateOpened " + s_evGate); RunDirector.ShiftClockForProbe(c.openGateAtSec + 0.5f); RunDirector.ForceTick(); L(" " + P(s_evGate == 1 && s_lastGateReason == RunGateReason.TimeLimit) + " openGateAtSec(" + c.openGateAtSec + "s) 경과 → BossGateOpened reason=" + s_lastGateReason + " phase=" + RunDirector.Phase); RunDirector.ShiftClockForProbe(c.runSeconds); RunDirector.ForceTick(); var r = s_lastEnd; L(" " + P(s_evEnd == 1 && r.outcome == RunOutcome.Timeout) + " RunEnded outcome=" + r.outcome + " totalSec=" + r.totalSec.ToString("F1") + " (runSeconds=" + c.runSeconds + ")"); L(" " + P(r.bossKills == 0 && r.bossClearSec < 0f) + " 보스 미처치 — bossKills=" + r.bossKills + " bossClearSec=" + r.bossClearSec + " · zonesCleared=" + r.zonesCleared + "/" + r.zoneCount); L(" " + P(r.runIndex == 2) + " runIndex=" + r.runIndex + " (두 번째 런)"); } // ───────────────────────────────────────── ⑤ 다음 런 static void Sec5_Restart() { L(""); L("── ⑤ 다음 런 — RunDirector.Restart()"); var c = WLRunSettings.Instance; int arenaBefore = BossArena.KillsOf(c.zoneSpawnerIds[0]); int idxBefore = RunDirector.RunIndex; s_evStart = s_evZone = s_evGate = s_evBoss = s_evEnd = s_evTick = 0; RunDirector.Restart(); L(" " + P(RunDirector.RunIndex == idxBefore + 1) + " runIndex " + idxBefore + " → " + RunDirector.RunIndex); L(" " + P(s_evStart == 1 && RunDirector.Phase == RunPhase.Zones) + " RunStarted 1회 · phase=" + RunDirector.Phase); L(" " + P(RunDirector.ZonesCleared == 0 && RunDirector.ZoneProgress(0) == 0) + " 집계 리셋 — zonesCleared=" + RunDirector.ZonesCleared + " zone0 진행=" + RunDirector.ZoneProgress(0)); L(" " + P(!RunDirector.GateOpened && !RunDirector.BossStarted) + " 게이트·보스 상태 리셋 — GateOpened=" + RunDirector.GateOpened + " BossStarted=" + RunDirector.BossStarted); L(" " + P(!BossArena.GateOpen && BossArena.GateArmed) + " BossArena.ResetGate() — GateOpen=" + BossArena.GateOpen + " GateArmed=" + BossArena.GateArmed + " BossKilled=" + BossArena.BossKilled); L(" " + P(BossArena.KillsOf(c.zoneSpawnerIds[0]) == arenaBefore) + " BossArena 존 누적 보존 " + arenaBefore + " → " + BossArena.KillsOf(c.zoneSpawnerIds[0]) + " (리셋 금지 조항)"); // 재시작 뒤 다시 존을 밀면 정상 진행되는가(스냅샷 재설정 확인) for (int i = 0; i < c.zoneClearKills[0]; i++) CombatEvents.RaiseKilled(s_zoneMob[0]); L(" " + P(s_evZone == 1) + " 재시작 후 존0 재클리어 ZoneCleared=" + s_evZone + " (진행 " + RunDirector.ZonesCleared + "/" + RunDirector.ZoneCount + ")"); L(" ※ Restart() 의 PC 워프(MyActor.On_Regen → Set_Warp(Get_ReincanationPos))는 에디트 모드에 PC·LoadMapMgr 가 없어 미측정 =「미확인」"); } // ───────────────────────────────────────── ⑥ RunTick 1 Hz static void Sec6_TickRate() { L(""); L("── ⑥ RunTick 주기 (SO tickIntervalSeconds)"); var c = WLRunSettings.Instance; int t0 = s_evTick; for (int i = 0; i < 100; i++) RunDirector.ForceTick(); int t1 = s_evTick; L(" " + P(t1 == t0) + " 같은 프레임 강제 틱 100회 → RunTick +" + (t1 - t0) + " (간격 미만이면 0 이어야 한다)"); RunDirector.ShiftClockForProbe(c.tickIntervalSeconds * 0.5f); RunDirector.ForceTick(); int t2 = s_evTick; L(" " + P(t2 == t1) + " 절반(" + (c.tickIntervalSeconds * 0.5f) + "s) 경과 → +" + (t2 - t1)); RunDirector.ShiftClockForProbe(c.tickIntervalSeconds * 0.5f + 0.01f); RunDirector.ForceTick(); int t3 = s_evTick; L(" " + P(t3 == t2 + 1) + " 나머지 절반 경과 → +" + (t3 - t2) + " = 1 Hz(tickIntervalSeconds=" + c.tickIntervalSeconds + ")"); int t4 = s_evTick; for (int i = 0; i < 10; i++) { RunDirector.ShiftClockForProbe(c.tickIntervalSeconds); RunDirector.ForceTick(); } L(" " + P(s_evTick == t4 + 10) + " 10초 진행 → RunTick +" + (s_evTick - t4) + " (기대 10)"); } // ───────────────────────────────────────── ⑦ GC static void Sec7_Gc() { L(""); L("── ⑦ GC — 틱·집계 100회"); var c = WLRunSettings.Instance; RunDirector.EndRun(RunOutcome.Abandon); BossArena.ResetGate(); RunDirector.StartRun("probe-gc"); // 워밍업(JIT) for (int i = 0; i < 20; i++) { RunDirector.NoteChain(i); RunDirector.NoteDamage(i, i); RunDirector.NoteLoot(1, 1, false); RunDirector.ForceTick(); } int g0 = GC.CollectionCount(0); long a0 = Alloc(), m0 = Mono(); for (int i = 0; i < 100; i++) { RunDirector.NoteKill(c.zoneSpawnerIds[0], false); RunDirector.NoteChain(i); RunDirector.NoteDamage(i, i * 2); RunDirector.NoteLoot(1, 1, false); RunDirector.NoteDeath(); RunDirector.NotePotion(); RunDirector.NoteSkillCast(); RunDirector.ShiftClockForProbe(c.tickIntervalSeconds); RunDirector.ForceTick(); } long a1 = Alloc(), m1 = Mono(); int g1 = GC.CollectionCount(0); // 대조군 — 의도적 할당(측정 감도 증명) long b0 = Alloc(), n0 = Mono(); var junk = new string[100]; for (int i = 0; i < 100; i++) junk[i] = "wl813p-" + i; long b1 = Alloc(), n1 = Mono(); bool zero = (a1 - a0) <= 0 && (m1 - m0) <= 0; L(" 본시험 ΔGetTotalMemory=" + (a1 - a0) + " B · ΔMonoUsed=" + (m1 - m0) + " B · gc0 " + g0 + "→" + g1); L(" 대조군 ΔGetTotalMemory=" + (b1 - b0) + " B · ΔMonoUsed=" + (n1 - n0) + " B (문자열 100개 · junk[" + junk.Length + "])"); L(" " + P(zero) + " 틱·집계 100회 할당 0 B"); RunDirector.EndRun(RunOutcome.Abandon); } // ───────────────────────────────────────── ⑧ C8 static void Sec8_Rollback() { L(""); L("── ⑧ C8 롤백"); RunDirector.ResetAll(); WLRunSettings.RuntimeDisabled = true; s_evStart = s_evTick = 0; RunDirector.StartRun("probe-c8"); RunDirector.ForceTick(); L(" " + P(!WLRunSettings.Enabled && s_evStart == 0 && RunDirector.Phase == RunPhase.Idle) + " RuntimeDisabled=1 → Enabled=" + WLRunSettings.Enabled + " · StartRun 무동작 · phase=" + RunDirector.Phase); WLRunSettings.RuntimeDisabled = false; WLCombatCoreSettings.RuntimeDisabled = true; RunDirector.StartRun("probe-c8-core"); L(" " + P(!WLRunSettings.Enabled && RunDirector.Phase == RunPhase.Idle) + " 코어 off → Enabled=" + WLRunSettings.Enabled + " · 런 구조도 안 걸린다(집계 소스가 죽으므로)"); WLCombatCoreSettings.RuntimeDisabled = false; L(" " + P(WLRunSettings.Enabled) + " 복구 후 Enabled=" + WLRunSettings.Enabled); } // ───────────────────────────────────────── ⑨ 정리 static void Teardown() { try { RunEvents.RunStarted.Remove(OnRunStarted); RunEvents.ZoneCleared.Remove(OnZoneCleared); RunEvents.BossGateOpened.Remove(OnGateOpened); RunEvents.BossStarted.Remove(OnBossStarted); RunEvents.RunEnded.Remove(OnRunEnded); RunEvents.RunTick.Remove(OnRunTick); RunDirector.EndRun(RunOutcome.Abandon); RunDirector.Unsubscribe(); RunDirector.ResetAll(); BossArena.Unsubscribe(); BossArena.ResetAll(); WLRunSettings.RuntimeDisabled = false; WLCombatCoreSettings.RuntimeDisabled = false; WLBossArenaSettings.RuntimeDisabled = false; if (s_zoneGo != null) for (int i = 0; i < s_zoneGo.Length; i++) Kill(s_zoneGo[i]); Kill(s_bossGo, s_pcGo, s_fbdGo); s_zoneGo = null; s_zoneMob = null; s_bossGo = s_pcGo = s_fbdGo = null; L(""); L("── ⑨ 정리 — 임시 GameObject 파괴 · 구독 해제 · 런타임 스위치 복구 (에셋/씬 저장 0)"); L(" RunEvents.TotalRaised=" + RunEvents.TotalRaised + " last=" + RunEvents.LastEvent); } catch (Exception ex) { L(" [WARN] 정리 예외 " + ex.Message); } } }