Project_WL/AgentScripts/WL813w3_Probe.cs

714 lines
42 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.

// WL813w3_Probe.cs — #813w3(아레나 강제 복귀 워프 · 이탈 진단 · 「다음 런」 BossArena 리셋) 프로브
// 에디트 모드 전용 · Play 불필요 · 로그인 0 · 씬/에셋 무수정(임시 GameObject·임시 SO 는 즉시 파괴/회수)
//
// 상주 에디터:
// unity command run_script --project-path <wt> --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); }
/// <summary>실에셋을 복사한 임시 SO(실에셋은 절대 안 건드린다). 쓰고 나면 DestroyImmediate.</summary>
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<PCActor>();
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<Actor>();
s_boss.m_Role = eRole.Mob; s_boss.m_SubRole = eSubRol.Boss;
s_mobGo = new GameObject("__WL813w3_Mob");
s_mob = s_mobGo.AddComponent<Actor>();
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<FieldBossData>();
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<MobActor>();
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<FieldBossData>();
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<MobActor>();
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<MobControlMgr>(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;
}
}