Project_WL/AgentScripts/WL813p_Probe.cs

546 lines
31 KiB
C#
Raw Permalink 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.

// WL813p_Probe.cs — #813p(3분 런 구조 · RunEvents · 집계 · 다음 런) 에디트 모드 검증 프로브
// 에디트 모드 전용 · Play 불필요 · 로그인 0 · 씬/에셋 무수정(임시 GameObject 는 즉시 파괴)
//
// 상주 에디터: unity command run_script --project-path <wt> --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<MobActor>();
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]);
}
/// <summary>MonsterAppear.json 의 한 행에서 정수 필드를 읽는다(코드 상수 0 · 실측 대조용).</summary>
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<FieldBossData>();
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<Actor>();
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); }
}
}