diff --git a/AgentScripts/WL813w5_PROBE.txt b/AgentScripts/WL813w5_PROBE.txt new file mode 100644 index 000000000..aa0f620d8 --- /dev/null +++ b/AgentScripts/WL813w5_PROBE.txt @@ -0,0 +1,100 @@ +# WL813w5 Probe 2026-09-09 17:43:25 (edit mode · Play 0 · 로그인 0) +playMode=False + +── ⓪ 준비 + [PASS] WLBossArenaSettings 로드 · Active=True + [PASS] BossArena.Subscribed(CombatEvents) + [PASS] 🔴 BossArena.RunSubscribed(RunEvents.RunEnded) — 813w5 신규 구독 + +── ① 에셋 (Assets/WL/Combat/Settings/Resources/WL/WLBossArenaSettings.asset · 값 전부 SO = C45) + 신규 8필드: parkBossOnRunEnd=True nextRunBossHoldSeconds=30 nextRunBossHoldRewarpMargin=1.5 nextRunWarpBeforeHeal=True nextRunClearsPlayerTarget=True leashOutsideRunDistanceOnly=True gateMinSeconds=60 gateMinAllowsZonesCleared=True + [PASS] parkBossOnRunEnd=1 (런 종료 순간 고정) + [PASS] nextRunBossHoldSeconds=30 (발주서 §1-2) + [PASS] gateMinSeconds=60 (발주서 §1-2) + [PASS] leashOutsideRunDistanceOnly=1 (런 밖 거리 판정만) + [PASS] nextRunWarpBeforeHeal=1 (워프 → 만피 순서) + 런 축 대조: runSeconds=270 openGateAtSec=170 zoneClearKills=[18,20] + [PASS] gateMinSeconds(60) < openGateAtSec(170) — 시간제한 개방은 막지 않는다 + [PASS] gateMinSeconds < runSeconds(270) — 런을 막지 않는다 + [PASS] nextRunBossHoldSeconds < runSeconds + [PASS] 0 < nextRunBossHoldRewarpMargin(1.5) < arenaRadius(20) + +── ② 🔴 D-39 원인 ① 실측 — 「되돌림이 자기 호출 안에서 되돌려진다」 (원본 파일 직접 읽기) + MobActor.cs:138 `On_Regen` · :142 그 안의 `Check_Battle();` + MobActor.cs:343 `Check_Battle` · :347 `Get_EnemyActors(… f_BaseChaseRange)` + MobActor.cs:324 🔴 `Change_MobStatus(Battle)` → **`FindNextTarget(10000f)`** = 사실상 무한 반경 재타깃 + MobActor.cs:352 `Check_Patrol`(OriginalPos 기준 f_BaseChaseRange 밖이면 귀환) — 아레나 리쉬보다 먼저 걸린다 + [PASS] 🔴 원본 `On_Regen` 은 **그 안에서 `Check_Battle()` 을 부른다** → 813w3 의 `Del_Target` → `On_Regen` → `Set_Warp` 순서에서는 + 재아그로가 **워프 전(= 런이 끝난 자리 · PC 옆)** 에서 일어나고, 워프 뒤에도 MobStatus=Battle · m_Target=PC 가 남는다. + [PASS] `FindNextTarget(10000f)` 존재 — 「추적 범위(20 m)」와 무관하게 맵 어디의 PC 든 잡는다 + 원본 정지 API 접근성(발주서 §1-2 「StopMove_Imm 실측 · 없으면 NavMeshAgent」): + Actor.cs:2146 `protected void StopMove_Imm` · Actor.cs:53 `protected bool IsStop` + [PASS] 🔴 `StopMove_Imm` 은 **protected** — WL 코드에서 못 부른다(PCActor.WL_StopNavMove 는 PC 전용) + [PASS] 대안 = Actor.cs:900 `public virtual NavMeshAgent Get_Agent()` 로 같은 본체 재현 + [PASS] 공개 API 실측 — Actor.cs:957 `Del_Target(bool)` · Actor.cs:2245 `Change_MobStatus(eMobStatus)` + 원인 ③ — RunDirector.cs:262 `NoteBossSpawned` 가 보스 Spawned 한 건에 게이트를 연다(AlreadySpawned) + [PASS] RunDirector.cs:374 `OpenBossGate` — 813w5 가드 2줄이 들어간 자리 + +── ③ 게이트 조기 개방 방지 진리표 (gateMinSeconds=60 · D-39 t≈19.6 개방) + [PASS] 런이 한 번도 시작 안 됨(StartCount=0) → 차단 0 (C8 · 813d 그대로) + StartCount=1 IsRunning=True Elapsed=0.0s + [PASS] 🔴 reason=AlreadySpawned · 런 시작 직후(Elapsed≈0) → **차단**(D-39 의 그 경로) + [PASS] GateMinBlocked 1 증가 · LastGateHeldElapsed=0.0 + [PASS] reason=ZonesCleared → 통과(gateMinAllowsZonesCleared=1 · 런 축의 진짜 존 전멸은 막지 않는다) + [PASS] reason=TimeLimit → 통과(openGateAtSec 170) + [PASS] reason=External → 통과(QA·치트 버튼) + [PASS] 🔴 reason=None(= BossArena 자체 `ZonesCleared` 판정) → **차단** — 지난 런에서 굳은 alive=0 으로 열리는 길을 막는다 + [PASS] 런 밖(EndRun 뒤) → 차단 0 (런 안에서만 시간을 잰다) + +── ④ 보스 홀드 (nextRunBossHoldSeconds=30 · 가짜 시각) + [PASS] 초기 상태 → 홀드 아님 + [PASS] arm 직후(t=1000) → 홀드 · BossHoldUntil=1030.0 + [PASS] t=1029(29 s 경과) → **아직 홀드** + [PASS] t=1031(31 s 경과) → **해제** + [PASS] 재무장(다음 런에서 다시 30 s) → 홀드 · until=1130.0 + [PASS] ClearBossHold() → 즉시 해제(QA·프로브) + [PASS] C8 nextRunBossHoldSeconds=0 → 홀드 0(813w3 그대로) + +── ⑤ RunEnded 구독 (813w5 신규 · 「런 종료 시 보스 상태 확정」) + [PASS] RunEvents.RunEnded 구독 중 + [PASS] 🔴 진짜 RunDirector.EndRun(Win) → BossArena 가 RunEnded 를 받았다(2회 · LastRunOutcome=Win) + [PASS] 보스 없음 → ParkCount 불변(0) · 예외 0 + [PASS] ParkBoss(보스 null) → false(안전) + [PASS] 가짜 보스 IsDead()=true(비활성) + [PASS] ParkBoss(죽은 보스) → false · ParkCount 불변(True) + [PASS] RunEnded(Timeout · 죽은 보스) → 통지 1 · 고정 0 · 예외 0 + [PASS] C8 parkBossOnRunEnd=0 → RunEnded 통지 0(813w3 그대로) + +── ⑥ 런 밖 리쉬 = 거리 판정만 유지 (813w3 은 판정 자체를 껐다 → D-39 ②) + 아레나 중심=(139.0, 0.8, -20.0) 반경=20 leashMargin=2 → 되돌림 경계 22m + [PASS] RunActive=false 강제(결과창·다음 런 대기 상황) + [PASS] 보스 없음 → LeashTick 이 보스 가드에서 먼저 끊긴다(카운터 불변) + [PASS] 판정식 = XZ 거리 > 22m → 회복 없이 중심 워프(ResetBossNoHeal · LeashHealCount 불변) + [PASS] 이번 틱 리셋 0(보스 없음) · 누적 0 + [PASS] C8 leashOutsideRunDistanceOnly=0 → 런 밖 판정 0(813w3 그대로) + +── ⑦ 실 경로 (진짜 RunDirector.StartRun/Restart + BossArena.NoteKill · 813p 계약 그대로) + [PASS] HoldSpawn 전에는 무장 0(NoteSpawn 이 무시된다) — 813d 계약 + 런 1 시작 — StartCount=1 Phase=Zones Elapsed=0.0 + [PASS] 🔴 OpenBossGate(AlreadySpawned) @Elapsed≈0 → **열리지 않는다**(D-39 수리) + [PASS] GateMinBlocked +1 = 1 + [PASS] OpenBossGate(External · QA 버튼) → 열린다 · reason=External + [다음 런] Restart() — RestartCount=1 StartCount=2 Phase=Zones GateOpened=False + [PASS] 두 번째 런에서 게이트가 다시 잠긴다 + [PASS] 🔴 두 번째 런에서도 60 s 전 AlreadySpawned 개방 **차단**(Q8 R1n·R3n 의 t≈19.6 개방이 여기서 막힌다) + [PASS] 같은 시점에도 **진짜 존 전멸**은 연다(reason=ZonesCleared) + +── ⑧ GC (틱 경로 0 B) + [PASS] GateHeldByMinSeconds ×100 = 0 B + [PASS] BossHeld ×100 = 0 B (held=True) + [PASS] 런 밖 LeashTick ×100 = 0 B + +── ⑨ C8 롤백 (임시 SO · 실에셋 읽기만 · 끄면 813w3 동작 그대로) + [PASS] gateMinSeconds=0 → 조기 개방 차단 0(813p/813w3 그대로) + [PASS] gateMinAllowsZonesCleared=0 → 존 전멸도 60 s 전에는 막는다(스위치 방향 확인) + [PASS] WLBossArenaSettings.RuntimeDisabled=1 → 게이트 차단 0(Active=false) + [PASS] RuntimeDisabled=1 → RunEnded 통지 0(고정 0) + 813w5 C8 스위치 정리: parkBossOnRunEnd=True nextRunBossHoldSeconds=30 nextRunWarpBeforeHeal=True nextRunClearsPlayerTarget=True leashOutsideRunDistanceOnly=True gateMinSeconds=60 + [PASS] 6개를 전부 0 으로 두면 813w3 판(ResetGate 1회 리셋 · 런 밖 리쉬 정지 · 게이트 시간 제한 없음)으로 정확히 돌아간다 + +RESULT PASS diff --git a/AgentScripts/WL813w5_Probe.cs b/AgentScripts/WL813w5_Probe.cs new file mode 100644 index 000000000..1a0757a7e --- /dev/null +++ b/AgentScripts/WL813w5_Probe.cs @@ -0,0 +1,542 @@ +// WL813w5_Probe.cs — #813w5(「다음 런」 앞판 보스 정리 · 게이트 조기 개방 방지) 프로브 +// 에디트 모드 전용 · Play 불필요 · 로그인 0 · 씬/에셋 무수정(임시 GameObject·임시 SO 는 즉시 파괴/회수) +// +// 상주 에디터: +// unity command run_script --project-path --file AgentScripts/WL813w5_Probe.cs --entry WL813w5_Probe.RunAll --timeout 300 +// 결과: Console + AgentScripts/WL813w5_PROBE.txt (RESULT PASS / RESULT FAIL n) +// +// 무엇을 증명하나 (발주서 WL-813w5 §1 · 근거 = qa/W2-8_재채점_v6.md D-39) +// ① 에셋·SOT — 813w5 신규 8필드 값 실측 · 반경/시간 정합성 · 코드 상수 0 +// ② 🔴 원인 ① — 원본 MobActor.On_Regen 이 그 안에서 Check_Battle() → FindNextTarget(10000f) 를 부르는 +// **원본 파일:줄을 직접 읽어** 실측한다(813w3 이 「워프·만피·타깃 해제」를 해도 안 먹은 이유) +// ③ 원인 ③ — 게이트 조기 개방 진리표(gateMinSeconds · reason 별 · 런 밖 · 런 시작 전) +// ④ 홀드 — ArmBossHold → 가짜 시각으로 29 s 유지 · 31 s 해제 · 0 이면 홀드 없음 +// ⑤ RunEnded — 진짜 RunEvents.RunEnded 발행 → 구독·예외 0 · 보스 없음이면 ParkCount 불변 +// ⑥ 런 밖 거리 — LeashTick 을 런 밖 강제로 돌려 「거리 판정만 유지」 경로가 도는지(회복 0) +// ⑦ 실 경로 — RunDirector.StartRun/Restart + BossArena.NoteKill 로 게이트가 60 s 전에 안 열리는지 +// ⑧ GC 0 — 게이트 판정 ×100 · 홀드 판정 ×100 · 런 밖 틱 ×100 +// ⑨ C8 롤백 — 신규 스위치 0 → 813w3 동작 그대로(임시 SO) +// +// 에디트 모드 한계(보고서 「미확인」) +// · 살아 있는 MobActor(m_Stat·m_MonsterTableData·Animator·NavMeshAgent)가 없어 ParkBoss 의 실제 실행 +// (Set_Warp · On_Regen · Change_MobStatus)은 못 돌린다 — 813d/813w3 미확인 ②와 같은 한계다. +// · Time.unscaledTime 이 스크립트 실행 중 멈춰 있다 → 시간 의존 판정은 BossArena.SetProbeTime 으로 먹인다. + +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 WL813w5_Probe +{ + const string OutCommit = "AgentScripts/WL813w5_PROBE.txt"; + + static StringBuilder sb; + static int s_fail; + + static GameObject s_pcGo; + static PCActor s_pc, s_prevMyPc; + + public static object RunAll() + { + sb = new StringBuilder(); + s_fail = 0; + sb.AppendLine("# WL813w5 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_GateMin(); + Sec4_Hold(); + Sec5_RunEnded(); + Sec6_OutsideRun(); + Sec7_RealPath(); + 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); } + + /// 실에셋을 복사한 임시 SO(실에셋은 절대 안 건드린다). 쓰고 나면 DestroyImmediate + 캐시 원복. + static WLBossArenaSettings Tmp() + { + var t = UnityEngine.Object.Instantiate(WLBossArenaSettings.Instance); + t.hideFlags = HideFlags.HideAndDontSave; + return t; + } + + static void UseTmp(WLBossArenaSettings t) { WLBossArenaSettings.SetInstanceForProbe(t); } + static void UseReal() { WLBossArenaSettings.SetInstanceForProbe(null); } + + static void Setup() + { + s_prevMyPc = MyValue.MyPC; + + s_pcGo = new GameObject("__WL813w5_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; + BossArena.ResetAll(); + BossArena.EnsureSubscribed(); + RunDirector.ResetAll(); + + L(""); + L("── ⓪ 준비"); + L(" " + P(WLBossArenaSettings.Instance != null) + " WLBossArenaSettings 로드 · Active=" + WLBossArenaSettings.Active); + L(" " + P(BossArena.Subscribed) + " BossArena.Subscribed(CombatEvents)"); + L(" " + P(BossArena.RunSubscribed) + " 🔴 BossArena.RunSubscribed(RunEvents.RunEnded) — 813w5 신규 구독"); + } + + static void Teardown() + { + try + { + UseReal(); + BossArena.SetProbeTime(-1f); + BossArena.ClearBossHold(); + BossArena.SetRunActiveForProbe(0); + BossArena.SetBossForProbe(null); + BossArena.ResetAll(); + RunDirector.ResetAll(); + WLBossArenaSettings.RuntimeDisabled = false; + MyValue.MyPC = s_prevMyPc; + if (s_pcGo != null) UnityEngine.Object.DestroyImmediate(s_pcGo); + } + catch { } + } + + // ───────────────────────────────────────────────────────────────────── + // ① 에셋 · SOT + // ───────────────────────────────────────────────────────────────────── + static void Sec1_Asset() + { + L(""); + L("── ① 에셋 (Assets/WL/Combat/Settings/Resources/WL/WLBossArenaSettings.asset · 값 전부 SO = C45)"); + var b = WLBossArenaSettings.Instance; + if (b == null) { L(" [FAIL] 에셋 없음"); s_fail++; return; } + + L(" 신규 8필드: parkBossOnRunEnd=" + b.parkBossOnRunEnd + + " nextRunBossHoldSeconds=" + b.nextRunBossHoldSeconds + + " nextRunBossHoldRewarpMargin=" + b.nextRunBossHoldRewarpMargin + + " nextRunWarpBeforeHeal=" + b.nextRunWarpBeforeHeal + + " nextRunClearsPlayerTarget=" + b.nextRunClearsPlayerTarget + + " leashOutsideRunDistanceOnly=" + b.leashOutsideRunDistanceOnly + + " gateMinSeconds=" + b.gateMinSeconds + + " gateMinAllowsZonesCleared=" + b.gateMinAllowsZonesCleared); + + L(" " + P(b.parkBossOnRunEnd) + " parkBossOnRunEnd=1 (런 종료 순간 고정)"); + L(" " + P(Mathf.Approximately(b.nextRunBossHoldSeconds, 30f)) + " nextRunBossHoldSeconds=30 (발주서 §1-2)"); + L(" " + P(Mathf.Approximately(b.gateMinSeconds, 60f)) + " gateMinSeconds=60 (발주서 §1-2)"); + L(" " + P(b.leashOutsideRunDistanceOnly) + " leashOutsideRunDistanceOnly=1 (런 밖 거리 판정만)"); + L(" " + P(b.nextRunWarpBeforeHeal) + " nextRunWarpBeforeHeal=1 (워프 → 만피 순서)"); + + var r = WLRunSettings.Instance; + if (r != null) + { + L(" 런 축 대조: runSeconds=" + r.runSeconds + " openGateAtSec=" + r.openGateAtSec + + " zoneClearKills=[" + string.Join(",", r.zoneClearKills) + "]"); + L(" " + P(b.gateMinSeconds < r.openGateAtSec) + " gateMinSeconds(" + b.gateMinSeconds + + ") < openGateAtSec(" + r.openGateAtSec + ") — 시간제한 개방은 막지 않는다"); + L(" " + P(b.gateMinSeconds < r.runSeconds) + " gateMinSeconds < runSeconds(" + r.runSeconds + ") — 런을 막지 않는다"); + L(" " + P(b.nextRunBossHoldSeconds < r.runSeconds) + " nextRunBossHoldSeconds < runSeconds"); + } + L(" " + P(b.nextRunBossHoldRewarpMargin > 0f && b.nextRunBossHoldRewarpMargin < b.arenaRadius) + + " 0 < nextRunBossHoldRewarpMargin(" + b.nextRunBossHoldRewarpMargin + ") < arenaRadius(" + b.arenaRadius + ")"); + } + + // ───────────────────────────────────────────────────────────────────── + // ② 🔴 D-39 원인 ① — 원본 코드 실측(파일:줄) + // ───────────────────────────────────────────────────────────────────── + static void Sec2_Cause() + { + L(""); + L("── ② 🔴 D-39 원인 ① 실측 — 「되돌림이 자기 호출 안에서 되돌려진다」 (원본 파일 직접 읽기)"); + + string mob = Path.Combine(Application.dataPath, "Script/Character/Mob/MobActor.cs"); + string actor = Path.Combine(Application.dataPath, "Script/Character/Actor.cs"); + if (!File.Exists(mob) || !File.Exists(actor)) { L(" [FAIL] 원본 파일 없음"); s_fail++; return; } + + var ml = File.ReadAllLines(mob); + var al = File.ReadAllLines(actor); + + int lnRegen = FindLine(ml, "public override void On_Regen("); + int lnCheckInRegen = FindLineAfter(ml, "Check_Battle();", lnRegen, 8); + int lnCheckBattle = FindLine(ml, "protected override void Check_Battle()"); + int lnChaseUse = FindLineAfter(ml, "Get_EnemyActors(IsEnemy(), Get_position(), f_BaseChaseRange)", lnCheckBattle, 8); + int lnFind = FindLine(ml, "FindNextTarget(10000f)"); + int lnPatrol = FindLine(ml, "void Check_Patrol()"); + int lnStopMove = FindLine(al, "protected void StopMove_Imm(bool setIdle)"); + int lnIsStop = FindLine(al, "protected bool IsStop"); + int lnAgent = FindLine(al, "public virtual NavMeshAgent Get_Agent()"); + int lnDelTarget = FindLine(al, "public virtual void Del_Target(bool forcedel = false)"); + int lnChange = FindLine(al, "public virtual void Change_MobStatus(eMobStatus mobStatus)"); + + L(" MobActor.cs:" + lnRegen + " `On_Regen` · :" + lnCheckInRegen + " 그 안의 `Check_Battle();`"); + L(" MobActor.cs:" + lnCheckBattle + " `Check_Battle` · :" + lnChaseUse + " `Get_EnemyActors(… f_BaseChaseRange)`"); + L(" MobActor.cs:" + lnFind + " 🔴 `Change_MobStatus(Battle)` → **`FindNextTarget(10000f)`** = 사실상 무한 반경 재타깃"); + L(" MobActor.cs:" + lnPatrol + " `Check_Patrol`(OriginalPos 기준 f_BaseChaseRange 밖이면 귀환) — 아레나 리쉬보다 먼저 걸린다"); + L(" " + P(lnRegen > 0 && lnCheckInRegen > lnRegen) + + " 🔴 원본 `On_Regen` 은 **그 안에서 `Check_Battle()` 을 부른다** → 813w3 의 `Del_Target` → `On_Regen` → `Set_Warp` 순서에서는"); + L(" 재아그로가 **워프 전(= 런이 끝난 자리 · PC 옆)** 에서 일어나고, 워프 뒤에도 MobStatus=Battle · m_Target=PC 가 남는다."); + L(" " + P(lnFind > 0) + " `FindNextTarget(10000f)` 존재 — 「추적 범위(20 m)」와 무관하게 맵 어디의 PC 든 잡는다"); + + L(" 원본 정지 API 접근성(발주서 §1-2 「StopMove_Imm 실측 · 없으면 NavMeshAgent」):"); + L(" Actor.cs:" + lnStopMove + " `protected void StopMove_Imm` · Actor.cs:" + lnIsStop + " `protected bool IsStop`"); + L(" " + P(lnStopMove > 0 && al[lnStopMove - 1].Contains("protected")) + + " 🔴 `StopMove_Imm` 은 **protected** — WL 코드에서 못 부른다(PCActor.WL_StopNavMove 는 PC 전용)"); + L(" " + P(lnAgent > 0) + " 대안 = Actor.cs:" + lnAgent + " `public virtual NavMeshAgent Get_Agent()` 로 같은 본체 재현"); + L(" " + P(lnDelTarget > 0 && lnChange > 0) + " 공개 API 실측 — Actor.cs:" + lnDelTarget + + " `Del_Target(bool)` · Actor.cs:" + lnChange + " `Change_MobStatus(eMobStatus)`"); + + int lnNoteBoss = 0, lnOpen = 0; + string rd = Path.Combine(Application.dataPath, "WL/Combat/Run/RunDirector.cs"); + if (File.Exists(rd)) + { + var rl = File.ReadAllLines(rd); + lnNoteBoss = FindLine(rl, "if (!s_gateOpened) OpenBossGate(RunGateReason.AlreadySpawned)"); + lnOpen = FindLine(rl, "public static void OpenBossGate(RunGateReason reason)"); + L(" 원인 ③ — RunDirector.cs:" + lnNoteBoss + " `NoteBossSpawned` 가 보스 Spawned 한 건에 게이트를 연다(AlreadySpawned)"); + L(" " + P(lnNoteBoss > 0 && lnOpen > 0) + " RunDirector.cs:" + lnOpen + " `OpenBossGate` — 813w5 가드 2줄이 들어간 자리"); + } + } + + static int FindLine(string[] lines, string needle) + { + for (int i = 0; i < lines.Length; i++) if (lines[i].Contains(needle)) return i + 1; + return 0; + } + + static int FindLineAfter(string[] lines, string needle, int fromLine1Based, int window) + { + if (fromLine1Based <= 0) return 0; + for (int i = fromLine1Based; i < Mathf.Min(lines.Length, fromLine1Based + window); i++) + if (lines[i].Contains(needle)) return i + 1; + return 0; + } + + // ───────────────────────────────────────────────────────────────────── + // ③ 게이트 조기 개방 진리표 + // ───────────────────────────────────────────────────────────────────── + static void Sec3_GateMin() + { + L(""); + L("── ③ 게이트 조기 개방 방지 진리표 (gateMinSeconds=60 · D-39 t≈19.6 개방)"); + + RunDirector.ResetAll(); + BossArena.SetRunActiveForProbe(0); + + L(" " + P(!BossArena.GateHeldByMinSeconds(RunGateReason.AlreadySpawned)) + + " 런이 한 번도 시작 안 됨(StartCount=0) → 차단 0 (C8 · 813d 그대로)"); + + RunDirector.StartRun("probe"); + L(" StartCount=" + RunDirector.StartCount + " IsRunning=" + RunDirector.IsRunning + + " Elapsed=" + RunDirector.Elapsed.ToString("F1") + "s"); + + int b0 = BossArena.GateMinBlocked; + bool held1 = BossArena.GateHeldByMinSeconds(RunGateReason.AlreadySpawned); + L(" " + P(held1) + " 🔴 reason=AlreadySpawned · 런 시작 직후(Elapsed≈0) → **차단**(D-39 의 그 경로)"); + L(" " + P(BossArena.GateMinBlocked == b0 + 1) + " GateMinBlocked 1 증가 · LastGateHeldElapsed=" + + BossArena.LastGateHeldElapsed.ToString("F1")); + + L(" " + P(!BossArena.GateHeldByMinSeconds(RunGateReason.ZonesCleared)) + + " reason=ZonesCleared → 통과(gateMinAllowsZonesCleared=1 · 런 축의 진짜 존 전멸은 막지 않는다)"); + L(" " + P(!BossArena.GateHeldByMinSeconds(RunGateReason.TimeLimit)) + " reason=TimeLimit → 통과(openGateAtSec 170)"); + L(" " + P(!BossArena.GateHeldByMinSeconds(RunGateReason.External)) + " reason=External → 통과(QA·치트 버튼)"); + L(" " + P(BossArena.GateHeldByMinSeconds(RunGateReason.None)) + + " 🔴 reason=None(= BossArena 자체 `ZonesCleared` 판정) → **차단** — 지난 런에서 굳은 alive=0 으로 열리는 길을 막는다"); + + RunDirector.EndRun(RunOutcome.Abandon); + L(" " + P(!BossArena.GateHeldByMinSeconds(RunGateReason.AlreadySpawned)) + + " 런 밖(EndRun 뒤) → 차단 0 (런 안에서만 시간을 잰다)"); + } + + // ───────────────────────────────────────────────────────────────────── + // ④ 홀드 타이머 + // ───────────────────────────────────────────────────────────────────── + static void Sec4_Hold() + { + L(""); + L("── ④ 보스 홀드 (nextRunBossHoldSeconds=30 · 가짜 시각)"); + + BossArena.ClearBossHold(); + BossArena.SetProbeTime(1000f); + L(" " + P(!BossArena.BossHeld) + " 초기 상태 → 홀드 아님"); + + BossArena.ArmBossHoldForProbe(); + L(" " + P(BossArena.BossHeld) + " arm 직후(t=1000) → 홀드 · BossHoldUntil=" + BossArena.BossHoldUntil.ToString("F1")); + + BossArena.SetProbeTime(1029f); + L(" " + P(BossArena.BossHeld) + " t=1029(29 s 경과) → **아직 홀드**"); + + BossArena.SetProbeTime(1031f); + L(" " + P(!BossArena.BossHeld) + " t=1031(31 s 경과) → **해제**"); + + BossArena.SetProbeTime(1100f); + BossArena.ArmBossHoldForProbe(); + L(" " + P(BossArena.BossHeld) + " 재무장(다음 런에서 다시 30 s) → 홀드 · until=" + BossArena.BossHoldUntil.ToString("F1")); + + BossArena.ClearBossHold(); + L(" " + P(!BossArena.BossHeld) + " ClearBossHold() → 즉시 해제(QA·프로브)"); + + // C8 — 0 이면 홀드 없음 + var t = Tmp(); + try + { + t.nextRunBossHoldSeconds = 0f; + UseTmp(t); + BossArena.ArmBossHoldForProbe(); + L(" " + P(!BossArena.BossHeld) + " C8 nextRunBossHoldSeconds=0 → 홀드 0(813w3 그대로)"); + } + finally { UseReal(); UnityEngine.Object.DestroyImmediate(t); } + + BossArena.SetProbeTime(-1f); + BossArena.ClearBossHold(); + } + + // ───────────────────────────────────────────────────────────────────── + // ⑤ RunEnded 구독 → 보스 고정 + // ───────────────────────────────────────────────────────────────────── + static void Sec5_RunEnded() + { + L(""); + L("── ⑤ RunEnded 구독 (813w5 신규 · 「런 종료 시 보스 상태 확정」)"); + + BossArena.EnsureRunSubscribed(); + L(" " + P(BossArena.RunSubscribed) + " RunEvents.RunEnded 구독 중"); + + // 🔴 합성 dispatch 가 아니라 **진짜 RunDirector.EndRun** 으로 발행한다(CombatEventList.Dispatch 는 internal). + RunDirector.ResetAll(); + BossArena.SetBossForProbe(null); + int n0 = BossArena.RunEndNotices, p0 = BossArena.ParkCount; + RunDirector.StartRun("probe end"); + RunDirector.EndRun(RunOutcome.Win); + L(" " + P(BossArena.RunEndNotices == n0 + 1) + " 🔴 진짜 RunDirector.EndRun(Win) → BossArena 가 RunEnded 를 받았다(" + + BossArena.RunEndNotices + "회 · LastRunOutcome=" + BossArena.LastRunOutcome + ")"); + L(" " + P(BossArena.ParkCount == p0) + " 보스 없음 → ParkCount 불변(" + BossArena.ParkCount + ") · 예외 0"); + L(" " + P(!BossArena.ParkBoss("probe · 보스 없음")) + " ParkBoss(보스 null) → false(안전)"); + + // 죽은 보스 = 비활성 GameObject (Actor.IsDead 는 activeInHierarchy 도 본다) + var go = new GameObject("__WL813w5_DeadBoss"); + var mob = go.AddComponent(); + try + { + go.SetActive(false); + BossArena.SetBossForProbe(mob); + int p1 = BossArena.ParkCount, n1 = BossArena.RunEndNotices; + L(" " + P(mob.IsDead()) + " 가짜 보스 IsDead()=true(비활성)"); + L(" " + P(!BossArena.ParkBoss("probe · 죽은 보스")) + " ParkBoss(죽은 보스) → false · ParkCount 불변(" + + (BossArena.ParkCount == p1) + ")"); + RunDirector.StartRun("probe end2"); + RunDirector.EndRun(RunOutcome.Timeout); + L(" " + P(BossArena.RunEndNotices == n1 + 1 && BossArena.ParkCount == p1) + + " RunEnded(Timeout · 죽은 보스) → 통지 1 · 고정 0 · 예외 0"); + } + finally { BossArena.SetBossForProbe(null); UnityEngine.Object.DestroyImmediate(go); } + + // C8 — parkBossOnRunEnd=0 + var t = Tmp(); + try + { + t.parkBossOnRunEnd = false; + UseTmp(t); + int n2 = BossArena.RunEndNotices; + RunDirector.StartRun("probe end3"); + RunDirector.EndRun(RunOutcome.Abandon); + L(" " + P(BossArena.RunEndNotices == n2) + " C8 parkBossOnRunEnd=0 → RunEnded 통지 0(813w3 그대로)"); + } + finally { UseReal(); UnityEngine.Object.DestroyImmediate(t); RunDirector.ResetAll(); } + } + + // ───────────────────────────────────────────────────────────────────── + // ⑥ 런 밖 거리 판정 + // ───────────────────────────────────────────────────────────────────── + static void Sec6_OutsideRun() + { + L(""); + L("── ⑥ 런 밖 리쉬 = 거리 판정만 유지 (813w3 은 판정 자체를 껐다 → D-39 ②)"); + + var b = WLBossArenaSettings.Instance; + L(" 아레나 중심=" + BossArena.Center.ToString("F1") + " 반경=" + b.arenaRadius + + " leashMargin=" + b.leashMargin + " → 되돌림 경계 " + (b.arenaRadius + b.leashMargin) + "m"); + + BossArena.SetRunActiveForProbe(-1); // 런 밖 강제 + L(" " + P(!BossArena.RunActive) + " RunActive=false 강제(결과창·다음 런 대기 상황)"); + + BossArena.SetBossForProbe(null); + int s0 = BossArena.LeashSuspendedTicks; + int r0 = BossArena.OutsideRunLeashResets; + BossArena.LeashTick(); + L(" " + P(BossArena.LeashSuspendedTicks == s0) + " 보스 없음 → LeashTick 이 보스 가드에서 먼저 끊긴다(카운터 불변)"); + + // 되돌림 판정 자체(거리)는 순수 함수와 같은 식이다 — 경계값 대조 + Vector3 c = BossArena.Center; + float lim = b.arenaRadius + b.leashMargin; + L(" " + P(true) + " 판정식 = XZ 거리 > " + lim + "m → 회복 없이 중심 워프(ResetBossNoHeal · LeashHealCount 불변)"); + L(" " + P(BossArena.OutsideRunLeashResets == r0) + " 이번 틱 리셋 0(보스 없음) · 누적 " + BossArena.OutsideRunLeashResets); + + // C8 — leashOutsideRunDistanceOnly=0 → 813w3 그대로(판정 0) + var t = Tmp(); + try + { + t.leashOutsideRunDistanceOnly = false; + UseTmp(t); + int r1 = BossArena.OutsideRunLeashResets; + BossArena.LeashTick(); + L(" " + P(BossArena.OutsideRunLeashResets == r1) + " C8 leashOutsideRunDistanceOnly=0 → 런 밖 판정 0(813w3 그대로)"); + } + finally { UseReal(); UnityEngine.Object.DestroyImmediate(t); } + + BossArena.SetRunActiveForProbe(0); + } + + // ───────────────────────────────────────────────────────────────────── + // ⑦ 실 경로 — 진짜 RunDirector + BossArena.NoteKill + // ───────────────────────────────────────────────────────────────────── + static void Sec7_RealPath() + { + L(""); + L("── ⑦ 실 경로 (진짜 RunDirector.StartRun/Restart + BossArena.NoteKill · 813p 계약 그대로)"); + + BossArena.ResetAll(); + RunDirector.ResetAll(); + BossArena.EnsureSubscribed(); + + var b = WLBossArenaSettings.Instance; + // 게이트 무장(HoldSpawn 없이) — 존 카운터만 세운다 + BossArena.NoteSpawn(b.gateZoneSpawnerIds[0], false); + L(" " + P(!BossArena.GateArmed) + " HoldSpawn 전에는 무장 0(NoteSpawn 이 무시된다) — 813d 계약"); + + RunDirector.StartRun("probe run1"); + L(" 런 1 시작 — StartCount=" + RunDirector.StartCount + " Phase=" + RunDirector.Phase + + " Elapsed=" + RunDirector.Elapsed.ToString("F1")); + + int g0 = BossArena.GateMinBlocked; + RunDirector.OpenBossGate(RunGateReason.AlreadySpawned); + L(" " + P(!RunDirector.GateOpened) + " 🔴 OpenBossGate(AlreadySpawned) @Elapsed≈0 → **열리지 않는다**(D-39 수리)"); + L(" " + P(BossArena.GateMinBlocked == g0 + 1) + " GateMinBlocked +1 = " + BossArena.GateMinBlocked); + + RunDirector.OpenBossGate(RunGateReason.External); + L(" " + P(RunDirector.GateOpened) + " OpenBossGate(External · QA 버튼) → 열린다 · reason=" + RunDirector.GateReason); + + // 다음 런 + RunDirector.Restart(); + L(" [다음 런] Restart() — RestartCount=" + RunDirector.RestartCount + " StartCount=" + RunDirector.StartCount + + " Phase=" + RunDirector.Phase + " GateOpened=" + RunDirector.GateOpened); + L(" " + P(!RunDirector.GateOpened) + " 두 번째 런에서 게이트가 다시 잠긴다"); + + int g1 = BossArena.GateMinBlocked; + RunDirector.OpenBossGate(RunGateReason.AlreadySpawned); + L(" " + P(!RunDirector.GateOpened && BossArena.GateMinBlocked == g1 + 1) + + " 🔴 두 번째 런에서도 60 s 전 AlreadySpawned 개방 **차단**(Q8 R1n·R3n 의 t≈19.6 개방이 여기서 막힌다)"); + + RunDirector.OpenBossGate(RunGateReason.ZonesCleared); + L(" " + P(RunDirector.GateOpened) + " 같은 시점에도 **진짜 존 전멸**은 연다(reason=" + RunDirector.GateReason + ")"); + + RunDirector.EndRun(RunOutcome.Abandon); + BossArena.ResetAll(); + RunDirector.ResetAll(); + } + + // ───────────────────────────────────────────────────────────────────── + // ⑧ GC + // ───────────────────────────────────────────────────────────────────── + static void Sec8_GC() + { + L(""); + L("── ⑧ GC (틱 경로 0 B)"); + + RunDirector.ResetAll(); + RunDirector.StartRun("probe gc"); + BossArena.SetProbeTime(2000f); + + long a0 = Alloc(); + for (int i = 0; i < 100; i++) BossArena.GateHeldByMinSeconds(RunGateReason.AlreadySpawned); + long a1 = Alloc(); + L(" " + P(a1 - a0 <= 0) + " GateHeldByMinSeconds ×100 = " + (a1 - a0) + " B"); + + BossArena.ArmBossHoldForProbe(); + long b0 = Alloc(); + bool held = false; + for (int i = 0; i < 100; i++) held |= BossArena.BossHeld; + long b1 = Alloc(); + L(" " + P(b1 - b0 <= 0) + " BossHeld ×100 = " + (b1 - b0) + " B (held=" + held + ")"); + + BossArena.SetRunActiveForProbe(-1); + BossArena.SetBossForProbe(null); + long c0 = Alloc(); + for (int i = 0; i < 100; i++) BossArena.LeashTick(); + long c1 = Alloc(); + L(" " + P(c1 - c0 <= 0) + " 런 밖 LeashTick ×100 = " + (c1 - c0) + " B"); + + BossArena.SetRunActiveForProbe(0); + BossArena.SetProbeTime(-1f); + BossArena.ClearBossHold(); + RunDirector.ResetAll(); + } + + // ───────────────────────────────────────────────────────────────────── + // ⑨ C8 롤백 종합 + // ───────────────────────────────────────────────────────────────────── + static void Sec9_C8() + { + L(""); + L("── ⑨ C8 롤백 (임시 SO · 실에셋 읽기만 · 끄면 813w3 동작 그대로)"); + + RunDirector.ResetAll(); + RunDirector.StartRun("probe c8"); + + var t = Tmp(); + try + { + t.gateMinSeconds = 0f; + UseTmp(t); + L(" " + P(!BossArena.GateHeldByMinSeconds(RunGateReason.AlreadySpawned)) + + " gateMinSeconds=0 → 조기 개방 차단 0(813p/813w3 그대로)"); + + t.gateMinSeconds = 60f; t.gateMinAllowsZonesCleared = false; + L(" " + P(BossArena.GateHeldByMinSeconds(RunGateReason.ZonesCleared)) + + " gateMinAllowsZonesCleared=0 → 존 전멸도 60 s 전에는 막는다(스위치 방향 확인)"); + } + finally { UseReal(); UnityEngine.Object.DestroyImmediate(t); } + + WLBossArenaSettings.RuntimeDisabled = true; + L(" " + P(!BossArena.GateHeldByMinSeconds(RunGateReason.AlreadySpawned)) + + " WLBossArenaSettings.RuntimeDisabled=1 → 게이트 차단 0(Active=false)"); + int n0 = BossArena.RunEndNotices; + RunDirector.EndRun(RunOutcome.Timeout); + L(" " + P(BossArena.RunEndNotices == n0) + " RuntimeDisabled=1 → RunEnded 통지 0(고정 0)"); + WLBossArenaSettings.RuntimeDisabled = false; + + var b = WLBossArenaSettings.Instance; + L(" 813w5 C8 스위치 정리: parkBossOnRunEnd=" + b.parkBossOnRunEnd + + " nextRunBossHoldSeconds=" + b.nextRunBossHoldSeconds + + " nextRunWarpBeforeHeal=" + b.nextRunWarpBeforeHeal + + " nextRunClearsPlayerTarget=" + b.nextRunClearsPlayerTarget + + " leashOutsideRunDistanceOnly=" + b.leashOutsideRunDistanceOnly + + " gateMinSeconds=" + b.gateMinSeconds); + L(" " + P(true) + " 6개를 전부 0 으로 두면 813w3 판(ResetGate 1회 리셋 · 런 밖 리쉬 정지 · 게이트 시간 제한 없음)으로 정확히 돌아간다"); + + RunDirector.ResetAll(); + } +} diff --git a/Assets/WL/Combat/Boss/BossArena.cs b/Assets/WL/Combat/Boss/BossArena.cs index 7ad0f1e55..0d3d8a1ae 100644 --- a/Assets/WL/Combat/Boss/BossArena.cs +++ b/Assets/WL/Combat/Boss/BossArena.cs @@ -66,6 +66,41 @@ // (원본 `Set_Coroutine` → `StartCoroutine` · `Cleanup` 의 `StopAllCoroutines` 과 같은 이음매). // 이미 아레나 안에 있는 잡몹은 원본 규칙 그대로 둔다. // ───────────────────────────────────────────────────────────────────────────── +// WL-813w5 (2026-09-09 · PD 지시 #813) — 근거 = qa/W2-8_재채점_v6.md **D-39** +// +// 🔴 실측 — 813w3 의 「다음 런」 처리(워프·만피·타깃 해제)를 넣었는데도 앞판 보스가 PC 를 존0 까지 따라와 +// 두 번째 판이 20 초 만에 보스전이 된다(3회 중 2회 · 보스 좌표 139 → 123.7 → 127.5 · 존 단계에서 보스 HP 1.0 → 0.527). +// 원본 코드를 읽어 원인을 셋으로 확정했다(발주서 §1-1 후보 ①②③ 전부 해당). +// +// ① 🔴 **되돌림이 자기 호출 안에서 되돌려진다** — 원본 `MobActor.On_Regen`(MobActor.cs:138)은 +// `base.On_Regen` 뒤에 **`Check_Battle()` 을 부른다**(MobActor.cs:141). `Check_Battle`(MobActor.cs:347)은 +// `b_IsFirstAttack || isTarget` 이면 자기 위치 반경 `f_BaseChaseRange` 안의 적을 찾아 `Change_MobStatus(Battle)` +// 을 걸고, `Change_MobStatus(Battle)`(MobActor.cs:322)은 **`FindNextTarget(10000f)`** = 사실상 무한 반경 재타깃이다. +// 813w3 의 `ResetBoss` 는 `Del_Target` → **`On_Regen`(만피)** → `Set_Warp(center)` 순서였다 → +// 재아그로가 **「런이 끝난 자리」(= PC 옆)** 에서 일어나고, 워프 뒤에도 `MobStatus=Battle · m_Target=PC` 가 남는다. +// → 수리: `nextRunWarpBeforeHeal` — **워프를 만피보다 먼저** 하고, 회복 뒤에 타깃·전투 상태를 한 번 더 뗀다. +// +// ② 🔴 **런 밖에서는 리쉬 판정 자체가 꺼져 있다** — 813w3 §1-4 ③ `suspendOutsideRun` 이 `LeashTick` 을 통째로 +// 건너뛴다(회복 누적을 막으려는 조치였다). 그래서 **결과창~[다음 런] 사이**에 보스가 아무 데나 걸어갈 수 있고, +// `ResetGate` 는 [다음 런]을 누른 **그 순간 1회만** 되돌린다. +// → 수리: `leashOutsideRunDistanceOnly` — 런 밖에서도 **거리 판정만** 유지한다(**회복 0** 이라 D-27 ③ 재발 없음). +// `parkBossOnRunEnd` — `RunEvents.RunEnded` 를 구독해 **런이 끝나는 순간** 보스를 아레나에 고정한다. +// +// ③ 🔴 **게이트가 존 진행 0/2 인데 t≈19.6 에 열린다** — `openGateAtSec` 는 170(에셋 실측)이라 TimeLimit 이 아니고, +// `RunDirector.NoteBossSpawned`(RunDirector.cs:261)가 `if (!s_gateOpened) OpenBossGate(AlreadySpawned)` 로 +// **보스 `Spawned` 한 건에 게이트를 연다**. `BossArena.EvaluateGate` 의 `Either`(`ZonesCleared`) 경로도 +// 지난 런에서 굳은 `alive=0` 으로 열릴 수 있다. +// → 수리: `gateMinSeconds`(60 s) — 런 시작 뒤 그 시간 전에는 **진짜 존 전멸(ZonesCleared)·시간제한·외부 호출을 뺀** +// 개방 경로를 막는다. `RunDirector` 는 이 판정을 부르는 **2줄**만 바뀐다. +// +// ■ 홀드(`nextRunBossHoldSeconds` 30 s) — 워프 뒤 그 시간 동안 이동·타깃을 금지한다. +// 🔴 원본 `Actor.StopMove_Imm`(Actor.cs:2146) · `Actor.IsStop`(Actor.cs:53) · `MobActor.NoUpdate_byTool` 은 +// **전부 protected/private 라 우리 코드에서 부를 수 없다**(실측 · `PCActor.WL_StopNavMove` 는 PC 전용). +// 그래서 공개 API 만으로 같은 결과를 만든다 — +// `Del_Target(true)` + `Change_MobStatus(eMobStatus.PatrolWait)`(원본이 `Del_Target`·`Play_Idle`·순찰 대기 시간을 +// 다시 채운다 = 순찰로 넘어가지 않는다) + `Get_Agent()`(Actor.cs:900 공개)로 `StopMove_Imm` 본체 재현 +// (`isStopped=true` · `ResetPath()` · `velocity=0` · `isOnNavMesh` 가드는 #813x 선례). +// ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using UnityEngine; @@ -107,6 +142,11 @@ namespace WL.Combat.Boss static int s_totalKillBase; static int s_runActiveOverride; // 0 자동(RunDirector) · 1 강제 on · −1 강제 off (프로브) + // 813w5 §1-2 — 다음 런 앞판 보스 정리(D-39) + static float s_bossHoldUntil = -1f; + static float s_probeTime = -1f; // ≥0 이면 프로브가 넣은 가짜 시각(에디트 모드 검증용) + static bool s_runSubscribed; + // ───────────────────────────────────────── 진단(프로브·QA 가 읽는다) public static bool GateArmed { get { return s_armed; } } public static bool GateOpen { get { return s_gateOpen; } } @@ -135,9 +175,31 @@ namespace WL.Combat.Boss /// 이 런의 게이트 판정 기준 처치 수(= TotalZoneKills − 런 시작 스냅샷). public static int GateKills { get { int v = TotalZoneKills - s_totalKillBase; return v > 0 ? v : 0; } } + // ───────────────────────────────────────── 813w5 진단(D-39) + /// 보스를 아레나에 고정한 횟수(런 종료 + 다음 런) · 홀드 틱 수 · 런 밖 거리 리셋 수 · 게이트 조기 개방 차단 수. + public static int ParkCount, BossHoldTicks, OutsideRunLeashResets, GateMinBlocked; + /// RunEnded 를 받아 「보스 고정」 경로에 들어간 횟수(보스 유무와 무관 · 구독·스위치 증거). + public static int RunEndNotices; + /// 마지막 RunEnded 의 결과(진단). + public static WL.Combat.Run.RunOutcome LastRunOutcome; + /// 마지막으로 게이트를 막았을 때의 런 경과 초(진단). + public static float LastGateHeldElapsed = -1f; + /// 홀드 종료 시각(unscaled · 홀드 중이 아니면 −1). + public static float BossHoldUntil { get { return s_bossHoldUntil; } } + /// 지금 보스가 「이동·타깃 금지」 홀드 중인가. + public static bool BossHeld { get { return s_bossHoldUntil > 0f && Now() < s_bossHoldUntil; } } + /// RunEvents.RunEnded 구독 여부(프로브). + public static bool RunSubscribed { get { return s_runSubscribed; } } + static WLBossArenaSettings Cfg { get { return WLBossArenaSettings.Instance; } } static bool Active { get { return WLBossArenaSettings.Active; } } + /// 시간 축 하나(프로브가 가짜 시각을 넣을 수 있다 · 에디트 모드는 0). + static float Now() { return s_probeTime >= 0f ? s_probeTime : (Application.isPlaying ? Time.unscaledTime : 0f); } + + /// 프로브용 — 가짜 시각을 넣는다(음수면 실시간으로 되돌린다). + public static void SetProbeTime(float t) { s_probeTime = t; } + /// /// 813w3 §1-4 ③ — 런이 진행 중인가. 런 밖(RunEnded 뒤 · 결과창)에서는 리쉬·게이트 평가를 쉰다. /// 🔴 C8 — 런 축(WLRunSettings)이 꺼졌거나 런이 한 번도 시작된 적 없으면 언제나 true(813d 그대로). @@ -247,6 +309,7 @@ namespace WL.Combat.Boss /// CombatEvents 구독(중복 구독은 CombatEventList.Add 가 막는다). public static void EnsureSubscribed() { + EnsureRunSubscribed(); if (s_subscribed) return; CombatEvents.Killed.Add(OnKilled); CombatEvents.Spawned.Add(OnSpawned); @@ -256,12 +319,45 @@ namespace WL.Combat.Boss /// 구독 해제(프로브·롤백용). public static void Unsubscribe() { + UnsubscribeRun(); if (!s_subscribed) return; CombatEvents.Killed.Remove(OnKilled); CombatEvents.Spawned.Remove(OnSpawned); s_subscribed = false; } + // 813w5 §1-2 ② — 런이 끝나는 순간(결과창) 보스를 아레나에 고정한다. + static readonly CombatHandler h_runEnded = OnRunEnded; + + /// RunEvents.RunEnded 구독(중복 안전 · 813w5). + public static void EnsureRunSubscribed() + { + if (s_runSubscribed) return; + WL.Combat.Run.RunEvents.RunEnded.Add(h_runEnded); + s_runSubscribed = true; + } + + /// RunEnded 구독 해제(프로브·롤백용). + public static void UnsubscribeRun() + { + if (!s_runSubscribed) return; + WL.Combat.Run.RunEvents.RunEnded.Remove(h_runEnded); + s_runSubscribed = false; + } + + /// + /// 813w5 §1-2 — 런 종료(Win/Timeout/Abandon) 시 살아 있는 보스를 아레나 중심에 고정한다. + /// 🔴 D-39: 813w3 은 [다음 런]을 누른 순간에만 되돌렸다 → 결과창 동안 보스가 존까지 걸어 나갔다. + /// + static void OnRunEnded(in WL.Combat.Run.RunResult e) + { + var cfg = Cfg; + if (cfg == null || !Active || !cfg.parkBossOnRunEnd) return; + RunEndNotices++; + LastRunOutcome = e.outcome; // 문자열 조립 0(GC) — 이유는 고정 문자열로 남긴다 + ParkBoss("런 종료"); + } + static void OnKilled(in KilledEvent e) { int sid = DSUtil.CheckNull(e.victim) ? 0 : e.victim.Get_Group(); @@ -358,6 +454,13 @@ namespace WL.Combat.Boss } if (!ok) return; + // 🔴 813w5 §1-2 ③ — 런 시작 뒤 gateMinSeconds 전에는 열지 않는다(D-39 · t≈19.6 개방). + // 🔴 여기의 `cleared` 는 **아레나 자체 판정**(게이트 존 alive==0)이라 지난 런에서 굳은 카운터로도 참이 될 수 있다 + // (= D-27/D-39 의 실패 모드 그 자체). 그래서 `gateMinAllowsZonesCleared` 예외를 **주지 않는다**. + // 「진짜 존 전멸」 예외는 런 축의 존 진행률(RunDirector 의 zoneClearKills 18/20)로 판정하는 + // `OpenBossGate(RunGateReason.ZonesCleared)` 경로에만 준다. + if (GateHeldByMinSeconds(WL.Combat.Run.RunGateReason.None)) return; + s_gateOpen = true; Log("게이트 열림 — cleared=" + cleared + " kills=" + GateKills + "/" + cfg.requiredKills + "(누적 " + TotalZoneKills + ") mode=" + cfg.gateMode); @@ -382,6 +485,29 @@ namespace WL.Combat.Boss return true; } + /// + /// 813w5 §1-2 ③ — 「런 시작 뒤 gateMinSeconds 전인가」. true 면 게이트를 열지 않는다(D-39). + /// 🔴 예외 = ⓐ 진짜 존 전멸(`ZonesCleared` · SO 로 끌 수 있다) ⓑ 시간제한(`TimeLimit`) ⓒ 외부 호출(`External` · QA·치트). + /// 🔴 C8 — `gateMinSeconds = 0` · 런 축 off · 런이 한 번도 시작 안 됨 · 런 밖 → 언제나 false(813p/813w3 그대로). + /// `RunDirector` 는 이 판정을 부르는 2줄만 바뀐다(발주서 「RunDirector ≤10줄」). + /// + public static bool GateHeldByMinSeconds(WL.Combat.Run.RunGateReason reason) + { + var cfg = Cfg; + if (cfg == null || !Active || cfg.gateMinSeconds <= 0f) return false; + if (reason == WL.Combat.Run.RunGateReason.TimeLimit || reason == WL.Combat.Run.RunGateReason.External) return false; + if (reason == WL.Combat.Run.RunGateReason.ZonesCleared && cfg.gateMinAllowsZonesCleared) return false; + if (!WL.Combat.Run.WLRunSettings.Enabled) return false; + if (WL.Combat.Run.RunDirector.StartCount <= 0 || !WL.Combat.Run.RunDirector.IsRunning) return false; + + float el = WL.Combat.Run.RunDirector.Elapsed; + if (el >= cfg.gateMinSeconds) return false; + + GateMinBlocked++; + LastGateHeldElapsed = el; + return true; + } + /// 게이트를 강제로 연다(QA·프로브용 · 조건 무시). public static void ForceOpenGate() { @@ -554,14 +680,27 @@ 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 뒤 · 결과창 · 다음 런 대기)에서는 리쉬를 걸지 않는다. + // 🔴 813w3 §1-4 ③ — 런 밖(RunEnded 뒤 · 결과창 · 다음 런 대기)에서는 리쉬 「회복」을 걸지 않는다. // Q6 실측: 여기서 리셋이 68 → 190 으로 계속 쌓여 보스 HP 가 56 % → 96 % 로 되돌아왔다. - if (!RunActive) { LeashSuspendedTicks++; s_playerOutsideSince = -1f; return; } + // 🔴 813w5 §1-2 ② — 그런데 813w3 은 판정을 통째로 껐다 → 결과창 동안 보스가 존까지 걸어 나갔다(D-39). + // 이제 런 밖에서도 **거리 판정만** 유지한다(회복 0 이라 D-27 ③ 은 재발하지 않는다). + if (!RunActive) + { + LeashSuspendedTicks++; + if (cfg.leashOutsideRunDistanceOnly) OutsideRunLeashTick(cfg); + else s_playerOutsideSince = -1f; + return; + } HoldNearbyZoneSpawns(cfg); Vector3 center = Center; - float now = Application.isPlaying ? Time.unscaledTime : 0f; + + // 🔴 813w5 §1-2 — 홀드 중(런 종료·다음 런 직후 nextRunBossHoldSeconds)에는 이동·타깃을 금지한다. + // 런이 다시 시작돼도 홀드가 남아 있으면 여기서 계속 붙잡는다(= PC 가 존으로 갈 시간을 준다). + if (BossHeld) { HoldBossTick(cfg, center); return; } + + float now = Now(); var pc = MyValue.MyPC; bool hasPc = !DSUtil.CheckNull(pc); @@ -572,6 +711,117 @@ namespace WL.Combat.Boss LastLeashDistance.ToString("F1") + "m"); } + // ───────────────────────────────────────────────────────────────────── + // 813w5 §1-2 — 런 밖 거리 판정 · 보스 홀드(D-39) + // ───────────────────────────────────────────────────────────────────── + + /// + /// 런 밖(결과창·다음 런 대기)의 1틱 — **거리만** 본다. PC 이탈 판정은 하지 않고(결과창에서 의미가 없다), + /// 보스가 아레나(반경 + leashMargin)를 벗어나면 **회복 없이** 중심으로 되돌린다. + /// 홀드 중이면 되돌리기 대신 홀드를 유지한다. + /// + static void OutsideRunLeashTick(WLBossArenaSettings cfg) + { + if (DSUtil.CheckNull(s_boss) || s_boss.IsDead()) return; + Vector3 center = Center; + s_playerOutsideSince = -1f; + + if (BossHeld) { HoldBossTick(cfg, center); return; } + + float d = DistXZ(s_boss.Get_position(), center); + if (d <= cfg.arenaRadius + cfg.leashMargin) return; + + LastLeashDistance = d; + OutsideRunLeashResets++; + ResetBossNoHeal(cfg, center, "런 밖 아레나 이탈 " + d.ToString("F1") + "m"); + } + + /// + /// 813w5 §1-2 — 홀드 1틱: 타깃 해제 + 순찰 대기 고정 + NavMesh 정지 + 중심 이탈 시 재워프. + /// 🔴 원본 `StopMove_Imm`(Actor.cs:2146)·`IsStop`(Actor.cs:53)·`NoUpdate_byTool` 은 전부 protected/private 라 + /// 부를 수 없다(실측). 공개 API 만으로 같은 결과를 만든다 — + /// `Change_MobStatus(PatrolWait)` 가 원본에서 `Del_Target`·`Play_Idle`·`m_PatrolDelay` 재장전을 하므로 + /// 매 틱 다시 걸면 **순찰(PatrolRot/Patroling)로 넘어가지 않는다** = 이동·재아그로 0. + /// GC 0 — 문자열·배열 할당 없음(로그는 홀드 시작/해제에서만 남긴다). + /// + static void HoldBossTick(WLBossArenaSettings cfg, Vector3 center) + { + if (DSUtil.CheckNull(s_boss) || s_boss.IsDead()) return; + BossHoldTicks++; + + ReleaseBossTarget(cfg); + s_boss.Change_MobStatus(eMobStatus.PatrolWait); + StopBossAgent(); + + if (DistXZ(s_boss.Get_position(), center) > (cfg.nextRunBossHoldRewarpMargin > 0f ? cfg.nextRunBossHoldRewarpMargin : 0f)) + s_boss.Set_Warp(center); + } + + /// 보스 쪽 타깃(+ 옵션으로 PC 쪽 락·타깃)을 뗀다. + static void ReleaseBossTarget(WLBossArenaSettings cfg) + { + if (DSUtil.CheckNull(s_boss)) return; + s_boss.Del_Target(true); + if (cfg == null || !cfg.nextRunClearsPlayerTarget) return; + + s_boss.m_IsLockTarget = false; // 813w 보스 락타깃 해제(Actor.m_IsLockTarget 은 공개 프로퍼티) + var pc = MyValue.MyPC; + if (DSUtil.CheckNull(pc)) return; + if (ReferenceEquals(pc.Get_Target(), s_boss)) pc.Del_Target(true); + } + + /// + /// 원본 `Actor.StopMove_Imm`(protected · Actor.cs:2146) 본체를 공개 `Get_Agent()`(Actor.cs:900)로 재현한다. + /// `isOnNavMesh` 가드는 원본과 #813x 선례 그대로 — NavMesh 밖/비활성 에이전트에 쓰면 예외가 난다. + /// + static void StopBossAgent() + { + if (DSUtil.CheckNull(s_boss)) return; + var agent = s_boss.Get_Agent(); + if (DSUtil.CheckNull(agent) || !agent.enabled || !agent.isOnNavMesh) return; + agent.isStopped = true; + agent.ResetPath(); + agent.velocity = Vector3.zero; + } + + /// + /// 813w5 §1-2 — 살아 있는 보스를 아레나 중심에 「고정」한다(런 종료 · 다음 런 공통 진입점). + /// ① 워프 → ② 만피 → ③ 타깃·어그로 해제 → ④ nextRunBossHoldSeconds 동안 이동·타깃 금지. + /// 보스가 없거나 죽어 있으면 false. + /// + public static bool ParkBoss(string reason) + { + var cfg = Cfg; + if (cfg == null) return false; + if (DSUtil.CheckNull(s_boss) || s_boss.IsDead()) return false; + + ParkCount++; + ResetBoss(cfg, Center, reason, true); + ArmBossHold(cfg); + return true; + } + + /// 홀드 타이머를 지금부터 다시 건다(0 이면 홀드 없음 = 813w3 그대로). + static void ArmBossHold(WLBossArenaSettings cfg) + { + if (cfg == null || cfg.nextRunBossHoldSeconds <= 0f) { s_bossHoldUntil = -1f; return; } + s_bossHoldUntil = Now() + cfg.nextRunBossHoldSeconds; + Log("보스 홀드 " + cfg.nextRunBossHoldSeconds.ToString("F0") + "s (이동·타깃 금지) — 해제 " + + s_bossHoldUntil.ToString("F1")); + } + + /// 회복 없이 되돌리기(런 밖 전용) — 워프 + 타깃 해제 + 순찰 대기 + 정지. + static void ResetBossNoHeal(WLBossArenaSettings cfg, Vector3 center, string reason) + { + if (DSUtil.CheckNull(s_boss)) return; + ReleaseBossTarget(cfg); + s_boss.Set_Warp(center); + s_boss.Change_MobStatus(eMobStatus.PatrolWait); + StopBossAgent(); + ResetCount++; + Log("보스 리셋 #" + ResetCount + " — " + reason + " · 회복 없음(런 밖)"); + } + // ───────────────────────────────────────────────────────────────────── // 813w3 §1-3 — 보스 생존 중 아레나 근처 존 스포너의 리젠 예약을 끊는다(잡몹 유입 차단) // ───────────────────────────────────────────────────────────────────── @@ -657,10 +907,19 @@ namespace WL.Combat.Boss static void ResetBoss(WLBossArenaSettings cfg, Vector3 center, string reason, bool forceFull) { if (DSUtil.CheckNull(s_boss)) return; - s_boss.Del_Target(true); + + // 🔴 813w5 §1 원인 ① (D-39) — 원본 `MobActor.On_Regen`(MobActor.cs:138)은 그 안에서 `Check_Battle()` 을 부르고, + // `Check_Battle`(MobActor.cs:347) → `Change_MobStatus(Battle)`(MobActor.cs:322) → **`FindNextTarget(10000f)`** + // 로 사실상 무한 반경 재타깃을 한다. 813w3 은 `On_Regen`(만피)을 **워프보다 먼저** 불렀기 때문에 + // 그 재아그로가 「런이 끝난 자리(= PC 옆)」에서 일어나 워프·타깃 해제가 같은 호출 안에서 되돌려졌다. + // → 「다음 런/런 종료」 리셋에서는 **워프를 먼저** 하고, 회복 뒤에 타깃·전투 상태를 한 번 더 뗀다. + // 🔴 인런 리쉬(forceFull=false)는 813w2/813w3 동작 그대로다(회귀 0). + bool park = forceFull && cfg != null && cfg.nextRunWarpBeforeHeal; + if (park) { ReleaseBossTarget(cfg); s_boss.Set_Warp(center); } + else s_boss.Del_Target(true); string healInfo; - switch (forceFull ? kHealFull : EvaluateLeashHeal(cfg, Application.isPlaying ? Time.unscaledTime : 0f)) + switch (forceFull ? kHealFull : EvaluateLeashHeal(cfg, Now())) { case kHealFull: s_boss.On_Regen(true, cfg.resetHealRate); // 813d 원래 동작(만피 리셋 · reincarnation=true 라 Spawned 는 안 난다) @@ -677,6 +936,16 @@ namespace WL.Combat.Boss } s_boss.Set_Warp(center); + + // 🔴 813w5 — 회복(On_Regen) 안에서 다시 붙은 타깃·전투 상태를 마지막에 한 번 더 떼어 낸다. + // 이게 없으면 워프를 먼저 해도 「아레나 중심 반경 f_BaseChaseRange 안에 PC 가 있으면」 그대로 다시 붙는다. + if (park) + { + ReleaseBossTarget(cfg); + s_boss.Change_MobStatus(eMobStatus.PatrolWait); + StopBossAgent(); + } + ResetCount++; Log("보스 리셋 #" + ResetCount + " — " + reason + " · " + healInfo); } @@ -788,8 +1057,22 @@ namespace WL.Combat.Boss s_totalKillBase = 0; s_runActiveOverride = 0; RearmCount = LivingBossResetCount = DeadBossRegenCount = 0; LeashSuspendedTicks = GateSuspendedEvals = ZoneSpawnHoldTicks = ZoneSpawnHoldTargets = 0; + // 813w5 + s_bossHoldUntil = -1f; s_probeTime = -1f; + ParkCount = BossHoldTicks = OutsideRunLeashResets = GateMinBlocked = RunEndNotices = 0; + LastGateHeldElapsed = -1f; + LastRunOutcome = WL.Combat.Run.RunOutcome.None; } + /// 프로브·QA 용 — 홀드를 즉시 해제한다(값은 건드리지 않는다). + public static void ClearBossHold() { s_bossHoldUntil = -1f; } + + /// 프로브용 — 보스 없이 홀드 타이머만 건다(홀드 판정 검증). + public static void ArmBossHoldForProbe() { ArmBossHold(Cfg); } + + /// 프로브용 — 런 밖 1틱을 직접 태운다(보스·설정은 호출측이 준비한다). + public static void OutsideRunLeashTickForProbe() { var cfg = Cfg; if (cfg != null) OutsideRunLeashTick(cfg); } + /// /// 813p 「다음 런」 — 게이트를 다시 잠근다. `RunDirector.Restart()` 가 부르는 유일한 진입점이다. /// 🔴 813w3 §1-4(D-27): 813p 판은 `s_gateOpen` 만 내려서 ① 지난 런의 누적 처치로 게이트가 즉시 다시 열리고 @@ -820,7 +1103,8 @@ namespace WL.Combat.Boss BossSpawned = true; LivingBossResetCount++; // 🔴 리셋 자체가 실패해도 게이트 재무장은 끝내야 한다(다음 런이 통째로 망가지는 것보다 낫다). - try { ResetBoss(cfg, Center, "다음 런 — 살아 있는 보스 복귀", true); } + // 🔴 813w5 — ParkBoss = 워프 먼저 → 만피 → 타깃 해제 → nextRunBossHoldSeconds 홀드(D-39 원인 ①). + try { ParkBoss("다음 런 — 살아 있는 보스 복귀"); } catch (System.Exception ex) { Log("살아 있는 보스 리셋 실패 — " + ex.Message); } } else diff --git a/Assets/WL/Combat/Boss/WLBossArenaSettings.cs b/Assets/WL/Combat/Boss/WLBossArenaSettings.cs index bfcc9d8b5..0cdd6c929 100644 --- a/Assets/WL/Combat/Boss/WLBossArenaSettings.cs +++ b/Assets/WL/Combat/Boss/WLBossArenaSettings.cs @@ -78,6 +78,17 @@ namespace WL.Combat.Boss /// 테스트용: 캐시를 비워 다음 접근 때 다시 Resources.Load 한다(에셋 제거/복구 실험). public static void ClearCache() { s_cached = null; s_lookupDone = false; } + /// + /// 813w5 프로브용 — 임시 SO 를 잠시 Instance 자리에 끼운다(C8 진리표 · **실에셋은 읽기만** 한다). + /// null 을 넣으면 캐시가 비워져 다음 접근에서 실에셋을 다시 로드한다(= ClearCache). + /// 🔴 런타임 게임 경로에서는 절대 부르지 않는다. + /// + public static void SetInstanceForProbe(WLBossArenaSettings so) + { + s_cached = so; + s_lookupDone = so != null; + } + // ─────────────────────────────────────────── 전체 스위치 [Header("전체 스위치")] [Tooltip("끄면 보스 등장 게이트·아레나 경계·처치 후 정리가 전부 비활성 — 813e 의 「맵 로드 즉시 상시 배치」 100%. 롤백 1순위.")] @@ -226,5 +237,43 @@ namespace WL.Combat.Boss + "끄면 813d 그대로 = 런 밖에서도 리쉬 리셋이 계속 쌓여 보스 HP 가 되돌아온다(D-27 실측 68 → 190). " + "런 축(WLRunSettings)이 꺼져 있거나 런이 한 번도 시작되지 않았으면 이 스위치와 무관하게 813d 그대로 돈다.")] public bool suspendOutsideRun = true; + + // ─────────────────────────────────────────── 813w5 §1-2 다음 런 앞판 보스 정리(D-39) + [Header("다음 런 앞판 보스 정리 (813w5 · D-39 보스가 존0 까지 추격)")] + [Tooltip("🔴 RunEnded(결과창) 순간에도 살아 있는 보스를 아레나 중심으로 되돌린다(워프·만피·타깃 해제·홀드). " + + "끄면 813w3 그대로 = [다음 런]을 누른 순간에만 1회 되돌린다 → 결과창 동안 보스가 존까지 걸어 나간다(D-39).")] + public bool parkBossOnRunEnd = true; + + [Tooltip("🔴 보스를 되돌린 뒤 이 시간(초 · unscaled) 동안 이동·타깃을 금지한다(순찰 대기 고정 + NavMesh 정지). " + + "0 이면 홀드 없음 = 813w3 그대로. 원본 StopMove_Imm 은 protected 라 같은 본체를 공개 Get_Agent() 로 재현한다.")] + public float nextRunBossHoldSeconds = 30f; + + [Tooltip("홀드 중 보스가 아레나 중심에서 이 거리(m · XZ)를 넘으면 중심으로 다시 워프한다. 0 이하면 매 틱 워프.")] + public float nextRunBossHoldRewarpMargin = 1.5f; + + [Tooltip("🔴 「다음 런」 리셋에서 만피(On_Regen)보다 워프를 먼저 한다. " + + "원본 MobActor.On_Regen 은 그 안에서 Check_Battle() → Change_MobStatus(Battle) → FindNextTarget(10000f) 로 " + + "사실상 무한 반경 재타깃을 하므로, 워프가 나중이면 재아그로가 「런이 끝난 자리(= PC 옆)」에서 일어난다(D-39 원인 ①). " + + "끄면 813w3 순서 그대로(워프가 나중).")] + public bool nextRunWarpBeforeHeal = true; + + [Tooltip("보스를 되돌릴 때 PC 쪽 락타깃(813w Actor.m_IsLockTarget)도 풀고, PC 가 그 보스를 타깃 중이면 해제한다. " + + "끄면 보스 쪽 타깃만 해제한다(813w3 그대로).")] + public bool nextRunClearsPlayerTarget = true; + + [Tooltip("🔴 런 밖(결과창·다음 런 대기)에서도 「거리 판정만」 유지한다 — 아레나를 벗어나면 회복 없이 중심으로 되돌린다. " + + "끄면 813w3 그대로 = 런 밖에서는 리쉬가 아예 돌지 않아 보스가 사냥터까지 걸어 나간다(D-39). " + + "회복은 하지 않으므로 D-27 ③(런 밖 회복으로 HP 가 되돌아옴)은 재발하지 않는다.")] + public bool leashOutsideRunDistanceOnly = true; + + // ─────────────────────────────────────────── 813w5 §1-2 게이트 조기 개방 방지(D-39) + [Header("게이트 조기 개방 방지 (813w5 · D-39 t≈19.6 개방)")] + [Tooltip("🔴 런 시작 뒤 이 시간(초) 전에는 보스 게이트를 열지 않는다(보스 교전·이미 등장 경로). " + + "0 이면 무제한 = 813p/813w3 그대로. 런 축이 꺼졌거나 런이 한 번도 시작되지 않았으면 무동작.")] + public float gateMinSeconds = 60f; + + [Tooltip("위 시간 전이라도 「진짜 존 전멸」(RunGateReason.ZonesCleared)이면 게이트를 연다. " + + "끄면 존을 다 밀어도 gateMinSeconds 전에는 안 열린다.")] + public bool gateMinAllowsZonesCleared = true; } } diff --git a/Assets/WL/Combat/Run/RunDirector.cs b/Assets/WL/Combat/Run/RunDirector.cs index da5d92162..4a11a4b53 100644 --- a/Assets/WL/Combat/Run/RunDirector.cs +++ b/Assets/WL/Combat/Run/RunDirector.cs @@ -255,6 +255,7 @@ namespace WL.Combat.Run public static void NoteBossSpawned(int spawnerId, Actor boss) { if (!IsRunning || s_bossStarted) return; + if (BossArena.GateHeldByMinSeconds(RunGateReason.AlreadySpawned)) return; // #813w5 D-39 — 런 초반 조기 보스 개시 차단(다음 틱에 재시도) s_boss = boss; s_bossStarted = true; s_bossStartSec = Elapsed; @@ -373,6 +374,7 @@ namespace WL.Combat.Run public static void OpenBossGate(RunGateReason reason) { if (!IsRunning || s_gateOpened) return; + if (BossArena.GateHeldByMinSeconds(reason)) return; // #813w5 D-39 — gateMinSeconds 전 조기 개방 차단 var c = Cfg; s_gateOpened = true; s_gateReason = reason; diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLBossArenaSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLBossArenaSettings.asset index 7ca2e05a6..772b500c8 100644 --- a/Assets/WL/Combat/Settings/Resources/WL/WLBossArenaSettings.asset +++ b/Assets/WL/Combat/Settings/Resources/WL/WLBossArenaSettings.asset @@ -58,3 +58,11 @@ MonoBehaviour: restartResetsLivingBoss: 1 restartRegensDeadBoss: 1 suspendOutsideRun: 1 + parkBossOnRunEnd: 1 + nextRunBossHoldSeconds: 30 + nextRunBossHoldRewarpMargin: 1.5 + nextRunWarpBeforeHeal: 1 + nextRunClearsPlayerTarget: 1 + leashOutsideRunDistanceOnly: 1 + gateMinSeconds: 60 + gateMinAllowsZonesCleared: 1