From 8135ab36a64a260de24a6625fd2c00ce0ee0f6bc Mon Sep 17 00:00:00 2001 From: swrring Date: Wed, 9 Sep 2026 14:47:10 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[WL-813w3]=20=EB=9F=B0=20=EA=B0=80=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=203=EC=B0=A8:=20=EC=95=84=EB=A0=88=EB=82=98=20?= =?UTF-8?q?=EC=9B=8C=ED=94=84=20=EB=B3=B5=EA=B7=80=20=C2=B7=20=EC=9D=B4?= =?UTF-8?q?=ED=83=88=20=EC=A7=84=EB=8B=A8=20=C2=B7=20=EB=8B=A4=EC=9D=8C=20?= =?UTF-8?q?=EB=9F=B0=20=EB=A6=AC=EC=85=8B=20(#813)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AgentScripts/WL813w3_PROBE.txt | 133 ++++ AgentScripts/WL813w3_Probe.cs | 700 ++++++++++++++++++ Assets/WL/Combat/Boss/BossArena.cs | 250 ++++++- Assets/WL/Combat/Boss/WLBossArenaSettings.cs | 34 + Assets/WL/Combat/Run/RunGuide.cs | 312 ++++++++ Assets/WL/Combat/Run/WLRunGuideSettings.cs | 42 ++ .../Resources/WL/WLBossArenaSettings.asset | 7 + .../Resources/WL/WLRunGuideSettings.asset | 10 + 8 files changed, 1475 insertions(+), 13 deletions(-) create mode 100644 AgentScripts/WL813w3_PROBE.txt create mode 100644 AgentScripts/WL813w3_Probe.cs diff --git a/AgentScripts/WL813w3_PROBE.txt b/AgentScripts/WL813w3_PROBE.txt new file mode 100644 index 000000000..d5a1fc8c7 --- /dev/null +++ b/AgentScripts/WL813w3_PROBE.txt @@ -0,0 +1,133 @@ +# WL813w3 Probe 2026-09-09 14:46:42 (edit mode · Play 0 · 로그인 0) +playMode=False + +── ⓪ 준비 + [PASS] 가짜 메인 PC IsMainPC()=true · MyValue.MyPC 등록 + [PASS] RunGuide.Active(에셋 · enabled · 런 축) + 아레나 중심 = (139.0,0.8,-20.0) · Agent=없음(에디트 모드 · 실 Set_Warp 은 못 돈다) + +── ① 에셋 · SOT (813w3 신규 필드 · C45 · 코드 상수 0) + [PASS] WLRunGuideSettings 로드 · [PASS] WLBossArenaSettings 로드 + [가이드 신규 10] warpBackEnabled=True warpBackAfterSeconds=1.5 warpCooldownSeconds=5 warpJitterRadius=3 warpNavSampleRadius=5 warpSnapsCameraLead=True followBossRadius=12 bossAwayFromCenterRadius=6 exitDiagEnabled=True exitDiagRepeatSeconds=0 + [아레나 신규 7] holdZoneSpawnNearArena=True zoneSpawnHoldMargin=10 restartRebasesGateKills=True restartRearmsHoldSpawn=True restartResetsLivingBoss=True restartRegensDeadBoss=True suspendOutsideRun=True + [PASS] 강제 복귀 on · 1.5 s(발주서 §1-2) + [PASS] 워프 지점 = 중심 ±3 m(발주서 §1-2) + [PASS] 워프 쿨 5 s ≥ 5(발주서 §1-2) + [PASS] 이탈 진단 on(verboseLog=False 와 무관하게 남는다) + [PASS] 보스 추적 판정 반경 12 m / 보스 이탈 기준 6 m + [PASS] bossHoldRadius(14) < 813d 리쉬 발동선(24) — 워프가 리쉬보다 먼저 걸린다 + [PASS] 워프 대기(1.5s)가 리쉬 유예(1.5s)와 같은 자릿수 + [PASS] D-27 스위치 3종 on + +── ② 이탈 원인 진단 (§1-1 · Q7 이 읽는 줄) — PC 를 반경 밖으로 옮긴다 + 시작 상태=Hold · 목적지=(139.0,0.8,-20.0) + [PASS] 중심에서 Hold(도착 반경 10 m) + [PASS] 반경 안 — 이탈 0(실측 0) + [PASS] ArenaExits=1(실측 1) · 이탈 1회 = 진단 1줄 + [PASS] 링버퍼 1칸(실측 1) + [PASS] 되돌림도 그대로 1회(실측 1) — 813w2 경로 유지 + 진단 줄 원문(보스 생존): + #1 t=221.5s f=12 · 중심 37.0m · 보스 37.0m(보스-중심 0.0m 점프=N 락=Y) · 타깃=보스 · 조이스틱=0(조종 N) · 애님=Idle CC=N · 에이전트 v=0.0 정지=N NavMesh=N 목적지-중심=-1.0m · 가이드=Hold · 피격=없음 + [PASS] 중심 거리 37.0 m + [PASS] 타깃 종류 = 보스(실측 1) · 락=True — 보스 생존 중엔 락이 걸려 있으니 「보스를 쫓아 나간 것」으로 읽는다 + [PASS] 보스는 중심(보스-중심 0.0 m) = 보스 점프 아님 → **잡몹/원본 이동이 끌고 나간 경우** + [PASS] PC-보스 37.0 m + [PASS] 가이드 상태 = Hold(이탈 순간) + [PASS] 이 줄은 이탈 줄(워프 줄 아님) + [PASS] frame=12 · index=1 · 조이스틱=0 · 애님=Idle · 에이전트 NavMesh=False + [PASS] 줄에 타깃·가이드 상태가 문자로 들어간다(Q7 이 로그만 읽는다) + [PASS] 반경 안 복귀 → 이탈 타이머 해제(OutsideSince=-1.0) + [PASS] 재진입해도 이탈 수는 그대로 1(에피소드당 1줄) + 진단 줄 원문(보스 없음 · 잡몹 타깃): + #1 t=221.5s f=12 · 중심 37.0m · 보스 -1.0m(보스-중심 -1.0m 점프=N 락=N) · 타깃=잡몹 · 조이스틱=0(조종 N) · 애님=Idle CC=N · 에이전트 v=0.0 정지=N NavMesh=N 목적지-중심=-1.0m · 가이드=Hold · 피격=없음 + [PASS] 🔴 타깃 종류 = 잡몹(실측 2) — 「누가 끌고 나갔는가」 + [PASS] 보스 없음 → 보스 필드 = −1(실측 -1.0) + [PASS] 줄에 「타깃=잡몹」 + +── ③ 강제 복귀 판정 (§1-2 · WarpDecision 순수 함수 · 가짜 거리·시각) + [PASS] 반경 안(10 m ≤ 14) → 판정 없음 + [PASS] 이탈 0.5 s(< 1.5) → 되돌림만(대기) + [PASS] 🔴 이탈 2.0 s → **워프**(발주서 §1-2 ②) + [PASS] 3 s 전 워프 → 쿨다운(5s)으로 보류 + [PASS] 6 s 전 워프 → 쿨다운 지나 다시 워프 + [PASS] 보스 곁 → 워프하지 않는다(정상 추적) + [PASS] 이탈 직후 폴링 — 워프 시도 0(1.5 s 대기 중) · OutsideSince=221.5 + [PASS] 가짜 시각 t=1000 — 이탈 기록 · OutsideSince=1000 + [PASS] t=1001(+1.0 s < 1.5) — 워프 경로 미도달 + [PASS] 🔴 t=1002(+2.0 s ≥ 1.5) — **워프 경로 도달**(워프 0 · NavMesh 지점 없음 1) + ※ 에디트 모드는 NavMesh 가 없어 NavMesh.SamplePosition 이 실패한다 → 실 Set_Warp 실행은 「미확인」(Play 필요) + [PASS] 쿨다운(5 s) 중 재이탈 → 워프 보류(실측 1) · 되돌림은 계속 + +── ④ 보스 점프 케이스 (§1-2 · Anubis 스킬2 Co_JumpToPosition 으로 보스가 멀어진 경우) + [PASS] 보스가 중심(0 m) · PC 37 m → 추적 아님(= 워프 대상) + [PASS] 🔴 보스-중심 25 m · PC-보스 5 m(≤ 12) → **워프하지 않는다** + [PASS] 보스-중심 25 m · PC 는 반대쪽 → 추적 아님(= 워프 대상) + [PASS] 🔴 중심 30.0 m 밖 · 보스 곁 5 m · 3 s 경과 — 보스 곁 보류 1회 · 워프 0회 · NavMesh 실패 0회 + [PASS] 진단 줄의 보스-중심 = 25.0 m(Q7 이 이 값으로 「보스가 튄 것」을 구분한다) + ※ bossJumping 은 보스 NavMeshAgent.enabled=false 로 잰다 — 에디트 모드엔 에이전트가 없어 항상 false(실측 False) = 「미확인」 + +── ⑤ 「다음 런」 BossArena 리셋 (§1-4 · D-27 게이트 조기 개방 · 보스 미등장) + [PASS] 1런 — 보스 스폰 보류(HoldSpawn=true) · GateArmed=True + [PASS] 누적 54/54 → 게이트 열림 · ReleaseCount=1 + [PASS] 보스 등장(BossSpawned=true · 생존) · 이 보스는 런 끝까지 살아남는다 + ResetGate() 실행 — 게이트 재무장(다음 런) — 기준 처치 54 · 보스 생존(리셋 1) · 보류 재무장 0 + [PASS] 게이트 닫힘(GateOpen=false) + [PASS] 🔴 이 런 기준 처치 = 0(실측 0) — 누적 54 는 813p 계약대로 유지 = **조기 개방 차단**(D-27 ①) + [PASS] 🔴 살아 있는 보스는 BossSpawned **유지**(실측 True) — 813p 판은 false 로 내려 813p 폴백이 BossStarted 를 못 냈다(D-27 ②) + [PASS] 살아 있는 보스라 보류 재무장 0(재스폰 = 클론 2개 방지) + [PASS] 🔴 다음 런 첫 처치(누적 55) — 게이트 **안 열린다**(813p 판은 여기서 즉시 열렸다 = Q6 R3 「t<30 에 이미 open」) + [PASS] 이 런 처치 55/54 → 정상 개방 + [PASS] 누적값은 계속 증가(54 → 109) = 813p 「BossArena 값은 리셋하지 않는다」 계약 유지 + [PASS] 보스를 「죽은 상태」로 만든다(activeInHierarchy=false) + ResetGate() 실행 — 게이트 재무장(다음 런) — 기준 처치 109 · 보스 사망(개방 시 재등장 예약) · 보류 재무장 0 + [PASS] 죽은 보스 → BossSpawned=false(등장 대기) + [PASS] 🔴 죽은 보스는 개방 시 원본 On_Regen 예약(FieldBossData.AfterDie 와 같은 경로 · 클론 증가 0) + [PASS] 보스 액터가 남아 있으므로 보류 재무장 0(= resume 재진입 금지) + ※ 실제 On_Regen 실행은 Play 전용(m_Stat 필요) — 여기서는 예약까지만 실측 = 「미확인」 + +── ⑥ 런 밖(RunEnded 뒤) 리쉬·게이트 정지 (§1-4 ③ · Q6 「리셋 68 → 190」) + [PASS] 🔴 런 밖에서 59킬 — 게이트 **안 열린다**(건너뛴 평가 59회) + [PASS] 🔴 보스가 아레나 40 m 밖인데도 런 밖이라 리쉬 판정을 **건너뛴다**(건너뛴 틱 1) + [PASS] 리셋 0(실측 0) — Q6 은 여기서 68 → 190 으로 쌓였다 + [PASS] 대조: 같은 좌표를 EvaluateLeash 에 직접 넣으면 「보스 이탈」 판정 = 정지가 없었다면 리셋이 걸렸을 조건이다 + [PASS] 런 재개 → 같은 조건에서 게이트 개방(= 정지가 「영구 차단」이 아니다) + [PASS] RunActive 강제 on 확인 · 자동 판정은 RunDirector.IsRunning 을 읽는다 + RunDirector: StartCount=0 IsRunning=False · WLRunSettings.Enabled=True + [PASS] 🔴 C8 — 런이 한 번도 시작 안 됐으면(StartCount=0) RunActive=true = 813d 그대로 + (런 밖 건너뛴 리쉬 틱 누계 1 · 이번 절 증가 1) + +── ⑦ 잡몹 유입 차단 (§1-3) — HoldSpawn 이 존 스포너에 쓰이는지 실측 + 실측: BossArena.HoldSpawn(FieldBossData, Action) 의 인자는 **FieldBossData 전용**이다 + · 존 스포너는 MobControlMgr(별도 클래스)이고 원본 훅(FieldBossData.Start 1줄)은 보스에만 있다 + · 원본 리젠 경로 = MobControlMgr.actDie → MyCoroutine.Set_Coroutine → StartCoroutine(같은 컴포넌트) + [PASS] → HoldSpawn 재사용 불가 · 새 공개 API BossArena.HoldNearbyZoneSpawns(cfg) 를 만들었다 + 차단 반경 = arenaRadius 20 + margin 10 = 30 m + 존0 폴백 앵커 (115.0,0.4,-22.0) → 중심 24.1 m · 차단 대상 + 존1 폴백 앵커 (147.0,2.6,30.0) → 중심 50.6 m · 무간섭 + 존2 폴백 앵커 (71.0,-0.3,18.0) → 중심 77.9 m · 무간섭 + 존3 폴백 앵커 (159.0,0.9,-22.0) → 중심 20.1 m · 차단 대상 + [PASS] 앵커 4개 중 2개가 차단 대상(전부도 아니고 0도 아니다 = 반경이 유효하다) + 씬의 MobControlMgr = 0개 + [PASS] 보스 없음 → 차단 0회(예외 0) · 대상 스캔 0개 + ※ 실 스포너 정지는 맵이 로드된 Play 에서만 잰다 = 「미확인」 + +── ⑧ GC (틱 경로 0 B · 이탈 1건의 실제 할당량) + [PASS] 반경 안 ForceTick ×100 = 0 B + [PASS] 대조군 64 KB 할당 = 69632 B(측정기 동작 확인) · len=65536 + 이탈 1건 = 16384 B — 링버퍼 기록(struct 배열 · 사전 할당)은 0 B이고, 이 값은 **콘솔 1줄 문자열**이다(발주서 §1-1 「1줄/이탈」의 대가 · 이탈이 없으면 0 B) + [PASS] 링버퍼에 기록됨(1칸) + [PASS] 이탈 40회 → 링버퍼 32칸에서 멈춘다(무한 증가 0) + [PASS] 가장 오래된 #9 → 최신 #40(오래된 것부터 밀려난다) + +── ⑨ C8 롤백 (임시 SO · 실에셋 읽기만) + [PASS] warpBackEnabled=0 → 10 s 밖에 있어도 워프 0 = **813w2 동작 그대로** + [PASS] warpBackAfterSeconds=0 → 워프 0(값 하나로도 끌 수 있다) + [PASS] followBossRadius=0 → 보스 추적 예외 없음(언제나 워프 대상) + [PASS] exitDiagEnabled=1 → 기록됨 + [PASS] exitDiagEnabled=0 → 기록·콘솔 0(카운터만 증가) + [PASS] RuntimeDisabled=1 → 틱 ×10 에 이탈 판정 0 · 조종 False · JoystickStatus 0 + 아레나 C8 스위치: restartRebasesGateKills=True restartRearmsHoldSpawn=True restartResetsLivingBoss=True restartRegensDeadBoss=True suspendOutsideRun=True holdZoneSpawnNearArena=True + [PASS] 전부 0 으로 두면 ResetGate 는 813p 판(게이트만 닫기)·리쉬는 813d 판·존 스포너 무간섭 + [PASS] WLBossArenaSettings.RuntimeDisabled=1 → 존 차단 0회 + +RESULT PASS diff --git a/AgentScripts/WL813w3_Probe.cs b/AgentScripts/WL813w3_Probe.cs new file mode 100644 index 000000000..e80170fea --- /dev/null +++ b/AgentScripts/WL813w3_Probe.cs @@ -0,0 +1,700 @@ +// 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; // 보스는 아레나 한복판 + MovePc(center); + RunGuide.BeginBoss("probe"); + RunGuide.ForceTick(); // 중심 = 도착 → Hold + 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; + } +} diff --git a/Assets/WL/Combat/Boss/BossArena.cs b/Assets/WL/Combat/Boss/BossArena.cs index 2a083bf04..7ad0f1e55 100644 --- a/Assets/WL/Combat/Boss/BossArena.cs +++ b/Assets/WL/Combat/Boss/BossArena.cs @@ -35,6 +35,37 @@ // // 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것. // ───────────────────────────────────────────────────────────────────────────── +// WL-813w3 (2026-09-09 · PD 지시 #813) — 근거 = qa/W2-6_재채점_v4.md **D-27** +// +// 🔴 실측 — 「다음 런」(RunDirector.Restart → ResetGate) 뒤 두 번째 런이 통째로 망가진다. +// 원인은 **한 개가 아니라 세 개**였다(발주서 §1-4 의 「보류 재무장 안 함」 추정은 그중 하나다). +// ① 게이트 조기 개방 — ResetGate 가 `s_gateOpen` 만 내리고 **존 누적 카운터를 그대로 둔다.** +// Either 판정은 `TotalZoneKills >= requiredKills` 라 지난 런의 누적(실측 121/54)이 남아 +// 두 번째 런 **첫 처치에서 즉시** 게이트가 열렸다(Q6 R3 = 런 시작 30 s 안 open). +// ② 보스 미등장 — 보류 목록의 `released` 가 true 인 채라 `Release()` 가 전부 건너뛴다. +// 게다가 `BossSpawned` 를 무조건 false 로 내려, **살아 있던 보스**마저 813p 폴백 +// (`RunDirector.Tick` 의 `BossArena.BossSpawned` 검사)에 안 걸려 `BossStarted` 가 안 났다 +// → 813c HP 바가 재바인딩되지 않고 이전 런의 바가 화면에 남는다(Q6 `PD3_07`). +// ③ 런 밖 리쉬 — `Tick()` 이 런 상태를 안 보므로 결과창·다음 런 대기 중에도 리쉬가 계속 걸려 +// 보스가 5 %씩 회복했다(Q6 실측 리셋 68 → 190 · 결과 「HP 남음 96 %」). +// +// ■ 수리 (전부 SO 스위치 · 끄면 813d/813p 동작 그대로) +// ① `restartRebasesGateKills` — ResetGate 가 존 누적 처치·스폰 수를 **이 런 기준으로 재스냅샷**한다 +// (`RunDirector` 가 존 진행률에 쓰는 「스냅샷 차감」과 같은 방식 · BossArena 누적값 자체는 유지). +// ② `restartRearmsHoldSpawn` — 보류 목록의 `released`·`resume` 을 되살린다. +// `restartResetsLivingBoss` — 살아 있는 보스는 리쉬 리셋 1회(워프·만피·타깃 해제)로 처리하고 +// `BossSpawned` 를 **유지**한다(재스폰 0 · 클론 증가 0). +// `restartRegensDeadBoss` — 죽은 보스는 게이트가 열릴 때 **원본 리스폰 규칙** +// (`FieldBossData.AfterDie` 의 `MobActor.On_Regen()`)을 한 번 태워 되살린다. +// 🔴 `FieldBossData.Start` 재진입(= resume)으로 되살리지 않는다 — 그러면 클론이 런마다 하나씩 는다. +// ③ `suspendOutsideRun` — 런이 진행 중이 아니면 리쉬 판정·게이트 평가를 쉰다. +// 런 축이 꺼져 있거나 런이 한 번도 시작된 적 없으면 이 스위치와 무관하게 813d 그대로 돈다(C8). +// +// ■ 잡몹 유입 차단(§1-3) — `HoldSpawn` 은 FieldBossData 전용이라 존 스포너에는 안 쓰인다(실측). +// `holdZoneSpawnNearArena` 가 보스 생존 중 아레나 근처 `MobControlMgr` 의 **리젠 예약 코루틴만** 끊는다 +// (원본 `Set_Coroutine` → `StartCoroutine` · `Cleanup` 의 `StopAllCoroutines` 과 같은 이음매). +// 이미 아레나 안에 있는 잡몹은 원본 규칙 그대로 둔다. +// ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using UnityEngine; @@ -50,11 +81,14 @@ namespace WL.Combat.Boss { public FieldBossData fbd; public System.Action resume; + /// 813w3: 「다음 런」 재무장용 원본 델리게이트(resume 은 개방 때 null 이 된다). + public System.Action resumeOriginal; public Vector3 center; public bool released; } - sealed class Zone { public int alive, kills, spawns; } + /// 813w3: killBase·spawnBase = 이 런 시작 시점의 스냅샷(게이트 판정은 그 차이만 본다). + sealed class Zone { public int alive, kills, spawns, killBase, spawnBase; } static readonly List s_held = new List(2); static readonly Dictionary s_zones = new Dictionary(8); @@ -67,6 +101,12 @@ namespace WL.Combat.Boss static float s_openDueTime = -1f, s_cleanupDueTime = -1f; static float s_playerOutsideSince = -1f, s_nextLeashCheck; + // 813w3 §1-3 · §1-4 + static readonly List s_nearZones = new List(4); + static bool s_nearZonesScanned, s_regenPending; + static int s_totalKillBase; + static int s_runActiveOverride; // 0 자동(RunDirector) · 1 강제 on · −1 강제 off (프로브) + // ───────────────────────────────────────── 진단(프로브·QA 가 읽는다) public static bool GateArmed { get { return s_armed; } } public static bool GateOpen { get { return s_gateOpen; } } @@ -85,9 +125,39 @@ namespace WL.Combat.Boss public static int KillsOf(int spawnerId) { Zone z; return s_zones.TryGetValue(spawnerId, out z) ? z.kills : 0; } public static int SpawnsOf(int spawnerId) { Zone z; return s_zones.TryGetValue(spawnerId, out z) ? z.spawns : 0; } + // ───────────────────────────────────────── 813w3 진단 + /// 「다음 런」 재무장 수 · 살아 있는 보스 리셋 수 · 죽은 보스 재등장 수. + public static int RearmCount, LivingBossResetCount, DeadBossRegenCount; + /// 런 밖이라 건너뛴 리쉬 판정 수 · 게이트 평가 수(D-27 ③ 증거). + public static int LeashSuspendedTicks, GateSuspendedEvals; + /// 존 스포너 리젠 예약을 끊은 횟수 · 대상 스포너 수(§1-3). + public static int ZoneSpawnHoldTicks, ZoneSpawnHoldTargets; + /// 이 런의 게이트 판정 기준 처치 수(= TotalZoneKills − 런 시작 스냅샷). + public static int GateKills { get { int v = TotalZoneKills - s_totalKillBase; return v > 0 ? v : 0; } } + static WLBossArenaSettings Cfg { get { return WLBossArenaSettings.Instance; } } static bool Active { get { return WLBossArenaSettings.Active; } } + /// + /// 813w3 §1-4 ③ — 런이 진행 중인가. 런 밖(RunEnded 뒤 · 결과창)에서는 리쉬·게이트 평가를 쉰다. + /// 🔴 C8 — 런 축(WLRunSettings)이 꺼졌거나 런이 한 번도 시작된 적 없으면 언제나 true(813d 그대로). + /// + public static bool RunActive + { + get + { + var cfg = Cfg; + if (cfg == null || !cfg.suspendOutsideRun) return true; + if (s_runActiveOverride != 0) return s_runActiveOverride > 0; + if (!WL.Combat.Run.WLRunSettings.Enabled) return true; + if (WL.Combat.Run.RunDirector.StartCount <= 0) return true; + return WL.Combat.Run.RunDirector.IsRunning; + } + } + + /// 프로브용 — 런 상태를 강제한다(0 자동 · 1 진행 중 · −1 런 밖). + public static void SetRunActiveForProbe(int mode) { s_runActiveOverride = mode; } + // ───────────────────────────────────────────────────────────────────── // 훅 (Assets/Script/Map/FieldBossData.cs Start 첫 줄 · 1줄) // ───────────────────────────────────────────────────────────────────── @@ -125,6 +195,7 @@ namespace WL.Combat.Boss { fbd = fbd, resume = resume, + resumeOriginal = resume, // 813w3: 「다음 런」 재무장에 다시 쓴다(개방 때 resume 은 null 이 된다) center = cfg.arenaEnabled && !cfg.useFieldBossPositionAsCenter ? cfg.arenaCenter : fbd.transform.position, }; s_held.Add(h); @@ -242,6 +313,7 @@ namespace WL.Combat.Boss { if (!BossKilled) { + s_regenPending = false; // 813w3: 죽었으니 「다음 런에 되살릴 대상」으로 다시 잡는다(ResetGate 가 세운다) BossKilled = true; var c0 = Cfg; float delay = c0 != null ? c0.cleanupDelaySeconds : 0f; @@ -253,7 +325,12 @@ namespace WL.Combat.Boss return; } - if (!s_gateOpen) EvaluateGate(); + if (!s_gateOpen) + { + // 813w3 §1-4 ③ — 런 밖(결과창·다음 런 대기)에서는 게이트를 평가하지 않는다. + if (!RunActive) { GateSuspendedEvals++; return; } + EvaluateGate(); + } } // ───────────────────────────────────────────────────────────────────── @@ -266,8 +343,10 @@ namespace WL.Combat.Boss var cfg = Cfg; if (cfg == null || s_gateOpen || !s_armed) return; + // 🔴 813w3 §1-4 ① — 누적값이 아니라 **이 런의 처치 수**로 본다(ResetGate 가 기준선을 다시 잡는다). + // 813p RunDirector 의 존 진행률(「스냅샷 차감」)과 같은 방식이다. bool cleared = ZonesCleared(cfg); - bool killed = cfg.requiredKills > 0 && TotalZoneKills >= cfg.requiredKills; + bool killed = cfg.requiredKills > 0 && GateKills >= cfg.requiredKills; bool ok; switch (cfg.gateMode) @@ -280,8 +359,8 @@ namespace WL.Combat.Boss if (!ok) return; s_gateOpen = true; - Log("게이트 열림 — cleared=" + cleared + " kills=" + TotalZoneKills + "/" + cfg.requiredKills + - " mode=" + cfg.gateMode); + Log("게이트 열림 — cleared=" + cleared + " kills=" + GateKills + "/" + cfg.requiredKills + + "(누적 " + TotalZoneKills + ") mode=" + cfg.gateMode); if (Application.isPlaying && cfg.gateOpenDelaySeconds > 0f) s_openDueTime = Time.unscaledTime + cfg.gateOpenDelaySeconds; @@ -296,7 +375,8 @@ namespace WL.Combat.Boss { Zone z; if (!s_zones.TryGetValue(s_gateZones[i], out z)) return false; - if (cfg.requireZoneSpawnedOnce && z.spawns <= 0) return false; + // 813w3 §1-4 ①: 「이 런에 한 번이라도 스폰됐는가」 — 지난 런의 스폰 이력으로 전멸을 오판하지 않는다. + if (cfg.requireZoneSpawnedOnce && z.spawns - z.spawnBase <= 0) return false; if (z.alive > 0) return false; } return true; @@ -334,9 +414,33 @@ namespace WL.Combat.Boss catch (System.Exception ex) { Debug.LogException(ex); } } } + + RegenDeadBoss(cfg); Log("보스 등장 개시 — released=" + ReleaseCount); } + /// + /// 813w3 §1-4 ② — 「다음 런」에서 게이트가 열릴 때, 지난 런에 죽어 있던 보스를 **원본 리스폰 규칙**으로 되살린다. + /// 원본 규칙 실측 = `FieldBossData.AfterDie` → `Set_Coroutine(() => m_FieldBoss.On_Regen(), RegenTime)` + /// (813d `suppressRegenAfterBossKill` 이 그 코루틴을 끊어 두므로 시간이 지나도 스스로는 안 돌아온다). + /// 여기서 같은 `MobActor.On_Regen()` 을 한 번 태운다 — `OriginalPos` 복귀 · 스폰 이펙트 · + /// `CombatEvents.RaiseSpawned`(= `BossSpawned` 재설정 · 813p 폴백이 `BossStarted` 를 낸다)까지 원본 경로 그대로. + /// 🔴 `resume`(= FieldBossData.Start) 재진입으로 되살리지 않는다 — 그러면 클론이 런마다 하나씩 늘어난다. + /// + static void RegenDeadBoss(WLBossArenaSettings cfg) + { + if (!s_regenPending) return; + s_regenPending = false; + if (cfg == null || !cfg.restartRegensDeadBoss) return; + if (DSUtil.CheckNull(s_boss) || !s_boss.IsDead()) return; + if (!Application.isPlaying) { Log("죽은 보스 재등장 보류 — 에디트 모드"); return; } + + DeadBossRegenCount++; + BossKilled = false; CleanupDone = false; + s_boss.On_Regen(); // reincarnation=false → Set_Warp(OriginalPos) + Spawned(isBoss) + Log("죽은 보스 재등장 #" + DeadBossRegenCount + " — 원본 On_Regen()(FieldBossData.AfterDie 와 같은 경로)"); + } + // ───────────────────────────────────────────────────────────────────── // 아레나 — 추적 반경 데이터화 · 리쉬 · 리셋 // ───────────────────────────────────────────────────────────────────── @@ -450,6 +554,12 @@ namespace WL.Combat.Boss if (cfg == null || !Active || !cfg.arenaEnabled || !cfg.leashEnabled || s_leashFaulted) return; if (DSUtil.CheckNull(s_boss) || s_boss.IsDead()) { s_playerOutsideSince = -1f; return; } + // 🔴 813w3 §1-4 ③ — 런 밖(RunEnded 뒤 · 결과창 · 다음 런 대기)에서는 리쉬를 걸지 않는다. + // Q6 실측: 여기서 리셋이 68 → 190 으로 계속 쌓여 보스 HP 가 56 % → 96 % 로 되돌아왔다. + if (!RunActive) { LeashSuspendedTicks++; s_playerOutsideSince = -1f; return; } + + HoldNearbyZoneSpawns(cfg); + Vector3 center = Center; float now = Application.isPlaying ? Time.unscaledTime : 0f; var pc = MyValue.MyPC; @@ -462,6 +572,52 @@ namespace WL.Combat.Boss LastLeashDistance.ToString("F1") + "m"); } + // ───────────────────────────────────────────────────────────────────── + // 813w3 §1-3 — 보스 생존 중 아레나 근처 존 스포너의 리젠 예약을 끊는다(잡몹 유입 차단) + // ───────────────────────────────────────────────────────────────────── + + /// + /// 보스가 살아 있는 동안 아레나 중심에서 (arenaRadius + zoneSpawnHoldMargin) 안의 존 스포너가 + /// 예약한 「사망 후 리젠」 코루틴(원본 `MobControlMgr.actDie` → `MyCoroutine.Set_Coroutine`)을 끊는다. + /// 이미 살아 있는 잡몹은 손대지 않는다(발주서 §1-3 「아레나 안 잡몹은 원본 규칙대로」). + /// 로딩이 끝나지 않은 스포너(`Get_LoadComplete()==false`)는 건너뛴다 — 그 `Start()` 코루틴을 끊으면 존이 죽는다. + /// + public static void HoldNearbyZoneSpawns(WLBossArenaSettings cfg) + { + if (cfg == null || !Active || !cfg.holdZoneSpawnNearArena || cfg.zoneSpawnHoldMargin < 0f) return; + if (DSUtil.CheckNull(s_boss) || s_boss.IsDead()) return; + if (!s_nearZonesScanned) ScanNearZones(cfg); + for (int i = 0; i < s_nearZones.Count; i++) + { + var m = s_nearZones[i]; + if (DSUtil.CheckNull(m) || !m.Get_LoadComplete()) continue; + m.StopAllCoroutines(); + ZoneSpawnHoldTicks++; + } + } + + /// 대상 존 스포너를 1회 훑는다(런당 몇 회 · 매 틱 아님). + static void ScanNearZones(WLBossArenaSettings cfg) + { + s_nearZonesScanned = true; + s_nearZones.Clear(); + if (cfg.zoneSpawnerIds == null) return; + Vector3 center = Center; + float lim = cfg.arenaRadius + cfg.zoneSpawnHoldMargin; + var all = Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); + for (int i = 0; all != null && i < all.Length; i++) + { + if (DSUtil.CheckNull(all[i])) continue; + bool listed = false; + for (int j = 0; j < cfg.zoneSpawnerIds.Length; j++) if (all[i].SpawnerID == cfg.zoneSpawnerIds[j]) { listed = true; break; } + if (!listed) continue; + if (DistXZ(all[i].transform.position, center) > lim) continue; + s_nearZones.Add(all[i]); + } + ZoneSpawnHoldTargets = s_nearZones.Count; + Log("존 스포너 유입 차단 대상 " + s_nearZones.Count + "개(중심 " + center.ToString("F1") + " 반경 " + lim.ToString("F0") + "m)"); + } + /// 마지막 부분 회복 시각(unscaled · 쿨다운 판정). static float s_lastLeashHeal = -999f; /// 리쉬 회복을 실제로 넣은 횟수 · 쿨다운으로 건너뛴 횟수(진단). @@ -495,17 +651,20 @@ namespace WL.Combat.Boss /// `leashHealCooldownSeconds`(10 s) 안에서는 회복을 건너뛴다(워프·타깃 해제는 그대로). /// `leashHealPercent >= 1` 로 두면 813d 원래 동작(On_Regen 만피)으로 정확히 되돌아간다(C8). /// - static void ResetBoss(WLBossArenaSettings cfg, Vector3 center, string reason) + static void ResetBoss(WLBossArenaSettings cfg, Vector3 center, string reason) { ResetBoss(cfg, center, reason, false); } + + /// 813w3 §1-4 — 「다음 런」 리셋은 쿨다운·부분 회복을 무시하고 만피로 되돌린다. + static void ResetBoss(WLBossArenaSettings cfg, Vector3 center, string reason, bool forceFull) { if (DSUtil.CheckNull(s_boss)) return; s_boss.Del_Target(true); string healInfo; - switch (EvaluateLeashHeal(cfg, Application.isPlaying ? Time.unscaledTime : 0f)) + switch (forceFull ? kHealFull : EvaluateLeashHeal(cfg, Application.isPlaying ? Time.unscaledTime : 0f)) { case kHealFull: - s_boss.On_Regen(true, cfg.resetHealRate); // 813d 원래 동작(만피 리셋) - healInfo = "만피(레거시)"; + s_boss.On_Regen(true, cfg.resetHealRate); // 813d 원래 동작(만피 리셋 · reincarnation=true 라 Spawned 는 안 난다) + healInfo = forceFull ? "만피(다음 런)" : "만피(레거시)"; break; case kHealPartial: double before = s_boss.Get_HP(); @@ -624,17 +783,82 @@ namespace WL.Combat.Boss TotalZoneKills = ResetCount = ReleaseCount = HoldCount = PassThroughCount = 0; LeashHealCount = LeashHealSkipped = 0; LastLeashHeal = 0d; s_lastLeashHeal = -999f; LastLog = ""; + // 813w3 + s_nearZones.Clear(); s_nearZonesScanned = false; s_regenPending = false; + s_totalKillBase = 0; s_runActiveOverride = 0; + RearmCount = LivingBossResetCount = DeadBossRegenCount = 0; + LeashSuspendedTicks = GateSuspendedEvals = ZoneSpawnHoldTicks = ZoneSpawnHoldTargets = 0; } - /// 813p 「다음 런」 — 게이트만 다시 잠근다. 존 누적 카운터(s_zones)·구독·보류 목록은 건드리지 않는다. + /// + /// 813p 「다음 런」 — 게이트를 다시 잠근다. `RunDirector.Restart()` 가 부르는 유일한 진입점이다. + /// 🔴 813w3 §1-4(D-27): 813p 판은 `s_gateOpen` 만 내려서 ① 지난 런의 누적 처치로 게이트가 즉시 다시 열리고 + /// ② 보류 목록이 `released` 인 채라 보스가 영영 안 나왔다. 아래 세 가지를 함께 처리한다(전부 SO 스위치). + /// 존 누적 카운터(`s_zones.kills/spawns`)는 813p 계약대로 **지우지 않고**, 기준선만 다시 잡는다. + /// public static void ResetGate() { if (!s_armed) return; + var cfg = Cfg; s_gateOpen = false; - BossSpawned = BossKilled = CleanupDone = false; + BossKilled = CleanupDone = false; s_openDueTime = s_cleanupDueTime = -1f; s_playerOutsideSince = -1f; - Log("게이트 재무장(813p 다음 런)"); + s_lastLeashHeal = -999f; + s_nearZonesScanned = false; // 맵/스포너가 바뀌었을 수 있다 — 다시 훑는다 + + // ① 게이트 조기 개방(D-27) — 이 런 기준선으로 재스냅샷 + if (cfg != null && cfg.restartRebasesGateKills) RebaseGateCounters(); + + bool alive = !DSUtil.CheckNull(s_boss) && !s_boss.IsDead() && s_boss.gameObject.activeInHierarchy; + s_regenPending = false; + + if (alive && cfg != null && cfg.restartResetsLivingBoss) + { + // ② 살아 있는 보스 — 재스폰하지 않는다. 리쉬 리셋 1회(워프·만피·타깃 해제)로 처리하고 + // BossSpawned 를 유지해 두 번째 런 게이트 개방에서 813p 폴백이 BossStarted 를 내게 한다. + BossSpawned = true; + LivingBossResetCount++; + // 🔴 리셋 자체가 실패해도 게이트 재무장은 끝내야 한다(다음 런이 통째로 망가지는 것보다 낫다). + try { ResetBoss(cfg, Center, "다음 런 — 살아 있는 보스 복귀", true); } + catch (System.Exception ex) { Log("살아 있는 보스 리셋 실패 — " + ex.Message); } + } + else + { + BossSpawned = false; + // 🔴 죽은 보스는 **재무장이 아니라 원본 리스폰 규칙**(On_Regen)으로 되살린다 — + // resume(= FieldBossData.Start) 을 다시 태우면 죽은 클론 위에 새 클론이 하나 더 생긴다. + if (!DSUtil.CheckNull(s_boss) && s_boss.IsDead()) + s_regenPending = cfg != null && cfg.restartRegensDeadBoss; + else if (cfg != null && cfg.restartRearmsHoldSpawn) + RearmHoldSpawn(); // 보스 액터가 아예 없다(Spawned 를 놓쳤거나 파괴됨) = 원본 스폰을 다시 태운다 + } + + Log("게이트 재무장(다음 런) — 기준 처치 " + s_totalKillBase + " · 보스 " + + (alive ? "생존(리셋 " + LivingBossResetCount + ")" : (s_regenPending ? "사망(개방 시 재등장 예약)" : "없음")) + + " · 보류 재무장 " + RearmCount); + } + + /// 813w3 §1-4 ① — 게이트 판정 기준선을 지금 값으로 다시 잡는다(누적값 자체는 유지 · 813p 계약). + static void RebaseGateCounters() + { + s_totalKillBase = TotalZoneKills; + foreach (var kv in s_zones) { kv.Value.killBase = kv.Value.kills; kv.Value.spawnBase = kv.Value.spawns; } + } + + /// 813w3 §1-4 ② — 보류 목록을 다시 무장한다(released·resume 원복). 개방 때 원본 Start 가 다시 돈다. + static void RearmHoldSpawn() + { + for (int i = 0; i < s_held.Count; i++) + { + var h = s_held[i]; + if (DSUtil.CheckNull(h.fbd) || !h.released || h.resumeOriginal == null) continue; + // 보스 액터가 남아 있으면(살아 있든 죽어 있든) 재무장하지 않는다 — resume 은 클론을 하나 더 만든다. + if (!DSUtil.CheckNull(s_boss)) continue; + h.released = false; + h.resume = h.resumeOriginal; + RearmCount++; + } } /// 프로브용 — 보스 참조를 직접 넣는다(에디트 모드에는 실제 스폰이 없다). diff --git a/Assets/WL/Combat/Boss/WLBossArenaSettings.cs b/Assets/WL/Combat/Boss/WLBossArenaSettings.cs index 22e874542..bfcc9d8b5 100644 --- a/Assets/WL/Combat/Boss/WLBossArenaSettings.cs +++ b/Assets/WL/Combat/Boss/WLBossArenaSettings.cs @@ -192,5 +192,39 @@ namespace WL.Combat.Boss [Tooltip("정리 때 게이트를 다시 잠근다(존을 다시 밀면 보스 재등장). 기본 off = 1회성.")] public bool rearmGateAfterBossKill = false; + + // ─────────────────────────────────────────── 813w3 §1-3 잡몹 유입 차단 + [Header("잡몹 유입 차단 (813w3 §1-3 · 보스 생존 중)")] + [Tooltip("보스가 살아 있는 동안 아레나 근처 존 스포너의 「사망 후 리젠」 예약(MobControlMgr 의 Set_Coroutine)을 " + + "끊어 새 잡몹이 아레나로 흘러드는 것을 막는다. 이미 살아 있는 잡몹은 원본 규칙 그대로 둔다. " + + "끄면 813d 그대로(존 스포너 무간섭).")] + public bool holdZoneSpawnNearArena = true; + + [Tooltip("차단 대상 = 아레나 중심에서 (arenaRadius + 이 값) 안에 있는 존 스포너(XZ). " + + "813e 실측 배치에서 813001(24.1 m)·813004(20.1 m)가 여기에 든다.")] + public float zoneSpawnHoldMargin = 10f; + + // ─────────────────────────────────────────── 813w3 §1-4 다음 런 리셋(D-27) + [Header("다음 런 리셋 (813w3 · D-27 게이트 조기 개방 · 보스 미등장)")] + [Tooltip("🔴 ResetGate() 때 존 누적 처치(TotalZoneKills)·스폰 수를 「이 런 기준」으로 다시 스냅샷한다. " + + "끄면 813p 그대로 = 지난 런의 누적 121 킬이 남아 두 번째 런 첫 처치에서 게이트가 즉시 열린다(D-27).")] + public bool restartRebasesGateKills = true; + + [Tooltip("🔴 ResetGate() 때 보스 스폰 보류(HoldSpawn)를 다시 무장한다(released 플래그 원복). " + + "끄면 813p 그대로 = Release() 가 보류 목록을 전부 건너뛰어 보스가 영영 안 나온다(D-27).")] + public bool restartRearmsHoldSpawn = true; + + [Tooltip("ResetGate() 때 살아 있는 보스를 아레나 중심으로 워프 · HP 만피 · 타깃 해제한다(리쉬 리셋 1회). " + + "이때 BossSpawned 를 유지해 두 번째 런의 게이트 개방에서 BossStarted 가 다시 난다(HP 바 재바인딩).")] + public bool restartResetsLivingBoss = true; + + [Tooltip("죽은 보스는 게이트가 열릴 때 원본 리스폰 규칙(FieldBossData.AfterDie 의 MobActor.On_Regen())을 " + + "그대로 한 번 태워 되살린다 — FieldBossData.Start 재진입(클론 2개)이 아니다. 끄면 되살리지 않는다.")] + public bool restartRegensDeadBoss = true; + + [Tooltip("🔴 런이 진행 중이 아니면(RunEnded 뒤 · 결과창) 리쉬 판정·게이트 평가를 멈춘다. " + + "끄면 813d 그대로 = 런 밖에서도 리쉬 리셋이 계속 쌓여 보스 HP 가 되돌아온다(D-27 실측 68 → 190). " + + "런 축(WLRunSettings)이 꺼져 있거나 런이 한 번도 시작되지 않았으면 이 스위치와 무관하게 813d 그대로 돈다.")] + public bool suspendOutsideRun = true; } } diff --git a/Assets/WL/Combat/Run/RunGuide.cs b/Assets/WL/Combat/Run/RunGuide.cs index 06418f4ea..aa0d93d36 100644 --- a/Assets/WL/Combat/Run/RunGuide.cs +++ b/Assets/WL/Combat/Run/RunGuide.cs @@ -74,6 +74,36 @@ // ■ C8 롤백 — `hitSuspendOnlyInHold=0` · `arriveStillEnabled=0` · `pullbackReleasesTarget=0` // · `bossRetargetOnlyInHold=0` 을 전부 끄면 813w 동작 그대로다(값 하나로 되돌릴 수 있다). // ───────────────────────────────────────────────────────────────────────────── +// WL-813w3 (2026-09-09 · PD 지시 #813) — 근거 = qa/W2-6_재채점_v4.md D-22 **재발** +// +// 🔴 실측 — 813w2 의 되돌림은 「돌긴 도는데 못 이긴다」 +// · 이탈 39.2 / 54.5 / 49.2 / 55.5 m · 아레나 리쉬 거리 25.2~56.5 m +// · 되돌림(HoldPullbacks) 3~5회 ≪ 리쉬 리셋 10~21회 · 보스 HP 최저 34~56 %(처치 0회) +// · 813w2 프로브 ⑥ 은 「바깥 드리프트 4 m/s vs 되돌림 5 m/s」 가정으로 최대 이탈 14.4 m 를 냈다. +// 인게임에서 39~56 m 가 나왔다 = **그 가정(드리프트 4 m/s)이 틀렸다.** 되돌림은 NavMeshAgent +// 목적지 지정이라 원본이 같은 프레임에 Set_Path(타깃)로 덮어쓰거나, 잡몹에 막히거나, +// 공격·CC·스킬 잠금(Steer 의 원본 우선 가드)으로 조종 자체를 못 하면 **속도가 0 이 된다.** +// +// ■ ① 이탈 원인 진단(코드로 남긴다 · §1-1) +// 이탈 순간의 **원본 상태를 그대로 찍는다** — 타깃(보스/잡몹/없음) · JoystickStatus · +// 보스 락(m_IsLockTarget) · 현재 애님·CC · NavMeshAgent(속도·정지·목적지가 아레나에서 몇 m) · +// 가이드 상태(Travel/Hold) · 최근 피격 경과 · **보스가 점프(Anubis 스킬2 Co_JumpToPosition · +// 그 동안 보스 NavMeshAgent.enabled=false)로 멀어져 PC 가 따라간 것인지**. +// 링버퍼 32칸(사전 할당 · 기록 자체는 GC 0) + `verboseLog` 와 무관하게 콘솔 1줄/이탈. +// → 「누가 PC 를 끌고 나갔는가」를 Q7 이 로그만 보고 판정할 수 있다. +// +// ■ ② 강제 복귀 2단(§1-2) +// ⓐ 기존 되돌림(SetDestination) 그대로 → ⓑ `warpBackAfterSeconds`(1.5 s) 동안 반경 밖이면 +// **원본 `Actor.Set_Warp`**(Actor.cs:2111 · 813i DeathFlow 의 `On_Regen → Set_Warp` 와 같은 API)로 +// 아레나 중심 ±`warpJitterRadius`(3 m) 지점으로 워프한다. NavMesh 위로 먼저 스냅(PC 는 +// `Get_NavSnapRadius()=0` 이라 Set_Warp 이 스스로 스냅하지 않는다) · 잔상/이펙트 0 · +// 카메라 리드는 `RealCamera.Get_MoveLead().Snap()`(811 카메라 리드 API)로 0 스냅 · 쿨 5 s · 로그 1줄. +// 🔴 보스가 스킬로 아레나 밖에 나가 있고 PC 가 그 보스 곁(`followBossRadius`)이면 **워프하지 않는다** — +// 그건 이탈이 아니라 정상 추적이고, 워프하면 오히려 전투가 끊긴다. +// +// ■ C8 롤백 — `warpBackEnabled=0` 이면 워프 경로가 통째로 죽고 813w2 동작 그대로다. +// `exitDiagEnabled=0` 이면 진단 로그도 0. +// ───────────────────────────────────────────────────────────────────────────── // // ■ 값(C45) 전부 WLRunGuideSettings. enabled = 0 이면 러너가 뜨지 않고 구독도 하지 않는다(C8). // @@ -108,6 +138,32 @@ namespace WL.Combat.Run Boss = 2, } + /// 813w3 §1-1 — 아레나 이탈 1건의 원본 상태 스냅샷(struct · 링버퍼에 사전 할당 · GC 0). + public struct ArenaExitRecord + { + /// 발생 시각(unscaled) · 프레임. + public float time; public int frame; + /// PC↔아레나 중심 XZ(m) · PC↔보스 XZ(m · 보스 없으면 −1) · 보스↔중심 XZ(m · 보스 없으면 −1). + public float distFromCenter, distFromBoss, bossFromCenter; + /// NavMeshAgent 의 현재 목적지가 아레나 중심에서 몇 m 인가(−1 = 경로 없음/에이전트 없음). + /// 🔴 이 값이 크면 **원본이 우리 목적지를 덮어썼다**는 직접 증거다(MyActor.cs:335 Set_Path). + public float agentDestFromCenter; + /// 0 없음 · 1 보스 · 2 잡몹(MobActor) · 3 그 밖. + public int targetKind; + /// PCActor.JoystickStatus 실측값(0 원본 / 1 우리 조종 / 2 스킬 이동 잠금). + public int joystickStatus; + /// Actor.Get_CurAnim()(eAnim 정수) · 가이드 상태(RunGuideState 정수). + public int anim, guideState; + /// 보스 m_IsLockTarget · 우리 조종 중 · PC CC · 보스 점프 중(NavMeshAgent.enabled=false) · 최근 피격 · 이 줄이 워프인가. + public bool bossLocked, steering, pcCC, bossJumping, recentHit, warped; + /// NavMeshAgent 정지 플래그 · NavMesh 위인가. + public bool agentStopped, agentOnNavMesh; + /// 마지막 피격 후 경과(초 · 응전 구간이 아니면 −1) · NavMeshAgent 수평 속도(m/s). + public float sinceHit, agentSpeed; + /// 이 이탈이 몇 번째인가(ArenaExits 값). + public int index; + } + /// 런 진행 가이드. 정적 · 새 Manager/Singleton 0(런너는 숨김 GameObject 1개) · GC 0. public static class RunGuide { @@ -145,6 +201,29 @@ namespace WL.Combat.Run /// 813w2: 마지막 RunTick 요약 줄(프로브·QA 가 읽는다). public static string LastTickLog = ""; + // ───────────────────────────────────────── 813w3 진단 (§1-1 · §1-2) + /// 아레나 이탈 에피소드 수 · 실제 워프 수. + public static int ArenaExits, ArenaWarps; + /// 워프를 건너뛴 사유별 수 — 쿨다운 · 보스 추적 중(정상) · NavMesh 지점 없음. + public static int WarpsSkippedCooldown, WarpsSkippedNearBoss, WarpsFailedNavMesh; + /// 마지막 이탈 진단 줄 원문(프로브·QA 가 읽는다 · 콘솔에 나간 그 문자열). + public static string LastExitLog = ""; + + /// 진단 링버퍼 칸 수. 게임 수치가 아니라 버퍼 크기다(발주서 §1-1 「링버퍼 32」). + const int kExitLogCapacity = 32; + static readonly ArenaExitRecord[] s_exitLog = new ArenaExitRecord[kExitLogCapacity]; + static int s_exitLogNext, s_exitLogCount; + + /// 링버퍼에 쌓인 기록 수(최대 32). + public static int ExitLogCount { get { return s_exitLogCount; } } + /// 링버퍼 읽기 — 0 = 가장 오래된 기록. 범위 밖이면 빈 구조체. + public static ArenaExitRecord ExitLogAt(int i) + { + if (i < 0 || i >= s_exitLogCount) return default(ArenaExitRecord); + int start = s_exitLogCount < kExitLogCapacity ? 0 : s_exitLogNext; + return s_exitLog[(start + i) % kExitLogCapacity]; + } + // ───────────────────────────────────────── 내부 상태 static RunGuideState s_state; static RunGuideDest s_destKind; @@ -158,6 +237,9 @@ namespace WL.Combat.Run static Vector3 s_lastPos; static float s_lastPosTime = -1f, s_stillSince = -1f, s_lastAccumTime = -1f, s_nextTickLog; + // 813w3 §1-2 강제 복귀 — 이탈 시작 시각 · 마지막 워프 시각 · 다음 반복 진단 시각 + static float s_outsideSince = -1f, s_lastWarp = -999f, s_nextExitDiag; + // 존 앵커 캐시(스포너 노드 실좌표 · 없으면 SO 폴백) const int kMaxZones = 16; static readonly Vector3[] s_anchors = new Vector3[kMaxZones]; @@ -218,6 +300,9 @@ namespace WL.Combat.Run ZoneHoldSeconds = BossHoldSeconds = TravelSeconds = MaxDistFromDest = 0f; ArrivedByStill = PullbacksWhileHit = 0; s_nextTickLog = 0f; s_lastAccumTime = -1f; LastTickLog = ""; + // 813w3: 이탈·워프 집계도 런 단위(QA 가 런 1회 표를 읽는다). 링버퍼 기록은 남긴다. + ArenaExits = ArenaWarps = WarpsSkippedCooldown = WarpsSkippedNearBoss = WarpsFailedNavMesh = 0; + s_outsideSince = -1f; s_lastWarp = -999f; s_nextExitDiag = 0f; var cfg = Cfg; if (cfg != null && cfg.zoneGuideEnabled && cfg.guideOnRunStart) BeginZone(0, "런 시작"); } @@ -268,6 +353,8 @@ namespace WL.Combat.Run " · 존체류 " + ZoneHoldSeconds.ToString("F0") + "s · 아레나체류 " + BossHoldSeconds.ToString("F0") + "s · 이동 " + TravelSeconds.ToString("F0") + "s · 최대이탈 " + MaxDistFromDest.ToString("F1") + "m · 도착 " + Arrived + "(정지 " + ArrivedByStill + ") · 되돌림 " + HoldPullbacks + + " · 이탈 " + ArenaExits + " 워프 " + ArenaWarps + + "(쿨 " + WarpsSkippedCooldown + " 보스곁 " + WarpsSkippedNearBoss + " 맵밖 " + WarpsFailedNavMesh + ")" + " · 존 " + e.zonesCleared + "/" + e.zoneCount + " " + e.zoneKills + "/" + e.zoneRequired; Debug.Log("[RunGuide] " + LastTickLog); } @@ -322,6 +409,7 @@ namespace WL.Combat.Run s_zoneIndex = -1; s_hitUntil = 0f; s_stillSince = -1f; s_lastPosTime = -1f; + s_outsideSince = -1f; s_nextExitDiag = 0f; // 813w3: 이탈 타이머는 목적지와 함께 끝난다 if (had) Log("가이드 종료 — " + reason); } @@ -386,6 +474,10 @@ namespace WL.Combat.Run bool hit = now < s_hitUntil; bool suspend = SuspendDecision(cfg, hit, s_state); + // ── 813w3 §1-1·§1-2: 보스 구간 이탈 추적 → 진단 1줄 → (1.5 s 뒤에도 밖이면) 강제 워프. + // 상태(Travel/Hold)와 무관하게 돈다 — 되돌림 중(Travel)에 못 돌아오는 것이 D-22 재발의 실체다. + if (isBoss && TrackArenaExit(cfg, pc, pcPos, d, hold, now, hit)) return; // 워프했다 = 이 폴링 끝 + if (s_state == RunGuideState.Travel) { if (d <= arrive) @@ -442,6 +534,210 @@ namespace WL.Combat.Run Log((isBoss ? "아레나" : "존 " + s_zoneIndex) + " " + why); } + // ───────────────────────────────────────────────────────────────────── + // 813w3 §1-1 · §1-2 — 아레나 이탈 진단 · 강제 복귀(워프) + // ───────────────────────────────────────────────────────────────────── + + /// + /// 보스 구간 이탈 추적. 이탈 시작에 진단 1줄을 남기고, `warpBackAfterSeconds` 동안 반경 밖이면 워프한다. + /// 반환 true = 이번 폴링에서 워프했다(호출측은 즉시 return 한다). + /// 프로브가 임시 SO·가짜 좌표로 그대로 부를 수 있다(pc 는 null 허용 — 그러면 워프만 건너뛴다). + /// + public static bool TrackArenaExit(WLRunGuideSettings cfg, PCActor pc, Vector3 pcPos, + float d, float hold, float now, bool hit) + { + if (cfg == null || hold <= 0f || d <= hold) + { + s_outsideSince = -1f; s_nextExitDiag = 0f; + return false; + } + + if (s_outsideSince < 0f) + { + s_outsideSince = now; + ArenaExits++; + s_nextExitDiag = cfg.exitDiagRepeatSeconds > 0f ? now + cfg.exitDiagRepeatSeconds : 0f; + RecordExit(cfg, pc, pcPos, d, now, hit, false); + } + else if (s_nextExitDiag > 0f && now >= s_nextExitDiag) + { + s_nextExitDiag = now + cfg.exitDiagRepeatSeconds; + RecordExit(cfg, pc, pcPos, d, now, hit, false); + } + + switch (WarpDecision(cfg, d, hold, s_outsideSince, s_lastWarp, now, IsFollowingAwayBoss(cfg, pcPos))) + { + // 🔴 보스가 스킬로 멀어졌고 PC 가 그 보스 곁이면 이탈이 아니다(§1-2) — 타이머만 다시 잡는다. + case kWarpNearBoss: WarpsSkippedNearBoss++; s_outsideSince = now; return false; + case kWarpCooldown: WarpsSkippedCooldown++; return false; + case kWarpGo: return !DSUtil.CheckNull(pc) && WarpToArena(cfg, pc, d, now, hit); + default: return false; + } + } + + /// 워프 판정 결과 — 0 반경 안 · 1 아직 기다린다(되돌림만) · 2 워프 · 3 쿨다운 · 4 보스 곁(정상 추적). + public const int kWarpNone = 0, kWarpWait = 1, kWarpGo = 2, kWarpCooldown = 3, kWarpNearBoss = 4; + + /// + /// 강제 복귀 판정(순수 함수 · Actor 접근 0 · 프로브가 가짜 거리·시각으로 그대로 부른다 · C8 증거). + /// `outsideSince` = 반경 밖이 된 시각(안이면 −1) · `lastWarp` = 마지막 워프 시각. + /// + public static int WarpDecision(WLRunGuideSettings cfg, float d, float hold, float outsideSince, + float lastWarp, float now, bool followingAwayBoss) + { + if (cfg == null || hold <= 0f || d <= hold) return kWarpNone; + if (!cfg.warpBackEnabled || cfg.warpBackAfterSeconds <= 0f) return kWarpWait; // C8 = 813w2 되돌림만 + if (outsideSince < 0f || now - outsideSince < cfg.warpBackAfterSeconds) return kWarpWait; + if (followingAwayBoss) return kWarpNearBoss; + if (now - lastWarp < cfg.warpCooldownSeconds) return kWarpCooldown; + return kWarpGo; + } + + /// + /// 보스가 아레나 중심에서 `bossAwayFromCenterRadius` 밖이고 PC 가 그 보스에게서 `followBossRadius` 안인가. + /// = Anubis 스킬2(`Co_JumpToPosition`)로 보스가 튄 뒤 PC 가 정상적으로 따라간 상태(워프하면 안 된다). + /// + public static bool IsFollowingAwayBoss(WLRunGuideSettings cfg, Vector3 pcPos) + { + if (cfg == null || cfg.followBossRadius <= 0f) return false; + var boss = s_boss; + if (!IsAliveBoss(boss)) return false; + Vector3 bp = boss.Get_position(); + if (DistXZ(bp, BossArena.Center) <= cfg.bossAwayFromCenterRadius) return false; + return DistXZ(pcPos, bp) <= cfg.followBossRadius; + } + + /// 원본 `Actor.Set_Warp`(Actor.cs:2111)으로 아레나 중심 ±jitter 지점에 내려놓는다. NavMesh 밖이면 하지 않는다. + static bool WarpToArena(WLRunGuideSettings cfg, PCActor pc, float d, float now, bool hit) + { + Vector3 center = BossArena.Center; + Vector3 target = center; + if (cfg.warpJitterRadius > 0f) + { + Vector2 o = UnityEngine.Random.insideUnitCircle * cfg.warpJitterRadius; // struct 반환 · GC 0 + target.x += o.x; target.z += o.y; + } + + float r = cfg.warpNavSampleRadius > 0f ? cfg.warpNavSampleRadius : 1f; + NavMeshHit nh; + if (NavMesh.SamplePosition(target, out nh, r, NavMesh.AllAreas)) target = nh.position; + else if (NavMesh.SamplePosition(center, out nh, r, NavMesh.AllAreas)) target = nh.position; + else { WarpsFailedNavMesh++; return false; } // 지형 뚫기 방지 — 워프 포기 + + ReleaseSteer(); // 조종을 먼저 놓는다(JoystickStatus 원상복귀) + pc.Del_Target(true); // 끌고 나간 타깃을 끊는다 — 도착 뒤 ApplyBossPriority 가 보스를 다시 건다 + var agent = pc.Get_Agent(); + if (agent != null && agent.enabled && agent.isOnNavMesh) agent.ResetPath(); // 남은 경로 제거 + pc.Set_Warp(target); // 813i DeathFlow 의 On_Regen → Set_Warp 와 같은 원본 API · 이펙트 0 + if (cfg.warpSnapsCameraLead) SnapCameraLead(); + + s_lastWarp = now; + s_outsideSince = -1f; s_nextExitDiag = 0f; + ArenaWarps++; + RecordExit(cfg, pc, target, d, now, hit, true); + EnterHold(true, "강제 복귀(워프) #" + ArenaWarps + " — 이탈 " + d.ToString("F1") + "m → " + Fmt(target)); + return true; + } + + /// 워프 직후 카메라 이동 리드를 0 으로 스냅한다(811 `CameraMoveLead.Snap()` · RealCamera 무수정). + static void SnapCameraLead() + { + var cam = MyValue.m_RealCamera; + if (DSUtil.CheckNull(cam)) return; + var lead = cam.Get_MoveLead(); + if (lead != null) lead.Snap(); + } + + /// + /// 이탈 1건을 링버퍼(사전 할당 · GC 0)에 넣고 콘솔에 1줄 남긴다. + /// 🔴 `verboseLog` 와 무관하게 남긴다 — Q7 이 이 줄만 보고 「누가 PC 를 끌고 나갔는지」를 판정한다. + /// + static void RecordExit(WLRunGuideSettings cfg, PCActor pc, Vector3 pcPos, float d, float now, bool hit, bool warped) + { + if (cfg == null || !cfg.exitDiagEnabled) return; + + var rec = default(ArenaExitRecord); + rec.time = now; rec.frame = Time.frameCount; + rec.index = ArenaExits; + rec.distFromCenter = d; + rec.guideState = (int)s_state; + rec.steering = s_steering; + rec.recentHit = hit; + rec.sinceHit = hit ? Mathf.Max(0f, now - (s_hitUntil - HitHold(cfg))) : -1f; + rec.warped = warped; + rec.distFromBoss = rec.bossFromCenter = rec.agentDestFromCenter = -1f; + + Vector3 center = BossArena.Center; + var boss = s_boss; + if (IsAliveBoss(boss)) + { + Vector3 bp = boss.Get_position(); + rec.distFromBoss = DistXZ(pcPos, bp); + rec.bossFromCenter = DistXZ(bp, center); + rec.bossLocked = boss.m_IsLockTarget; + var ba = boss.Get_Agent(); + rec.bossJumping = ba != null && !ba.enabled; // Co_JumpToPosition 이 에이전트를 끈다(Actor.cs:1868) + } + + if (!DSUtil.CheckNull(pc)) + { + rec.joystickStatus = pc.JoystickStatus; + rec.anim = (int)pc.Get_CurAnim(); + var t = pc.isTarget ? pc.Get_Target() : null; + if (!DSUtil.CheckNull(t)) + rec.targetKind = (!DSUtil.CheckNull(boss) && ReferenceEquals(t, boss)) ? 1 + : (t.IsRole(eRole.Mob) ? 2 : 3); // 원본 역할(Actor.cs:978)로 분류한다 + var agent = pc.Get_Agent(); + if (agent != null && agent.enabled) + { + rec.agentOnNavMesh = agent.isOnNavMesh; + if (agent.isOnNavMesh) + { + rec.agentStopped = agent.isStopped; + Vector3 v = agent.velocity; v.y = 0f; + rec.agentSpeed = v.magnitude; + if (agent.hasPath) rec.agentDestFromCenter = DistXZ(agent.destination, center); + } + } + try { rec.pcCC = pc.IsCC(); } catch (System.Exception) { rec.pcCC = false; } // 에디트 모드엔 dic_ccData 가 없다 + } + + s_exitLog[s_exitLogNext] = rec; + s_exitLogNext = (s_exitLogNext + 1) % kExitLogCapacity; + if (s_exitLogCount < kExitLogCapacity) s_exitLogCount++; + + LastExitLog = FormatExit(in rec); + Debug.Log("[RunGuide][이탈] " + LastExitLog); + } + + /// 피격 응전 구간 길이(초) — sinceHit 역산용. + static float HitHold(WLRunGuideSettings cfg) + { + return s_destKind == RunGuideDest.Boss ? cfg.bossHitSuspendSeconds : cfg.resumeAfterHitSeconds; + } + + /// 진단 1줄 포맷(프로브·Q7 공용 · 순수 함수). + public static string FormatExit(in ArenaExitRecord r) + { + return "#" + r.index + (r.warped ? " 워프" : "") + + " t=" + r.time.ToString("F1") + "s f=" + r.frame + + " · 중심 " + r.distFromCenter.ToString("F1") + "m" + + " · 보스 " + r.distFromBoss.ToString("F1") + "m(보스-중심 " + r.bossFromCenter.ToString("F1") + + "m 점프=" + (r.bossJumping ? "Y" : "N") + " 락=" + (r.bossLocked ? "Y" : "N") + ")" + + " · 타깃=" + TargetName(r.targetKind) + + " · 조이스틱=" + r.joystickStatus + "(조종 " + (r.steering ? "Y" : "N") + ")" + + " · 애님=" + (eAnim)r.anim + " CC=" + (r.pcCC ? "Y" : "N") + + " · 에이전트 v=" + r.agentSpeed.ToString("F1") + " 정지=" + (r.agentStopped ? "Y" : "N") + + " NavMesh=" + (r.agentOnNavMesh ? "Y" : "N") + " 목적지-중심=" + r.agentDestFromCenter.ToString("F1") + "m" + + " · 가이드=" + (RunGuideState)r.guideState + + " · 피격=" + (r.recentHit ? r.sinceHit.ToString("F1") + "s전" : "없음"); + } + + static string TargetName(int kind) + { + switch (kind) { case 1: return "보스"; case 2: return "잡몹"; case 3: return "기타"; default: return "없음"; } + } + /// /// 피격 응전 판정(순수 · 프로브가 임시 SO 로 그대로 부른다 · C8 증거). /// 🔴 813w2 의 핵심 — `hitSuspendOnlyInHold` 가 켜져 있으면 **Travel(= 이동·되돌림)에서는 @@ -728,8 +1024,24 @@ namespace WL.Combat.Run s_nextPoll = s_nextRepath = s_travelStart = s_hitUntil = s_nextTickLog = 0f; s_lastPosTime = s_stillSince = s_lastAccumTime = -1f; s_lastPos = Vector3.zero; s_faulted = false; s_anchorCount = 0; + // 813w3 + ArenaExits = ArenaWarps = WarpsSkippedCooldown = WarpsSkippedNearBoss = WarpsFailedNavMesh = 0; + s_outsideSince = -1f; s_lastWarp = -999f; s_nextExitDiag = 0f; + s_exitLogNext = s_exitLogCount = 0; LastExitLog = ""; } + /// 프로브용: 워프 쿨다운·이탈 타이머만 비운다(시나리오 사이 초기화). + public static void ResetWarpStateForProbe() { s_outsideSince = -1f; s_lastWarp = -999f; s_nextExitDiag = 0f; } + /// 프로브용: 마지막 워프 시각을 밀어 넣는다(에디트 모드에서는 실제 워프가 못 돌아 쿨다운을 못 만든다). + public static void SetLastWarpForProbe(float t) { s_lastWarp = t; } + /// 프로브용: 이탈 시작 시각을 밀어 넣는다(에디트 모드에서는 Time 이 흐르지 않는다). + public static void SetOutsideSinceForProbe(float t) { s_outsideSince = t; } + /// 프로브용: PC 가 반경 밖이 된 시각(unscaled · 안이면 −1) · 마지막 워프 시각. + public static float OutsideSince { get { return s_outsideSince; } } + public static float LastWarpTime { get { return s_lastWarp; } } + /// 프로브용: 가이드 상태를 직접 세운다(에디트 모드에서 Hold 시나리오를 만들 때). + public static void SetStateForProbe(RunGuideState st) { s_state = st; } + /// 프로브용: 조종 플래그를 직접 세운다(에디트 모드엔 NavMeshAgent 가 없어 Steer 가 되돌아간다). public static void SetSteeringForProbe(bool on) { s_steering = on; if (!on) { s_stillSince = -1f; s_lastPosTime = -1f; } } /// 프로브용: 상태를 직접 읽는다(정지 판정 내부 시각). diff --git a/Assets/WL/Combat/Run/WLRunGuideSettings.cs b/Assets/WL/Combat/Run/WLRunGuideSettings.cs index 96b121683..6d9b9a2ae 100644 --- a/Assets/WL/Combat/Run/WLRunGuideSettings.cs +++ b/Assets/WL/Combat/Run/WLRunGuideSettings.cs @@ -179,5 +179,47 @@ namespace WL.Combat.Run [Tooltip("보스에게 원본 락타깃(m_IsLockTarget)을 건다 — 잡몹으로 타깃이 새는 원본 경로 3곳을 함께 막는다. 보스 사망 시 해제.")] public bool bossLockTarget = true; + + // ─────────────────────────────────────────── 813w3 §1-2 강제 복귀(워프) + [Header("아레나 강제 복귀 (813w3 · D-22 재발 · 이탈 39~56 m)")] + [Tooltip("되돌림(SetDestination)이 warpBackAfterSeconds 안에 PC 를 반경 안으로 못 넣으면 " + + "원본 Set_Warp(Actor.cs:2111 · 813i DeathFlow 의 On_Regen 이 쓰는 그 API)로 아레나로 순간이동시킨다. " + + "끄면 813w2 동작 그대로(되돌림만 · C8 롤백 1순위).")] + public bool warpBackEnabled = true; + + [Tooltip("이탈(bossHoldRadius 밖)이 이 시간(초 · unscaled) 이상 이어지면 워프한다. 0 이하면 워프하지 않는다.")] + public float warpBackAfterSeconds = 1.5f; + + [Tooltip("워프 최소 간격(초 · unscaled). 이 안에 다시 이탈해도 되돌림만 한다(연속 순간이동 방지).")] + public float warpCooldownSeconds = 5f; + + [Tooltip("워프 지점 = 아레나 중심 ± 이 반경(m) 안의 임의 지점. 0 이면 정확히 중심.")] + public float warpJitterRadius = 3f; + + [Tooltip("워프 지점을 NavMesh 위로 되돌릴 때 쓰는 탐색 반경(m). PC 는 Get_NavSnapRadius()=0 이라 " + + "Set_Warp 이 스스로 스냅하지 않는다 → 여기서 NavMesh.SamplePosition 으로 먼저 스냅한다. " + + "NavMesh 위 지점을 못 찾으면 워프하지 않는다(지형 뚫기 방지).")] + public float warpNavSampleRadius = 5f; + + [Tooltip("워프 직후 카메라 이동 리드(RealCamera.Get_MoveLead().Snap())를 0 으로 스냅해 카메라가 튀지 않게 한다. " + + "끄면 리드가 SmoothDamp 로 따라온다.")] + public bool warpSnapsCameraLead = true; + + [Tooltip("🔴 보스가 스킬(Anubis 스킬2 Co_JumpToPosition)로 아레나 중심에서 멀어졌을 때는 " + + "PC 가 보스에게서 이 거리(m) 안이면 「정상 추적」으로 보고 워프하지 않는다(§1-2).")] + public float followBossRadius = 12f; + + [Tooltip("보스가 아레나 중심에서 이 거리(m)를 넘으면 「보스가 멀어졌다」로 본다(위 followBossRadius 판정 조건).")] + public float bossAwayFromCenterRadius = 6f; + + // ─────────────────────────────────────────── 813w3 §1-1 이탈 원인 진단 + [Header("이탈 원인 진단 로그 (813w3 · Q7 이 읽는다)")] + [Tooltip("보스 구간에서 PC 가 bossHoldRadius 를 넘는 순간 「누가 이동을 시켰는지」를 한 줄 남긴다. " + + "🔴 verboseLog 와 무관하게 언제나 남긴다(이탈 1회 = 1줄 · 워프 1회 = 1줄). " + + "기록은 링버퍼 32칸(사전 할당 · GC 0)에도 함께 쌓인다.")] + public bool exitDiagEnabled = true; + + [Tooltip("이탈이 이어지는 동안 이 주기(초)마다 진단 줄을 더 남긴다. 0 이면 이탈 시작·워프 때만(기본).")] + public float exitDiagRepeatSeconds = 0f; } } diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLBossArenaSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLBossArenaSettings.asset index c07c47ab4..7ca2e05a6 100644 --- a/Assets/WL/Combat/Settings/Resources/WL/WLBossArenaSettings.asset +++ b/Assets/WL/Combat/Settings/Resources/WL/WLBossArenaSettings.asset @@ -51,3 +51,10 @@ MonoBehaviour: cleanupDelaySeconds: 3 deactivateZonesAfterBossKill: 0 rearmGateAfterBossKill: 0 + holdZoneSpawnNearArena: 1 + zoneSpawnHoldMargin: 10 + restartRebasesGateKills: 1 + restartRearmsHoldSpawn: 1 + restartResetsLivingBoss: 1 + restartRegensDeadBoss: 1 + suspendOutsideRun: 1 diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLRunGuideSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLRunGuideSettings.asset index 1fc838fcc..47395fdd2 100644 --- a/Assets/WL/Combat/Settings/Resources/WL/WLRunGuideSettings.asset +++ b/Assets/WL/Combat/Settings/Resources/WL/WLRunGuideSettings.asset @@ -43,3 +43,13 @@ MonoBehaviour: bossArriveRadius: 10 bossHoldRadius: 14 bossLockTarget: 1 + warpBackEnabled: 1 + warpBackAfterSeconds: 1.5 + warpCooldownSeconds: 5 + warpJitterRadius: 3 + warpNavSampleRadius: 5 + warpSnapsCameraLead: 1 + followBossRadius: 12 + bossAwayFromCenterRadius: 6 + exitDiagEnabled: 1 + exitDiagRepeatSeconds: 0 From 55f5908d259aa5b4aa1e298e6ecf39c17e9f419c Mon Sep 17 00:00:00 2001 From: swrring Date: Wed, 9 Sep 2026 14:49:19 +0900 Subject: [PATCH 2/2] =?UTF-8?q?[WL-813w3]=20=ED=9B=84=EC=86=8D:=20?= =?UTF-8?q?=EC=95=84=EB=A0=88=EB=82=98=20=EB=8F=84=EC=B0=A9=20=EC=A0=84(?= =?UTF-8?q?=E3=80=8C=EA=B0=80=EB=8A=94=20=EC=A4=91=E3=80=8D)=EC=9D=80=20?= =?UTF-8?q?=EC=9D=B4=ED=83=88=EB=A1=9C=20=EC=84=B8=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8A=94=EB=8B=A4=20=E2=80=94=20=EA=B2=8C=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?=EA=B0=9C=EB=B0=A9=20=EC=A7=81=ED=9B=84=20=EC=88=9C=EA=B0=84?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=20=EB=B0=A9=EC=A7=80=20(#813)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AgentScripts/WL813w3_PROBE.txt | 12 +++++++----- AgentScripts/WL813w3_Probe.cs | 13 +++++++++++++ Assets/WL/Combat/Run/RunGuide.cs | 18 ++++++++++++++---- 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/AgentScripts/WL813w3_PROBE.txt b/AgentScripts/WL813w3_PROBE.txt index d5a1fc8c7..3ac406aab 100644 --- a/AgentScripts/WL813w3_PROBE.txt +++ b/AgentScripts/WL813w3_PROBE.txt @@ -1,4 +1,4 @@ -# WL813w3 Probe 2026-09-09 14:46:42 (edit mode · Play 0 · 로그인 0) +# WL813w3 Probe 2026-09-09 14:48:56 (edit mode · Play 0 · 로그인 0) playMode=False ── ⓪ 준비 @@ -20,6 +20,8 @@ playMode=False [PASS] D-27 스위치 3종 on ── ② 이탈 원인 진단 (§1-1 · Q7 이 읽는 줄) — PC 를 반경 밖으로 옮긴다 + [PASS] 🔴 도착 전 50 m — 이탈 0 · 진단 0(BossArrivedOnce=False) = 게이트 개방 직후 순간이동이 생기지 않는다 + [PASS] 아레나 도착 → 이탈 감시 개시(BossArrivedOnce=true) 시작 상태=Hold · 목적지=(139.0,0.8,-20.0) [PASS] 중심에서 Hold(도착 반경 10 m) [PASS] 반경 안 — 이탈 0(실측 0) @@ -27,19 +29,19 @@ playMode=False [PASS] 링버퍼 1칸(실측 1) [PASS] 되돌림도 그대로 1회(실측 1) — 813w2 경로 유지 진단 줄 원문(보스 생존): - #1 t=221.5s f=12 · 중심 37.0m · 보스 37.0m(보스-중심 0.0m 점프=N 락=Y) · 타깃=보스 · 조이스틱=0(조종 N) · 애님=Idle CC=N · 에이전트 v=0.0 정지=N NavMesh=N 목적지-중심=-1.0m · 가이드=Hold · 피격=없음 + #1 t=355.1s f=14 · 중심 37.0m · 보스 37.0m(보스-중심 0.0m 점프=N 락=Y) · 타깃=보스 · 조이스틱=0(조종 N) · 애님=Idle CC=N · 에이전트 v=0.0 정지=N NavMesh=N 목적지-중심=-1.0m · 가이드=Hold · 피격=없음 [PASS] 중심 거리 37.0 m [PASS] 타깃 종류 = 보스(실측 1) · 락=True — 보스 생존 중엔 락이 걸려 있으니 「보스를 쫓아 나간 것」으로 읽는다 [PASS] 보스는 중심(보스-중심 0.0 m) = 보스 점프 아님 → **잡몹/원본 이동이 끌고 나간 경우** [PASS] PC-보스 37.0 m [PASS] 가이드 상태 = Hold(이탈 순간) [PASS] 이 줄은 이탈 줄(워프 줄 아님) - [PASS] frame=12 · index=1 · 조이스틱=0 · 애님=Idle · 에이전트 NavMesh=False + [PASS] frame=14 · index=1 · 조이스틱=0 · 애님=Idle · 에이전트 NavMesh=False [PASS] 줄에 타깃·가이드 상태가 문자로 들어간다(Q7 이 로그만 읽는다) [PASS] 반경 안 복귀 → 이탈 타이머 해제(OutsideSince=-1.0) [PASS] 재진입해도 이탈 수는 그대로 1(에피소드당 1줄) 진단 줄 원문(보스 없음 · 잡몹 타깃): - #1 t=221.5s f=12 · 중심 37.0m · 보스 -1.0m(보스-중심 -1.0m 점프=N 락=N) · 타깃=잡몹 · 조이스틱=0(조종 N) · 애님=Idle CC=N · 에이전트 v=0.0 정지=N NavMesh=N 목적지-중심=-1.0m · 가이드=Hold · 피격=없음 + #1 t=355.1s f=14 · 중심 37.0m · 보스 -1.0m(보스-중심 -1.0m 점프=N 락=N) · 타깃=잡몹 · 조이스틱=0(조종 N) · 애님=Idle CC=N · 에이전트 v=0.0 정지=N NavMesh=N 목적지-중심=-1.0m · 가이드=Hold · 피격=없음 [PASS] 🔴 타깃 종류 = 잡몹(실측 2) — 「누가 끌고 나갔는가」 [PASS] 보스 없음 → 보스 필드 = −1(실측 -1.0) [PASS] 줄에 「타깃=잡몹」 @@ -51,7 +53,7 @@ playMode=False [PASS] 3 s 전 워프 → 쿨다운(5s)으로 보류 [PASS] 6 s 전 워프 → 쿨다운 지나 다시 워프 [PASS] 보스 곁 → 워프하지 않는다(정상 추적) - [PASS] 이탈 직후 폴링 — 워프 시도 0(1.5 s 대기 중) · OutsideSince=221.5 + [PASS] 이탈 직후 폴링 — 워프 시도 0(1.5 s 대기 중) · OutsideSince=355.1 [PASS] 가짜 시각 t=1000 — 이탈 기록 · OutsideSince=1000 [PASS] t=1001(+1.0 s < 1.5) — 워프 경로 미도달 [PASS] 🔴 t=1002(+2.0 s ≥ 1.5) — **워프 경로 도달**(워프 0 · NavMesh 지점 없음 1) diff --git a/AgentScripts/WL813w3_Probe.cs b/AgentScripts/WL813w3_Probe.cs index e80170fea..abeca2de3 100644 --- a/AgentScripts/WL813w3_Probe.cs +++ b/AgentScripts/WL813w3_Probe.cs @@ -198,9 +198,22 @@ public static class WL813w3_Probe 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 + ")"); diff --git a/Assets/WL/Combat/Run/RunGuide.cs b/Assets/WL/Combat/Run/RunGuide.cs index aa0d93d36..face24759 100644 --- a/Assets/WL/Combat/Run/RunGuide.cs +++ b/Assets/WL/Combat/Run/RunGuide.cs @@ -239,6 +239,10 @@ namespace WL.Combat.Run // 813w3 §1-2 강제 복귀 — 이탈 시작 시각 · 마지막 워프 시각 · 다음 반복 진단 시각 static float s_outsideSince = -1f, s_lastWarp = -999f, s_nextExitDiag; + // 🔴 813w3: 아레나에 **한 번이라도 도착한 뒤**부터만 이탈로 센다. + // 게이트가 열린 순간 PC 는 존에 있어 중심에서 50 m 밖일 수 있는데, 그건 이탈이 아니라 「가는 중」이다. + // 이 가드가 없으면 게이트 개방 1.5 s 뒤에 PC 가 아레나로 순간이동해 이동 구간이 통째로 사라진다. + static bool s_bossArrivedOnce; // 존 앵커 캐시(스포너 노드 실좌표 · 없으면 SO 폴백) const int kMaxZones = 16; @@ -302,7 +306,7 @@ namespace WL.Combat.Run s_nextTickLog = 0f; s_lastAccumTime = -1f; LastTickLog = ""; // 813w3: 이탈·워프 집계도 런 단위(QA 가 런 1회 표를 읽는다). 링버퍼 기록은 남긴다. ArenaExits = ArenaWarps = WarpsSkippedCooldown = WarpsSkippedNearBoss = WarpsFailedNavMesh = 0; - s_outsideSince = -1f; s_lastWarp = -999f; s_nextExitDiag = 0f; + s_outsideSince = -1f; s_lastWarp = -999f; s_nextExitDiag = 0f; s_bossArrivedOnce = false; var cfg = Cfg; if (cfg != null && cfg.zoneGuideEnabled && cfg.guideOnRunStart) BeginZone(0, "런 시작"); } @@ -393,6 +397,7 @@ namespace WL.Combat.Run s_destKind = RunGuideDest.Boss; s_zoneIndex = -1; s_dest = BossArena.Center; + s_bossArrivedOnce = false; // 813w3: 새 보스 유도 = 「가는 중」부터 다시 시작 EnterTravel(); GuideBegun++; Log("보스 아레나 유도 시작(" + reason + ") → " + Fmt(s_dest)); @@ -410,6 +415,7 @@ namespace WL.Combat.Run s_hitUntil = 0f; s_stillSince = -1f; s_lastPosTime = -1f; s_outsideSince = -1f; s_nextExitDiag = 0f; // 813w3: 이탈 타이머는 목적지와 함께 끝난다 + s_bossArrivedOnce = false; if (had) Log("가이드 종료 — " + reason); } @@ -475,8 +481,9 @@ namespace WL.Combat.Run bool suspend = SuspendDecision(cfg, hit, s_state); // ── 813w3 §1-1·§1-2: 보스 구간 이탈 추적 → 진단 1줄 → (1.5 s 뒤에도 밖이면) 강제 워프. - // 상태(Travel/Hold)와 무관하게 돈다 — 되돌림 중(Travel)에 못 돌아오는 것이 D-22 재발의 실체다. - if (isBoss && TrackArenaExit(cfg, pc, pcPos, d, hold, now, hit)) return; // 워프했다 = 이 폴링 끝 + // ① 아레나에 한 번이라도 도착한 뒤부터(= 「가는 중」은 이탈이 아니다) + // ② 상태(Travel/Hold)와 무관하게 — 되돌림 중(Travel)에 못 돌아오는 것이 D-22 재발의 실체다. + if (isBoss && s_bossArrivedOnce && TrackArenaExit(cfg, pc, pcPos, d, hold, now, hit)) return; // 워프 = 폴링 끝 if (s_state == RunGuideState.Travel) { @@ -531,6 +538,7 @@ namespace WL.Combat.Run s_state = RunGuideState.Hold; ReleaseSteer(); s_stillSince = -1f; s_lastPosTime = -1f; + if (isBoss) s_bossArrivedOnce = true; // 813w3: 여기서부터 「이탈」을 센다 Log((isBoss ? "아레나" : "존 " + s_zoneIndex) + " " + why); } @@ -1026,7 +1034,7 @@ namespace WL.Combat.Run s_faulted = false; s_anchorCount = 0; // 813w3 ArenaExits = ArenaWarps = WarpsSkippedCooldown = WarpsSkippedNearBoss = WarpsFailedNavMesh = 0; - s_outsideSince = -1f; s_lastWarp = -999f; s_nextExitDiag = 0f; + s_outsideSince = -1f; s_lastWarp = -999f; s_nextExitDiag = 0f; s_bossArrivedOnce = false; s_exitLogNext = s_exitLogCount = 0; LastExitLog = ""; } @@ -1041,6 +1049,8 @@ namespace WL.Combat.Run public static float LastWarpTime { get { return s_lastWarp; } } /// 프로브용: 가이드 상태를 직접 세운다(에디트 모드에서 Hold 시나리오를 만들 때). public static void SetStateForProbe(RunGuideState st) { s_state = st; } + /// 아레나에 한 번이라도 도착했는가 — 이탈 감시는 이 뒤부터 돈다(진단·프로브). + public static bool BossArrivedOnce { get { return s_bossArrivedOnce; } } /// 프로브용: 조종 플래그를 직접 세운다(에디트 모드엔 NavMeshAgent 가 없어 Steer 가 되돌아간다). public static void SetSteeringForProbe(bool on) { s_steering = on; if (!on) { s_stillSince = -1f; s_lastPosTime = -1f; } }