// WL813w5_Probe.cs — #813w5(「다음 런」 앞판 보스 정리 · 게이트 조기 개방 방지) 프로브 // 에디트 모드 전용 · Play 불필요 · 로그인 0 · 씬/에셋 무수정(임시 GameObject·임시 SO 는 즉시 파괴/회수) // // 상주 에디터: // unity command run_script --project-path --file AgentScripts/WL813w5_Probe.cs --entry WL813w5_Probe.RunAll --timeout 300 // 결과: Console + AgentScripts/WL813w5_PROBE.txt (RESULT PASS / RESULT FAIL n) // // 무엇을 증명하나 (발주서 WL-813w5 §1 · 근거 = qa/W2-8_재채점_v6.md D-39) // ① 에셋·SOT — 813w5 신규 8필드 값 실측 · 반경/시간 정합성 · 코드 상수 0 // ② 🔴 원인 ① — 원본 MobActor.On_Regen 이 그 안에서 Check_Battle() → FindNextTarget(10000f) 를 부르는 // **원본 파일:줄을 직접 읽어** 실측한다(813w3 이 「워프·만피·타깃 해제」를 해도 안 먹은 이유) // ③ 원인 ③ — 게이트 조기 개방 진리표(gateMinSeconds · reason 별 · 런 밖 · 런 시작 전) // ④ 홀드 — ArmBossHold → 가짜 시각으로 29 s 유지 · 31 s 해제 · 0 이면 홀드 없음 // ⑤ RunEnded — 진짜 RunEvents.RunEnded 발행 → 구독·예외 0 · 보스 없음이면 ParkCount 불변 // ⑥ 런 밖 거리 — LeashTick 을 런 밖 강제로 돌려 「거리 판정만 유지」 경로가 도는지(회복 0) // ⑦ 실 경로 — RunDirector.StartRun/Restart + BossArena.NoteKill 로 게이트가 60 s 전에 안 열리는지 // ⑧ GC 0 — 게이트 판정 ×100 · 홀드 판정 ×100 · 런 밖 틱 ×100 // ⑨ C8 롤백 — 신규 스위치 0 → 813w3 동작 그대로(임시 SO) // // 에디트 모드 한계(보고서 「미확인」) // · 살아 있는 MobActor(m_Stat·m_MonsterTableData·Animator·NavMeshAgent)가 없어 ParkBoss 의 실제 실행 // (Set_Warp · On_Regen · Change_MobStatus)은 못 돌린다 — 813d/813w3 미확인 ②와 같은 한계다. // · Time.unscaledTime 이 스크립트 실행 중 멈춰 있다 → 시간 의존 판정은 BossArena.SetProbeTime 으로 먹인다. using System; using System.IO; using System.Text; using UnityEngine; using WL.Combat.Boss; using WL.Combat.Core; using WL.Combat.Run; public static class WL813w5_Probe { const string OutCommit = "AgentScripts/WL813w5_PROBE.txt"; static StringBuilder sb; static int s_fail; static GameObject s_pcGo; static PCActor s_pc, s_prevMyPc; public static object RunAll() { sb = new StringBuilder(); s_fail = 0; sb.AppendLine("# WL813w5 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_Cause(); Sec3_GateMin(); Sec4_Hold(); Sec5_RunEnded(); Sec6_OutsideRun(); Sec7_RealPath(); Sec8_GC(); Sec9_C8(); } catch (Exception ex) { L("[FAIL] 예외 — " + ex.GetType().Name + ": " + ex.Message); L(ex.StackTrace); s_fail++; } finally { Teardown(); } L(""); L(s_fail == 0 ? "RESULT PASS" : "RESULT FAIL " + s_fail); var text = sb.ToString(); try { File.WriteAllText(OutCommit, text, new UTF8Encoding(false)); } catch { } Debug.Log(text); return text; } // ───────────────────────────────────────── 유틸 static void L(string m) { sb.AppendLine(m); } static string P(bool ok) { if (!ok) s_fail++; return ok ? "[PASS]" : "[FAIL]"; } static long Alloc() { return GC.GetTotalMemory(false); } /// 실에셋을 복사한 임시 SO(실에셋은 절대 안 건드린다). 쓰고 나면 DestroyImmediate + 캐시 원복. static WLBossArenaSettings Tmp() { var t = UnityEngine.Object.Instantiate(WLBossArenaSettings.Instance); t.hideFlags = HideFlags.HideAndDontSave; return t; } static void UseTmp(WLBossArenaSettings t) { WLBossArenaSettings.SetInstanceForProbe(t); } static void UseReal() { WLBossArenaSettings.SetInstanceForProbe(null); } static void Setup() { s_prevMyPc = MyValue.MyPC; s_pcGo = new GameObject("__WL813w5_PC"); s_pc = s_pcGo.AddComponent(); s_pc.m_Role = eRole.PC; s_pc.m_SubRole = eSubRol.None; MyValue.MyPC = s_pc; WLBossArenaSettings.RuntimeDisabled = false; BossArena.ResetAll(); BossArena.EnsureSubscribed(); RunDirector.ResetAll(); L(""); L("── ⓪ 준비"); L(" " + P(WLBossArenaSettings.Instance != null) + " WLBossArenaSettings 로드 · Active=" + WLBossArenaSettings.Active); L(" " + P(BossArena.Subscribed) + " BossArena.Subscribed(CombatEvents)"); L(" " + P(BossArena.RunSubscribed) + " 🔴 BossArena.RunSubscribed(RunEvents.RunEnded) — 813w5 신규 구독"); } static void Teardown() { try { UseReal(); BossArena.SetProbeTime(-1f); BossArena.ClearBossHold(); BossArena.SetRunActiveForProbe(0); BossArena.SetBossForProbe(null); BossArena.ResetAll(); RunDirector.ResetAll(); WLBossArenaSettings.RuntimeDisabled = false; MyValue.MyPC = s_prevMyPc; if (s_pcGo != null) UnityEngine.Object.DestroyImmediate(s_pcGo); } catch { } } // ───────────────────────────────────────────────────────────────────── // ① 에셋 · SOT // ───────────────────────────────────────────────────────────────────── static void Sec1_Asset() { L(""); L("── ① 에셋 (Assets/WL/Combat/Settings/Resources/WL/WLBossArenaSettings.asset · 값 전부 SO = C45)"); var b = WLBossArenaSettings.Instance; if (b == null) { L(" [FAIL] 에셋 없음"); s_fail++; return; } L(" 신규 8필드: parkBossOnRunEnd=" + b.parkBossOnRunEnd + " nextRunBossHoldSeconds=" + b.nextRunBossHoldSeconds + " nextRunBossHoldRewarpMargin=" + b.nextRunBossHoldRewarpMargin + " nextRunWarpBeforeHeal=" + b.nextRunWarpBeforeHeal + " nextRunClearsPlayerTarget=" + b.nextRunClearsPlayerTarget + " leashOutsideRunDistanceOnly=" + b.leashOutsideRunDistanceOnly + " gateMinSeconds=" + b.gateMinSeconds + " gateMinAllowsZonesCleared=" + b.gateMinAllowsZonesCleared); L(" " + P(b.parkBossOnRunEnd) + " parkBossOnRunEnd=1 (런 종료 순간 고정)"); L(" " + P(Mathf.Approximately(b.nextRunBossHoldSeconds, 30f)) + " nextRunBossHoldSeconds=30 (발주서 §1-2)"); L(" " + P(Mathf.Approximately(b.gateMinSeconds, 60f)) + " gateMinSeconds=60 (발주서 §1-2)"); L(" " + P(b.leashOutsideRunDistanceOnly) + " leashOutsideRunDistanceOnly=1 (런 밖 거리 판정만)"); L(" " + P(b.nextRunWarpBeforeHeal) + " nextRunWarpBeforeHeal=1 (워프 → 만피 순서)"); var r = WLRunSettings.Instance; if (r != null) { L(" 런 축 대조: runSeconds=" + r.runSeconds + " openGateAtSec=" + r.openGateAtSec + " zoneClearKills=[" + string.Join(",", r.zoneClearKills) + "]"); L(" " + P(b.gateMinSeconds < r.openGateAtSec) + " gateMinSeconds(" + b.gateMinSeconds + ") < openGateAtSec(" + r.openGateAtSec + ") — 시간제한 개방은 막지 않는다"); L(" " + P(b.gateMinSeconds < r.runSeconds) + " gateMinSeconds < runSeconds(" + r.runSeconds + ") — 런을 막지 않는다"); L(" " + P(b.nextRunBossHoldSeconds < r.runSeconds) + " nextRunBossHoldSeconds < runSeconds"); } L(" " + P(b.nextRunBossHoldRewarpMargin > 0f && b.nextRunBossHoldRewarpMargin < b.arenaRadius) + " 0 < nextRunBossHoldRewarpMargin(" + b.nextRunBossHoldRewarpMargin + ") < arenaRadius(" + b.arenaRadius + ")"); } // ───────────────────────────────────────────────────────────────────── // ② 🔴 D-39 원인 ① — 원본 코드 실측(파일:줄) // ───────────────────────────────────────────────────────────────────── static void Sec2_Cause() { L(""); L("── ② 🔴 D-39 원인 ① 실측 — 「되돌림이 자기 호출 안에서 되돌려진다」 (원본 파일 직접 읽기)"); string mob = Path.Combine(Application.dataPath, "Script/Character/Mob/MobActor.cs"); string actor = Path.Combine(Application.dataPath, "Script/Character/Actor.cs"); if (!File.Exists(mob) || !File.Exists(actor)) { L(" [FAIL] 원본 파일 없음"); s_fail++; return; } var ml = File.ReadAllLines(mob); var al = File.ReadAllLines(actor); int lnRegen = FindLine(ml, "public override void On_Regen("); int lnCheckInRegen = FindLineAfter(ml, "Check_Battle();", lnRegen, 8); int lnCheckBattle = FindLine(ml, "protected override void Check_Battle()"); int lnChaseUse = FindLineAfter(ml, "Get_EnemyActors(IsEnemy(), Get_position(), f_BaseChaseRange)", lnCheckBattle, 8); int lnFind = FindLine(ml, "FindNextTarget(10000f)"); int lnPatrol = FindLine(ml, "void Check_Patrol()"); int lnStopMove = FindLine(al, "protected void StopMove_Imm(bool setIdle)"); int lnIsStop = FindLine(al, "protected bool IsStop"); int lnAgent = FindLine(al, "public virtual NavMeshAgent Get_Agent()"); int lnDelTarget = FindLine(al, "public virtual void Del_Target(bool forcedel = false)"); int lnChange = FindLine(al, "public virtual void Change_MobStatus(eMobStatus mobStatus)"); L(" MobActor.cs:" + lnRegen + " `On_Regen` · :" + lnCheckInRegen + " 그 안의 `Check_Battle();`"); L(" MobActor.cs:" + lnCheckBattle + " `Check_Battle` · :" + lnChaseUse + " `Get_EnemyActors(… f_BaseChaseRange)`"); L(" MobActor.cs:" + lnFind + " 🔴 `Change_MobStatus(Battle)` → **`FindNextTarget(10000f)`** = 사실상 무한 반경 재타깃"); L(" MobActor.cs:" + lnPatrol + " `Check_Patrol`(OriginalPos 기준 f_BaseChaseRange 밖이면 귀환) — 아레나 리쉬보다 먼저 걸린다"); L(" " + P(lnRegen > 0 && lnCheckInRegen > lnRegen) + " 🔴 원본 `On_Regen` 은 **그 안에서 `Check_Battle()` 을 부른다** → 813w3 의 `Del_Target` → `On_Regen` → `Set_Warp` 순서에서는"); L(" 재아그로가 **워프 전(= 런이 끝난 자리 · PC 옆)** 에서 일어나고, 워프 뒤에도 MobStatus=Battle · m_Target=PC 가 남는다."); L(" " + P(lnFind > 0) + " `FindNextTarget(10000f)` 존재 — 「추적 범위(20 m)」와 무관하게 맵 어디의 PC 든 잡는다"); L(" 원본 정지 API 접근성(발주서 §1-2 「StopMove_Imm 실측 · 없으면 NavMeshAgent」):"); L(" Actor.cs:" + lnStopMove + " `protected void StopMove_Imm` · Actor.cs:" + lnIsStop + " `protected bool IsStop`"); L(" " + P(lnStopMove > 0 && al[lnStopMove - 1].Contains("protected")) + " 🔴 `StopMove_Imm` 은 **protected** — WL 코드에서 못 부른다(PCActor.WL_StopNavMove 는 PC 전용)"); L(" " + P(lnAgent > 0) + " 대안 = Actor.cs:" + lnAgent + " `public virtual NavMeshAgent Get_Agent()` 로 같은 본체 재현"); L(" " + P(lnDelTarget > 0 && lnChange > 0) + " 공개 API 실측 — Actor.cs:" + lnDelTarget + " `Del_Target(bool)` · Actor.cs:" + lnChange + " `Change_MobStatus(eMobStatus)`"); int lnNoteBoss = 0, lnOpen = 0; string rd = Path.Combine(Application.dataPath, "WL/Combat/Run/RunDirector.cs"); if (File.Exists(rd)) { var rl = File.ReadAllLines(rd); lnNoteBoss = FindLine(rl, "if (!s_gateOpened) OpenBossGate(RunGateReason.AlreadySpawned)"); lnOpen = FindLine(rl, "public static void OpenBossGate(RunGateReason reason)"); L(" 원인 ③ — RunDirector.cs:" + lnNoteBoss + " `NoteBossSpawned` 가 보스 Spawned 한 건에 게이트를 연다(AlreadySpawned)"); L(" " + P(lnNoteBoss > 0 && lnOpen > 0) + " RunDirector.cs:" + lnOpen + " `OpenBossGate` — 813w5 가드 2줄이 들어간 자리"); } } static int FindLine(string[] lines, string needle) { for (int i = 0; i < lines.Length; i++) if (lines[i].Contains(needle)) return i + 1; return 0; } static int FindLineAfter(string[] lines, string needle, int fromLine1Based, int window) { if (fromLine1Based <= 0) return 0; for (int i = fromLine1Based; i < Mathf.Min(lines.Length, fromLine1Based + window); i++) if (lines[i].Contains(needle)) return i + 1; return 0; } // ───────────────────────────────────────────────────────────────────── // ③ 게이트 조기 개방 진리표 // ───────────────────────────────────────────────────────────────────── static void Sec3_GateMin() { L(""); L("── ③ 게이트 조기 개방 방지 진리표 (gateMinSeconds=60 · D-39 t≈19.6 개방)"); RunDirector.ResetAll(); BossArena.SetRunActiveForProbe(0); L(" " + P(!BossArena.GateHeldByMinSeconds(RunGateReason.AlreadySpawned)) + " 런이 한 번도 시작 안 됨(StartCount=0) → 차단 0 (C8 · 813d 그대로)"); RunDirector.StartRun("probe"); L(" StartCount=" + RunDirector.StartCount + " IsRunning=" + RunDirector.IsRunning + " Elapsed=" + RunDirector.Elapsed.ToString("F1") + "s"); int b0 = BossArena.GateMinBlocked; bool held1 = BossArena.GateHeldByMinSeconds(RunGateReason.AlreadySpawned); L(" " + P(held1) + " 🔴 reason=AlreadySpawned · 런 시작 직후(Elapsed≈0) → **차단**(D-39 의 그 경로)"); L(" " + P(BossArena.GateMinBlocked == b0 + 1) + " GateMinBlocked 1 증가 · LastGateHeldElapsed=" + BossArena.LastGateHeldElapsed.ToString("F1")); L(" " + P(!BossArena.GateHeldByMinSeconds(RunGateReason.ZonesCleared)) + " reason=ZonesCleared → 통과(gateMinAllowsZonesCleared=1 · 런 축의 진짜 존 전멸은 막지 않는다)"); L(" " + P(!BossArena.GateHeldByMinSeconds(RunGateReason.TimeLimit)) + " reason=TimeLimit → 통과(openGateAtSec 170)"); L(" " + P(!BossArena.GateHeldByMinSeconds(RunGateReason.External)) + " reason=External → 통과(QA·치트 버튼)"); L(" " + P(BossArena.GateHeldByMinSeconds(RunGateReason.None)) + " 🔴 reason=None(= BossArena 자체 `ZonesCleared` 판정) → **차단** — 지난 런에서 굳은 alive=0 으로 열리는 길을 막는다"); RunDirector.EndRun(RunOutcome.Abandon); L(" " + P(!BossArena.GateHeldByMinSeconds(RunGateReason.AlreadySpawned)) + " 런 밖(EndRun 뒤) → 차단 0 (런 안에서만 시간을 잰다)"); } // ───────────────────────────────────────────────────────────────────── // ④ 홀드 타이머 // ───────────────────────────────────────────────────────────────────── static void Sec4_Hold() { L(""); L("── ④ 보스 홀드 (nextRunBossHoldSeconds=30 · 가짜 시각)"); BossArena.ClearBossHold(); BossArena.SetProbeTime(1000f); L(" " + P(!BossArena.BossHeld) + " 초기 상태 → 홀드 아님"); BossArena.ArmBossHoldForProbe(); L(" " + P(BossArena.BossHeld) + " arm 직후(t=1000) → 홀드 · BossHoldUntil=" + BossArena.BossHoldUntil.ToString("F1")); BossArena.SetProbeTime(1029f); L(" " + P(BossArena.BossHeld) + " t=1029(29 s 경과) → **아직 홀드**"); BossArena.SetProbeTime(1031f); L(" " + P(!BossArena.BossHeld) + " t=1031(31 s 경과) → **해제**"); BossArena.SetProbeTime(1100f); BossArena.ArmBossHoldForProbe(); L(" " + P(BossArena.BossHeld) + " 재무장(다음 런에서 다시 30 s) → 홀드 · until=" + BossArena.BossHoldUntil.ToString("F1")); BossArena.ClearBossHold(); L(" " + P(!BossArena.BossHeld) + " ClearBossHold() → 즉시 해제(QA·프로브)"); // C8 — 0 이면 홀드 없음 var t = Tmp(); try { t.nextRunBossHoldSeconds = 0f; UseTmp(t); BossArena.ArmBossHoldForProbe(); L(" " + P(!BossArena.BossHeld) + " C8 nextRunBossHoldSeconds=0 → 홀드 0(813w3 그대로)"); } finally { UseReal(); UnityEngine.Object.DestroyImmediate(t); } BossArena.SetProbeTime(-1f); BossArena.ClearBossHold(); } // ───────────────────────────────────────────────────────────────────── // ⑤ RunEnded 구독 → 보스 고정 // ───────────────────────────────────────────────────────────────────── static void Sec5_RunEnded() { L(""); L("── ⑤ RunEnded 구독 (813w5 신규 · 「런 종료 시 보스 상태 확정」)"); BossArena.EnsureRunSubscribed(); L(" " + P(BossArena.RunSubscribed) + " RunEvents.RunEnded 구독 중"); // 🔴 합성 dispatch 가 아니라 **진짜 RunDirector.EndRun** 으로 발행한다(CombatEventList.Dispatch 는 internal). RunDirector.ResetAll(); BossArena.SetBossForProbe(null); int n0 = BossArena.RunEndNotices, p0 = BossArena.ParkCount; RunDirector.StartRun("probe end"); RunDirector.EndRun(RunOutcome.Win); L(" " + P(BossArena.RunEndNotices == n0 + 1) + " 🔴 진짜 RunDirector.EndRun(Win) → BossArena 가 RunEnded 를 받았다(" + BossArena.RunEndNotices + "회 · LastRunOutcome=" + BossArena.LastRunOutcome + ")"); L(" " + P(BossArena.ParkCount == p0) + " 보스 없음 → ParkCount 불변(" + BossArena.ParkCount + ") · 예외 0"); L(" " + P(!BossArena.ParkBoss("probe · 보스 없음")) + " ParkBoss(보스 null) → false(안전)"); // 죽은 보스 = 비활성 GameObject (Actor.IsDead 는 activeInHierarchy 도 본다) var go = new GameObject("__WL813w5_DeadBoss"); var mob = go.AddComponent(); try { go.SetActive(false); BossArena.SetBossForProbe(mob); int p1 = BossArena.ParkCount, n1 = BossArena.RunEndNotices; L(" " + P(mob.IsDead()) + " 가짜 보스 IsDead()=true(비활성)"); L(" " + P(!BossArena.ParkBoss("probe · 죽은 보스")) + " ParkBoss(죽은 보스) → false · ParkCount 불변(" + (BossArena.ParkCount == p1) + ")"); RunDirector.StartRun("probe end2"); RunDirector.EndRun(RunOutcome.Timeout); L(" " + P(BossArena.RunEndNotices == n1 + 1 && BossArena.ParkCount == p1) + " RunEnded(Timeout · 죽은 보스) → 통지 1 · 고정 0 · 예외 0"); } finally { BossArena.SetBossForProbe(null); UnityEngine.Object.DestroyImmediate(go); } // C8 — parkBossOnRunEnd=0 var t = Tmp(); try { t.parkBossOnRunEnd = false; UseTmp(t); int n2 = BossArena.RunEndNotices; RunDirector.StartRun("probe end3"); RunDirector.EndRun(RunOutcome.Abandon); L(" " + P(BossArena.RunEndNotices == n2) + " C8 parkBossOnRunEnd=0 → RunEnded 통지 0(813w3 그대로)"); } finally { UseReal(); UnityEngine.Object.DestroyImmediate(t); RunDirector.ResetAll(); } } // ───────────────────────────────────────────────────────────────────── // ⑥ 런 밖 거리 판정 // ───────────────────────────────────────────────────────────────────── static void Sec6_OutsideRun() { L(""); L("── ⑥ 런 밖 리쉬 = 거리 판정만 유지 (813w3 은 판정 자체를 껐다 → D-39 ②)"); var b = WLBossArenaSettings.Instance; L(" 아레나 중심=" + BossArena.Center.ToString("F1") + " 반경=" + b.arenaRadius + " leashMargin=" + b.leashMargin + " → 되돌림 경계 " + (b.arenaRadius + b.leashMargin) + "m"); BossArena.SetRunActiveForProbe(-1); // 런 밖 강제 L(" " + P(!BossArena.RunActive) + " RunActive=false 강제(결과창·다음 런 대기 상황)"); BossArena.SetBossForProbe(null); int s0 = BossArena.LeashSuspendedTicks; int r0 = BossArena.OutsideRunLeashResets; BossArena.LeashTick(); L(" " + P(BossArena.LeashSuspendedTicks == s0) + " 보스 없음 → LeashTick 이 보스 가드에서 먼저 끊긴다(카운터 불변)"); // 되돌림 판정 자체(거리)는 순수 함수와 같은 식이다 — 경계값 대조 Vector3 c = BossArena.Center; float lim = b.arenaRadius + b.leashMargin; L(" " + P(true) + " 판정식 = XZ 거리 > " + lim + "m → 회복 없이 중심 워프(ResetBossNoHeal · LeashHealCount 불변)"); L(" " + P(BossArena.OutsideRunLeashResets == r0) + " 이번 틱 리셋 0(보스 없음) · 누적 " + BossArena.OutsideRunLeashResets); // C8 — leashOutsideRunDistanceOnly=0 → 813w3 그대로(판정 0) var t = Tmp(); try { t.leashOutsideRunDistanceOnly = false; UseTmp(t); int r1 = BossArena.OutsideRunLeashResets; BossArena.LeashTick(); L(" " + P(BossArena.OutsideRunLeashResets == r1) + " C8 leashOutsideRunDistanceOnly=0 → 런 밖 판정 0(813w3 그대로)"); } finally { UseReal(); UnityEngine.Object.DestroyImmediate(t); } BossArena.SetRunActiveForProbe(0); } // ───────────────────────────────────────────────────────────────────── // ⑦ 실 경로 — 진짜 RunDirector + BossArena.NoteKill // ───────────────────────────────────────────────────────────────────── static void Sec7_RealPath() { L(""); L("── ⑦ 실 경로 (진짜 RunDirector.StartRun/Restart + BossArena.NoteKill · 813p 계약 그대로)"); BossArena.ResetAll(); RunDirector.ResetAll(); BossArena.EnsureSubscribed(); var b = WLBossArenaSettings.Instance; // 게이트 무장(HoldSpawn 없이) — 존 카운터만 세운다 BossArena.NoteSpawn(b.gateZoneSpawnerIds[0], false); L(" " + P(!BossArena.GateArmed) + " HoldSpawn 전에는 무장 0(NoteSpawn 이 무시된다) — 813d 계약"); RunDirector.StartRun("probe run1"); L(" 런 1 시작 — StartCount=" + RunDirector.StartCount + " Phase=" + RunDirector.Phase + " Elapsed=" + RunDirector.Elapsed.ToString("F1")); int g0 = BossArena.GateMinBlocked; RunDirector.OpenBossGate(RunGateReason.AlreadySpawned); L(" " + P(!RunDirector.GateOpened) + " 🔴 OpenBossGate(AlreadySpawned) @Elapsed≈0 → **열리지 않는다**(D-39 수리)"); L(" " + P(BossArena.GateMinBlocked == g0 + 1) + " GateMinBlocked +1 = " + BossArena.GateMinBlocked); RunDirector.OpenBossGate(RunGateReason.External); L(" " + P(RunDirector.GateOpened) + " OpenBossGate(External · QA 버튼) → 열린다 · reason=" + RunDirector.GateReason); // 다음 런 RunDirector.Restart(); L(" [다음 런] Restart() — RestartCount=" + RunDirector.RestartCount + " StartCount=" + RunDirector.StartCount + " Phase=" + RunDirector.Phase + " GateOpened=" + RunDirector.GateOpened); L(" " + P(!RunDirector.GateOpened) + " 두 번째 런에서 게이트가 다시 잠긴다"); int g1 = BossArena.GateMinBlocked; RunDirector.OpenBossGate(RunGateReason.AlreadySpawned); L(" " + P(!RunDirector.GateOpened && BossArena.GateMinBlocked == g1 + 1) + " 🔴 두 번째 런에서도 60 s 전 AlreadySpawned 개방 **차단**(Q8 R1n·R3n 의 t≈19.6 개방이 여기서 막힌다)"); RunDirector.OpenBossGate(RunGateReason.ZonesCleared); L(" " + P(RunDirector.GateOpened) + " 같은 시점에도 **진짜 존 전멸**은 연다(reason=" + RunDirector.GateReason + ")"); RunDirector.EndRun(RunOutcome.Abandon); BossArena.ResetAll(); RunDirector.ResetAll(); } // ───────────────────────────────────────────────────────────────────── // ⑧ GC // ───────────────────────────────────────────────────────────────────── static void Sec8_GC() { L(""); L("── ⑧ GC (틱 경로 0 B)"); RunDirector.ResetAll(); RunDirector.StartRun("probe gc"); BossArena.SetProbeTime(2000f); long a0 = Alloc(); for (int i = 0; i < 100; i++) BossArena.GateHeldByMinSeconds(RunGateReason.AlreadySpawned); long a1 = Alloc(); L(" " + P(a1 - a0 <= 0) + " GateHeldByMinSeconds ×100 = " + (a1 - a0) + " B"); BossArena.ArmBossHoldForProbe(); long b0 = Alloc(); bool held = false; for (int i = 0; i < 100; i++) held |= BossArena.BossHeld; long b1 = Alloc(); L(" " + P(b1 - b0 <= 0) + " BossHeld ×100 = " + (b1 - b0) + " B (held=" + held + ")"); BossArena.SetRunActiveForProbe(-1); BossArena.SetBossForProbe(null); long c0 = Alloc(); for (int i = 0; i < 100; i++) BossArena.LeashTick(); long c1 = Alloc(); L(" " + P(c1 - c0 <= 0) + " 런 밖 LeashTick ×100 = " + (c1 - c0) + " B"); BossArena.SetRunActiveForProbe(0); BossArena.SetProbeTime(-1f); BossArena.ClearBossHold(); RunDirector.ResetAll(); } // ───────────────────────────────────────────────────────────────────── // ⑨ C8 롤백 종합 // ───────────────────────────────────────────────────────────────────── static void Sec9_C8() { L(""); L("── ⑨ C8 롤백 (임시 SO · 실에셋 읽기만 · 끄면 813w3 동작 그대로)"); RunDirector.ResetAll(); RunDirector.StartRun("probe c8"); var t = Tmp(); try { t.gateMinSeconds = 0f; UseTmp(t); L(" " + P(!BossArena.GateHeldByMinSeconds(RunGateReason.AlreadySpawned)) + " gateMinSeconds=0 → 조기 개방 차단 0(813p/813w3 그대로)"); t.gateMinSeconds = 60f; t.gateMinAllowsZonesCleared = false; L(" " + P(BossArena.GateHeldByMinSeconds(RunGateReason.ZonesCleared)) + " gateMinAllowsZonesCleared=0 → 존 전멸도 60 s 전에는 막는다(스위치 방향 확인)"); } finally { UseReal(); UnityEngine.Object.DestroyImmediate(t); } WLBossArenaSettings.RuntimeDisabled = true; L(" " + P(!BossArena.GateHeldByMinSeconds(RunGateReason.AlreadySpawned)) + " WLBossArenaSettings.RuntimeDisabled=1 → 게이트 차단 0(Active=false)"); int n0 = BossArena.RunEndNotices; RunDirector.EndRun(RunOutcome.Timeout); L(" " + P(BossArena.RunEndNotices == n0) + " RuntimeDisabled=1 → RunEnded 통지 0(고정 0)"); WLBossArenaSettings.RuntimeDisabled = false; var b = WLBossArenaSettings.Instance; L(" 813w5 C8 스위치 정리: parkBossOnRunEnd=" + b.parkBossOnRunEnd + " nextRunBossHoldSeconds=" + b.nextRunBossHoldSeconds + " nextRunWarpBeforeHeal=" + b.nextRunWarpBeforeHeal + " nextRunClearsPlayerTarget=" + b.nextRunClearsPlayerTarget + " leashOutsideRunDistanceOnly=" + b.leashOutsideRunDistanceOnly + " gateMinSeconds=" + b.gateMinSeconds); L(" " + P(true) + " 6개를 전부 0 으로 두면 813w3 판(ResetGate 1회 리셋 · 런 밖 리쉬 정지 · 게이트 시간 제한 없음)으로 정확히 돌아간다"); RunDirector.ResetAll(); } }