Project_WL/AgentScripts/WL813w3_Probe.cs

714 lines
42 KiB
C#
Raw Normal View History

// 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;
}
}