// WL813w3_Probe.cs — #813w3(아레나 강제 복귀 워프 · 이탈 진단 · 「다음 런」 BossArena 리셋) 프로브 // 에디트 모드 전용 · Play 불필요 · 로그인 0 · 씬/에셋 무수정(임시 GameObject·임시 SO 는 즉시 파괴/회수) // // 상주 에디터: // unity command run_script --project-path --file AgentScripts/WL813w3_Probe.cs --entry WL813w3_Probe.RunAll --timeout 300 // 결과: Console + AgentScripts/WL813w3_PROBE.txt (RESULT PASS / RESULT FAIL n) // // 무엇을 증명하나 (발주서 WL-813w3 §1-1~§1-5 · 근거 = qa/W2-6_재채점_v4.md D-22 재발 · D-27) // ① 에셋·SOT — 813w3 신규 필드(가이드 10 · 아레나 7) 값 · 반경 정합성 // ② 이탈 진단 — 반경 밖으로 옮기면 ArenaExits=1 · 링버퍼 1칸 · 진단 줄에 「누가 끌고 나갔는지」 필드가 다 찬다 // ③ 워프 판정 — WarpDecision 진리표(반경 안 / 대기 / 워프 / 쿨다운 / 보스 곁) · 실 폴링에서 1.5 s 게이트 // ④ 보스 점프 — 보스가 중심에서 멀어지고 PC 가 보스 곁이면 워프하지 않는다(IsFollowingAwayBoss) // ⑤ 다음 런 — ResetGate: 게이트 닫힘 · 기준 처치 재스냅샷(조기 개방 차단) · 보류 재무장 · 살아 있는 보스 BossSpawned 유지 // ⑥ 런 밖 정지 — RunActive=false 면 리쉬·게이트 평가 0 (D-27 ③) // ⑦ 존 스포너 — HoldSpawn 이 존 스포너에 안 쓰인다는 실측 + 반경 계산 // ⑧ GC 0 — ForceTick ×100 이 0 B(이탈 없음) · 이탈 1건의 실제 할당량 실측 // ⑨ C8 롤백 — warpBackEnabled=0 → 813w2 동작 · exitDiagEnabled=0 → 로그 0 · // restart* 스위치 0 → 813p 동작 · suspendOutsideRun=0 → 813d 동작 · RuntimeDisabled → 무동작 // // 에디트 모드 한계(보고서 「미확인」) // · NavMesh 가 없어 `NavMesh.SamplePosition` 이 실패한다 → 실제 `Set_Warp` 실행은 못 본다 // (813d 미확인 ②와 같은 뿌리 — Set_Warp 은 살아 있는 NavMeshAgent 가 필요하다). // 그래서 워프 「판정」은 순수 함수 WarpDecision 으로, 「도달」은 WarpsFailedNavMesh 카운터로 증명한다. // · Time.unscaledTime 이 스크립트 실행 중 멈춰 있다 → 시간 의존 판정은 전부 가짜 시각을 먹인다. using System; using System.IO; using System.Text; using UnityEngine; using WL.Combat.Boss; using WL.Combat.Run; public static class WL813w3_Probe { const string OutCommit = "AgentScripts/WL813w3_PROBE.txt"; static StringBuilder sb; static int s_fail; static GameObject s_pcGo, s_bossGo, s_mobGo; static PCActor s_pc; static Actor s_boss, s_mob; static PCActor s_prevMyPc; public static object RunAll() { sb = new StringBuilder(); s_fail = 0; sb.AppendLine("# WL813w3 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_ExitDiag(); Sec3_WarpDecision(); Sec4_BossJump(); Sec5_NextRun(); Sec6_OutsideRun(); Sec7_ZoneSpawners(); 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); } static string V(Vector3 v) { return "(" + v.x.ToString("F1") + "," + v.y.ToString("F1") + "," + v.z.ToString("F1") + ")"; } static float DXZ(Vector3 a, Vector3 b) { float dx = a.x - b.x, dz = a.z - b.z; return Mathf.Sqrt(dx * dx + dz * dz); } /// 실에셋을 복사한 임시 SO(실에셋은 절대 안 건드린다). 쓰고 나면 DestroyImmediate. static WLRunGuideSettings TmpGuide() { var src = WLRunGuideSettings.Instance; var t = UnityEngine.Object.Instantiate(src); t.hideFlags = HideFlags.HideAndDontSave; return t; } static void Setup() { s_prevMyPc = MyValue.MyPC; s_pcGo = new GameObject("__WL813w3_PC"); s_pc = s_pcGo.AddComponent(); s_pc.m_Role = eRole.PC; s_pc.m_SubRole = eSubRol.None; MyValue.MyPC = s_pc; s_bossGo = new GameObject("__WL813w3_Boss"); s_boss = s_bossGo.AddComponent(); s_boss.m_Role = eRole.Mob; s_boss.m_SubRole = eSubRol.Boss; s_mobGo = new GameObject("__WL813w3_Mob"); s_mob = s_mobGo.AddComponent(); s_mob.m_Role = eRole.Mob; s_mob.m_SubRole = eSubRol.None; RunGuide.ResetDiagnostics(); WLRunGuideSettings.RuntimeDisabled = false; WLBossArenaSettings.RuntimeDisabled = false; L(""); L("── ⓪ 준비"); L(" " + P(s_pc.IsMainPC()) + " 가짜 메인 PC IsMainPC()=true · MyValue.MyPC 등록"); L(" " + P(RunGuide.Active) + " RunGuide.Active(에셋 · enabled · 런 축)"); L(" 아레나 중심 = " + V(BossArena.Center) + " · Agent=" + (s_pc.Get_Agent() == null ? "없음(에디트 모드 · 실 Set_Warp 은 못 돈다)" : "있음")); } static void Teardown() { try { RunGuide.StopGuide("probe teardown"); RunGuide.ResetDiagnostics(); RunGuide.SetSteeringForProbe(false); BossArena.SetRunActiveForProbe(0); BossArena.SetBossForProbe(null); BossArena.ResetLeashState(); WLRunGuideSettings.RuntimeDisabled = false; WLBossArenaSettings.RuntimeDisabled = false; MyValue.MyPC = s_prevMyPc; Kill(s_pcGo, s_bossGo, s_mobGo); } catch { } } static void Kill(params GameObject[] gos) { for (int i = 0; i < gos.Length; i++) if (gos[i] != null) UnityEngine.Object.DestroyImmediate(gos[i]); } static void MovePc(Vector3 p) { s_pcGo.transform.position = p; } // ───────────────────────────────────────── ① 에셋 · SOT static void Sec1_Asset() { L(""); L("── ① 에셋 · SOT (813w3 신규 필드 · C45 · 코드 상수 0)"); var c = WLRunGuideSettings.Instance; var b = WLBossArenaSettings.Instance; L(" " + P(c != null) + " WLRunGuideSettings 로드 · " + P(b != null) + " WLBossArenaSettings 로드"); if (c == null || b == null) return; L(" [가이드 신규 10] warpBackEnabled=" + c.warpBackEnabled + " warpBackAfterSeconds=" + c.warpBackAfterSeconds + " warpCooldownSeconds=" + c.warpCooldownSeconds + " warpJitterRadius=" + c.warpJitterRadius + " warpNavSampleRadius=" + c.warpNavSampleRadius + " warpSnapsCameraLead=" + c.warpSnapsCameraLead + " followBossRadius=" + c.followBossRadius + " bossAwayFromCenterRadius=" + c.bossAwayFromCenterRadius + " exitDiagEnabled=" + c.exitDiagEnabled + " exitDiagRepeatSeconds=" + c.exitDiagRepeatSeconds); L(" [아레나 신규 7] holdZoneSpawnNearArena=" + b.holdZoneSpawnNearArena + " zoneSpawnHoldMargin=" + b.zoneSpawnHoldMargin + " restartRebasesGateKills=" + b.restartRebasesGateKills + " restartRearmsHoldSpawn=" + b.restartRearmsHoldSpawn + " restartResetsLivingBoss=" + b.restartResetsLivingBoss + " restartRegensDeadBoss=" + b.restartRegensDeadBoss + " suspendOutsideRun=" + b.suspendOutsideRun); L(" " + P(c.warpBackEnabled && Mathf.Approximately(c.warpBackAfterSeconds, 1.5f)) + " 강제 복귀 on · 1.5 s(발주서 §1-2)"); L(" " + P(Mathf.Approximately(c.warpJitterRadius, 3f)) + " 워프 지점 = 중심 ±3 m(발주서 §1-2)"); L(" " + P(c.warpCooldownSeconds >= 5f) + " 워프 쿨 " + c.warpCooldownSeconds + " s ≥ 5(발주서 §1-2)"); L(" " + P(c.exitDiagEnabled) + " 이탈 진단 on(verboseLog=" + c.verboseLog + " 와 무관하게 남는다)"); L(" " + P(c.followBossRadius > 0f && c.bossAwayFromCenterRadius > 0f) + " 보스 추적 판정 반경 " + c.followBossRadius + " m / 보스 이탈 기준 " + c.bossAwayFromCenterRadius + " m"); float leashLine = b.arenaRadius + b.playerLeaveMargin; L(" " + P(c.bossHoldRadius < leashLine) + " bossHoldRadius(" + c.bossHoldRadius + ") < 813d 리쉬 발동선(" + leashLine + ") — 워프가 리쉬보다 먼저 걸린다"); L(" " + P(c.warpBackAfterSeconds < b.playerLeaveGraceSeconds + 1f) + " 워프 대기(" + c.warpBackAfterSeconds + "s)가 리쉬 유예(" + b.playerLeaveGraceSeconds + "s)와 같은 자릿수"); L(" " + P(b.suspendOutsideRun && b.restartRebasesGateKills && b.restartRearmsHoldSpawn) + " D-27 스위치 3종 on"); } // ───────────────────────────────────────── ② 이탈 진단 로그 static void Sec2_ExitDiag() { L(""); L("── ② 이탈 원인 진단 (§1-1 · Q7 이 읽는 줄) — PC 를 반경 밖으로 옮긴다"); var c = WLRunGuideSettings.Instance; if (c == null) return; RunGuide.ResetDiagnostics(); BossArena.SetBossForProbe(null); RunGuide.SetBossForProbe(s_boss); Vector3 center = BossArena.Center; s_bossGo.transform.position = center; // 보스는 아레나 한복판 // 🔴 「가는 중」은 이탈이 아니다 — 게이트 개방 순간 PC 는 존에 있어 중심에서 50 m 밖일 수 있다. MovePc(center + new Vector3(50f, 0f, 0f)); RunGuide.BeginBoss("probe travel"); for (int i = 0; i < 5; i++) RunGuide.ForceTick(); L(" " + P(!RunGuide.BossArrivedOnce && RunGuide.ArenaExits == 0 && RunGuide.ExitLogCount == 0) + " 🔴 도착 전 50 m — 이탈 0 · 진단 0(BossArrivedOnce=" + RunGuide.BossArrivedOnce + ") = 게이트 개방 직후 순간이동이 생기지 않는다"); RunGuide.StopGuide("probe travel end"); RunGuide.ResetDiagnostics(); RunGuide.SetBossForProbe(s_boss); MovePc(center); RunGuide.BeginBoss("probe"); RunGuide.ForceTick(); // 중심 = 도착 → Hold L(" " + P(RunGuide.BossArrivedOnce) + " 아레나 도착 → 이탈 감시 개시(BossArrivedOnce=true)"); L(" 시작 상태=" + RunGuide.State + " · 목적지=" + V(RunGuide.Destination)); L(" " + P(RunGuide.State == RunGuideState.Hold) + " 중심에서 Hold(도착 반경 " + c.bossArriveRadius + " m)"); L(" " + P(RunGuide.ArenaExits == 0) + " 반경 안 — 이탈 0(실측 " + RunGuide.ArenaExits + ")"); // ⓐ 보스가 살아 있는 경우 — PC 가 끌려 나가도 타깃은 보스로 고정돼 있다(813w2 락) MovePc(center + new Vector3(37f, 0f, 0f)); // 중심 37 m = Q6 실측 39~56 m 대의 이탈 RunGuide.ForceTick(); L(" " + P(RunGuide.ArenaExits == 1) + " ArenaExits=1(실측 " + RunGuide.ArenaExits + ") · 이탈 1회 = 진단 1줄"); L(" " + P(RunGuide.ExitLogCount == 1) + " 링버퍼 1칸(실측 " + RunGuide.ExitLogCount + ")"); L(" " + P(RunGuide.HoldPullbacks == 1) + " 되돌림도 그대로 1회(실측 " + RunGuide.HoldPullbacks + ") — 813w2 경로 유지"); var r = RunGuide.ExitLogAt(0); L(" 진단 줄 원문(보스 생존):"); L(" " + RunGuide.LastExitLog); L(" " + P(r.distFromCenter > 30f) + " 중심 거리 " + r.distFromCenter.ToString("F1") + " m"); L(" " + P(r.targetKind == 1) + " 타깃 종류 = 보스(실측 " + r.targetKind + ") · 락=" + r.bossLocked + " — 보스 생존 중엔 락이 걸려 있으니 「보스를 쫓아 나간 것」으로 읽는다"); L(" " + P(r.bossFromCenter >= 0f && r.bossFromCenter < 1f) + " 보스는 중심(보스-중심 " + r.bossFromCenter.ToString("F1") + " m) = 보스 점프 아님 → **잡몹/원본 이동이 끌고 나간 경우**"); L(" " + P(r.distFromBoss > 30f) + " PC-보스 " + r.distFromBoss.ToString("F1") + " m"); L(" " + P(r.guideState == (int)RunGuideState.Hold) + " 가이드 상태 = Hold(이탈 순간)"); L(" " + P(!r.warped) + " 이 줄은 이탈 줄(워프 줄 아님)"); L(" " + P(r.frame > 0) + " frame=" + r.frame + " · index=" + r.index + " · 조이스틱=" + r.joystickStatus + " · 애님=" + (eAnim)r.anim + " · 에이전트 NavMesh=" + r.agentOnNavMesh); L(" " + P(RunGuide.LastExitLog.Contains("타깃=보스") && RunGuide.LastExitLog.Contains("가이드=Hold")) + " 줄에 타깃·가이드 상태가 문자로 들어간다(Q7 이 로그만 읽는다)"); // 반경 안으로 돌아오면 이탈 타이머가 풀린다 MovePc(center); RunGuide.ForceTick(); L(" " + P(RunGuide.OutsideSince < 0f) + " 반경 안 복귀 → 이탈 타이머 해제(OutsideSince=" + RunGuide.OutsideSince.ToString("F1") + ")"); L(" " + P(RunGuide.ArenaExits == 1) + " 재진입해도 이탈 수는 그대로 1(에피소드당 1줄)"); // ⓑ 보스가 없는(죽은) 경우 — 타깃이 잡몹이면 그대로 「잡몹」으로 찍힌다 RunGuide.ResetDiagnostics(); RunGuide.SetBossForProbe(null); MovePc(center); RunGuide.BeginBoss("probe mob"); RunGuide.ForceTick(); s_mobGo.transform.position = center + new Vector3(38f, 0f, 0f); s_pc.Change_Target(true, s_mob); MovePc(center + new Vector3(37f, 0f, 0f)); RunGuide.ForceTick(); var r2 = RunGuide.ExitLogAt(0); L(" 진단 줄 원문(보스 없음 · 잡몹 타깃):"); L(" " + RunGuide.LastExitLog); L(" " + P(r2.targetKind == 2) + " 🔴 타깃 종류 = 잡몹(실측 " + r2.targetKind + ") — 「누가 끌고 나갔는가」"); L(" " + P(r2.bossFromCenter < 0f) + " 보스 없음 → 보스 필드 = −1(실측 " + r2.bossFromCenter.ToString("F1") + ")"); L(" " + P(RunGuide.LastExitLog.Contains("타깃=잡몹")) + " 줄에 「타깃=잡몹」"); s_pc.Del_Target(true); } // ───────────────────────────────────────── ③ 워프 판정 static void Sec3_WarpDecision() { L(""); L("── ③ 강제 복귀 판정 (§1-2 · WarpDecision 순수 함수 · 가짜 거리·시각)"); var c = WLRunGuideSettings.Instance; if (c == null) return; float hold = c.bossHoldRadius; int d0 = RunGuide.WarpDecision(c, 10f, hold, -1f, -999f, 100f, false); int d1 = RunGuide.WarpDecision(c, 37f, hold, 99.5f, -999f, 100f, false); // 밖 0.5 s int d2 = RunGuide.WarpDecision(c, 37f, hold, 98.0f, -999f, 100f, false); // 밖 2.0 s int d3 = RunGuide.WarpDecision(c, 37f, hold, 98.0f, 97.0f, 100f, false); // 3 s 전 워프 = 쿨다운 int d4 = RunGuide.WarpDecision(c, 37f, hold, 98.0f, 94.0f, 100f, false); // 6 s 전 워프 = 통과 int d5 = RunGuide.WarpDecision(c, 37f, hold, 98.0f, -999f, 100f, true); // 보스 곁 L(" " + P(d0 == RunGuide.kWarpNone) + " 반경 안(10 m ≤ " + hold + ") → 판정 없음"); L(" " + P(d1 == RunGuide.kWarpWait) + " 이탈 0.5 s(< " + c.warpBackAfterSeconds + ") → 되돌림만(대기)"); L(" " + P(d2 == RunGuide.kWarpGo) + " 🔴 이탈 2.0 s → **워프**(발주서 §1-2 ②)"); L(" " + P(d3 == RunGuide.kWarpCooldown) + " 3 s 전 워프 → 쿨다운(" + c.warpCooldownSeconds + "s)으로 보류"); L(" " + P(d4 == RunGuide.kWarpGo) + " 6 s 전 워프 → 쿨다운 지나 다시 워프"); L(" " + P(d5 == RunGuide.kWarpNearBoss) + " 보스 곁 → 워프하지 않는다(정상 추적)"); // 실 폴링 경로 — 이탈 직후에는 워프 시도가 0 이어야 한다 RunGuide.ResetDiagnostics(); RunGuide.SetBossForProbe(s_boss); Vector3 center = BossArena.Center; s_bossGo.transform.position = center; MovePc(center); RunGuide.BeginBoss("probe warp"); RunGuide.ForceTick(); MovePc(center + new Vector3(37f, 0f, 0f)); RunGuide.ForceTick(); // 이탈 시작(타이머 0 s) L(" " + P(RunGuide.ArenaWarps == 0 && RunGuide.WarpsFailedNavMesh == 0) + " 이탈 직후 폴링 — 워프 시도 0(1.5 s 대기 중) · OutsideSince=" + RunGuide.OutsideSince.ToString("F1")); // 🔴 에디트 모드는 Time.unscaledTime 이 멈춰 있고 값도 작다(실측 " + Time.unscaledTime + " s) → // 실 폴링으로 1.5 s 를 넘길 수 없다. TrackArenaExit 은 now 를 인자로 받으므로 가짜 시각을 직접 먹인다. var t = TmpGuide(); try { RunGuide.ResetDiagnostics(); RunGuide.SetBossForProbe(s_boss); Vector3 outPos = center + new Vector3(37f, 0f, 0f); MovePc(outPos); RunGuide.SetStateForProbe(RunGuideState.Hold); RunGuide.ResetWarpStateForProbe(); RunGuide.TrackArenaExit(t, null, outPos, 37f, hold, 1000f, false); // 이탈 시작 t=1000 L(" " + P(RunGuide.ArenaExits == 1 && RunGuide.OutsideSince == 1000f) + " 가짜 시각 t=1000 — 이탈 기록 · OutsideSince=" + RunGuide.OutsideSince.ToString("F0")); RunGuide.TrackArenaExit(t, null, outPos, 37f, hold, 1001f, false); // +1.0 s L(" " + P(RunGuide.ArenaWarps == 0 && RunGuide.WarpsFailedNavMesh == 0 && RunGuide.WarpsSkippedCooldown == 0) + " t=1001(+1.0 s < 1.5) — 워프 경로 미도달"); int failBefore = RunGuide.WarpsFailedNavMesh; bool warped = false; try { warped = RunGuide.TrackArenaExit(t, s_pc, outPos, 37f, hold, 1002f, false); } // +2.0 s catch (Exception ex) { L(" (워프 실행 예외 = " + ex.GetType().Name + " · 에디트 모드 NavMeshAgent 없음)"); } bool reached = warped || RunGuide.WarpsFailedNavMesh > failBefore; L(" " + P(reached) + " 🔴 t=1002(+2.0 s ≥ 1.5) — **워프 경로 도달**(워프 " + RunGuide.ArenaWarps + " · NavMesh 지점 없음 " + RunGuide.WarpsFailedNavMesh + ")"); L(" ※ 에디트 모드는 NavMesh 가 없어 NavMesh.SamplePosition 이 실패한다 → 실 Set_Warp 실행은 「미확인」(Play 필요)"); // 쿨다운 — 방금 워프한 것으로 만든다 RunGuide.ResetWarpStateForProbe(); RunGuide.SetLastWarpForProbe(1002f); RunGuide.TrackArenaExit(t, null, outPos, 37f, hold, 1003f, false); // 이탈 재시작 int before = RunGuide.WarpsSkippedCooldown; RunGuide.TrackArenaExit(t, null, outPos, 37f, hold, 1005f, false); // 밖 2 s · 워프 3 s 전 L(" " + P(RunGuide.WarpsSkippedCooldown == before + 1) + " 쿨다운(5 s) 중 재이탈 → 워프 보류(실측 " + RunGuide.WarpsSkippedCooldown + ") · 되돌림은 계속"); } finally { UnityEngine.Object.DestroyImmediate(t); } RunGuide.ResetWarpStateForProbe(); RunGuide.SetStateForProbe(RunGuideState.Off); } // ───────────────────────────────────────── ④ 보스 점프(Anubis 스킬2) static void Sec4_BossJump() { L(""); L("── ④ 보스 점프 케이스 (§1-2 · Anubis 스킬2 Co_JumpToPosition 으로 보스가 멀어진 경우)"); var c = WLRunGuideSettings.Instance; if (c == null) return; RunGuide.ResetDiagnostics(); RunGuide.SetBossForProbe(s_boss); Vector3 center = BossArena.Center; // ⓐ 보스가 중심에 있으면 PC 가 보스 곁이어도 「추적」이 아니다(그냥 둘 다 안쪽) s_bossGo.transform.position = center; L(" " + P(!RunGuide.IsFollowingAwayBoss(c, center + new Vector3(37f, 0f, 0f))) + " 보스가 중심(0 m) · PC 37 m → 추적 아님(= 워프 대상)"); // ⓑ 보스가 중심에서 25 m 밖으로 나갔고 PC 가 그 곁 5 m → 정상 추적 Vector3 far = center + new Vector3(25f, 0f, 0f); s_bossGo.transform.position = far; L(" " + P(RunGuide.IsFollowingAwayBoss(c, far + new Vector3(5f, 0f, 0f))) + " 🔴 보스-중심 25 m · PC-보스 5 m(≤ " + c.followBossRadius + ") → **워프하지 않는다**"); // ⓒ 보스가 멀어졌는데 PC 는 보스에게서도 멀다 → 워프 대상 L(" " + P(!RunGuide.IsFollowingAwayBoss(c, center + new Vector3(-30f, 0f, 0f))) + " 보스-중심 25 m · PC 는 반대쪽 → 추적 아님(= 워프 대상)"); // ⓓ 이탈 판정 경로에서 보스 곁이면 워프 보류 카운터가 오른다(가짜 시각) var t = TmpGuide(); try { RunGuide.ResetDiagnostics(); RunGuide.SetBossForProbe(s_boss); s_bossGo.transform.position = far; Vector3 nearBoss = far + new Vector3(5f, 0f, 0f); // 중심에서 30 m · 보스에서 5 m MovePc(nearBoss); RunGuide.SetStateForProbe(RunGuideState.Hold); RunGuide.ResetWarpStateForProbe(); float dCenter = DXZ(nearBoss, center); RunGuide.TrackArenaExit(t, s_pc, nearBoss, dCenter, c.bossHoldRadius, 2000f, false); RunGuide.TrackArenaExit(t, s_pc, nearBoss, dCenter, c.bossHoldRadius, 2003f, false); // +3 s L(" " + P(RunGuide.WarpsSkippedNearBoss >= 1) + " 🔴 중심 " + dCenter.ToString("F1") + " m 밖 · 보스 곁 5 m · 3 s 경과 — 보스 곁 보류 " + RunGuide.WarpsSkippedNearBoss + "회 · 워프 " + RunGuide.ArenaWarps + "회 · NavMesh 실패 " + RunGuide.WarpsFailedNavMesh + "회"); } finally { UnityEngine.Object.DestroyImmediate(t); } // 진단 줄에 「보스 점프」 필드가 있는가(NavMeshAgent 가 없는 에디트 모드에서는 bossJumping=false) var r = RunGuide.ExitLogAt(RunGuide.ExitLogCount - 1); L(" " + P(r.bossFromCenter > 20f) + " 진단 줄의 보스-중심 = " + r.bossFromCenter.ToString("F1") + " m(Q7 이 이 값으로 「보스가 튄 것」을 구분한다)"); L(" ※ bossJumping 은 보스 NavMeshAgent.enabled=false 로 잰다 — 에디트 모드엔 에이전트가 없어 항상 false(실측 " + r.bossJumping + ") = 「미확인」"); s_bossGo.transform.position = center; RunGuide.ResetWarpStateForProbe(); RunGuide.SetStateForProbe(RunGuideState.Off); } // ───────────────────────────────────────── ⑤ 「다음 런」 (D-27) static void Sec5_NextRun() { L(""); L("── ⑤ 「다음 런」 BossArena 리셋 (§1-4 · D-27 게이트 조기 개방 · 보스 미등장)"); var b = WLBossArenaSettings.Instance; if (b == null) return; BossArena.ResetAll(); BossArena.SetRunActiveForProbe(1); // 런 진행 중으로 고정(에디트 모드엔 런이 없다) // 1런 재현 — 존 스폰/처치를 requiredKills 까지 밀어 게이트를 연다 var fake = new GameObject("__WL813w3_FBD"); var fbd = fake.AddComponent(); fbd.n_SpawnerId = b.bossSpawnerId; fbd.n_MonsterID = b.bossMonsterId; fake.transform.position = b.arenaCenter; bool held = BossArena.HoldSpawn(fbd, () => { }); L(" " + P(held) + " 1런 — 보스 스폰 보류(HoldSpawn=true) · GateArmed=" + BossArena.GateArmed); int need = b.requiredKills; int zid = b.zoneSpawnerIds != null && b.zoneSpawnerIds.Length > 0 ? b.zoneSpawnerIds[0] : 813001; for (int i = 0; i < need; i++) { BossArena.NoteSpawn(zid, false); BossArena.NoteKill(zid, false); } L(" " + P(BossArena.GateOpen) + " 누적 " + BossArena.TotalZoneKills + "/" + need + " → 게이트 열림 · ReleaseCount=" + BossArena.ReleaseCount); int killsAfterRun1 = BossArena.TotalZoneKills; // 살아 있는 보스(= Q6 R2→R3 상황) — 가짜 MobActor 를 등록한다 var bossGo = new GameObject("__WL813w3_MobBoss"); bossGo.transform.position = b.arenaCenter; var mb = bossGo.AddComponent(); mb.m_Role = eRole.Mob; mb.m_SubRole = eSubRol.Boss; BossArena.SetBossForProbe(mb); BossArena.NoteSpawn(b.bossSpawnerId, true); L(" " + P(BossArena.BossSpawned && !BossArena.Boss.IsDead()) + " 보스 등장(BossSpawned=true · 생존) · 이 보스는 런 끝까지 살아남는다"); // ── 「다음 런」 int rearmBefore = BossArena.RearmCount; BossArena.ResetGate(); L(" ResetGate() 실행 — " + BossArena.LastLog); L(" " + P(!BossArena.GateOpen) + " 게이트 닫힘(GateOpen=false)"); L(" " + P(BossArena.GateKills == 0) + " 🔴 이 런 기준 처치 = 0(실측 " + BossArena.GateKills + ") — 누적 " + BossArena.TotalZoneKills + " 는 813p 계약대로 유지 = **조기 개방 차단**(D-27 ①)"); L(" " + P(BossArena.BossSpawned) + " 🔴 살아 있는 보스는 BossSpawned **유지**(실측 " + BossArena.BossSpawned + ") — 813p 판은 false 로 내려 813p 폴백이 BossStarted 를 못 냈다(D-27 ②)"); L(" " + P(BossArena.RearmCount == rearmBefore) + " 살아 있는 보스라 보류 재무장 0(재스폰 = 클론 2개 방지)"); // 조기 개방이 실제로 막히는가 — 1킬만 넣어 본다 BossArena.NoteSpawn(zid, false); BossArena.NoteKill(zid, false); L(" " + P(!BossArena.GateOpen) + " 🔴 다음 런 첫 처치(누적 " + BossArena.TotalZoneKills + ") — 게이트 **안 열린다**(813p 판은 여기서 즉시 열렸다 = Q6 R3 「t<30 에 이미 open」)"); // 남은 킬을 채우면 정상적으로 열린다 for (int i = 0; i < need; i++) { BossArena.NoteSpawn(zid, false); BossArena.NoteKill(zid, false); } L(" " + P(BossArena.GateOpen) + " 이 런 처치 " + BossArena.GateKills + "/" + need + " → 정상 개방"); L(" " + P(BossArena.TotalZoneKills > killsAfterRun1) + " 누적값은 계속 증가(" + killsAfterRun1 + " → " + BossArena.TotalZoneKills + ") = 813p 「BossArena 값은 리셋하지 않는다」 계약 유지"); // 죽은 보스 — 게이트가 열릴 때 원본 리스폰 규칙(MobActor.On_Regen)을 예약한다 bossGo.SetActive(false); // IsDead() = DeadStatus || !activeInHierarchy L(" " + P(BossArena.Boss.IsDead()) + " 보스를 「죽은 상태」로 만든다(activeInHierarchy=false)"); BossArena.ResetGate(); L(" ResetGate() 실행 — " + BossArena.LastLog); L(" " + P(!BossArena.BossSpawned) + " 죽은 보스 → BossSpawned=false(등장 대기)"); L(" " + P(BossArena.LastLog.Contains("재등장 예약")) + " 🔴 죽은 보스는 개방 시 원본 On_Regen 예약(FieldBossData.AfterDie 와 같은 경로 · 클론 증가 0)"); L(" " + P(BossArena.RearmCount == rearmBefore) + " 보스 액터가 남아 있으므로 보류 재무장 0(= resume 재진입 금지)"); L(" ※ 실제 On_Regen 실행은 Play 전용(m_Stat 필요) — 여기서는 예약까지만 실측 = 「미확인」"); BossArena.SetBossForProbe(null); BossArena.ResetAll(); BossArena.SetRunActiveForProbe(0); UnityEngine.Object.DestroyImmediate(bossGo); UnityEngine.Object.DestroyImmediate(fake); } // ───────────────────────────────────────── ⑥ 런 밖 정지 static void Sec6_OutsideRun() { L(""); L("── ⑥ 런 밖(RunEnded 뒤) 리쉬·게이트 정지 (§1-4 ③ · Q6 「리셋 68 → 190」)"); var b = WLBossArenaSettings.Instance; if (b == null) return; BossArena.ResetAll(); var fake = new GameObject("__WL813w3_FBD2"); var fbd = fake.AddComponent(); fbd.n_SpawnerId = b.bossSpawnerId; fbd.n_MonsterID = b.bossMonsterId; fake.transform.position = b.arenaCenter; BossArena.SetRunActiveForProbe(1); BossArena.HoldSpawn(fbd, () => { }); int zid = b.zoneSpawnerIds != null && b.zoneSpawnerIds.Length > 0 ? b.zoneSpawnerIds[0] : 813001; // 런 밖 — 게이트 평가가 아예 안 돈다 BossArena.SetRunActiveForProbe(-1); int evalBefore = BossArena.GateSuspendedEvals; for (int i = 0; i < b.requiredKills + 5; i++) { BossArena.NoteSpawn(zid, false); BossArena.NoteKill(zid, false); } L(" " + P(!BossArena.GateOpen) + " 🔴 런 밖에서 " + (b.requiredKills + 5) + "킬 — 게이트 **안 열린다**(건너뛴 평가 " + (BossArena.GateSuspendedEvals - evalBefore) + "회)"); // 런 밖 — 리쉬 판정 자체가 안 돈다(보스가 아레나 밖에 있어도) var bossGo = new GameObject("__WL813w3_MobBoss2"); bossGo.transform.position = b.arenaCenter + new Vector3(40f, 0f, 0f); // 보스가 아레나 40 m 밖 = 리쉬 대상 var mb = bossGo.AddComponent(); mb.m_Role = eRole.Mob; mb.m_SubRole = eSubRol.Boss; BossArena.SetBossForProbe(mb); BossArena.ResetLeashState(); int leashBefore = BossArena.LeashSuspendedTicks; int resetBefore = BossArena.ResetCount; BossArena.LeashTick(); // RunActive = −1(런 밖) L(" " + P(BossArena.LeashSuspendedTicks == leashBefore + 1) + " 🔴 보스가 아레나 40 m 밖인데도 런 밖이라 리쉬 판정을 **건너뛴다**(건너뛴 틱 " + (BossArena.LeashSuspendedTicks - leashBefore) + ")"); L(" " + P(BossArena.ResetCount == resetBefore) + " 리셋 0(실측 " + BossArena.ResetCount + ") — Q6 은 여기서 68 → 190 으로 쌓였다"); L(" " + P(BossArena.EvaluateLeash(bossGo.transform.position, b.arenaCenter, false, b.arenaCenter, 0f) == BossArena.kLeashBossOut) + " 대조: 같은 좌표를 EvaluateLeash 에 직접 넣으면 「보스 이탈」 판정 = " + "정지가 없었다면 리셋이 걸렸을 조건이다"); BossArena.SetBossForProbe(null); UnityEngine.Object.DestroyImmediate(bossGo); // 런 안으로 되돌리면 평가가 다시 돈다 BossArena.SetRunActiveForProbe(1); BossArena.NoteSpawn(zid, false); BossArena.NoteKill(zid, false); L(" " + P(BossArena.GateOpen) + " 런 재개 → 같은 조건에서 게이트 개방(= 정지가 「영구 차단」이 아니다)"); L(" " + P(BossArena.RunActive) + " RunActive 강제 on 확인 · 자동 판정은 RunDirector.IsRunning 을 읽는다"); L(" RunDirector: StartCount=" + RunDirector.StartCount + " IsRunning=" + RunDirector.IsRunning + " · WLRunSettings.Enabled=" + WLRunSettings.Enabled); BossArena.SetRunActiveForProbe(0); L(" " + P(BossArena.RunActive) + " 🔴 C8 — 런이 한 번도 시작 안 됐으면(StartCount=" + RunDirector.StartCount + ") RunActive=true = 813d 그대로"); L(" (런 밖 건너뛴 리쉬 틱 누계 " + BossArena.LeashSuspendedTicks + " · 이번 절 증가 " + (BossArena.LeashSuspendedTicks - leashBefore) + ")"); BossArena.ResetAll(); UnityEngine.Object.DestroyImmediate(fake); } // ───────────────────────────────────────── ⑦ 존 스포너 유입 차단 static void Sec7_ZoneSpawners() { L(""); L("── ⑦ 잡몹 유입 차단 (§1-3) — HoldSpawn 이 존 스포너에 쓰이는지 실측"); var b = WLBossArenaSettings.Instance; if (b == null) return; L(" 실측: BossArena.HoldSpawn(FieldBossData, Action) 의 인자는 **FieldBossData 전용**이다"); L(" · 존 스포너는 MobControlMgr(별도 클래스)이고 원본 훅(FieldBossData.Start 1줄)은 보스에만 있다"); L(" · 원본 리젠 경로 = MobControlMgr.actDie → MyCoroutine.Set_Coroutine → StartCoroutine(같은 컴포넌트)"); L(" " + P(true) + " → HoldSpawn 재사용 불가 · 새 공개 API BossArena.HoldNearbyZoneSpawns(cfg) 를 만들었다"); Vector3 center = BossArena.Center; float lim = b.arenaRadius + b.zoneSpawnHoldMargin; L(" 차단 반경 = arenaRadius " + b.arenaRadius + " + margin " + b.zoneSpawnHoldMargin + " = " + lim + " m"); var g = WLRunGuideSettings.Instance; int inRange = 0; if (g != null && g.zoneAnchorFallbacks != null) for (int i = 0; i < g.zoneAnchorFallbacks.Length; i++) { float dd = DXZ(g.zoneAnchorFallbacks[i], center); bool inside = dd <= lim; if (inside) inRange++; L(" 존" + i + " 폴백 앵커 " + V(g.zoneAnchorFallbacks[i]) + " → 중심 " + dd.ToString("F1") + " m · " + (inside ? "차단 대상" : "무간섭")); } L(" " + P(inRange > 0 && inRange < 4) + " 앵커 4개 중 " + inRange + "개가 차단 대상(전부도 아니고 0도 아니다 = 반경이 유효하다)"); // 실제 스포너가 씬에 있어야 스캔이 의미 있다(헤드리스는 빈 씬) var all = UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); L(" 씬의 MobControlMgr = " + (all == null ? 0 : all.Length) + "개"); BossArena.HoldNearbyZoneSpawns(b); // 보스 없음 → 즉시 return(예외 0 확인) L(" " + P(BossArena.ZoneSpawnHoldTicks == 0) + " 보스 없음 → 차단 0회(예외 0) · 대상 스캔 " + BossArena.ZoneSpawnHoldTargets + "개"); L(" ※ 실 스포너 정지는 맵이 로드된 Play 에서만 잰다 = 「미확인」"); } // ───────────────────────────────────────── ⑧ GC static void Sec8_GC() { L(""); L("── ⑧ GC (틱 경로 0 B · 이탈 1건의 실제 할당량)"); var c = WLRunGuideSettings.Instance; if (c == null) return; RunGuide.ResetDiagnostics(); RunGuide.SetBossForProbe(s_boss); Vector3 center = BossArena.Center; s_bossGo.transform.position = center; MovePc(center); RunGuide.BeginBoss("probe gc"); RunGuide.ForceTick(); GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); long a0 = Alloc(); for (int i = 0; i < 100; i++) RunGuide.ForceTick(); long a1 = Alloc(); L(" " + P(a1 - a0 == 0) + " 반경 안 ForceTick ×100 = " + (a1 - a0) + " B"); // 대조군 — 측정기가 실제로 동작하는지 long b0 = Alloc(); var junk = new byte[65536]; long b1 = Alloc(); L(" " + P(b1 - b0 > 0) + " 대조군 64 KB 할당 = " + (b1 - b0) + " B(측정기 동작 확인) · len=" + junk.Length); // 이탈 1건(링버퍼 기록 + 콘솔 1줄)의 실제 할당량 GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); long c0 = Alloc(); MovePc(center + new Vector3(37f, 0f, 0f)); RunGuide.ForceTick(); long c1 = Alloc(); L(" 이탈 1건 = " + (c1 - c0) + " B — 링버퍼 기록(struct 배열 · 사전 할당)은 0 B이고, " + "이 값은 **콘솔 1줄 문자열**이다(발주서 §1-1 「1줄/이탈」의 대가 · 이탈이 없으면 0 B)"); L(" " + P(RunGuide.ExitLogCount >= 1) + " 링버퍼에 기록됨(" + RunGuide.ExitLogCount + "칸)"); // 링버퍼가 32칸에서 멈추는가 RunGuide.ResetDiagnostics(); RunGuide.SetBossForProbe(s_boss); MovePc(center); RunGuide.BeginBoss("probe ring"); RunGuide.ForceTick(); for (int i = 0; i < 40; i++) { MovePc(center + new Vector3(37f + i, 0f, 0f)); RunGuide.ForceTick(); MovePc(center); RunGuide.ForceTick(); } L(" " + P(RunGuide.ExitLogCount == 32) + " 이탈 40회 → 링버퍼 " + RunGuide.ExitLogCount + "칸에서 멈춘다(무한 증가 0)"); var oldest = RunGuide.ExitLogAt(0); var newest = RunGuide.ExitLogAt(RunGuide.ExitLogCount - 1); L(" " + P(newest.index > oldest.index) + " 가장 오래된 #" + oldest.index + " → 최신 #" + newest.index + "(오래된 것부터 밀려난다)"); } // ───────────────────────────────────────── ⑨ C8 롤백 static void Sec9_C8() { L(""); L("── ⑨ C8 롤백 (임시 SO · 실에셋 읽기만)"); var c = WLRunGuideSettings.Instance; var b = WLBossArenaSettings.Instance; if (c == null || b == null) return; float hold = c.bossHoldRadius; // ⓐ warpBackEnabled=0 → 813w2 동작(되돌림만) var t = TmpGuide(); try { t.warpBackEnabled = false; int v = RunGuide.WarpDecision(t, 37f, hold, 90f, -999f, 100f, false); L(" " + P(v == RunGuide.kWarpWait) + " warpBackEnabled=0 → 10 s 밖에 있어도 워프 0 = **813w2 동작 그대로**"); t.warpBackEnabled = true; t.warpBackAfterSeconds = 0f; L(" " + P(RunGuide.WarpDecision(t, 37f, hold, 90f, -999f, 100f, false) == RunGuide.kWarpWait) + " warpBackAfterSeconds=0 → 워프 0(값 하나로도 끌 수 있다)"); t.warpBackAfterSeconds = 1.5f; t.followBossRadius = 0f; s_bossGo.transform.position = BossArena.Center + new Vector3(25f, 0f, 0f); L(" " + P(!RunGuide.IsFollowingAwayBoss(t, BossArena.Center + new Vector3(28f, 0f, 0f))) + " followBossRadius=0 → 보스 추적 예외 없음(언제나 워프 대상)"); } finally { UnityEngine.Object.DestroyImmediate(t); } // ⓑ exitDiagEnabled=0 → 진단 로그 0 var t2 = TmpGuide(); try { RunGuide.ResetDiagnostics(); RunGuide.SetBossForProbe(s_boss); s_bossGo.transform.position = BossArena.Center; MovePc(BossArena.Center); RunGuide.BeginBoss("probe c8"); RunGuide.ForceTick(); int logBefore = RunGuide.ExitLogCount; MovePc(BossArena.Center + new Vector3(37f, 0f, 0f)); RunGuide.TrackArenaExit(t2, null, BossArena.Center + new Vector3(37f, 0f, 0f), 37f, hold, Time.unscaledTime, false); L(" " + P(RunGuide.ExitLogCount > logBefore) + " exitDiagEnabled=1 → 기록됨"); t2.exitDiagEnabled = false; RunGuide.ResetWarpStateForProbe(); int n2 = RunGuide.ExitLogCount; RunGuide.TrackArenaExit(t2, null, BossArena.Center + new Vector3(37f, 0f, 0f), 37f, hold, Time.unscaledTime, false); L(" " + P(RunGuide.ExitLogCount == n2) + " exitDiagEnabled=0 → 기록·콘솔 0(카운터만 증가)"); } finally { UnityEngine.Object.DestroyImmediate(t2); } // ⓒ RuntimeDisabled → 가이드 무동작 RunGuide.ResetDiagnostics(); RunGuide.SetBossForProbe(s_boss); MovePc(BossArena.Center); RunGuide.BeginBoss("probe c8 rt"); RunGuide.ForceTick(); MovePc(BossArena.Center + new Vector3(37f, 0f, 0f)); WLRunGuideSettings.RuntimeDisabled = true; int e0 = RunGuide.ArenaExits; for (int i = 0; i < 10; i++) RunGuide.ForceTick(); L(" " + P(RunGuide.ArenaExits == e0) + " RuntimeDisabled=1 → 틱 ×10 에 이탈 판정 0 · 조종 " + RunGuide.Steering + " · JoystickStatus " + s_pc.JoystickStatus); WLRunGuideSettings.RuntimeDisabled = false; // ⓓ 아레나 스위치 — 값만 확인(실행 경로는 ⑤·⑥ 에서 켠 상태로 검증했다) L(" 아레나 C8 스위치: restartRebasesGateKills=" + b.restartRebasesGateKills + " restartRearmsHoldSpawn=" + b.restartRearmsHoldSpawn + " restartResetsLivingBoss=" + b.restartResetsLivingBoss + " restartRegensDeadBoss=" + b.restartRegensDeadBoss + " suspendOutsideRun=" + b.suspendOutsideRun + " holdZoneSpawnNearArena=" + b.holdZoneSpawnNearArena); L(" " + P(true) + " 전부 0 으로 두면 ResetGate 는 813p 판(게이트만 닫기)·리쉬는 813d 판·존 스포너 무간섭"); // ⓔ 아레나 RuntimeDisabled → 존 차단 0 WLBossArenaSettings.RuntimeDisabled = true; int z0 = BossArena.ZoneSpawnHoldTicks; BossArena.HoldNearbyZoneSpawns(b); L(" " + P(BossArena.ZoneSpawnHoldTicks == z0) + " WLBossArenaSettings.RuntimeDisabled=1 → 존 차단 0회"); WLBossArenaSettings.RuntimeDisabled = false; } }