From 018c43a95fe915f155039962d38cddecdad06b32 Mon Sep 17 00:00:00 2001 From: swrring Date: Wed, 9 Sep 2026 20:50:26 +0900 Subject: [PATCH] =?UTF-8?q?[WL-813w7]=20=EB=8B=A4=EC=9D=8C=20=EB=9F=B0=20?= =?UTF-8?q?=EC=A1=B4=20=EC=9E=AC=EB=AC=B4=EC=9E=A5=20=C2=B7=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=EC=B0=BD=20=EC=A4=91=20PC=20=EC=A0=95=EC=A7=80=20(#81?= =?UTF-8?q?3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AgentScripts/WL813w7_PROBE.txt | 95 +++ AgentScripts/WL813w7_Probe.cs | 588 ++++++++++++++++++ Assets/WL/Combat/Boss/BossArena.cs | 235 +++++++ Assets/WL/Combat/Boss/WLBossArenaSettings.cs | 41 ++ Assets/WL/Combat/Run/RunDirector.cs | 1 + Assets/WL/Combat/Run/RunGuide.cs | 155 ++++- Assets/WL/Combat/Run/WLRunGuideSettings.cs | 33 + .../Resources/WL/WLBossArenaSettings.asset | 8 + .../Resources/WL/WLRunGuideSettings.asset | 4 + 9 files changed, 1153 insertions(+), 7 deletions(-) create mode 100644 AgentScripts/WL813w7_PROBE.txt create mode 100644 AgentScripts/WL813w7_Probe.cs diff --git a/AgentScripts/WL813w7_PROBE.txt b/AgentScripts/WL813w7_PROBE.txt new file mode 100644 index 000000000..0a2beda20 --- /dev/null +++ b/AgentScripts/WL813w7_PROBE.txt @@ -0,0 +1,95 @@ +# WL813w7 Probe 2026-09-09 20:49:10 (edit mode · Play 0 · 로그인 0) +playMode=False + +── ⓪ 준비 + [PASS] WLBossArenaSettings 로드 · Active=True + [PASS] WLRunGuideSettings 로드 · Enabled=True + [PASS] WLRunSettings 로드 · Enabled=True + [PASS] RunGuide.Subscribed(RunEvents) + [PASS] HoldSpawn(FieldBossData) → 게이트 무장(armed=True open=False) + +── ① 에셋 (813w7 신규 12필드 · 값 전부 SO = C45) + RunGuide: holdPcOnRunEnd=True runEndHoldMaxSeconds=120 runEndHoldClearsTarget=True runEndHoldStopsAgent=True + [PASS] holdPcOnRunEnd=1 (발주서 §1-2 ① 기본값) + [PASS] runEndHoldMaxSeconds>0 (결과창을 닫고 방치해도 스스로 풀린다) + [PASS] runEndHoldClearsTarget=1 (자동 스킬 루프 차단 · PCActor.cs:93 isTarget 조건) + [PASS] runEndHoldStopsAgent=1 (잔여 경로 제거) + BossArena: holdZoneSpawnOnlyAfterGate=True holdZoneSpawnSkipsRunZones=True rearmZoneSpawnsEnabled=True rearmZoneEmptySeconds=8 rearmZoneCheckSeconds=1 rearmZoneRegenMax=15 rearmZoneRunZonesOnly=True rearmZoneSpawnsOnRestart=True + [PASS] holdZoneSpawnOnlyAfterGate=1 (차단을 보스전으로 좁힌다) + [PASS] holdZoneSpawnSkipsRunZones=1 (런 사냥 존은 대상 제외) + [PASS] rearmZoneSpawnsEnabled=1 + [PASS] rearmZoneSpawnsOnRestart=1 ([다음 런] 즉시 1회) + [PASS] rearmZoneRegenMax>0 (테이블 실패 시 폴백·상한) + [PASS] 🔴 rearmZoneEmptySeconds(8s) > 원본 리듬 5s — 멀쩡한 스포너와 겹치지 않는다(이중 스폰 방지) + [PASS] 감시 주기(1s) < 임계(8s) + [PASS] 813w3 holdZoneSpawnNearArena=1 유지(차단 경로 자체는 그대로 · 조건만 좁혔다) + +── ② 🔴 D-45 원인 산식 (Q10: R1n 0/18 · R2n 1/18 PC (112.93,0.06,−16.60) 고정 무피해 · R3n 14/18 정지) + 아레나 중심 (139.00, 0.80, -20.00) · 차단 반경 = arenaRadius 20 + zoneSpawnHoldMargin 10 = 30 m + 존0(id 813001) 앵커 (115.00, 0.39, -22.00) → 중심 24.1 m · 차단 사정권 안 🔴 + 존1(id 813002) 앵커 (147.00, 2.59, 30.00) → 중심 50.6 m · 차단 사정권 밖 + [PASS] 🔴 존0 이 차단 사정권 안(24.1 m ≤ 30 m) = D-45 의 직접 원인 + [PASS] Q10 R2n PC 좌표 (112.93, 0.06, -16.60) ↔ 존0 앵커 5.8 m ≤ zoneArriveRadius 6 = **도착(Hold)했는데 때릴 몹이 없었다** + 차단 방식 = MobControlMgr.StopAllCoroutines() → PhaseAllKill 의 `killmobcount = 0`(MobControlMgr.cs:218)이 + 함께 끊겨 카운터가 n_MaxMonsterCount 에 굳는다 → `==` 판정(:214)이 두 번 다시 참이 안 된다 = 영구 소진. + (MyCoroutine.Set_Coroutine = StartCoroutine(MyCoroutine.cs) 이므로 StopAllCoroutines 에 걸린다 · 실측) + +── ③ 수리 ① 결과창 중 PC 정지 (D-45 원인 = 결과창 100 s 사이 z0 처치 26 → 47) + [PASS] 가짜 RunEnded(Win) → 정지 시작(RunEndHeld=True RunEndHolds=1) + [PASS] 가이드는 꺼진다(State=Off Dest=None) — 813w6 계약 유지 + [PASS] 🔴 정지 틱 → JoystickStatus=1(1 = 원본 자동 교전 접근 루프 차단 · PCActor.cs:139) Steering=True + [PASS] 🔴 정지 틱 → 타깃 해제(isTarget=False RunEndTargetClears=1) = 자동 스킬 루프(PCActor.cs:93)도 돌지 않는다 + [PASS] 정지가 이어진다(틱 3 · 상한 전) + [PASS] 🔴 가짜 RunStarted("restart") → 정지 해제(RunEndHeld=False 해제 1 사유 '런 시작') + [PASS] 🔴 조종 원복 JoystickStatus=0(0 = 원본 그대로) Steering=False + [PASS] 813w4 재유도는 그대로 산다(RestartGuides=1) — 정지 해제가 유도보다 앞 + [PASS] 🔴 상한 만료 자동 해제(199 s 유지 → 201 s 해제 · 사유 '상한 만료') + [PASS] 만료 해제에서도 조종 원복(JoystickStatus=0) + [PASS] JoystickStatus=2(스킬 이동 잠금)는 건드리지 않는다(=2 Steering=False) + +── ④ 수리 ②-a 리젠 차단 판정(ShouldHoldZoneSpawn · 순수 함수) + [PASS] IsRunZoneSpawner(813001)=true (런 사냥 존) + [PASS] IsRunZoneSpawner(813005)=false (런 밖 존) + [PASS] 🔴 게이트 **닫힘** + 런 밖 존 → 차단 안 함(= 813w6 이 만든 「다음 런 내내 차단」이 사라진다) + [PASS] 🔴 게이트 열림 + **런 사냥 존** → 차단 안 함(D-45 재발 차단) + [PASS] 게이트 열림 + 런 밖 존 → 차단(813w3 잡몹 유입 차단 의도 유지) + [PASS] C8: 두 스위치 0 → 게이트 전 + 런 존도 차단 = 813w3 동작 그대로 + [PASS] C8: holdZoneSpawnNearArena=0 → 언제나 차단 0 + +── ⑤ 수리 ②-b 존 재무장(원본 리셋 API 없음 → MobActor.On_Regen = MobControlMgr.cs:221 과 같은 호출) + 감시 대상 2개: 813001 / 813002 + [PASS] 대상 = WLRunSettings.zoneSpawnerIds 전부(rearmZoneRunZonesOnly=1) + [PASS] 첫 대상 = 존0(813001) + [PASS] WaveSize(813001)=15 (테이블 n_MaxMonsterCount → 없으면 rearmZoneRegenMax 15 · 상한도 그 값) + [PASS] alive=1 → 빈 시각 미기록(-1) + [PASS] alive=0 → 빈 시각 100 기록 + [PASS] 임계(8s) 전 → 트리거 없음(빈 시각 그대로 100) + [PASS] 🔴 임계 경과 → 재무장 트리거(빈 시각이 108.5 로 갱신 = RearmZone 호출) + (에디트 모드는 ActorInfo.Ins 가 없어 실제 On_Regen 은 0마리 — 「언제 트리거되는가」까지 증명 · §미확인) + [PASS] 런 밖(결과창)에서는 감시 정지(빈 시각 108.5 불변) + [PASS] RearmZoneSpawns() 진입점 동작(재등장 0마리 · 에디트 모드라 0 · 목록 2개 재구축) + +── ⑥ 수리 ③ 런 밖 처치가 존 카운터에 안 실린다(Q10: 결과창 동안 z0 26 → 47) + [PASS] BossArena 누적은 계속 센다(1 → 22) = 813p 계약(리셋 금지) 유지 + [PASS] 런 밖 게이트 평가는 건너뛴다(GateSuspendedEvals 0 → 21) + [PASS] 🔴 다음 런 시작 직후 존0 진행 = 0/18 — 런 밖 21처치가 한 건도 안 실렸다(StartRun 스냅샷 차감) + [PASS] 런 안 처치 1건은 정상 반영(1/18) + +── ⑦ GC 0 (틱 경로 · 각 100회) + [PASS] 정지 틱 ×100 = 0 B + [PASS] 빈 존 감시 ×100 = 0 B + [PASS] 차단 판정 ×100 = 0 B (acc=False) + [PASS] 런 존 판정 ×100 = 0 B (acc=False) + +── ⑧ C8 롤백 (신규 스위치 0 → 813w6 동작 그대로) + [PASS] holdPcOnRunEnd=0 → 정지 0(RunEndHeld=False RunEndHolds=0) + [PASS] 조종도 잡지 않는다(JoystickStatus=0) = 813w6 그대로 + [PASS] 정지 중 스위치 off → 즉시 해제(사유 '스위치 off') + [PASS] rearmZoneSpawnsEnabled=0 → 감시 무동작(빈 시각 -1) + [PASS] RearmZoneSpawns() 도 0 + [PASS] rearmZoneSpawnsOnRestart=0 → [다음 런] 즉시 재무장 0 + [PASS] RunGuide RuntimeDisabled → Active=false + [PASS] Tick 이 정지를 풀고 원본으로 돌려준다(RunEndHeld=False) + [PASS] BossArena RuntimeDisabled → 감시 무동작 + +RESULT PASS diff --git a/AgentScripts/WL813w7_Probe.cs b/AgentScripts/WL813w7_Probe.cs new file mode 100644 index 000000000..f87d76115 --- /dev/null +++ b/AgentScripts/WL813w7_Probe.cs @@ -0,0 +1,588 @@ +// WL813w7_Probe.cs — #813w7(다음 런 존 재무장 · 결과창 중 PC 정지 D-45) 프로브 +// 에디트 모드 전용 · Play 불필요 · 로그인 0 · 씬/에셋 무수정(임시 GameObject·임시 SO 는 즉시 파괴/회수) +// +// 상주 에디터: +// unity command run_script --project-path --file AgentScripts/WL813w7_Probe.cs --entry WL813w7_Probe.RunAll --timeout 300 +// 결과: Console + AgentScripts/WL813w7_PROBE.txt (RESULT PASS / RESULT FAIL n) +// +// 무엇을 증명하나 (발주서 WL-813w7 §1 · 근거 = qa/W2-10_재채점_v8.md D-45) +// ① 에셋·SOT — 813w7 신규 12필드 값 실측 · 이중 스폰 방지 부등식 · 코드 상수 0 +// ② 원인 산식 — 존0 앵커 ↔ 아레나 중심 24.1 m ≤ arenaRadius+zoneSpawnHoldMargin 30 m +// = 813w3 리젠 차단의 사정권 안이다(D-45 의 직접 원인 · 값으로 재현) +// ③ 수리 ① — 가짜 `RunEnded` → PC 조종 잡힘(JoystickStatus 1) · 타깃 해제 · +// 가짜 `RunStarted("restart")` → 조종 원복(0) · 상한 만료 자동 해제 +// ④ 수리 ②-a — 리젠 차단 판정(ShouldHoldZoneSpawn) 진리표: 게이트 전 0 · 런 존 0 · 그 밖 1 +// ⑤ 수리 ②-b — 빈 존 감시 타이밍(8 s) · Restart 즉시 재무장 · 1웨이브 마리 수(WaveSize) +// ⑥ 수리 ③ — 런 밖 처치는 다음 런 존 진행에 안 센다(StartRun 스냅샷 차감) +// ⑦ GC 0 — 정지 틱 ×100 · 빈 존 감시 ×100 · 차단 판정 ×100 · 런 존 판정 ×100 +// ⑧ C8 롤백 — 신규 스위치 0 → 813w6 동작 그대로 · RuntimeDisabled → 무동작 +// +// 에디트 모드 한계(보고서 「미확인」) +// · `ActorInfo.Ins` 도 `table_monsterappear.Ins` 도 없다(플레이 모드 전용) → 실제 `On_Regen()` 재무장은 +// 못 돌린다. 「언제 재무장을 트리거하는가」와 「1웨이브 마리 수 산정」까지를 증명한다. +// · `Time.unscaledTime` 이 스크립트 실행 중 멈춰 있다 → 시간 판정은 전부 인자로 먹인다. +// · PC 에 살아 있는 타깃(m_Target)을 붙일 수 없다(Set_Outline 이 렌더러를 만진다) → 타깃 해제는 +// 「isTarget 플래그가 내려가는가」로 증명한다. + +using System; +using System.IO; +using System.Text; +using UnityEngine; +using WL.Combat.Boss; +using WL.Combat.Core; +using WL.Combat.Run; + +public static class WL813w7_Probe +{ + const string OutCommit = "AgentScripts/WL813w7_PROBE.txt"; + + static StringBuilder sb; + static int s_fail; + + static GameObject s_pcGo, s_fbdGo; + static PCActor s_pc, s_prevMyPc; + static FieldBossData s_fbd; + + public static object RunAll() + { + sb = new StringBuilder(); + s_fail = 0; + sb.AppendLine("# WL813w7 Probe " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " (edit mode · Play 0 · 로그인 0)"); + sb.AppendLine("playMode=" + Application.isPlaying); + + try + { + Setup(); + Sec1_Asset(); + Sec2_Cause(); + Sec3_RunEndHold(); + Sec4_HoldDecision(); + Sec5_ZoneRearm(); + Sec6_OutsideRunKills(); + Sec7_GC(); + Sec8_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 WLBossArenaSettings TmpB() + { + var t = UnityEngine.Object.Instantiate(WLBossArenaSettings.Instance); + t.hideFlags = HideFlags.HideAndDontSave; + return t; + } + static WLRunGuideSettings TmpG() + { + var t = UnityEngine.Object.Instantiate(WLRunGuideSettings.Instance); + t.hideFlags = HideFlags.HideAndDontSave; + return t; + } + static void UseTmpB(WLBossArenaSettings t) { WLBossArenaSettings.SetInstanceForProbe(t); } + static void UseRealB() { WLBossArenaSettings.SetInstanceForProbe(null); } + static void UseTmpG(WLRunGuideSettings t) { WLRunGuideSettings.SetInstanceForProbe(t); } + static void UseRealG() { WLRunGuideSettings.SetInstanceForProbe(null); } + + static float DistXZ(Vector3 a, Vector3 b) + { + float dx = a.x - b.x, dz = a.z - b.z; + return Mathf.Sqrt(dx * dx + dz * dz); + } + + static void Setup() + { + s_prevMyPc = MyValue.MyPC; + + s_pcGo = new GameObject("__WL813w7_PC"); + s_pc = s_pcGo.AddComponent(); + s_pc.m_Role = eRole.PC; s_pc.m_SubRole = eSubRol.None; + MyValue.MyPC = s_pc; + + WLBossArenaSettings.RuntimeDisabled = false; + WLRunGuideSettings.RuntimeDisabled = false; + BossArena.ResetAll(); + BossArena.EnsureSubscribed(); + RunDirector.ResetAll(); + RunGuide.EnsureSubscribed(); + RunGuide.ResetDiagnostics(); + + // 게이트 무장 — 원본 훅과 같은 진입점(HoldSpawn)으로 Arm 한다(s_zones 에 존 id 가 들어간다). + var cfg = WLBossArenaSettings.Instance; + s_fbdGo = new GameObject("__WL813w7_FBD"); + s_fbd = s_fbdGo.AddComponent(); + s_fbd.n_SpawnerId = cfg != null ? cfg.bossSpawnerId : 813010; + s_fbd.n_MonsterID = cfg != null ? cfg.bossMonsterId : 10006; + bool held = BossArena.HoldSpawn(s_fbd, NoopResume); + + L(""); + L("── ⓪ 준비"); + L(" " + P(WLBossArenaSettings.Instance != null) + " WLBossArenaSettings 로드 · Active=" + WLBossArenaSettings.Active); + L(" " + P(WLRunGuideSettings.Instance != null) + " WLRunGuideSettings 로드 · Enabled=" + WLRunGuideSettings.Enabled); + L(" " + P(WLRunSettings.Instance != null) + " WLRunSettings 로드 · Enabled=" + WLRunSettings.Enabled); + L(" " + P(RunGuide.Subscribed) + " RunGuide.Subscribed(RunEvents)"); + L(" " + P(held && BossArena.GateArmed && !BossArena.GateOpen) + + " HoldSpawn(FieldBossData) → 게이트 무장(armed=" + BossArena.GateArmed + " open=" + BossArena.GateOpen + ")"); + } + + static void NoopResume() { } + + static void Teardown() + { + try + { + UseRealB(); UseRealG(); + BossArena.SetProbeTime(-1f); + BossArena.SetRunActiveForProbe(0); + BossArena.SetBossForProbe(null); + BossArena.ResetAll(); + RunGuide.EndRunEndHold("teardown"); + RunGuide.ResetDiagnostics(); + RunDirector.ResetAll(); + WLBossArenaSettings.RuntimeDisabled = false; + WLRunGuideSettings.RuntimeDisabled = false; + MyValue.MyPC = s_prevMyPc; + if (s_pcGo != null) UnityEngine.Object.DestroyImmediate(s_pcGo); + if (s_fbdGo != null) UnityEngine.Object.DestroyImmediate(s_fbdGo); + } + catch { } + } + + // ───────────────────────────────────────────────────────────────────── + // ① 에셋 · SOT (C45 — 값은 전부 .asset) + // ───────────────────────────────────────────────────────────────────── + static void Sec1_Asset() + { + L(""); + L("── ① 에셋 (813w7 신규 12필드 · 값 전부 SO = C45)"); + + var g = WLRunGuideSettings.Instance; + var b = WLBossArenaSettings.Instance; + if (g == null || b == null) { L(" [FAIL] 에셋 없음"); s_fail++; return; } + + L(" RunGuide: holdPcOnRunEnd=" + g.holdPcOnRunEnd + + " runEndHoldMaxSeconds=" + g.runEndHoldMaxSeconds + + " runEndHoldClearsTarget=" + g.runEndHoldClearsTarget + + " runEndHoldStopsAgent=" + g.runEndHoldStopsAgent); + L(" " + P(g.holdPcOnRunEnd) + " holdPcOnRunEnd=1 (발주서 §1-2 ① 기본값)"); + L(" " + P(g.runEndHoldMaxSeconds > 0f) + " runEndHoldMaxSeconds>0 (결과창을 닫고 방치해도 스스로 풀린다)"); + L(" " + P(g.runEndHoldClearsTarget) + " runEndHoldClearsTarget=1 (자동 스킬 루프 차단 · PCActor.cs:93 isTarget 조건)"); + L(" " + P(g.runEndHoldStopsAgent) + " runEndHoldStopsAgent=1 (잔여 경로 제거)"); + + L(" BossArena: holdZoneSpawnOnlyAfterGate=" + b.holdZoneSpawnOnlyAfterGate + + " holdZoneSpawnSkipsRunZones=" + b.holdZoneSpawnSkipsRunZones + + " rearmZoneSpawnsEnabled=" + b.rearmZoneSpawnsEnabled + + " rearmZoneEmptySeconds=" + b.rearmZoneEmptySeconds + + " rearmZoneCheckSeconds=" + b.rearmZoneCheckSeconds + + " rearmZoneRegenMax=" + b.rearmZoneRegenMax + + " rearmZoneRunZonesOnly=" + b.rearmZoneRunZonesOnly + + " rearmZoneSpawnsOnRestart=" + b.rearmZoneSpawnsOnRestart); + L(" " + P(b.holdZoneSpawnOnlyAfterGate) + " holdZoneSpawnOnlyAfterGate=1 (차단을 보스전으로 좁힌다)"); + L(" " + P(b.holdZoneSpawnSkipsRunZones) + " holdZoneSpawnSkipsRunZones=1 (런 사냥 존은 대상 제외)"); + L(" " + P(b.rearmZoneSpawnsEnabled) + " rearmZoneSpawnsEnabled=1"); + L(" " + P(b.rearmZoneSpawnsOnRestart) + " rearmZoneSpawnsOnRestart=1 ([다음 런] 즉시 1회)"); + L(" " + P(b.rearmZoneRegenMax > 0) + " rearmZoneRegenMax>0 (테이블 실패 시 폴백·상한)"); + + // 🔴 이중 스폰 방지 부등식 — 원본 리듬 = 시체 3.0 s(Actor.cs:631) + f_SpawnerDelay 2.0 s(813e2 표 A) = 5.0 s + const float kOriginalRegenRhythm = 5.0f; // 게임 수치가 아니라 813e2 실측 리듬(검증 기준값) + L(" " + P(b.rearmZoneEmptySeconds > kOriginalRegenRhythm) + + " 🔴 rearmZoneEmptySeconds(" + b.rearmZoneEmptySeconds + "s) > 원본 리듬 " + kOriginalRegenRhythm + + "s — 멀쩡한 스포너와 겹치지 않는다(이중 스폰 방지)"); + L(" " + P(b.rearmZoneCheckSeconds > 0f && b.rearmZoneCheckSeconds < b.rearmZoneEmptySeconds) + + " 감시 주기(" + b.rearmZoneCheckSeconds + "s) < 임계(" + b.rearmZoneEmptySeconds + "s)"); + L(" " + P(b.holdZoneSpawnNearArena) + " 813w3 holdZoneSpawnNearArena=1 유지(차단 경로 자체는 그대로 · 조건만 좁혔다)"); + } + + // ───────────────────────────────────────────────────────────────────── + // ② 원인 산식 — 존0 이 리젠 차단 사정권 안이다 + // ───────────────────────────────────────────────────────────────────── + static void Sec2_Cause() + { + L(""); + L("── ② 🔴 D-45 원인 산식 (Q10: R1n 0/18 · R2n 1/18 PC (112.93,0.06,−16.60) 고정 무피해 · R3n 14/18 정지)"); + + var b = WLBossArenaSettings.Instance; + var g = WLRunGuideSettings.Instance; + var rs = WLRunSettings.Instance; + if (b == null || g == null || rs == null) { L(" [FAIL] 에셋 없음"); s_fail++; return; } + + Vector3 center = b.arenaCenter; + float lim = b.arenaRadius + b.zoneSpawnHoldMargin; + L(" 아레나 중심 " + center.ToString("F2") + " · 차단 반경 = arenaRadius " + b.arenaRadius + + " + zoneSpawnHoldMargin " + b.zoneSpawnHoldMargin + " = " + lim.ToString("F0") + " m"); + + var fb = g.zoneAnchorFallbacks; + for (int i = 0; i < rs.zoneSpawnerIds.Length; i++) + { + if (fb == null || i >= fb.Length) continue; + float d = DistXZ(fb[i], center); + L(" 존" + i + "(id " + rs.zoneSpawnerIds[i] + ") 앵커 " + fb[i].ToString("F2") + + " → 중심 " + d.ToString("F1") + " m · 차단 사정권 " + (d <= lim ? "안 🔴" : "밖")); + } + + float d0 = fb != null && fb.Length > 0 ? DistXZ(fb[0], center) : -1f; + L(" " + P(d0 >= 0f && d0 <= lim) + + " 🔴 존0 이 차단 사정권 안(" + d0.ToString("F1") + " m ≤ " + lim.ToString("F0") + " m) = D-45 의 직접 원인"); + + // PC 고정 좌표(Q10 R2n 실측)가 존0 앵커에 도착한 자리인가 = 「PC 는 존에 있는데 몹이 없다」 + var pcSeen = new Vector3(112.93f, 0.06f, -16.60f); + float dPc = fb != null && fb.Length > 0 ? DistXZ(pcSeen, fb[0]) : -1f; + L(" " + P(dPc >= 0f && dPc <= g.zoneArriveRadius) + + " Q10 R2n PC 좌표 " + pcSeen.ToString("F2") + " ↔ 존0 앵커 " + dPc.ToString("F1") + + " m ≤ zoneArriveRadius " + g.zoneArriveRadius + " = **도착(Hold)했는데 때릴 몹이 없었다**"); + + L(" 차단 방식 = MobControlMgr.StopAllCoroutines() → PhaseAllKill 의 `killmobcount = 0`(MobControlMgr.cs:218)이"); + L(" 함께 끊겨 카운터가 n_MaxMonsterCount 에 굳는다 → `==` 판정(:214)이 두 번 다시 참이 안 된다 = 영구 소진."); + L(" (MyCoroutine.Set_Coroutine = StartCoroutine(MyCoroutine.cs) 이므로 StopAllCoroutines 에 걸린다 · 실측)"); + } + + // ───────────────────────────────────────────────────────────────────── + // ③ 수리 ① — 결과창 중 PC 정지 + // ───────────────────────────────────────────────────────────────────── + static void Sec3_RunEndHold() + { + L(""); + L("── ③ 수리 ① 결과창 중 PC 정지 (D-45 원인 = 결과창 100 s 사이 z0 처치 26 → 47)"); + + var g = WLRunGuideSettings.Instance; + RunGuide.ResetDiagnostics(); + s_pc.Set_JoystickStatus(3); + s_pc.isTarget = false; + + int holds0 = RunGuide.RunEndHolds; + RunGuide.ProbeRunEnded(RunOutcome.Win); + RunGuide.SetRunEndHoldUntilForProbe(-1f); // 에디트 모드는 Time 이 안 흘러 상한 판정을 따로 검증한다(아래 ⓑ) + L(" " + P(RunGuide.RunEndHeld && RunGuide.RunEndHolds == holds0 + 1) + + " 가짜 RunEnded(Win) → 정지 시작(RunEndHeld=" + RunGuide.RunEndHeld + " RunEndHolds=" + RunGuide.RunEndHolds + ")"); + L(" " + P(RunGuide.State == RunGuideState.Off && RunGuide.DestKind == RunGuideDest.None) + + " 가이드는 꺼진다(State=" + RunGuide.State + " Dest=" + RunGuide.DestKind + ") — 813w6 계약 유지"); + + // 정지 1틱 — 조종을 잡고 타깃을 뗀다 + s_pc.isTarget = true; + int clears0 = RunGuide.RunEndTargetClears; + RunGuide.RunEndHoldTick(g, s_pc, 100f); + L(" " + P(s_pc.JoystickStatus == 1 && RunGuide.Steering) + + " 🔴 정지 틱 → JoystickStatus=" + s_pc.JoystickStatus + "(1 = 원본 자동 교전 접근 루프 차단 · PCActor.cs:139) Steering=" + RunGuide.Steering); + L(" " + P(!s_pc.isTarget && RunGuide.RunEndTargetClears == clears0 + 1) + + " 🔴 정지 틱 → 타깃 해제(isTarget=" + s_pc.isTarget + " RunEndTargetClears=" + RunGuide.RunEndTargetClears + + ") = 자동 스킬 루프(PCActor.cs:93)도 돌지 않는다"); + + int ticks0 = RunGuide.RunEndHoldTicks; + RunGuide.RunEndHoldTick(g, s_pc, 101f); + RunGuide.RunEndHoldTick(g, s_pc, 102f); + L(" " + P(RunGuide.RunEndHoldTicks == ticks0 + 2 && RunGuide.RunEndHeld) + + " 정지가 이어진다(틱 " + RunGuide.RunEndHoldTicks + " · 상한 전)"); + + // 복귀 ⓐ — 다음 런 시작(RunDirector.Restart → StartRun("restart")) + int rel0 = RunGuide.RunEndHoldReleases; + RunGuide.ProbeRunStarted(2, "restart"); + L(" " + P(!RunGuide.RunEndHeld && RunGuide.RunEndHoldReleases == rel0 + 1) + + " 🔴 가짜 RunStarted(\"restart\") → 정지 해제(RunEndHeld=" + RunGuide.RunEndHeld + + " 해제 " + RunGuide.RunEndHoldReleases + " 사유 '" + RunGuide.LastRunEndHoldReason + "')"); + L(" " + P(s_pc.JoystickStatus == 0 && !RunGuide.Steering) + + " 🔴 조종 원복 JoystickStatus=" + s_pc.JoystickStatus + "(0 = 원본 그대로) Steering=" + RunGuide.Steering); + L(" " + P(RunGuide.RestartGuides >= 1) + + " 813w4 재유도는 그대로 산다(RestartGuides=" + RunGuide.RestartGuides + ") — 정지 해제가 유도보다 앞"); + + // 복귀 ⓑ — 상한 만료(결과창을 [닫기]로 닫고 런을 다시 시작하지 않는 경우) + RunGuide.StopGuide("프로브"); + RunGuide.BeginRunEndHold("상한 시험"); + RunGuide.SetRunEndHoldUntilForProbe(200f); + RunGuide.RunEndHoldTick(g, s_pc, 199f); + bool stillHeld = RunGuide.RunEndHeld; + RunGuide.RunEndHoldTick(g, s_pc, 201f); + L(" " + P(stillHeld && !RunGuide.RunEndHeld) + + " 🔴 상한 만료 자동 해제(199 s 유지 → 201 s 해제 · 사유 '" + RunGuide.LastRunEndHoldReason + "')"); + L(" " + P(s_pc.JoystickStatus == 0) + " 만료 해제에서도 조종 원복(JoystickStatus=" + s_pc.JoystickStatus + ")"); + + // 이미 스킬 이동 잠금(2)이면 건드리지 않는다 + RunGuide.ResetDiagnostics(); + s_pc.Set_JoystickStatus(2); + RunGuide.BeginRunEndHold("잠금 시험"); + RunGuide.SetRunEndHoldUntilForProbe(-1f); + RunGuide.RunEndHoldTick(g, s_pc, 300f); + L(" " + P(s_pc.JoystickStatus == 2 && !RunGuide.Steering) + + " JoystickStatus=2(스킬 이동 잠금)는 건드리지 않는다(=" + s_pc.JoystickStatus + " Steering=" + RunGuide.Steering + ")"); + RunGuide.EndRunEndHold("프로브 정리"); + s_pc.Set_JoystickStatus(3); + RunGuide.ResetDiagnostics(); + } + + // ───────────────────────────────────────────────────────────────────── + // ④ 수리 ②-a — 리젠 차단 판정 진리표 + // ───────────────────────────────────────────────────────────────────── + static void Sec4_HoldDecision() + { + L(""); + L("── ④ 수리 ②-a 리젠 차단 판정(ShouldHoldZoneSpawn · 순수 함수)"); + + var rs = WLRunSettings.Instance; + int runZone = rs != null && rs.zoneSpawnerIds != null && rs.zoneSpawnerIds.Length > 0 ? rs.zoneSpawnerIds[0] : 813001; + int other = 813005; + + L(" " + P(BossArena.IsRunZoneSpawner(runZone)) + " IsRunZoneSpawner(" + runZone + ")=true (런 사냥 존)"); + L(" " + P(!BossArena.IsRunZoneSpawner(other)) + " IsRunZoneSpawner(" + other + ")=false (런 밖 존)"); + + var t = TmpB(); + try + { + UseTmpB(t); + t.holdZoneSpawnNearArena = true; + t.holdZoneSpawnOnlyAfterGate = true; + t.holdZoneSpawnSkipsRunZones = true; + + L(" " + P(!BossArena.ShouldHoldZoneSpawn(t, other, false)) + + " 🔴 게이트 **닫힘** + 런 밖 존 → 차단 안 함(= 813w6 이 만든 「다음 런 내내 차단」이 사라진다)"); + L(" " + P(!BossArena.ShouldHoldZoneSpawn(t, runZone, true)) + + " 🔴 게이트 열림 + **런 사냥 존** → 차단 안 함(D-45 재발 차단)"); + L(" " + P(BossArena.ShouldHoldZoneSpawn(t, other, true)) + + " 게이트 열림 + 런 밖 존 → 차단(813w3 잡몹 유입 차단 의도 유지)"); + + // C8 — 두 스위치를 끄면 813w3 그대로(보스 생존 = 상시 차단) + t.holdZoneSpawnOnlyAfterGate = false; + t.holdZoneSpawnSkipsRunZones = false; + L(" " + P(BossArena.ShouldHoldZoneSpawn(t, runZone, false)) + + " C8: 두 스위치 0 → 게이트 전 + 런 존도 차단 = 813w3 동작 그대로"); + + t.holdZoneSpawnNearArena = false; + L(" " + P(!BossArena.ShouldHoldZoneSpawn(t, other, true)) + " C8: holdZoneSpawnNearArena=0 → 언제나 차단 0"); + } + finally { UseRealB(); UnityEngine.Object.DestroyImmediate(t); } + } + + // ───────────────────────────────────────────────────────────────────── + // ⑤ 수리 ②-b — 빈 존 감시 · 재무장 + // ───────────────────────────────────────────────────────────────────── + static void Sec5_ZoneRearm() + { + L(""); + L("── ⑤ 수리 ②-b 존 재무장(원본 리셋 API 없음 → MobActor.On_Regen = MobControlMgr.cs:221 과 같은 호출)"); + + var b = WLBossArenaSettings.Instance; + var rs = WLRunSettings.Instance; + int z0 = rs != null && rs.zoneSpawnerIds != null && rs.zoneSpawnerIds.Length > 0 ? rs.zoneSpawnerIds[0] : 813001; + + // 감시 목록 — 런 존만 + BossArena.RebuildRearmZonesForProbe(); + L(" 감시 대상 " + BossArena.RearmZoneCount + "개: " + + (BossArena.RearmZoneCount > 0 ? BossArena.RearmZoneIdAt(0).ToString() : "-") + + (BossArena.RearmZoneCount > 1 ? " / " + BossArena.RearmZoneIdAt(1) : "")); + L(" " + P(rs != null && BossArena.RearmZoneCount == rs.zoneSpawnerIds.Length) + + " 대상 = WLRunSettings.zoneSpawnerIds 전부(rearmZoneRunZonesOnly=1)"); + L(" " + P(BossArena.RearmZoneCount > 0 && BossArena.RearmZoneIdAt(0) == z0) + " 첫 대상 = 존0(" + z0 + ")"); + + // 1웨이브 마리 수 — 에디트 모드엔 table_monsterappear 가 없어 SO 폴백을 쓴다 + int wave = BossArena.WaveSize(b, z0); + L(" " + P(wave > 0 && wave <= b.rearmZoneRegenMax) + + " WaveSize(" + z0 + ")=" + wave + " (테이블 n_MaxMonsterCount → 없으면 rearmZoneRegenMax " + b.rearmZoneRegenMax + " · 상한도 그 값)"); + + // 타이밍 — 살아 있으면 타이머가 안 돈다 / 비면 임계까지 기다렸다 트리거 + BossArena.SetRunActiveForProbe(1); + BossArena.NoteSpawn(z0, false); // alive 1 + BossArena.ZoneRearmTick(b, 100f); + L(" " + P(BossArena.AliveOf(z0) > 0 && BossArena.RearmZoneEmptySinceAt(0) < 0f) + + " alive=" + BossArena.AliveOf(z0) + " → 빈 시각 미기록(" + BossArena.RearmZoneEmptySinceAt(0) + ")"); + + BossArena.NoteKill(z0, false); // alive 0 + BossArena.ZoneRearmTick(b, 100f); + L(" " + P(BossArena.AliveOf(z0) == 0 && Mathf.Approximately(BossArena.RearmZoneEmptySinceAt(0), 100f)) + + " alive=0 → 빈 시각 " + BossArena.RearmZoneEmptySinceAt(0) + " 기록"); + + BossArena.ZoneRearmTick(b, 100f + b.rearmZoneEmptySeconds - 1f); + L(" " + P(Mathf.Approximately(BossArena.RearmZoneEmptySinceAt(0), 100f)) + + " 임계(" + b.rearmZoneEmptySeconds + "s) 전 → 트리거 없음(빈 시각 그대로 " + BossArena.RearmZoneEmptySinceAt(0) + ")"); + + float fire = 100f + b.rearmZoneEmptySeconds + 0.5f; + BossArena.ZoneRearmTick(b, fire); + L(" " + P(Mathf.Approximately(BossArena.RearmZoneEmptySinceAt(0), fire)) + + " 🔴 임계 경과 → 재무장 트리거(빈 시각이 " + BossArena.RearmZoneEmptySinceAt(0) + " 로 갱신 = RearmZone 호출)"); + L(" (에디트 모드는 ActorInfo.Ins 가 없어 실제 On_Regen 은 0마리 — 「언제 트리거되는가」까지 증명 · §미확인)"); + + // 런 밖에서는 감시하지 않는다(결과창 동안 몹을 우리가 늘리지 않는다) + BossArena.SetRunActiveForProbe(-1); + float before = BossArena.RearmZoneEmptySinceAt(0); + BossArena.ZoneRearmTick(b, fire + 100f); + L(" " + P(Mathf.Approximately(BossArena.RearmZoneEmptySinceAt(0), before)) + + " 런 밖(결과창)에서는 감시 정지(빈 시각 " + BossArena.RearmZoneEmptySinceAt(0) + " 불변)"); + BossArena.SetRunActiveForProbe(1); + + // [다음 런] 즉시 재무장 진입점 + int n = BossArena.RearmZoneSpawns("프로브 다음 런"); + L(" " + P(BossArena.RearmZoneCount > 0) + + " RearmZoneSpawns() 진입점 동작(재등장 " + n + "마리 · 에디트 모드라 0 · 목록 " + BossArena.RearmZoneCount + "개 재구축)"); + BossArena.SetRunActiveForProbe(0); + } + + // ───────────────────────────────────────────────────────────────────── + // ⑥ 수리 ③ — 런 밖 처치는 다음 런 존 진행에 안 센다 + // ───────────────────────────────────────────────────────────────────── + static void Sec6_OutsideRunKills() + { + L(""); + L("── ⑥ 수리 ③ 런 밖 처치가 존 카운터에 안 실린다(Q10: 결과창 동안 z0 26 → 47)"); + + var rs = WLRunSettings.Instance; + int z0 = rs.zoneSpawnerIds[0]; + + RunDirector.ResetAll(); + RunDirector.EnsureSubscribed(); + BossArena.SetRunActiveForProbe(-1); // 런 밖(결과창) + + int arena0 = BossArena.KillsOf(z0); + int gateEvals0 = BossArena.GateSuspendedEvals; + for (int i = 0; i < 21; i++) // Q10 실측과 같은 21마리 + { + RunDirector.NoteKill(z0, false); // 런 밖 → IsRunning=false → 무시 + BossArena.NoteKill(z0, false); // 누적값은 813p 계약대로 계속 센다 + } + L(" " + P(BossArena.KillsOf(z0) == arena0 + 21) + + " BossArena 누적은 계속 센다(" + arena0 + " → " + BossArena.KillsOf(z0) + ") = 813p 계약(리셋 금지) 유지"); + L(" " + P(BossArena.GateSuspendedEvals > gateEvals0) + + " 런 밖 게이트 평가는 건너뛴다(GateSuspendedEvals " + gateEvals0 + " → " + BossArena.GateSuspendedEvals + ")"); + + BossArena.SetRunActiveForProbe(1); + RunDirector.StartRun("probe restart"); // ← 여기서 s_zoneBase 를 다시 잡는다 + L(" " + P(RunDirector.ZoneProgress(0) == 0) + + " 🔴 다음 런 시작 직후 존0 진행 = " + RunDirector.ZoneProgress(0) + "/" + RunDirector.ZoneRequired(0) + + " — 런 밖 21처치가 한 건도 안 실렸다(StartRun 스냅샷 차감)"); + + RunDirector.NoteKill(z0, false); + L(" " + P(RunDirector.ZoneProgress(0) == 1) + + " 런 안 처치 1건은 정상 반영(" + RunDirector.ZoneProgress(0) + "/" + RunDirector.ZoneRequired(0) + ")"); + + RunDirector.ResetAll(); + BossArena.SetRunActiveForProbe(0); + RunGuide.ResetDiagnostics(); + } + + // ───────────────────────────────────────────────────────────────────── + // ⑦ GC 0 + // ───────────────────────────────────────────────────────────────────── + static void Sec7_GC() + { + L(""); + L("── ⑦ GC 0 (틱 경로 · 각 100회)"); + + var g = WLRunGuideSettings.Instance; + var b = WLBossArenaSettings.Instance; + var rs = WLRunSettings.Instance; + int z0 = rs.zoneSpawnerIds[0]; + int other = 813005; + + // ⓐ 결과창 정지 틱 + RunGuide.ResetDiagnostics(); + s_pc.Set_JoystickStatus(3); + RunGuide.BeginRunEndHold("GC"); + RunGuide.SetRunEndHoldUntilForProbe(-1f); + RunGuide.RunEndHoldTick(g, s_pc, 10f); // 워밍업(조종 획득 1회) + long a0 = Alloc(); + for (int i = 0; i < 100; i++) RunGuide.RunEndHoldTick(g, s_pc, 10f + i); + long a1 = Alloc(); + L(" " + P(a1 - a0 <= 0) + " 정지 틱 ×100 = " + (a1 - a0) + " B"); + RunGuide.EndRunEndHold("GC 끝"); + s_pc.Set_JoystickStatus(3); + + // ⓑ 빈 존 감시 + BossArena.SetRunActiveForProbe(1); + BossArena.ZoneRearmTick(b, 500f); + long b0 = Alloc(); + for (int i = 0; i < 100; i++) BossArena.ZoneRearmTick(b, 500f + i * 0.01f); + long b1 = Alloc(); + L(" " + P(b1 - b0 <= 0) + " 빈 존 감시 ×100 = " + (b1 - b0) + " B"); + BossArena.SetRunActiveForProbe(0); + + // ⓒ 차단 판정 · 런 존 판정 + long c0 = Alloc(); + bool acc = false; + for (int i = 0; i < 100; i++) acc ^= BossArena.ShouldHoldZoneSpawn(b, i % 2 == 0 ? z0 : other, i % 2 == 0); + long c1 = Alloc(); + L(" " + P(c1 - c0 <= 0) + " 차단 판정 ×100 = " + (c1 - c0) + " B (acc=" + acc + ")"); + + long d0 = Alloc(); + for (int i = 0; i < 100; i++) acc ^= BossArena.IsRunZoneSpawner(i % 2 == 0 ? z0 : other); + long d1 = Alloc(); + L(" " + P(d1 - d0 <= 0) + " 런 존 판정 ×100 = " + (d1 - d0) + " B (acc=" + acc + ")"); + + RunGuide.ResetDiagnostics(); + } + + // ───────────────────────────────────────────────────────────────────── + // ⑧ C8 롤백 + // ───────────────────────────────────────────────────────────────────── + static void Sec8_C8() + { + L(""); + L("── ⑧ C8 롤백 (신규 스위치 0 → 813w6 동작 그대로)"); + + // ⓐ holdPcOnRunEnd = 0 → 런 종료 정지 경로가 통째로 죽는다 + var tg = TmpG(); + try + { + UseTmpG(tg); + tg.holdPcOnRunEnd = false; + RunGuide.ResetDiagnostics(); + s_pc.Set_JoystickStatus(3); + RunGuide.ProbeRunEnded(RunOutcome.Timeout); + L(" " + P(!RunGuide.RunEndHeld && RunGuide.RunEndHolds == 0) + + " holdPcOnRunEnd=0 → 정지 0(RunEndHeld=" + RunGuide.RunEndHeld + " RunEndHolds=" + RunGuide.RunEndHolds + ")"); + L(" " + P(s_pc.JoystickStatus == 0) + " 조종도 잡지 않는다(JoystickStatus=" + s_pc.JoystickStatus + ") = 813w6 그대로"); + + // 정지 중에 스위치를 끄면 그 자리에서 풀린다 + tg.holdPcOnRunEnd = true; + RunGuide.BeginRunEndHold("C8"); + tg.holdPcOnRunEnd = false; + RunGuide.RunEndHoldTick(tg, s_pc, 900f); + L(" " + P(!RunGuide.RunEndHeld) + " 정지 중 스위치 off → 즉시 해제(사유 '" + RunGuide.LastRunEndHoldReason + "')"); + } + finally { UseRealG(); UnityEngine.Object.DestroyImmediate(tg); RunGuide.ResetDiagnostics(); } + + // ⓑ rearmZoneSpawnsEnabled = 0 → 감시·재무장 경로가 통째로 죽는다 + var tb = TmpB(); + try + { + UseTmpB(tb); + BossArena.SetRunActiveForProbe(1); + BossArena.RebuildRearmZonesForProbe(); + tb.rearmZoneSpawnsEnabled = false; + BossArena.ZoneRearmTick(tb, 1000f); + L(" " + P(BossArena.RearmZoneEmptySinceAt(0) < 0f) + + " rearmZoneSpawnsEnabled=0 → 감시 무동작(빈 시각 " + BossArena.RearmZoneEmptySinceAt(0) + ")"); + L(" " + P(BossArena.RearmZoneSpawns("C8") == 0) + " RearmZoneSpawns() 도 0"); + + tb.rearmZoneSpawnsEnabled = true; + tb.rearmZoneSpawnsOnRestart = false; + L(" " + P(BossArena.RearmZoneSpawns("C8") == 0) + " rearmZoneSpawnsOnRestart=0 → [다음 런] 즉시 재무장 0"); + } + finally { UseRealB(); BossArena.SetRunActiveForProbe(0); UnityEngine.Object.DestroyImmediate(tb); } + + // ⓒ RuntimeDisabled — 축 전체 off + WLRunGuideSettings.RuntimeDisabled = true; + RunGuide.ResetDiagnostics(); + RunGuide.BeginRunEndHold("RuntimeDisabled"); + RunGuide.ForceTick(); + L(" " + P(!WLRunGuideSettings.Enabled && !RunGuide.Active) + " RunGuide RuntimeDisabled → Active=false"); + L(" " + P(!RunGuide.RunEndHeld) + " Tick 이 정지를 풀고 원본으로 돌려준다(RunEndHeld=" + RunGuide.RunEndHeld + ")"); + WLRunGuideSettings.RuntimeDisabled = false; + + WLBossArenaSettings.RuntimeDisabled = true; + var b2 = WLBossArenaSettings.Instance; + BossArena.SetRunActiveForProbe(1); + BossArena.RebuildRearmZonesForProbe(); + BossArena.ZoneRearmTick(b2, 2000f); + L(" " + P(BossArena.RearmZoneEmptySinceAt(0) < 0f) + " BossArena RuntimeDisabled → 감시 무동작"); + WLBossArenaSettings.RuntimeDisabled = false; + BossArena.SetRunActiveForProbe(0); + RunGuide.ResetDiagnostics(); + } +} diff --git a/Assets/WL/Combat/Boss/BossArena.cs b/Assets/WL/Combat/Boss/BossArena.cs index e62e8e35c..8eb1c9160 100644 --- a/Assets/WL/Combat/Boss/BossArena.cs +++ b/Assets/WL/Combat/Boss/BossArena.cs @@ -115,6 +115,24 @@ // → 수리 순서 = **HP 스냅샷(`SnapshotBossHp` · RunDirector.EndRun) → `RunEnded` 발행 → Park**. // `parkBossOnRunEndDeferred`(기본 on)가 고정을 **발행 뒤(다음 Tick)** 로 미루고, `RunResult.bossHpRatio/bossHp/bossHpMax` // 가 Park 이전 값을 싣는다. **Park 뒤 만피는 그대로 유지**한다(D-27 ③ 방지 로직 불변). +// +// ■ WL-813w7 (Q10 D-45 · 2026-09-09) — 813w3 §1-3 의 잡몹 유입 차단이 813w6 과 만나 존을 영구 소진시킨다 +// 🔴 원인(코드로 확정 · 발주서 §1-1 「원인 실측」) +// ⓐ `HoldNearbyZoneSpawns`(이 파일)는 **보스가 살아 있는 동안** 아레나 중심 `arenaRadius + zoneSpawnHoldMargin` +// (= 20 + 10 = **30 m**) 안의 존 스포너에 `StopAllCoroutines()` 를 건다. +// ⓑ 존0(813001) 앵커 `(115, 0.39, −22)` ↔ 아레나 중심 `(139, 0.8, −20)` = **24.1 m** → **차단 대상이다.** +// ⓒ 원본 `MobControlMgr` 의 PhaseAllKill 리젠은 **그 코루틴 안에서** `killmobcount = 0` 을 한다 +// (MobControlMgr.cs:216-222). 코루틴이 끊기면 `killmobcount` 가 `n_MaxMonsterCount` 에 굳고, +// 판정이 `==`(MobControlMgr.cs:214)라 다음 사망부터 Max+1, Max+2… → **두 번 다시 참이 되지 않는다.** +// = 그 스포너는 맵 세션이 끝날 때까지 **영구 소진**된다(리셋 API 없음 · 전부 private). +// ⓓ 813w6 이 재스폰 보스를 **게이트 개방까지** 살려 두면서, 차단이 「보스전 동안」이 아니라 +// **다음 런 내내** 걸렸다 → R1n 0/18 · R2n 1/18(PC 는 존0 앵커에서 5.8 m 에 서 있었다) · R3n 14/18 정지. +// ■ 수리 ① `holdZoneSpawnOnlyAfterGate`(기본 on) — 차단을 **게이트 개방 뒤**(= 진짜 보스전)로 좁힌다. +// ② `holdZoneSpawnSkipsRunZones`(기본 on) — 런이 쓰는 존(WLRunSettings.zoneSpawnerIds)은 아예 대상에서 뺀다. +// ③ `rearmZoneSpawnsEnabled` — 존이 `rearmZoneEmptySeconds`(8 s > 원본 리듬 5.0 s) 넘게 비면 +// **원본과 같은 공개 경로**(`MobActor.On_Regen()` = MobControlMgr.cs:221 과 동일한 호출)로 1웨이브를 되살린다. +// `RunDirector.Restart()` 도 즉시 1회 부른다(`rearmZoneSpawnsOnRestart` · 이미 굳은 세션 구제). +// ■ C8 롤백 — 위 4개 스위치를 전부 끄면 813w6 동작 그대로다. // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; @@ -167,6 +185,13 @@ namespace WL.Combat.Boss static bool s_parkPending; // RunEnded 발행이 끝난 뒤로 미뤄 둔 고정이 있는가 static float s_parkDueTime = -1f; // 그 고정을 실행할 시각(unscaled · 0 지연이면 다음 Tick) + // 813w7 §1-2 ② — 존 스포너 재무장(D-45). 감시 대상 id 와 「빈 지 얼마나 됐나」를 사전 할당 배열로 든다(GC 0). + const int kMaxRearmZones = 16; // 버퍼 크기지 게임 수치가 아니다 + static readonly int[] s_rearmIds = new int[kMaxRearmZones]; + static readonly float[] s_rearmEmptySince = new float[kMaxRearmZones]; + static int s_rearmCount = -1; // −1 = 아직 목록을 안 잡았다 + static float s_nextZoneRearmCheck; + // ───────────────────────────────────────── 진단(프로브·QA 가 읽는다) public static bool GateArmed { get { return s_armed; } } public static bool GateOpen { get { return s_gateOpen; } } @@ -192,6 +217,8 @@ namespace WL.Combat.Boss public static int LeashSuspendedTicks, GateSuspendedEvals; /// 존 스포너 리젠 예약을 끊은 횟수 · 대상 스포너 수(§1-3). public static int ZoneSpawnHoldTicks, ZoneSpawnHoldTargets; + /// 813w7 §1-2 ②: 차단을 건너뛴 횟수(게이트 전 · 런 존 제외) — D-45 수리가 실제로 도는지의 증거. + public static int ZoneSpawnHoldSkipped; /// 이 런의 게이트 판정 기준 처치 수(= TotalZoneKills − 런 시작 스냅샷). public static int GateKills { get { int v = TotalZoneKills - s_totalKillBase; return v > 0 ? v : 0; } } @@ -1021,16 +1048,46 @@ namespace WL.Combat.Boss { if (cfg == null || !Active || !cfg.holdZoneSpawnNearArena || cfg.zoneSpawnHoldMargin < 0f) return; if (DSUtil.CheckNull(s_boss) || s_boss.IsDead()) return; + + // 🔴 813w7 §1-2 ②(D-45) — 「보스 생존 중」이 아니라 「게이트 개방 뒤」로 좁힌다. + // 813w6 이 재스폰 보스를 **게이트 개방까지** 살려 두면서, 이 차단이 다음 런 내내 걸렸다. + // 차단은 `StopAllCoroutines` 라 PhaseAllKill 의 `killmobcount = 0`(MobControlMgr.cs:218)까지 함께 + // 끊어 카운터를 `n_MaxMonsterCount` 에 굳힌다 → `== 비교`(:214) 가 영영 참이 안 된다(영구 소진). + if (cfg.holdZoneSpawnOnlyAfterGate && !s_gateOpen) { ZoneSpawnHoldSkipped++; 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; + // 🔴 런이 쓰는 존(= 다음 런의 사냥터)은 건드리지 않는다 — 여기서 굳으면 D-45 가 그대로 재발한다. + if (!ShouldHoldZoneSpawn(cfg, m.SpawnerID, s_gateOpen)) { ZoneSpawnHoldSkipped++; continue; } m.StopAllCoroutines(); ZoneSpawnHoldTicks++; } } + /// + /// 813w7 §1-2 ② — 이 스포너의 리젠 예약을 끊어도 되는가(순수 함수 · Actor 접근 0 · 프로브가 그대로 부른다 · C8 증거). + /// 🔴 「끊는다 = 그 스포너의 PhaseAllKill 카운터를 영구히 굳힌다」이므로 판정은 보수적이어야 한다. + /// + public static bool ShouldHoldZoneSpawn(WLBossArenaSettings cfg, int spawnerId, bool gateOpen) + { + if (cfg == null || !cfg.holdZoneSpawnNearArena) return false; + if (cfg.holdZoneSpawnOnlyAfterGate && !gateOpen) return false; + if (cfg.holdZoneSpawnSkipsRunZones && IsRunZoneSpawner(spawnerId)) return false; + return true; + } + + /// 이 스포너가 런 축(WLRunSettings.zoneSpawnerIds)의 사냥 존인가(순수 · 프로브가 그대로 부른다). + public static bool IsRunZoneSpawner(int spawnerId) + { + var rs = WL.Combat.Run.WLRunSettings.Instance; + if (rs == null || rs.zoneSpawnerIds == null) return false; + for (int i = 0; i < rs.zoneSpawnerIds.Length; i++) if (rs.zoneSpawnerIds[i] == spawnerId) return true; + return false; + } + /// 대상 존 스포너를 1회 훑는다(런당 몇 회 · 매 틱 아님). static void ScanNearZones(WLBossArenaSettings cfg) { @@ -1053,6 +1110,171 @@ namespace WL.Combat.Boss Log("존 스포너 유입 차단 대상 " + s_nearZones.Count + "개(중심 " + center.ToString("F1") + " 반경 " + lim.ToString("F0") + "m)"); } + // ───────────────────────────────────────────────────────────────────── + // 813w7 §1-2 ② — 존 스포너 재무장 (D-45 · 다음 런 사냥터가 비어 있다) + // ───────────────────────────────────────────────────────────────────── + // + // 🔴 원본에 「스포너 리셋 API」는 없다(실측) — `MobControlMgr` 의 공개 멤버는 + // `SpawnerID` · `MobGenRange` · `text_lv` · `Get_LoadComplete()` 뿐이고, + // `killmobcount` · `orderIndex` · `dic_actors` 는 전부 private 이다. + // 그래서 **원본이 스스로 쓰는 공개 경로**를 그대로 다시 태운다 — + // 813e2 표 A 의 `MobControlMgr.cs:220-221` `dic_actors[orderIndex][i].On_Regen()` 와 + // 같은 호출을, 그 스포너에 등록된 비활성 몹(`ActorInfo.Get_Actors`)에 대해 한다. + // `MobActor.On_Regen()` 은 원본 그대로 `Set_Warp(OriginalPos)` + 스폰 이펙트 + + // `CombatEvents.RaiseSpawned` 까지 하므로 존 카운터(z.alive/z.spawns)도 정상으로 붙는다. + // + // 🔴 이중 스폰 방지 = `rearmZoneEmptySeconds`(기본 8 s) > 원본 리듬 5.0 s(시체 3.0 + 리젠 2.0 · 813e2 표 A). + // 스포너가 멀쩡하면 5.0 s 에 스스로 되살아나 alive > 0 이 되므로 이 경로는 아예 들어오지 않는다. + + /// 감시 대상 존 id 목록을 다시 잡는다(런 존만 / 아레나 존 전체 · SO). + static void BuildRearmZones(WLBossArenaSettings cfg) + { + s_rearmCount = 0; + int[] src = null; + if (cfg.rearmZoneRunZonesOnly) + { + var rs = WL.Combat.Run.WLRunSettings.Instance; + if (rs != null) src = rs.zoneSpawnerIds; + } + if (src == null) src = cfg.zoneSpawnerIds; + if (src == null) return; + + for (int i = 0; i < src.Length && s_rearmCount < kMaxRearmZones; i++) + { + s_rearmIds[s_rearmCount] = src[i]; + s_rearmEmptySince[s_rearmCount] = -1f; + s_rearmCount++; + } + } + + /// + /// 빈 존 감시 1틱 — 살아 있는 몹이 0 인 상태가 `rearmZoneEmptySeconds` 이상이면 그 존을 재무장한다. + /// 🔴 폴링 자체는 카운터만 본다 = 할당 0. 실제 재무장(드문 경로)만 원본 등록부를 훑는다. + /// 프로브가 가짜 시각으로 그대로 부를 수 있다. + /// + public static void ZoneRearmTick(WLBossArenaSettings cfg, float now) + { + if (cfg == null || !Active || !cfg.rearmZoneSpawnsEnabled || !s_armed) return; + if (cfg.rearmZoneEmptySeconds <= 0f) return; + if (!RunActive) return; // 결과창·다음 런 대기 중에는 손대지 않는다 + if (s_rearmCount < 0) BuildRearmZones(cfg); + + for (int i = 0; i < s_rearmCount; i++) + { + if (AliveOf(s_rearmIds[i]) > 0) { s_rearmEmptySince[i] = -1f; continue; } + if (s_rearmEmptySince[i] < 0f) { s_rearmEmptySince[i] = now; continue; } + if (now - s_rearmEmptySince[i] < cfg.rearmZoneEmptySeconds) continue; + s_rearmEmptySince[i] = now; // 성공·실패와 무관하게 다음 주기를 다시 잰다 + RearmZone(cfg, s_rearmIds[i], "빈 존 " + cfg.rearmZoneEmptySeconds.ToString("F0") + "s"); + } + } + + /// + /// 대상 존을 전부 즉시 1회 재무장한다(「다음 런」 진입점 · `RunDirector.Restart()` 가 부른다). + /// 이미 살아 있는 몹이 있으면 그 존은 건너뛴다. + /// + public static int RearmZoneSpawns(string reason) + { + var cfg = Cfg; + if (cfg == null || !Active || !cfg.rearmZoneSpawnsEnabled || !cfg.rearmZoneSpawnsOnRestart) return 0; + BuildRearmZones(cfg); // 런 존 목록이 바뀌었을 수 있다 + s_nextZoneRearmCheck = 0f; + + int total = 0; + for (int i = 0; i < s_rearmCount; i++) + { + s_rearmEmptySince[i] = Now(); + if (AliveOf(s_rearmIds[i]) > 0) continue; + total += RearmZone(cfg, s_rearmIds[i], reason); + } + if (total > 0) Log("존 재무장(" + reason + ") — " + total + "마리 재등장"); + return total; + } + + /// + /// 존 하나를 재무장한다 — 그 스포너의 **비활성** 몹을 1웨이브분만큼 `On_Regen()` 한다. + /// 반환 = 실제로 되살린 마리 수. 원본 등록부(`ActorInfo`)가 없거나 대상이 없으면 0. + /// + public static int RearmZone(WLBossArenaSettings cfg, int spawnerId, string reason) + { + if (cfg == null || !Application.isPlaying) return 0; + var info = ActorInfo.Ins; + if (DSUtil.CheckNull(info)) return 0; + + int want = WaveSize(cfg, spawnerId); + if (want <= 0) return 0; + + // 🔴 `ActorInfo.Get_Actors` 는 dic_actor[role][spawnerId] 를 직접 인덱싱한다 — + // 등록이 없는 스포너면 KeyNotFoundException 이 난다. 재무장 실패가 런을 죽이면 안 되므로 감싼다. + List actors = null; + int done = 0; + try + { + actors = info.Get_Actors(eRole.Mob, spawnerId, true); + if (actors == null) return 0; + + // 이미 살아 있는 개체가 있으면(카운터 드리프트) 재무장하지 않는다 — 이중 스폰 방지. + for (int i = 0; i < actors.Count; i++) + { + var a = actors[i]; + if (!DSUtil.CheckNull(a) && a.isActiveAndEnabled && !a.IsDead()) return 0; + } + + for (int i = 0; i < actors.Count && done < want; i++) + { + var mob = actors[i] as MobActor; + if (DSUtil.CheckNull(mob) || mob.isActiveAndEnabled) continue; + mob.On_Regen(); // 원본 웨이브와 같은 호출(MobControlMgr.cs:221) — 좌표·이펙트·Spawned 이벤트까지 원본 그대로 + done++; + } + } + catch (System.Exception ex) { Log("존 " + spawnerId + " 재무장 실패 — " + ex.Message); } + finally { if (actors != null) info.Release_Actors(actors); } + + if (done > 0) + { + ZoneRearmCount++; + ZoneRearmMobs += done; + LastRearmZoneId = spawnerId; + Log("존 " + spawnerId + " 재무장(" + reason + ") — " + done + "/" + want + "마리"); + } + return done; + } + + /// + /// 1웨이브 마리 수 — 원본 테이블 `n_MaxMonsterCount`(= `MobControlMgr` 이 쓰는 그 값)를 읽고, + /// 못 읽으면 SO `rearmZoneRegenMax`. SO 값은 언제나 상한으로 쓴다(안전). + /// + public static int WaveSize(WLBossArenaSettings cfg, int spawnerId) + { + int cap = cfg != null && cfg.rearmZoneRegenMax > 0 ? cfg.rearmZoneRegenMax : 0; + int n = cap; + // 🔴 table_monsterappear.Get_Data_orNull 은 dic_Data[id] 직접 인덱싱이라 없는 행이면 던진다. + try + { + var tbl = table_monsterappear.Ins; + if (!DSUtil.CheckNull(tbl)) + { + var row = tbl.Get_Data_orNull(spawnerId); + if (row != null && row.n_MaxMonsterCount > 0) n = row.n_MaxMonsterCount; + } + } + catch (System.Exception) { n = cap; } + if (cap > 0 && n > cap) n = cap; + return n; + } + + /// 813w7 진단 — 재무장을 실행한 존 수 · 되살린 총 마리 수 · 마지막 대상 존 id. + public static int ZoneRearmCount, ZoneRearmMobs; + public static int LastRearmZoneId; + /// 감시 대상 존 수(진단 · 아직 목록을 안 잡았으면 −1). + public static int RearmZoneCount { get { return s_rearmCount; } } + /// 감시 대상 i 의 스포너 id · 「비었다」로 잡힌 시각(unscaled · 안 비었으면 −1). + public static int RearmZoneIdAt(int i) { return i >= 0 && i < s_rearmCount ? s_rearmIds[i] : 0; } + public static float RearmZoneEmptySinceAt(int i) { return i >= 0 && i < s_rearmCount ? s_rearmEmptySince[i] : -1f; } + /// 프로브용 — 감시 목록을 다시 잡는다. + public static void RebuildRearmZonesForProbe() { var c = Cfg; if (c != null) BuildRearmZones(c); } + /// 마지막 부분 회복 시각(unscaled · 쿨다운 판정). static float s_lastLeashHeal = -999f; /// 리쉬 회복을 실제로 넣은 횟수 · 쿨다운으로 건너뛴 횟수(진단). @@ -1208,6 +1430,14 @@ namespace WL.Combat.Boss // 🔴 813w6 §1-2 (D-44) — RunEnded 발행이 끝난 뒤로 미뤄 둔 고정을 여기서 실행한다. if (s_parkPending) PumpDeferredPark(); + // 🔴 813w7 §1-2 ② (D-45) — 빈 존 감시(자체 주기 · 리쉬 주기와 독립). + if (cfg.rearmZoneSpawnsEnabled && now >= s_nextZoneRearmCheck) + { + s_nextZoneRearmCheck = now + Mathf.Max(0.1f, cfg.rearmZoneCheckSeconds); + try { ZoneRearmTick(cfg, now); } + catch (System.Exception ex) { Log("존 재무장 감시 실패 — " + ex.Message); } + } + if (now < s_nextLeashCheck) return; s_nextLeashCheck = now + Mathf.Max(0f, cfg.leashCheckIntervalSeconds); try { LeashTick(); } @@ -1245,6 +1475,10 @@ namespace WL.Combat.Boss s_totalKillBase = 0; s_runActiveOverride = 0; RearmCount = LivingBossResetCount = DeadBossRegenCount = 0; LeashSuspendedTicks = GateSuspendedEvals = ZoneSpawnHoldTicks = ZoneSpawnHoldTargets = 0; + // 813w7 + ZoneSpawnHoldSkipped = ZoneRearmCount = ZoneRearmMobs = LastRearmZoneId = 0; + s_rearmCount = -1; s_nextZoneRearmCheck = 0f; + for (int i = 0; i < kMaxRearmZones; i++) s_rearmEmptySince[i] = -1f; // 813w5 s_bossHoldUntil = -1f; s_probeTime = -1f; ParkCount = BossHoldTicks = OutsideRunLeashResets = GateMinBlocked = RunEndNotices = 0; @@ -1283,6 +1517,7 @@ namespace WL.Combat.Boss s_lastLeashHeal = -999f; s_nearZonesScanned = false; // 맵/스포너가 바뀌었을 수 있다 — 다시 훑는다 s_parkPending = false; s_parkDueTime = -1f; // 813w6 — 여기서 보스를 직접 처리하므로 미뤄 둔 고정은 버린다 + s_rearmCount = -1; // 813w7 — 존 감시 목록도 다시 잡는다(런 존이 바뀌었을 수 있다) // ① 게이트 조기 개방(D-27) — 이 런 기준선으로 재스냅샷 if (cfg != null && cfg.restartRebasesGateKills) RebaseGateCounters(); diff --git a/Assets/WL/Combat/Boss/WLBossArenaSettings.cs b/Assets/WL/Combat/Boss/WLBossArenaSettings.cs index 11880553e..5867a9c9f 100644 --- a/Assets/WL/Combat/Boss/WLBossArenaSettings.cs +++ b/Assets/WL/Combat/Boss/WLBossArenaSettings.cs @@ -308,5 +308,46 @@ namespace WL.Combat.Boss [Tooltip("🔴 런 종료 순간(Park 이전)의 보스 HP 를 RunResult.bossHpRatio/bossHp/bossHpMax 에 담는다. " + "끄면 −1/0/0 = 813p 계약 그대로(필드는 있지만 비어 있다).")] public bool snapshotBossHpOnRunEnd = true; + + // ─────────────────────────────────────────── 813w7 §1-2 ② 존 스포너 재무장(D-45) + [Header("존 스포너 재무장 (813w7 · D-45 다음 런 사냥터가 비어 있다)")] + [Tooltip("🔴 존 스포너 리젠 차단(holdZoneSpawnNearArena)을 **게이트가 열린 뒤**(= 진짜 보스전)에만 한다. " + + "813w3 은 「보스가 살아 있는 동안」이 조건이었는데, 813w6 이 재스폰 보스를 게이트 개방까지 살려 두면서 " + + "다음 런 내내 차단이 걸렸다 → 존0 이 통째로 얼어붙는다(D-45 · R2n 1/18). " + + "끄면 813w3 그대로(보스 생존 = 상시 차단).")] + public bool holdZoneSpawnOnlyAfterGate = true; + + [Tooltip("🔴 리젠 차단 대상에서 **런이 쓰는 존**(WLRunSettings.zoneSpawnerIds)은 뺀다. " + + "차단은 원본 `MobControlMgr` 의 예약 코루틴을 StopAllCoroutines 로 끊는 방식인데, " + + "PhaseAllKill 은 그 코루틴 안에서 `killmobcount = 0` 을 하므로(MobControlMgr.cs:218) " + + "끊기면 카운터가 `n_MaxMonsterCount` 에 굳어 `== 비교`(:214)가 두 번 다시 참이 되지 않는다 = 영구 소진. " + + "끄면 813w3 그대로(런 존도 차단 대상).")] + public bool holdZoneSpawnSkipsRunZones = true; + + [Tooltip("🔴 존 스포너가 비어 있는 채로 굳으면 WL 이 원본과 같은 경로(MobActor.On_Regen)로 한 웨이브를 다시 깨운다. " + + "원본 리셋 API 가 없어(MobControlMgr 공개 멤버 = SpawnerID·MobGenRange·text_lv·Get_LoadComplete 뿐) " + + "813e2 PhaseAllKill 웨이브(:220-221 `dic_actors[orderIndex][i].On_Regen()`)와 같은 호출로 대신한다. " + + "끄면 재무장 경로가 통째로 죽는다(C8).")] + public bool rearmZoneSpawnsEnabled = true; + + [Tooltip("존의 살아 있는 몹이 0 인 상태가 이 시간(초 · unscaled) 이상 이어지면 재무장한다. " + + "🔴 원본 리듬(시체 3.0 s + f_SpawnerDelay 2.0 s = 5.0 s · 813e2 표 A)보다 **길게** 둘 것 — " + + "짧으면 멀쩡한 스포너의 웨이브와 겹쳐 이중 스폰이 된다.")] + public float rearmZoneEmptySeconds = 8f; + + [Tooltip("빈 존 감시 주기(초 · unscaled). 이 주기의 폴링 자체는 할당 0(카운터만 본다).")] + public float rearmZoneCheckSeconds = 1f; + + [Tooltip("한 번에 되살릴 최대 마리 수. 기본은 테이블 `n_MaxMonsterCount`(= 1웨이브)를 쓰고, " + + "테이블을 못 읽으면 이 값을 쓴다. 테이블 값이 이 값보다 크면 이 값으로 자른다(안전 상한).")] + public int rearmZoneRegenMax = 15; + + [Tooltip("재무장 대상 = 런이 쓰는 존(WLRunSettings.zoneSpawnerIds)만. " + + "끄면 이 에셋의 zoneSpawnerIds 전체(게이트 존 포함)를 대상으로 한다.")] + public bool rearmZoneRunZonesOnly = true; + + [Tooltip("🔴 [다음 런](RunDirector.Restart)에서 대기 없이 즉시 1회 재무장한다 — 앞 런에서 이미 굳은 스포너를 " + + "다음 런 시작 시점에 되살린다(D-45 의 직접 처방). 끄면 위의 빈 존 감시로만 되살아난다.")] + public bool rearmZoneSpawnsOnRestart = true; } } diff --git a/Assets/WL/Combat/Run/RunDirector.cs b/Assets/WL/Combat/Run/RunDirector.cs index 9226a4887..5437d1e0a 100644 --- a/Assets/WL/Combat/Run/RunDirector.cs +++ b/Assets/WL/Combat/Run/RunDirector.cs @@ -523,6 +523,7 @@ namespace WL.Combat.Run } } if (c.restartRearmsBossGate) BossArena.ResetGate(); // 존 누적 카운터는 건드리지 않는다 + BossArena.RearmZoneSpawns("다음 런"); // 🔴 #813w7 D-45 — 굳은 존 스포너를 1웨이브 되살린다(SO 로 끈다) if (c.restartResetsPotions) PotionUse.ResetRun("run restart"); } catch (System.Exception ex) { Log("재시작 일부 실패 — " + ex.Message); } diff --git a/Assets/WL/Combat/Run/RunGuide.cs b/Assets/WL/Combat/Run/RunGuide.cs index a0dc14ec0..cfc755c70 100644 --- a/Assets/WL/Combat/Run/RunGuide.cs +++ b/Assets/WL/Combat/Run/RunGuide.cs @@ -104,6 +104,24 @@ // ■ C8 롤백 — `warpBackEnabled=0` 이면 워프 경로가 통째로 죽고 813w2 동작 그대로다. // `exitDiagEnabled=0` 이면 진단 로그도 0. // ───────────────────────────────────────────────────────────────────────────── +// WL-813w7 (2026-09-09 · PD 지시 #813) — 근거 = qa/W2-10_재채점_v8.md **D-45** +// +// 🔴 실측 결함 — 「다음 런」의 사냥터가 비어 있다(R1n 0/18 · R2n 1/18 · R3n 14/18 정지). +// R2n 은 PC 좌표 `(112.93, 0.06, −16.60)` = 존0 앵커 `(115, 0.39, −22)` 에서 5.8 m, +// 즉 **PC 는 존0 에 도착해 Hold 로 서 있는데 때릴 몹이 없다**(HP 310/310 무피해). +// +// ■ 이 파일이 맡은 몫 = ① 결과창 동안 PC 를 세운다(§1-2 ①) +// 813w6 까지는 `OnRunEnded` 가 `StopGuide` 만 했다 = 가이드를 끄고 **원본 자동전투 100%** 로 돌려줬다. +// Q10 실측: 결과창을 100 초 띄워 둔 사이 존0 처치가 **26 → 47**(21마리)로 늘었다. +// 이제 런이 끝나면 다음 런이 시작될 때까지(또는 `runEndHoldMaxSeconds`) PC 를 붙잡는다 — +// ⓐ `Set_JoystickStatus(1)` → PCActor.cs:139 이 `base.Update()`(자동 교전 접근 루프)를 건너뛴다 +// ⓑ `Del_Target(true)` 매 폴링 → PCActor.cs:93 자동 스킬 루프는 `isTarget` 이 있어야만 돈다 +// ⓒ `NavMeshAgent.ResetPath()` → 런 종료 순간의 잔여 경로로 계속 걸어가지 않는다 +// 복귀 = 다음 `RunStarted`(= `RunDirector.Restart()`) 또는 상한 만료 또는 PC 사망. +// 🔴 UI 0 — RunGuide 는 결과창을 보지 않는다. 「결과창 닫힘」은 상한으로만 판정한다(§4 미확인). +// +// ■ C8 롤백 — `holdPcOnRunEnd=0` 이면 이 경로가 통째로 죽고 813w6 동작 그대로다. +// ───────────────────────────────────────────────────────────────────────────── // // ■ 값(C45) 전부 WLRunGuideSettings. enabled = 0 이면 러너가 뜨지 않고 구독도 하지 않는다(C8). // @@ -217,6 +235,16 @@ namespace WL.Combat.Run /// 마지막 이탈 진단 줄 원문(프로브·QA 가 읽는다 · 콘솔에 나간 그 문자열). public static string LastExitLog = ""; + // ───────────────────────────────────────── 813w7 진단 (D-45 · §1-2 ①) + /// 813w7 §1-2 ①: 런 종료 정지를 건 횟수 · 푼 횟수 · 정지 폴링 틱 수 · 정지 중 뗀 타깃 수. + public static int RunEndHolds, RunEndHoldReleases, RunEndHoldTicks, RunEndTargetClears; + /// 지금 「결과창 중 PC 정지」 상태인가(프로브·QA 가 읽는다). + public static bool RunEndHeld { get { return s_runEndHold; } } + /// 정지 상한 만료 시각(unscaled · 무제한이면 −1). + public static float RunEndHoldUntil { get { return s_runEndHoldUntil; } } + /// 마지막으로 정지를 푼 사유(진단). + public static string LastRunEndHoldReason = ""; + /// 진단 링버퍼 칸 수. 게임 수치가 아니라 버퍼 크기다(발주서 §1-1 「링버퍼 32」). const int kExitLogCapacity = 32; static readonly ArenaExitRecord[] s_exitLog = new ArenaExitRecord[kExitLogCapacity]; @@ -257,6 +285,10 @@ namespace WL.Combat.Run static float s_idleSince = -1f, s_lastStallGuide = -999f; static int s_lastRestartCount; + // 813w7 §1-2 ① 결과창 중 PC 정지 — 정지 중인가 · 상한 만료 시각(−1 = 무제한) + static bool s_runEndHold; + static float s_runEndHoldUntil = -1f; + // 813w4 §1-2 엘리트 등록부 — 사전 할당 배열(GC 0 · 버퍼 크기지 게임 수치가 아니다) const int kMaxElites = 16; static readonly Actor[] s_elites = new Actor[kMaxElites]; @@ -321,6 +353,8 @@ namespace WL.Combat.Run static void OnRunStarted(in RunStartedEvent e) { + // 🔴 813w7 §1-2 ① — 런이 다시 시작됐다 = 결과창이 끝났다. PC 정지를 먼저 푼다(StopGuide 보다 앞). + EndRunEndHold("런 시작"); StopGuide("런 시작"); s_boss = null; s_anchorCount = 0; // 맵이 바뀌었을 수 있다 — 앵커를 다시 읽는다 @@ -403,7 +437,9 @@ namespace WL.Combat.Run BeginBoss("보스 등장"); } - static void OnRunEnded(in RunResult e) { StopGuide("런 종료"); } + // 🔴 813w7 §1-2 ① (D-45) — 813w6 까지는 여기서 가이드만 껐다 = 결과창 동안 원본 자동전투 100%. + // Q10 실측: 결과창 100 s 사이 존0 처치 26 → 47 → 다음 런 시작 시 사냥터가 비었다. + static void OnRunEnded(in RunResult e) { StopGuide("런 종료"); BeginRunEndHold("런 종료"); } static void OnDamaged(in DamagedEvent e) { @@ -463,6 +499,9 @@ namespace WL.Combat.Run "(쿨 " + WarpsSkippedCooldown + " 보스곁 " + WarpsSkippedNearBoss + " 맵밖 " + WarpsFailedNavMesh + ")" + // 813w4: 「다음 런」 재유도 · 정지 감시 · 엘리트 우선 타깃(D-34 · D-20 을 이 줄로 판정한다) " · 재유도 " + RestartGuides + "/정지 " + StallGuides + + // 813w7 §1-2 ①: 결과창 중 PC 정지(D-45) — QA 가 이 값으로 「빈 사냥터」 수리를 판정한다 + " · 결과정지 " + RunEndHolds + "→" + RunEndHoldReleases + + "(틱 " + RunEndHoldTicks + " 타깃 " + RunEndTargetClears + ")" + " · 엘리트 " + EliteCount + "(고정 " + (DSUtil.CheckNull(s_lockedElite) ? "-" : LastEliteName) + " 락 " + EliteLocks + " 재타깃 " + EliteRetargets + " 해제 " + EliteReleases + ")" + " · 존 " + e.zonesCleared + "/" + e.zoneCount + " " + e.zoneKills + "/" + e.zoneRequired; @@ -542,7 +581,7 @@ namespace WL.Combat.Run internal static void Tick() { if (s_faulted) return; - if (!Active) { if (s_steering) ReleaseSteer(); return; } + if (!Active) { if (s_runEndHold) EndRunEndHold("가이드 off"); else if (s_steering) ReleaseSteer(); return; } var cfg = Cfg; float now = Time.unscaledTime; @@ -563,6 +602,10 @@ namespace WL.Combat.Run var pc = MyValue.MyPC; if (DSUtil.CheckNull(pc) || pc.IsDead()) { ReleaseSteer(); s_lastPosTime = -1f; return; } + // 🔴 813w7 §1-2 ①(D-45) — 결과창 동안 PC 정지. 목적지 판정보다 **앞**에 둔다 + // (이 구간에는 목적지가 없고, 원본 자동전투가 존 몹을 소진하는 것을 막는 것이 전부다). + if (s_runEndHold) { RunEndHoldTick(cfg, pc, now); return; } + // 🔴 813w4 §1-1(D-34) 안전망 — 목적지가 없는 동안에도 「런은 도는데 PC 가 멈춰 있는지」를 본다. if (s_destKind == RunGuideDest.None && TrackIdleStall(cfg, pc, pc.Get_position(), now)) return; @@ -654,6 +697,80 @@ namespace WL.Combat.Run Log((isBoss ? "아레나" : "존 " + s_zoneIndex) + " " + why); } + // ───────────────────────────────────────────────────────────────────── + // 813w7 §1-2 ① — 결과창 중 PC 정지 (D-45) + // ───────────────────────────────────────────────────────────────────── + + /// + /// 런 종료(결과창) 정지를 건다. 다음 `RunStarted`·상한 만료·가이드 off 에서 풀린다. + /// `holdPcOnRunEnd` 가 꺼져 있으면 아무 일도 하지 않는다(C8 = 813w6 그대로). + /// 프로브가 직접 부를 수 있다. + /// + public static void BeginRunEndHold(string reason) + { + var cfg = Cfg; + if (cfg == null || !cfg.holdPcOnRunEnd) return; + if (s_runEndHold) return; // 중복 안전(RunEnded 는 런당 1회지만 Abandon 경로가 겹칠 수 있다) + s_runEndHold = true; + s_runEndHoldUntil = cfg.runEndHoldMaxSeconds > 0f ? Time.unscaledTime + cfg.runEndHoldMaxSeconds : -1f; + RunEndHolds++; + Log("결과창 중 PC 정지 시작(" + reason + ") — 상한 " + + (s_runEndHoldUntil > 0f ? cfg.runEndHoldMaxSeconds.ToString("F0") + "s" : "무제한")); + } + + /// 런 종료 정지를 푼다(조종 원복 포함). 프로브가 직접 부를 수 있다. + public static void EndRunEndHold(string reason) + { + if (!s_runEndHold) { s_runEndHoldUntil = -1f; return; } + s_runEndHold = false; + s_runEndHoldUntil = -1f; + LastRunEndHoldReason = reason; + RunEndHoldReleases++; + + // 🔴 정지 중 세워 둔 에이전트를 반드시 다시 풀어 준다 — 안 그러면 다음 런에서 PC 가 못 걷는다. + var pc0 = MyValue.MyPC; + if (!DSUtil.CheckNull(pc0)) + { + var ag = pc0.Get_Agent(); + if (ag != null && ag.enabled && ag.isOnNavMesh) ag.isStopped = false; + } + ReleaseSteer(); // JoystickStatus 를 우리가 1 로 만든 경우에만 3(=0)으로 되돌린다 + Log("결과창 중 PC 정지 해제 — " + reason); + } + + /// + /// 정지 1틱 — 조종을 잡고(자동 교전 접근 루프 차단) 타깃을 떼고(자동 스킬 차단) 잔여 경로를 지운다. + /// 🔴 GC 0 — 문자열·배열 할당 없음(로그는 시작·해제에서만). + /// 프로브가 가짜 시각으로 그대로 부를 수 있다(pc 는 살아 있는 참조여야 한다). + /// + public static void RunEndHoldTick(WLRunGuideSettings cfg, PCActor pc, float now) + { + if (cfg == null || !cfg.holdPcOnRunEnd) { EndRunEndHold("스위치 off"); return; } + if (s_runEndHoldUntil > 0f && now >= s_runEndHoldUntil) { EndRunEndHold("상한 만료"); return; } + if (DSUtil.CheckNull(pc)) return; + + RunEndHoldTicks++; + + // ⓐ 자동 교전 접근 루프 차단(PCActor.cs:139) — 우리가 0 → 1 로 만든 경우에만. + AcquireSteer(pc); + + // ⓑ 자동 스킬 루프 차단(PCActor.cs:93 `isTarget` 조건) — 매 폴링 뗀다. + if (cfg.runEndHoldClearsTarget && pc.isTarget) + { + var t = pc.Get_Target(); + if (!DSUtil.CheckNull(t)) { LastIgnoredTarget = t.name; pc.Del_Target(true); } + else pc.isTarget = false; // 참조 없이 플래그만 남은 상태 — Del_Target 은 여기서 m_Target 을 NRE 로 만진다 + RunEndTargetClears++; + } + + // ⓒ 런 종료 순간의 잔여 경로로 계속 걸어가지 않게 한다. + if (!cfg.runEndHoldStopsAgent) return; + var agent = pc.Get_Agent(); + if (agent == null || !agent.enabled || !agent.isOnNavMesh) return; + if (agent.hasPath) agent.ResetPath(); + agent.isStopped = true; + } + // ───────────────────────────────────────────────────────────────────── // 813w4 §1-1 — 정지 감시(D-34 안전망) // ───────────────────────────────────────────────────────────────────── @@ -1004,11 +1121,7 @@ namespace WL.Combat.Run if (anim == eAnim.Dodge || anim >= eAnim.Attack || pc.IsCC()) return; // 공격·스킬·회피·CC 는 원본 우선 if (pc.JoystickStatus == 2) return; // 스킬 이동 잠금(Whirlwind 등) - if (!s_steering && pc.JoystickStatus == 0) - { - pc.Set_JoystickStatus(1); // 원본 자동 교전 접근 루프를 비켜 세운다(PCActor.cs:139) - s_steering = true; - } + AcquireSteer(pc); // 🔴 813w2: 타깃이 남아 있으면 Play_Run() 이 `Set_Path(타깃)`(MyActor.cs:335)으로 경로를 덮어쓴다. // 보통은 바로 위 ClearTravelTarget 이 타깃을 비웠으므로 그런 일이 없지만, 원본이 같은 프레임에 @@ -1027,6 +1140,19 @@ namespace WL.Combat.Run } } + /// + /// 조종을 잡는다 — 원본 자동 교전 접근 루프를 비켜 세운다(PCActor.cs:139). + /// 🔴 우리가 **0 → 1** 로 만든 경우에만 잡는다. 2(Whirlwind 등 스킬 이동 잠금)는 건드리지 않는다. + /// 813w7 §1-2 ①: 이동(Steer)과 결과창 정지(RunEndHoldTick)가 같은 진입점을 쓴다. + /// + static void AcquireSteer(PCActor pc) + { + if (s_steering || DSUtil.CheckNull(pc)) return; + if (pc.JoystickStatus != 0) return; + pc.Set_JoystickStatus(1); + s_steering = true; + } + /// 조종을 놓는다(우리가 1 로 만든 경우에만 0 으로 되돌린다). static void ReleaseSteer() { @@ -1267,6 +1393,21 @@ namespace WL.Combat.Run for (int i = 0; i < kMaxElites; i++) s_elites[i] = null; s_idleSince = -1f; s_lastStallGuide = -999f; s_idlePos = Vector3.zero; s_lastRestartCount = RunDirector.RestartCount; + // 813w7 + EndRunEndHold("진단 초기화"); + RunEndHolds = RunEndHoldReleases = RunEndHoldTicks = RunEndTargetClears = 0; + LastRunEndHoldReason = ""; + } + + // ── 813w7 프로브 API + /// 프로브용 — 정지 상한 만료 시각을 직접 밀어 넣는다(에디트 모드에서는 Time 이 흐르지 않는다). + public static void SetRunEndHoldUntilForProbe(float t) { s_runEndHoldUntil = t; } + /// 프로브용 — 진짜 RunEnded 페이로드로 핸들러를 그대로 태운다(Play 불필요). + public static void ProbeRunEnded(RunOutcome outcome) + { + var e = default(RunResult); + e.outcome = outcome; e.time = Time.unscaledTime; e.frame = Time.frameCount; + OnRunEnded(in e); } // ── 813w4 프로브 API diff --git a/Assets/WL/Combat/Run/WLRunGuideSettings.cs b/Assets/WL/Combat/Run/WLRunGuideSettings.cs index 5bf0833dd..10f98f6b5 100644 --- a/Assets/WL/Combat/Run/WLRunGuideSettings.cs +++ b/Assets/WL/Combat/Run/WLRunGuideSettings.cs @@ -63,6 +63,17 @@ namespace WL.Combat.Run /// 테스트용: 캐시를 비워 다음 접근 때 다시 Resources.Load 한다. public static void ClearCache() { s_cached = null; s_lookupDone = false; } + /// + /// 813w7 프로브용 — 임시 SO 를 잠시 Instance 자리에 끼운다(C8 진리표 · **실에셋은 읽기만** 한다). + /// null 을 넣으면 캐시가 비워져 다음 접근에서 실에셋을 다시 로드한다(= ClearCache). + /// 🔴 런타임 게임 경로에서는 절대 부르지 않는다(WLBossArenaSettings.SetInstanceForProbe 선례 그대로). + /// + public static void SetInstanceForProbe(WLRunGuideSettings so) + { + s_cached = so; + s_lookupDone = so != null; + } + // ─────────────────────────────────────────── 전체 스위치 [Header("전체 스위치")] [Tooltip("끄면 존 이동 가이드·보스 우선 타깃이 전부 비활성 — 원본 자동전투 100%(Q4b 상태 그대로). 롤백 1순위.")] @@ -263,5 +274,27 @@ namespace WL.Combat.Run [Tooltip("이탈이 이어지는 동안 이 주기(초)마다 진단 줄을 더 남긴다. 0 이면 이탈 시작·워프 때만(기본).")] public float exitDiagRepeatSeconds = 0f; + + // ─────────────────────────────────────────── 813w7 §1-2 ① 결과창 중 PC 정지(D-45) + [Header("결과창 중 PC 정지 (813w7 · D-45 결과창 동안 PC 가 존 몹을 소진)")] + [Tooltip("🔴 런이 끝나면(RunEnded) 다음 런이 시작될 때까지 PC 를 세운다 — 813w 과 같은 " + + "Set_JoystickStatus(1)(PCActor.cs:139 이 base.Update() = 자동 교전 접근 루프를 통째로 건너뛴다) + " + + "타깃 해제(PCActor.cs:93 자동 스킬 루프는 isTarget 이 있어야만 돈다). " + + "Q10 D-45 실측: 결과창 100 초 동안 z0 처치가 26 → 47 로 늘어 다음 런의 사냥터가 비었다. " + + "끄면 813w6 그대로(런 종료 = 가이드만 끄고 원본 자동전투 100%).")] + public bool holdPcOnRunEnd = true; + + [Tooltip("정지 상한(초 · unscaled). 결과창을 [닫기]로 닫고 런을 다시 시작하지 않는 경우 PC 가 영영 굳지 않게 " + + "이 시간이 지나면 스스로 푼다. 0 이면 무제한(다음 RunStarted 까지). " + + "🔴 RunGuide 는 UI 를 보지 않는다(UI 0) — 「결과창 닫힘」은 이 상한과 다음 런 시작으로만 판정한다.")] + public float runEndHoldMaxSeconds = 120f; + + [Tooltip("정지 중 매 폴링마다 PC 타깃을 뗀다(Actor.Del_Target(true)). " + + "🔴 이것이 꺼지면 조이스틱 잠금만으로는 자동 스킬(PCActor.cs:93)이 계속 돌아 몹이 계속 죽는다.")] + public bool runEndHoldClearsTarget = true; + + [Tooltip("정지 중 NavMeshAgent 의 남은 경로를 지운다(ResetPath · isStopped). 런 종료 순간의 잔여 경로로 " + + "PC 가 계속 걸어가는 것을 막는다. 끄면 좌표만 그대로 두고 조종만 잡는다.")] + public bool runEndHoldStopsAgent = true; } } diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLBossArenaSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLBossArenaSettings.asset index 8d672deea..e6a9d193f 100644 --- a/Assets/WL/Combat/Settings/Resources/WL/WLBossArenaSettings.asset +++ b/Assets/WL/Combat/Settings/Resources/WL/WLBossArenaSettings.asset @@ -72,3 +72,11 @@ MonoBehaviour: parkBossOnRunEndDeferred: 1 parkBossOnRunEndDelaySeconds: 0 snapshotBossHpOnRunEnd: 1 + holdZoneSpawnOnlyAfterGate: 1 + holdZoneSpawnSkipsRunZones: 1 + rearmZoneSpawnsEnabled: 1 + rearmZoneEmptySeconds: 8 + rearmZoneCheckSeconds: 1 + rearmZoneRegenMax: 15 + rearmZoneRunZonesOnly: 1 + rearmZoneSpawnsOnRestart: 1 diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLRunGuideSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLRunGuideSettings.asset index 638bdbdcf..7998c44a0 100644 --- a/Assets/WL/Combat/Settings/Resources/WL/WLRunGuideSettings.asset +++ b/Assets/WL/Combat/Settings/Resources/WL/WLRunGuideSettings.asset @@ -62,3 +62,7 @@ MonoBehaviour: bossAwayFromCenterRadius: 6 exitDiagEnabled: 1 exitDiagRepeatSeconds: 0 + holdPcOnRunEnd: 1 + runEndHoldMaxSeconds: 120 + runEndHoldClearsTarget: 1 + runEndHoldStopsAgent: 1