Project_WL/AgentScripts/WL813w5_Probe.cs

543 lines
30 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

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

// WL813w5_Probe.cs — #813w5(「다음 런」 앞판 보스 정리 · 게이트 조기 개방 방지) 프로브
// 에디트 모드 전용 · Play 불필요 · 로그인 0 · 씬/에셋 무수정(임시 GameObject·임시 SO 는 즉시 파괴/회수)
//
// 상주 에디터:
// unity command run_script --project-path <wt> --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); }
/// <summary>실에셋을 복사한 임시 SO(실에셋은 절대 안 건드린다). 쓰고 나면 DestroyImmediate + 캐시 원복.</summary>
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<PCActor>();
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<MobActor>();
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();
}
}