From 224bc91aaa7b70a988005a8fdefcfaaaa985e901 Mon Sep 17 00:00:00 2001 From: swrring Date: Wed, 9 Sep 2026 18:57:50 +0900 Subject: [PATCH] =?UTF-8?q?[WL-813w6]=20=EC=9E=AC=EC=8A=A4=ED=8F=B0=20?= =?UTF-8?q?=EB=B3=B4=EC=8A=A4=20=EA=B3=A0=EC=A0=95=20=C2=B7=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=20HP=20=EC=8A=A4=EB=83=85=EC=83=B7=20=EC=88=9C?= =?UTF-8?q?=EC=84=9C=20(#813)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AgentScripts/WL813w6_PROBE.txt | 82 +++ AgentScripts/WL813w6_Probe.cs | 573 ++++++++++++++++++ Assets/WL/Combat/Boss/BossArena.cs | 212 ++++++- Assets/WL/Combat/Boss/WLBossArenaSettings.cs | 33 + Assets/WL/Combat/Run/RunDirector.cs | 7 + Assets/WL/Combat/Run/RunEvents.cs | 9 + .../Resources/WL/WLBossArenaSettings.asset | 6 + 7 files changed, 913 insertions(+), 9 deletions(-) create mode 100644 AgentScripts/WL813w6_PROBE.txt create mode 100644 AgentScripts/WL813w6_Probe.cs diff --git a/AgentScripts/WL813w6_PROBE.txt b/AgentScripts/WL813w6_PROBE.txt new file mode 100644 index 000000000..ba7b4c498 --- /dev/null +++ b/AgentScripts/WL813w6_PROBE.txt @@ -0,0 +1,82 @@ +# WL813w6 Probe 2026-09-09 18:56:53 (edit mode · Play 0 · 로그인 0) +playMode=False + +── ⓪ 준비 + [PASS] WLBossArenaSettings 로드 · Active=True + [PASS] BossArena.Subscribed(CombatEvents) + [PASS] BossArena.RunSubscribed(RunEvents.RunEnded · 813w5) + [PASS] 🔴 HoldSpawn(FieldBossData 813010) → 게이트 무장(armed=True open=False) + +── ① 에셋 (Assets/WL/Combat/Settings/Resources/WL/WLBossArenaSettings.asset · 값 전부 SO = C45) + 신규 6필드: parkBossOnRespawn=True bossHoldUntilGateOpen=True preGateLeashDistanceOnly=True parkBossOnRunEndDeferred=True parkBossOnRunEndDelaySeconds=0 snapshotBossHpOnRunEnd=True + [PASS] parkBossOnRespawn=1 (D-43 재스폰 즉시 고정) + [PASS] bossHoldUntilGateOpen=1 (발주서 §1-1 기본 = 게이트 개방까지) + [PASS] preGateLeashDistanceOnly=1 (게이트 전 거리 판정 · 회복 0) + [PASS] parkBossOnRunEndDeferred=1 (D-44 스냅샷 → 발행 → Park) + [PASS] parkBossOnRunEndDelaySeconds=0 (다음 Tick) + [PASS] snapshotBossHpOnRunEnd=1 (RunResult.bossHpRatio 채움) + 기존 값(813w5) 대조: nextRunBossHoldSeconds=30 gateMinSeconds=60 arenaRadius=20 leashMargin=2 + [PASS] 지연(0s) < 홀드(30s) — 고정 전 공백이 홀드보다 짧다 + [PASS] 813w5 parkBossOnRunEnd=1 유지(런 종료 고정 경로 자체는 그대로) + +── ② D-43 재스폰 보스 고정 (Q9 R2n: ParkCount=0 · 홀드 미무장 · t=41.9 mobStatus=Chase) + [PASS] RunActive=false 강제 · GateOpen=False + [PASS] 가짜 보스 살아 있음(activeInHierarchy=true) + [PASS] 스폰 전 홀드 없음(BossHeld=false) + [PASS] 🔴 Spawned(isBoss · 게이트 닫힘) → 고정 시도 +1 (attempts=1 · 실제 워프 성공 0회 = 에디트 모드 한계) + [PASS] 🔴 **홀드 무장됨**(BossHeld=true) — D-43 의 본질(홀드 미무장)이 사라졌다 + [PASS] 🔴 홀드 모드 = 「게이트 개방까지」(HoldUntilGate=true) + [PASS] BossSpawned=true(813p 폴백이 BossStarted 를 낼 수 있다 · D-27ⓒ 유지) + [PASS] 게이트 개방 뒤 Spawned(isBoss) → 고정 0(정상 보스전 · attempts 불변 1) + [PASS] 그 보스는 홀드 없이 싸운다(BossHeld=false) + [PASS] 잡몹 Spawned(isBoss=false) → 고정 0 + +── ③ 홀드 = 「게이트 개방까지」 (813w5 는 30 s 뒤 풀려 R1n t=77 에 중심 21.89 m 까지 왔다) + arm@t=1000 → BossHoldUntil=1030.0 (= 1000 + nextRunBossHoldSeconds 30) + [PASS] HoldUntilGate=true(bossHoldUntilGateOpen=1) + [PASS] t=만료 −1s → 홀드 유지(시간제 구간 · 813w5 와 동일) + [PASS] 🔴 t=만료 +1s → **여전히 홀드**(813w5 라면 여기서 풀려 존0 로 걸어 나갔다) + [PASS] 🔴 t=만료 +9970s → 여전히 홀드(게이트가 닫혀 있는 한 유지) + [PASS] 🔴 게이트 개방(Release) → 홀드 해제 +1 (1회) + [PASS] 홀드 완전 해제(BossHeld=false · HoldUntilGate=false · BossHoldUntil=-1.0) = 정상 보스전 + +── ④ 게이트 개방 전 리쉬 = 거리 판정만 (런 중이라도 회복 0 · D-27 ③ 재발 방지) + 아레나 중심=(0.0, 0.0, 0.0) 반경=20 leashMargin=2 → 되돌림 경계 22m + [PASS] RunActive=true · GateOpen=false · BossHeld=false (D-43 의 그 구간) + [PASS] 보스 없음 → 보스 가드에서 먼저 끊긴다(카운터 불변) + [PASS] 가짜 보스 위치 (100.0, 0.0, 0.0) → 중심 거리 100.0m > 경계 22m + [PASS] 🔴 게이트 전 이탈 → 되돌림 경로 진입 +1 (PreGateLeashResets=1 · 워프 본체는 NullReferenceException = 에디트 모드 한계) + [PASS] 🔴 **회복 0**(LeashHealCount 불변 0) — D-27 ③(런 밖 회복으로 HP 되돌아옴) 재발 없음 + [PASS] C8 preGateLeashDistanceOnly=0 → 게이트 전 경로 미진입(813w5 그대로) + +── ⑤ D-44 결과 페이로드 보스 HP (Q9 R3: 실제 24.4 % = 439/1800 인데 성적표는 100 %) + [PASS] SnapshotBossHp → ratio=0.2439 hp=439 max=1800 (= 24.4 %) + [PASS] RunEnded 1회 수신(구독자 시점 기록) + [PASS] 🔴 **발행 시점 페이로드 bossHpRatio=0.2439** (= Park 이전 24.4 %) · hp=439/1800 + [PASS] 🔴 발행 도중 고정 0(ParkCount 0 · DeferredParkCount 0) — 만피 복구가 결과창보다 **뒤**에 온다 + [PASS] 발행 시점에 「미뤄 둔 고정」 예약됨(ParkPending=true) + [PASS] RunDirector.LastResult.bossHpRatio=0.2439 (결과창이 늦게 떠도 읽을 수 있다 · 813p 계약) + [PASS] 🔴 발행 뒤 Tick → 미뤄 둔 고정 실행(DeferredParkCount=1 · ParkPending=False) + [PASS] 고정 실패(에디트 모드 워프 불가)해도 **홀드는 무장**(BossHeld=true) + [PASS] 두 번째 펌프 → 중복 실행 0 + [PASS] 보스 없음 → ratio=−1 · hp/max=0 (결과창은 「미처치」만 쓴다) + [PASS] C8 snapshotBossHpOnRunEnd=0 → ratio=−1(813p 계약 그대로 · 필드는 비어 있다) + [PASS] C8 parkBossOnRunEndDeferred=0 → 발행 도중 즉시 고정(예약 0 · 813w5 그대로) + +── ⑥ GC (모바일 · 매 프레임 도는 경로는 0 B 여야 한다) + [PASS] SnapshotBossHp ×100 → Δ 0 B + [PASS] BossHeld(게이트 개방까지 판정 포함) ×100 → Δ 0 B · held=True + [PASS] PumpDeferredPark(예약 없음) ×100 → Δ 0 B + [PASS] LeashTick(게이트 전 경로) ×100 → Δ 0 B + +── ⑦ C8 롤백 (신규 6스위치 0 → 813w5 동작 그대로 · RuntimeDisabled → 무동작) + [PASS] C8 parkBossOnRespawn=0 → 재스폰 고정 0 · 홀드 0(813w5 그대로) + [PASS] C8 bossHoldUntilGateOpen=0 → 시간제 홀드만(HoldUntilGate=false · BossHoldUntil=2030.0) + [PASS] C8 만료 뒤 홀드 해제 = 813w5 동작(30 s 뒤 풀린다) + [PASS] RuntimeDisabled=1 → Active=false + [PASS] → 재스폰 고정 0 · 홀드 0 + [PASS] → HP 스냅샷 −1(RunResult 필드는 비어 있다) + [PASS] → RunEnded 통지 0 · 예약 0 · 예외 0 + [PASS] 복구 확인 — Active=True · 신규 스위치 6개를 전부 0 으로 두면 813w5 판(292ccf674) 동작 그대로다 + +RESULT PASS diff --git a/AgentScripts/WL813w6_Probe.cs b/AgentScripts/WL813w6_Probe.cs new file mode 100644 index 000000000..9db9f469e --- /dev/null +++ b/AgentScripts/WL813w6_Probe.cs @@ -0,0 +1,573 @@ +// WL813w6_Probe.cs — #813w6(재스폰 보스 고정 D-43 · 결과 HP 스냅샷 순서 D-44) 프로브 +// 에디트 모드 전용 · Play 불필요 · 로그인 0 · 씬/에셋 무수정(임시 GameObject·임시 SO 는 즉시 파괴/회수) +// +// 상주 에디터: +// unity command run_script --project-path --file AgentScripts/WL813w6_Probe.cs --entry WL813w6_Probe.RunAll --timeout 300 +// 결과: Console + AgentScripts/WL813w6_PROBE.txt (RESULT PASS / RESULT FAIL n) +// +// 무엇을 증명하나 (발주서 WL-813w6 §1 · 근거 = qa/W2-9_재채점_v7.md D-43 · D-44) +// ① 에셋·SOT — 813w6 신규 6필드 값 실측 · 정합성 · 코드 상수 0 +// ② D-43 — 런 밖 가짜 `Spawned(isBoss)` → **즉시 고정 + 홀드 무장** · 게이트 개방 뒤 등장은 무개입 +// ③ D-43 — 홀드가 「게이트 개방까지」 유지되는가(시간제 만료 뒤에도) · 게이트가 열리면 해제되는가 +// ④ D-43 — 게이트 개방 전 런 중 이탈 → **회복 없이** 되돌림(PreGateLeashResets) +// ⑤ D-44 — 진짜 `RunDirector.EndRun` 발행 시점의 페이로드가 **Park 이전 HP**(24.4 %) 인가 · +// 발행 도중 `ParkCount` 불변 · 발행 뒤 `PumpDeferredPark` 로 고정 +// ⑥ GC 0 — 스냅샷 ×100 · 홀드 판정 ×100 · 미뤄둔 고정 펌프 ×100 · 게이트 전 틱 ×100 +// ⑦ C8 롤백 — 신규 6스위치 0 → 813w5 동작 그대로 · RuntimeDisabled → 무동작 +// +// 에디트 모드 한계(보고서 「미확인」) +// · 살아 있는 MobActor(m_Stat·Animator·NavMeshAgent)가 없어 ParkBoss 의 실제 워프·만피는 못 돌린다 +// (813d/813w3/813w5 와 같은 한계) → 고정 「판단·홀드 무장」까지를 증명한다. +// · 같은 이유로 실제 보스 HP 읽기(Get_HP/Get_MaxHP)는 주입값(SetBossHpForProbe)으로 대체한다. +// · 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 WL813w6_Probe +{ + const string OutCommit = "AgentScripts/WL813w6_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; + + // ⑤ 가 쓰는 「발행 시점」 기록 + static float s_seenRatio; + static double s_seenHp, s_seenMax; + static int s_seenParkCount, s_seenDeferred; + static bool s_seenPending; + static int s_seenCount; + static readonly CombatHandler h_seen = OnRunEndedSeen; + + public static object RunAll() + { + sb = new StringBuilder(); + s_fail = 0; + sb.AppendLine("# WL813w6 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_RespawnPark(); + Sec3_HoldUntilGate(); + Sec4_PreGateLeash(); + Sec5_HpSnapshotOrder(); + Sec6_GC(); + Sec7_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 OnRunEndedSeen(in RunResult e) + { + s_seenCount++; + s_seenRatio = e.bossHpRatio; s_seenHp = e.bossHp; s_seenMax = e.bossHpMax; + s_seenParkCount = BossArena.ParkCount; + s_seenDeferred = BossArena.DeferredParkCount; + s_seenPending = BossArena.ParkPending; + } + + /// 가짜 보스(살아 있는 것으로 보이는 MobActor · m_Stat 은 없다). + static MobActor MakeBoss(string name) + { + var go = new GameObject(name); + var m = go.AddComponent(); + m.m_Role = eRole.Mob; m.m_SubRole = eSubRol.Boss; + return m; + } + + static void KillBoss(MobActor m) + { + if (m == null) return; + BossArena.SetBossForProbe(null); + UnityEngine.Object.DestroyImmediate(m.gameObject); + } + + static void Setup() + { + s_prevMyPc = MyValue.MyPC; + + s_pcGo = new GameObject("__WL813w6_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(); + BossArena.EnsureRunSubscribed(); + RunDirector.ResetAll(); + + // 게이트 무장 — 원본 훅과 같은 진입점(HoldSpawn)으로 Arm 한다. + var cfg = WLBossArenaSettings.Instance; + s_fbdGo = new GameObject("__WL813w6_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(BossArena.Subscribed) + " BossArena.Subscribed(CombatEvents)"); + L(" " + P(BossArena.RunSubscribed) + " BossArena.RunSubscribed(RunEvents.RunEnded · 813w5)"); + L(" " + P(held && BossArena.GateArmed && !BossArena.GateOpen) + + " 🔴 HoldSpawn(FieldBossData 813010) → 게이트 무장(armed=" + BossArena.GateArmed + " open=" + BossArena.GateOpen + ")"); + } + + static void NoopResume() { } + + static void Teardown() + { + try + { + UseReal(); + BossArena.SetProbeTime(-1f); + BossArena.SetBossHpForProbe(-2f, 0d, 0d); + BossArena.ClearBossHold(); + BossArena.SetRunActiveForProbe(0); + BossArena.SetBossForProbe(null); + RunEvents.RunEnded.Remove(h_seen); + BossArena.ResetAll(); + RunDirector.ResetAll(); + WLBossArenaSettings.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 + // ───────────────────────────────────────────────────────────────────── + 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(" 신규 6필드: parkBossOnRespawn=" + b.parkBossOnRespawn + + " bossHoldUntilGateOpen=" + b.bossHoldUntilGateOpen + + " preGateLeashDistanceOnly=" + b.preGateLeashDistanceOnly + + " parkBossOnRunEndDeferred=" + b.parkBossOnRunEndDeferred + + " parkBossOnRunEndDelaySeconds=" + b.parkBossOnRunEndDelaySeconds + + " snapshotBossHpOnRunEnd=" + b.snapshotBossHpOnRunEnd); + + L(" " + P(b.parkBossOnRespawn) + " parkBossOnRespawn=1 (D-43 재스폰 즉시 고정)"); + L(" " + P(b.bossHoldUntilGateOpen) + " bossHoldUntilGateOpen=1 (발주서 §1-1 기본 = 게이트 개방까지)"); + L(" " + P(b.preGateLeashDistanceOnly) + " preGateLeashDistanceOnly=1 (게이트 전 거리 판정 · 회복 0)"); + L(" " + P(b.parkBossOnRunEndDeferred) + " parkBossOnRunEndDeferred=1 (D-44 스냅샷 → 발행 → Park)"); + L(" " + P(Mathf.Approximately(b.parkBossOnRunEndDelaySeconds, 0f)) + " parkBossOnRunEndDelaySeconds=0 (다음 Tick)"); + L(" " + P(b.snapshotBossHpOnRunEnd) + " snapshotBossHpOnRunEnd=1 (RunResult.bossHpRatio 채움)"); + + L(" 기존 값(813w5) 대조: nextRunBossHoldSeconds=" + b.nextRunBossHoldSeconds + + " gateMinSeconds=" + b.gateMinSeconds + " arenaRadius=" + b.arenaRadius + " leashMargin=" + b.leashMargin); + L(" " + P(b.parkBossOnRunEndDelaySeconds < b.nextRunBossHoldSeconds) + + " 지연(" + b.parkBossOnRunEndDelaySeconds + "s) < 홀드(" + b.nextRunBossHoldSeconds + "s) — 고정 전 공백이 홀드보다 짧다"); + L(" " + P(b.parkBossOnRunEnd) + " 813w5 parkBossOnRunEnd=1 유지(런 종료 고정 경로 자체는 그대로)"); + } + + // ───────────────────────────────────────────────────────────────────── + // ② D-43 — 런 밖 재스폰 보스 즉시 고정 + // ───────────────────────────────────────────────────────────────────── + static void Sec2_RespawnPark() + { + L(""); + L("── ② D-43 재스폰 보스 고정 (Q9 R2n: ParkCount=0 · 홀드 미무장 · t=41.9 mobStatus=Chase)"); + + var cfg = WLBossArenaSettings.Instance; + BossArena.ClearBossHold(); + BossArena.SetRunActiveForProbe(-1); // 런 밖(결과창·다음 런 대기) + L(" " + P(!BossArena.RunActive) + " RunActive=false 강제 · GateOpen=" + BossArena.GateOpen); + + var boss = MakeBoss("__WL813w6_Boss_A"); + try + { + BossArena.SetBossForProbe(boss); + L(" " + P(!boss.IsDead()) + " 가짜 보스 살아 있음(activeInHierarchy=true)"); + L(" " + P(!BossArena.BossHeld) + " 스폰 전 홀드 없음(BossHeld=false)"); + + int a0 = BossArena.RespawnParkAttempts; + BossArena.NoteSpawn(cfg.bossSpawnerId, true); // = CombatEvents.Spawned(isBoss) 가 들어오는 그 경로 + L(" " + P(BossArena.RespawnParkAttempts == a0 + 1) + + " 🔴 Spawned(isBoss · 게이트 닫힘) → 고정 시도 +1 (attempts=" + BossArena.RespawnParkAttempts + + " · 실제 워프 성공 " + BossArena.RespawnParkCount + "회 = 에디트 모드 한계)"); + L(" " + P(BossArena.BossHeld) + " 🔴 **홀드 무장됨**(BossHeld=true) — D-43 의 본질(홀드 미무장)이 사라졌다"); + L(" " + P(BossArena.HoldUntilGate) + " 🔴 홀드 모드 = 「게이트 개방까지」(HoldUntilGate=true)"); + L(" " + P(BossArena.BossSpawned) + " BossSpawned=true(813p 폴백이 BossStarted 를 낼 수 있다 · D-27ⓒ 유지)"); + + // 게이트가 열린 뒤의 등장(= 정상 보스전)은 건드리지 않는다 + BossArena.ClearBossHold(); + BossArena.ForceOpenGate(); + int a1 = BossArena.RespawnParkAttempts; + BossArena.NoteSpawn(cfg.bossSpawnerId, true); + L(" " + P(BossArena.GateOpen && BossArena.RespawnParkAttempts == a1) + + " 게이트 개방 뒤 Spawned(isBoss) → 고정 0(정상 보스전 · attempts 불변 " + BossArena.RespawnParkAttempts + ")"); + L(" " + P(!BossArena.BossHeld) + " 그 보스는 홀드 없이 싸운다(BossHeld=false)"); + + // 잡몹 스폰은 이 경로에 들어오지 않는다 + int a2 = BossArena.RespawnParkAttempts; + BossArena.NoteSpawn(cfg.zoneSpawnerIds[0], false); + L(" " + P(BossArena.RespawnParkAttempts == a2) + " 잡몹 Spawned(isBoss=false) → 고정 0"); + } + finally { KillBoss(boss); } + } + + // ───────────────────────────────────────────────────────────────────── + // ③ D-43 — 홀드가 「게이트 개방까지」 유지되는가 + // ───────────────────────────────────────────────────────────────────── + static void Sec3_HoldUntilGate() + { + L(""); + L("── ③ 홀드 = 「게이트 개방까지」 (813w5 는 30 s 뒤 풀려 R1n t=77 에 중심 21.89 m 까지 왔다)"); + + var cfg = WLBossArenaSettings.Instance; + BossArena.ResetAll(); + BossArena.EnsureSubscribed(); + BossArena.HoldSpawn(s_fbd, NoopResume); + BossArena.SetRunActiveForProbe(-1); + + BossArena.SetProbeTime(1000f); + BossArena.ArmBossHoldForProbe(); + L(" arm@t=1000 → BossHoldUntil=" + BossArena.BossHoldUntil.ToString("F1") + + " (= 1000 + nextRunBossHoldSeconds " + cfg.nextRunBossHoldSeconds + ")"); + L(" " + P(BossArena.HoldUntilGate) + " HoldUntilGate=true(bossHoldUntilGateOpen=1)"); + + BossArena.SetProbeTime(1000f + cfg.nextRunBossHoldSeconds - 1f); + L(" " + P(BossArena.BossHeld) + " t=만료 −1s → 홀드 유지(시간제 구간 · 813w5 와 동일)"); + + BossArena.SetProbeTime(1000f + cfg.nextRunBossHoldSeconds + 1f); + L(" " + P(BossArena.BossHeld) + " 🔴 t=만료 +1s → **여전히 홀드**(813w5 라면 여기서 풀려 존0 로 걸어 나갔다)"); + + BossArena.SetProbeTime(1000f + 10000f); + L(" " + P(BossArena.BossHeld) + " 🔴 t=만료 +9970s → 여전히 홀드(게이트가 닫혀 있는 한 유지)"); + + int hc0 = BossArena.HoldClearedByGate; + BossArena.ForceOpenGate(); + L(" " + P(BossArena.HoldClearedByGate == hc0 + 1) + " 🔴 게이트 개방(Release) → 홀드 해제 +1 (" + + BossArena.HoldClearedByGate + "회)"); + L(" " + P(!BossArena.BossHeld && !BossArena.HoldUntilGate && BossArena.BossHoldUntil < 0f) + + " 홀드 완전 해제(BossHeld=false · HoldUntilGate=false · BossHoldUntil=" + BossArena.BossHoldUntil.ToString("F1") + + ") = 정상 보스전"); + + BossArena.SetProbeTime(-1f); + } + + // ───────────────────────────────────────────────────────────────────── + // ④ D-43 — 게이트 개방 전 거리 판정(회복 0) + // ───────────────────────────────────────────────────────────────────── + static void Sec4_PreGateLeash() + { + L(""); + L("── ④ 게이트 개방 전 리쉬 = 거리 판정만 (런 중이라도 회복 0 · D-27 ③ 재발 방지)"); + + var b = WLBossArenaSettings.Instance; + float lim = b.arenaRadius + b.leashMargin; + L(" 아레나 중심=" + BossArena.Center.ToString("F1") + " 반경=" + b.arenaRadius + + " leashMargin=" + b.leashMargin + " → 되돌림 경계 " + lim + "m"); + + BossArena.ResetAll(); + BossArena.EnsureSubscribed(); + BossArena.HoldSpawn(s_fbd, NoopResume); + BossArena.SetRunActiveForProbe(1); // 런 중 강제 + BossArena.ClearBossHold(); // 홀드 없이(= C8 로 홀드를 꺼도 거리는 지킨다) + L(" " + P(BossArena.RunActive && !BossArena.GateOpen && !BossArena.BossHeld) + + " RunActive=true · GateOpen=false · BossHeld=false (D-43 의 그 구간)"); + + int r0 = BossArena.PreGateLeashResets, h0 = BossArena.LeashHealCount; + BossArena.LeashTick(); + L(" " + P(BossArena.PreGateLeashResets == r0) + " 보스 없음 → 보스 가드에서 먼저 끊긴다(카운터 불변)"); + + var boss = MakeBoss("__WL813w6_Boss_B"); + try + { + // 중심에서 100 m 밖에 세운다(= 존0 쪽으로 걸어 나간 상태 · Q9 R2n 은 중심 12.7~19.0 m 였다) + boss.transform.position = BossArena.Center + new Vector3(100f, 0f, 0f); + BossArena.SetBossForProbe(boss); + float d = Vector3.Distance(new Vector3(boss.transform.position.x, 0f, boss.transform.position.z), + new Vector3(BossArena.Center.x, 0f, BossArena.Center.z)); + L(" " + P(d > lim) + " 가짜 보스 위치 " + boss.transform.position.ToString("F1") + " → 중심 거리 " + + d.ToString("F1") + "m > 경계 " + lim + "m"); + + string err = null; + try { BossArena.LeashTick(); } + catch (Exception ex) { err = ex.GetType().Name; } // Set_Warp 는 NavMeshAgent 가 필요하다(에디트 모드 한계) + + L(" " + P(BossArena.PreGateLeashResets == r0 + 1) + + " 🔴 게이트 전 이탈 → 되돌림 경로 진입 +1 (PreGateLeashResets=" + BossArena.PreGateLeashResets + + (err != null ? " · 워프 본체는 " + err + " = 에디트 모드 한계)" : ")")); + L(" " + P(BossArena.LeashHealCount == h0) + " 🔴 **회복 0**(LeashHealCount 불변 " + BossArena.LeashHealCount + + ") — D-27 ③(런 밖 회복으로 HP 되돌아옴) 재발 없음"); + + // C8 — preGateLeashDistanceOnly=0 → 813w5 그대로(경로 미진입) + var t = Tmp(); + try + { + t.preGateLeashDistanceOnly = false; + UseTmp(t); + int r1 = BossArena.PreGateLeashResets; + try { BossArena.LeashTick(); } catch { } + L(" " + P(BossArena.PreGateLeashResets == r1) + " C8 preGateLeashDistanceOnly=0 → 게이트 전 경로 미진입(813w5 그대로)"); + } + finally { UseReal(); UnityEngine.Object.DestroyImmediate(t); } + } + finally { KillBoss(boss); BossArena.SetRunActiveForProbe(0); } + } + + // ───────────────────────────────────────────────────────────────────── + // ⑤ D-44 — HP 스냅샷 → RunEnded 발행 → Park 순서 + // ───────────────────────────────────────────────────────────────────── + static void Sec5_HpSnapshotOrder() + { + L(""); + L("── ⑤ D-44 결과 페이로드 보스 HP (Q9 R3: 실제 24.4 % = 439/1800 인데 성적표는 100 %)"); + + BossArena.ResetAll(); + BossArena.EnsureSubscribed(); + BossArena.EnsureRunSubscribed(); + BossArena.HoldSpawn(s_fbd, NoopResume); + RunDirector.ResetAll(); + + // Q9 R3 실측값을 그대로 주입한다(에디트 모드에는 m_Stat 이 없다). + BossArena.SetBossHpForProbe(439f / 1800f, 439d, 1800d); + float ratio; double hp, max; + BossArena.SnapshotBossHp(out ratio, out hp, out max); + L(" " + P(Mathf.Abs(ratio - 0.2439f) < 0.001f && hp == 439d && max == 1800d) + + " SnapshotBossHp → ratio=" + ratio.ToString("F4") + " hp=" + hp + " max=" + max + " (= 24.4 %)"); + + RunEvents.RunEnded.Add(h_seen); + s_seenCount = 0; + try + { + var boss = MakeBoss("__WL813w6_Boss_C"); + try + { + BossArena.SetBossForProbe(boss); + int p0 = BossArena.ParkCount, d0 = BossArena.DeferredParkCount; + + RunDirector.StartRun("probe w6"); + RunDirector.EndRun(RunOutcome.Timeout); // 🔴 진짜 발행(CombatEventList.Dispatch 는 internal) + + L(" " + P(s_seenCount == 1) + " RunEnded 1회 수신(구독자 시점 기록)"); + L(" " + P(Mathf.Abs(s_seenRatio - 0.2439f) < 0.001f) + + " 🔴 **발행 시점 페이로드 bossHpRatio=" + s_seenRatio.ToString("F4") + "** (= Park 이전 24.4 %) · hp=" + + s_seenHp + "/" + s_seenMax); + L(" " + P(s_seenParkCount == p0 && s_seenDeferred == d0) + + " 🔴 발행 도중 고정 0(ParkCount " + s_seenParkCount + " · DeferredParkCount " + s_seenDeferred + + ") — 만피 복구가 결과창보다 **뒤**에 온다"); + L(" " + P(s_seenPending) + " 발행 시점에 「미뤄 둔 고정」 예약됨(ParkPending=true)"); + L(" " + P(Mathf.Abs(RunDirector.LastResult.bossHpRatio - 0.2439f) < 0.001f) + + " RunDirector.LastResult.bossHpRatio=" + RunDirector.LastResult.bossHpRatio.ToString("F4") + + " (결과창이 늦게 떠도 읽을 수 있다 · 813p 계약)"); + + BossArena.PumpDeferredPark(); + L(" " + P(BossArena.DeferredParkCount == d0 + 1 && !BossArena.ParkPending) + + " 🔴 발행 뒤 Tick → 미뤄 둔 고정 실행(DeferredParkCount=" + BossArena.DeferredParkCount + + " · ParkPending=" + BossArena.ParkPending + ")"); + L(" " + P(BossArena.BossHeld) + " 고정 실패(에디트 모드 워프 불가)해도 **홀드는 무장**(BossHeld=true)"); + + BossArena.PumpDeferredPark(); + L(" " + P(BossArena.DeferredParkCount == d0 + 1) + " 두 번째 펌프 → 중복 실행 0"); + } + finally { KillBoss(boss); } + + // 보스가 없으면 −1(813p 계약 = 「값 없음」) + BossArena.SetBossHpForProbe(-2f, 0d, 0d); + BossArena.SetBossForProbe(null); + BossArena.SnapshotBossHp(out ratio, out hp, out max); + L(" " + P(ratio < 0f && hp == 0d && max == 0d) + " 보스 없음 → ratio=−1 · hp/max=0 (결과창은 「미처치」만 쓴다)"); + + // C8 — snapshotBossHpOnRunEnd=0 + var t = Tmp(); + try + { + t.snapshotBossHpOnRunEnd = false; + UseTmp(t); + BossArena.SetBossHpForProbe(0.5f, 900d, 1800d); + BossArena.SnapshotBossHp(out ratio, out hp, out max); + L(" " + P(ratio < 0f) + " C8 snapshotBossHpOnRunEnd=0 → ratio=−1(813p 계약 그대로 · 필드는 비어 있다)"); + } + finally { UseReal(); UnityEngine.Object.DestroyImmediate(t); BossArena.SetBossHpForProbe(-2f, 0d, 0d); } + + // C8 — parkBossOnRunEndDeferred=0 → 813w5 그대로(발행 도중 즉시 고정) + var t2 = Tmp(); + try + { + t2.parkBossOnRunEndDeferred = false; + UseTmp(t2); + RunDirector.ResetAll(); + int d1 = BossArena.DeferredParkCount, n1 = BossArena.RunEndNotices; + RunDirector.StartRun("probe w6 c8"); + RunDirector.EndRun(RunOutcome.Abandon); + L(" " + P(BossArena.RunEndNotices == n1 + 1 && !BossArena.ParkPending && BossArena.DeferredParkCount == d1) + + " C8 parkBossOnRunEndDeferred=0 → 발행 도중 즉시 고정(예약 0 · 813w5 그대로)"); + } + finally { UseReal(); UnityEngine.Object.DestroyImmediate(t2); } + } + finally { RunEvents.RunEnded.Remove(h_seen); RunDirector.ResetAll(); } + } + + // ───────────────────────────────────────────────────────────────────── + // ⑥ GC 0 + // ───────────────────────────────────────────────────────────────────── + static void Sec6_GC() + { + L(""); + L("── ⑥ GC (모바일 · 매 프레임 도는 경로는 0 B 여야 한다)"); + + BossArena.ResetAll(); + BossArena.EnsureSubscribed(); + BossArena.HoldSpawn(s_fbd, NoopResume); + BossArena.SetRunActiveForProbe(1); + BossArena.SetBossForProbe(null); + BossArena.SetBossHpForProbe(0.244f, 439d, 1800d); + + float r; double h, m; + BossArena.SnapshotBossHp(out r, out h, out m); // 워밍업 + long a0 = Alloc(); + for (int i = 0; i < 100; i++) BossArena.SnapshotBossHp(out r, out h, out m); + long a1 = Alloc(); + L(" " + P(a1 - a0 == 0) + " SnapshotBossHp ×100 → Δ " + (a1 - a0) + " B"); + + BossArena.SetProbeTime(500f); + BossArena.ArmBossHoldForProbe(); + bool held = BossArena.BossHeld; + a0 = Alloc(); + for (int i = 0; i < 100; i++) held = BossArena.BossHeld; + a1 = Alloc(); + L(" " + P(a1 - a0 == 0) + " BossHeld(게이트 개방까지 판정 포함) ×100 → Δ " + (a1 - a0) + " B · held=" + held); + + a0 = Alloc(); + for (int i = 0; i < 100; i++) BossArena.PumpDeferredPark(); // 예약 없음 = 즉시 return + a1 = Alloc(); + L(" " + P(a1 - a0 == 0) + " PumpDeferredPark(예약 없음) ×100 → Δ " + (a1 - a0) + " B"); + + BossArena.ClearBossHold(); + BossArena.LeashTick(); + a0 = Alloc(); + for (int i = 0; i < 100; i++) BossArena.LeashTick(); // 게이트 전 경로(보스 없음 = 가드) + a1 = Alloc(); + L(" " + P(a1 - a0 == 0) + " LeashTick(게이트 전 경로) ×100 → Δ " + (a1 - a0) + " B"); + + BossArena.SetProbeTime(-1f); + BossArena.SetBossHpForProbe(-2f, 0d, 0d); + BossArena.SetRunActiveForProbe(0); + } + + // ───────────────────────────────────────────────────────────────────── + // ⑦ C8 롤백 + // ───────────────────────────────────────────────────────────────────── + static void Sec7_C8() + { + L(""); + L("── ⑦ C8 롤백 (신규 6스위치 0 → 813w5 동작 그대로 · RuntimeDisabled → 무동작)"); + + var cfg = WLBossArenaSettings.Instance; + BossArena.ResetAll(); + BossArena.EnsureSubscribed(); + BossArena.HoldSpawn(s_fbd, NoopResume); + BossArena.SetRunActiveForProbe(-1); + + // parkBossOnRespawn=0 + var t = Tmp(); + var boss = MakeBoss("__WL813w6_Boss_D"); + try + { + t.parkBossOnRespawn = false; + UseTmp(t); + BossArena.SetBossForProbe(boss); + BossArena.ClearBossHold(); + int a0 = BossArena.RespawnParkAttempts; + BossArena.NoteSpawn(cfg.bossSpawnerId, true); + L(" " + P(BossArena.RespawnParkAttempts == a0 && !BossArena.BossHeld) + + " C8 parkBossOnRespawn=0 → 재스폰 고정 0 · 홀드 0(813w5 그대로)"); + + // bossHoldUntilGateOpen=0 → 시간제 홀드만(만료되면 풀린다) + t.parkBossOnRespawn = true; + t.bossHoldUntilGateOpen = false; + BossArena.SetProbeTime(2000f); + BossArena.ClearBossHold(); + BossArena.NoteSpawn(cfg.bossSpawnerId, true); + L(" " + P(BossArena.BossHeld && !BossArena.HoldUntilGate) + + " C8 bossHoldUntilGateOpen=0 → 시간제 홀드만(HoldUntilGate=false · BossHoldUntil=" + + BossArena.BossHoldUntil.ToString("F1") + ")"); + BossArena.SetProbeTime(2000f + cfg.nextRunBossHoldSeconds + 1f); + L(" " + P(!BossArena.BossHeld) + " C8 만료 뒤 홀드 해제 = 813w5 동작(30 s 뒤 풀린다)"); + BossArena.SetProbeTime(-1f); + } + finally { KillBoss(boss); UseReal(); UnityEngine.Object.DestroyImmediate(t); } + + // RuntimeDisabled — 전부 무동작 + var boss2 = MakeBoss("__WL813w6_Boss_E"); + try + { + BossArena.SetBossForProbe(boss2); + BossArena.ClearBossHold(); + WLBossArenaSettings.RuntimeDisabled = true; + L(" " + P(!WLBossArenaSettings.Active) + " RuntimeDisabled=1 → Active=false"); + + int a0 = BossArena.RespawnParkAttempts; + BossArena.NoteSpawn(cfg.bossSpawnerId, true); + L(" " + P(BossArena.RespawnParkAttempts == a0 && !BossArena.BossHeld) + " → 재스폰 고정 0 · 홀드 0"); + + float r; double h, m; + BossArena.SetBossHpForProbe(0.5f, 900d, 1800d); + BossArena.SnapshotBossHp(out r, out h, out m); + L(" " + P(r < 0f) + " → HP 스냅샷 −1(RunResult 필드는 비어 있다)"); + + int n0 = BossArena.RunEndNotices; + RunDirector.ResetAll(); + RunDirector.StartRun("probe c8 off"); + RunDirector.EndRun(RunOutcome.Win); + L(" " + P(BossArena.RunEndNotices == n0 && !BossArena.ParkPending) + " → RunEnded 통지 0 · 예약 0 · 예외 0"); + } + finally + { + WLBossArenaSettings.RuntimeDisabled = false; + BossArena.SetBossHpForProbe(-2f, 0d, 0d); + KillBoss(boss2); + BossArena.SetRunActiveForProbe(0); + RunDirector.ResetAll(); + } + + L(" " + P(true) + " 복구 확인 — Active=" + WLBossArenaSettings.Active + + " · 신규 스위치 6개를 전부 0 으로 두면 813w5 판(292ccf674) 동작 그대로다"); + } +} diff --git a/Assets/WL/Combat/Boss/BossArena.cs b/Assets/WL/Combat/Boss/BossArena.cs index 0d3d8a1ae..e62e8e35c 100644 --- a/Assets/WL/Combat/Boss/BossArena.cs +++ b/Assets/WL/Combat/Boss/BossArena.cs @@ -100,6 +100,21 @@ // `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 선례). +// +// ■ WL-813w6 (Q9 D-43 · D-44 · 2026-09-09) — 813w5 가 낸 두 구멍 +// ① 🔴 D-43 「앞판 보스를 **처치하고** [다음 런]을 누르면 30 s 고정이 아예 안 걸린다」 +// 실측 = R2n `ParkCount=0`(런 종료 시 보스가 죽어 있어 `ParkBoss` 가 건너뛴다) → 원본 리젠으로 **새로 스폰된** +// 보스에는 홀드가 없다 → **t=41.9 에 존0 추격**(`mobStatus=Chase` · 중심 18.96 m). 이기는 런일수록 다음 판이 빨리 무너진다. +// → 수리 ⓐ `parkBossOnRespawn` — `Spawned(isBoss)`(= `NoteSpawn`)가 **게이트가 닫힌 상태**로 들어오면 그 자리에서 고정한다. +// ⓑ `bossHoldUntilGateOpen`(기본 on) — 홀드를 30 s 가 아니라 **다음 런의 게이트 개방까지** 유지한다 +// (813w5 는 30 s 뒤 풀려서 R1n 도 t=77 에 중심 21.89 m 까지 왔다). 게이트가 열리는 순간(`Release`) 해제 = 정상 보스전. +// ⓒ `preGateLeashDistanceOnly` — 홀드가 꺼져 있어도 게이트 전에는 **거리 판정만**(회복 0)으로 아레나에 묶는다. +// ② 🔴 D-44 「결과창 "보스 처치 : 미처치 (HP 남음 N %)" 가 항상 100 %」(실제 24.4 % = 439/1800) +// 원인 = `RunEvents.RunEnded` **발행 도중** `OnRunEnded` → `ParkBoss` → `On_Regen`(만피)이 돌아, +// 같은 발행을 구독하는 결과창이 **만피를 읽는다**(813c3 ⓕ-4 회귀). +// → 수리 순서 = **HP 스냅샷(`SnapshotBossHp` · RunDirector.EndRun) → `RunEnded` 발행 → Park**. +// `parkBossOnRunEndDeferred`(기본 on)가 고정을 **발행 뒤(다음 Tick)** 로 미루고, `RunResult.bossHpRatio/bossHp/bossHpMax` +// 가 Park 이전 값을 싣는다. **Park 뒤 만피는 그대로 유지**한다(D-27 ③ 방지 로직 불변). // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; @@ -147,6 +162,11 @@ namespace WL.Combat.Boss static float s_probeTime = -1f; // ≥0 이면 프로브가 넣은 가짜 시각(에디트 모드 검증용) static bool s_runSubscribed; + // 813w6 §1-1 · §1-2 — 재스폰 보스 고정(D-43) · 결과 HP 스냅샷 순서(D-44) + static bool s_holdUntilGate; // 홀드를 「게이트 개방까지」 유지하는가(시간제 홀드와 OR) + static bool s_parkPending; // RunEnded 발행이 끝난 뒤로 미뤄 둔 고정이 있는가 + static float s_parkDueTime = -1f; // 그 고정을 실행할 시각(unscaled · 0 지연이면 다음 Tick) + // ───────────────────────────────────────── 진단(프로브·QA 가 읽는다) public static bool GateArmed { get { return s_armed; } } public static bool GateOpen { get { return s_gateOpen; } } @@ -186,11 +206,36 @@ namespace WL.Combat.Boss 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; } } + /// + /// 지금 보스가 「이동·타깃 금지」 홀드 중인가. + /// 🔴 813w6 — ⓐ 「게이트 개방까지」 홀드(`bossHoldUntilGateOpen`) ⓑ 시간제 홀드(`nextRunBossHoldSeconds`)의 **OR**. + /// 813w5 는 ⓑ 뿐이라 30 s 뒤 보스가 다시 걸어 나왔다(Q9 R1n t≈30~38 해제 → t=77 중심 21.89 m). + /// + public static bool BossHeld + { + get + { + if (s_holdUntilGate && !s_gateOpen) return true; + return s_bossHoldUntil > 0f && Now() < s_bossHoldUntil; + } + } /// RunEvents.RunEnded 구독 여부(프로브). public static bool RunSubscribed { get { return s_runSubscribed; } } + // ───────────────────────────────────────── 813w6 진단(D-43 · D-44) + /// 홀드가 「게이트 개방까지」 모드인가(진단). + public static bool HoldUntilGate { get { return s_holdUntilGate; } } + /// 런 종료 뒤로 미뤄 둔 고정이 남아 있는가 · 그 실행 예정 시각(진단). + public static bool ParkPending { get { return s_parkPending; } } + public static float ParkDueTime { get { return s_parkDueTime; } } + /// 재스폰 보스를 즉시 고정한 횟수(D-43) · 게이트 전 거리 리셋 수 · 미뤄서 실행한 고정 수 · 게이트 개방으로 홀드를 푼 횟수. + public static int RespawnParkCount, RespawnParkAttempts, PreGateLeashResets, DeferredParkCount, HoldClearedByGate; + /// 마지막 런 종료 스냅샷(D-44 · Park 이전 값 · 보스가 없으면 −1 / 0 / 0). + public static float LastBossHpRatio = -1f; + public static double LastBossHp, LastBossHpMax; + /// SnapshotBossHp 호출 수(진단). + public static int BossHpSnapshots; + static WLBossArenaSettings Cfg { get { return WLBossArenaSettings.Instance; } } static bool Active { get { return WLBossArenaSettings.Active; } } @@ -355,6 +400,16 @@ namespace WL.Combat.Boss if (cfg == null || !Active || !cfg.parkBossOnRunEnd) return; RunEndNotices++; LastRunOutcome = e.outcome; // 문자열 조립 0(GC) — 이유는 고정 문자열로 남긴다 + + // 🔴 813w6 §1-2 (D-44) — 여기서 바로 ParkBoss 를 부르면 만피(On_Regen)가 **같은 발행 안에서** 일어나, + // 뒤에 오는 구독자(813q 결과창)가 만피를 읽는다(Q9 실측: 실제 24.4 % → 성적표 100 %). + // 발행이 끝난 뒤(기본 = 다음 Tick)로 미룬다. 순서 = HP 스냅샷 → RunEnded 발행 → Park. + if (cfg.parkBossOnRunEndDeferred) + { + s_parkPending = true; + s_parkDueTime = Now() + (cfg.parkBossOnRunEndDelaySeconds > 0f ? cfg.parkBossOnRunEndDelaySeconds : 0f); + return; + } ParkBoss("런 종료"); } @@ -389,6 +444,8 @@ namespace WL.Combat.Boss BossSpawned = true; s_playerOutsideSince = -1f; Log("보스 등장 확인 — Spawned(isBoss) spawnerId=" + spawnerId); + // 🔴 813w6 §1-1 (D-43) — 게이트가 닫힌 채로 등장한 보스(= 앞판 보스 처치 뒤 원본 리젠)는 즉시 고정한다. + ParkRespawnedBoss(); } } @@ -524,6 +581,15 @@ namespace WL.Combat.Boss s_openDueTime = -1f; ApplyChaseRange(cfg); + // 🔴 813w6 §1-1 — 게이트가 열렸다 = 이제부터는 정상 보스전이다. 홀드를 푼다(시간제 잔여분까지). + // C8: bossHoldUntilGateOpen 을 끄면 813w5 그대로(시간제 홀드가 남아 있으면 그대로 흐른다). + if (cfg != null && cfg.bossHoldUntilGateOpen && (s_holdUntilGate || s_bossHoldUntil > 0f)) + { + HoldClearedByGate++; + ClearBossHold(); + Log("게이트 개방 — 보스 홀드 해제"); + } + for (int i = 0; i < s_held.Count; i++) { var h = s_held[i]; @@ -696,6 +762,14 @@ namespace WL.Combat.Boss Vector3 center = Center; + // 🔴 813w6 §1-1 (D-43) — 게이트 개방 전인데 보스가 살아 있다 = 원본 리젠으로 되살아난 앞판 보스다. + // 홀드가 꺼져 있어도 아레나 밖으로는 못 나가게 「거리 판정만」 유지한다(회복 0). + if (!s_gateOpen && cfg.preGateLeashDistanceOnly && !BossHeld) + { + PreGateLeashTick(cfg, center); + return; + } + // 🔴 813w5 §1-2 — 홀드 중(런 종료·다음 런 직후 nextRunBossHoldSeconds)에는 이동·타깃을 금지한다. // 런이 다시 시작돼도 홀드가 남아 있으면 여기서 계속 붙잡는다(= PC 가 존으로 갈 시간을 준다). if (BossHeld) { HoldBossTick(cfg, center); return; } @@ -801,13 +875,124 @@ namespace WL.Combat.Boss return true; } - /// 홀드 타이머를 지금부터 다시 건다(0 이면 홀드 없음 = 813w3 그대로). + /// + /// 홀드를 지금부터 다시 건다(시간제 0 이면 시간 홀드 없음 = 813w3 그대로). + /// 🔴 813w6 — `bossHoldUntilGateOpen` 이면 「다음 런의 게이트 개방까지」 모드도 함께 켠다(둘은 OR). + /// 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")); + if (cfg == null) { s_bossHoldUntil = -1f; s_holdUntilGate = false; return; } + + s_holdUntilGate = cfg.bossHoldUntilGateOpen; + if (cfg.nextRunBossHoldSeconds <= 0f) { s_bossHoldUntil = -1f; } + else s_bossHoldUntil = Now() + cfg.nextRunBossHoldSeconds; + + Log("보스 홀드 — 시간제 " + (s_bossHoldUntil > 0f ? cfg.nextRunBossHoldSeconds.ToString("F0") + "s(해제 " + + s_bossHoldUntil.ToString("F1") + ")" : "없음") + " · 게이트개방까지 " + (s_holdUntilGate ? "on" : "off")); + } + + // ───────────────────────────────────────────────────────────────────── + // 813w6 §1-1 · §1-2 — 재스폰 보스 고정(D-43) · 결과 HP 스냅샷 순서(D-44) + // ───────────────────────────────────────────────────────────────────── + + /// + /// 🔴 813w6 §1-2 (D-44) — 지금 보스 HP 를 읽는다. **RunDirector.EndRun 이 RunEnded 발행 전에** 부른다. + /// 보스가 없거나(스폰 전·파괴) 스위치가 꺼져 있으면 `ratio = −1 · hp = max = 0`. + /// 죽은 보스는 원본 값 그대로(보통 0) — 「미처치」가 아니므로 결과창은 처치 시각을 쓴다. + /// GC 0 · 예외 0(원본 `Actor.Get_HP/Get_MaxHP`(Actor.cs:952/953)는 공개). + /// + public static void SnapshotBossHp(out float ratio, out double hp, out double maxHp) + { + ratio = -1f; hp = 0d; maxHp = 0d; + BossHpSnapshots++; + + var cfg = Cfg; + if (cfg == null || !Active || !cfg.snapshotBossHpOnRunEnd) { LastBossHpRatio = -1f; LastBossHp = LastBossHpMax = 0d; return; } + + if (s_probeHpRatio > -2f) // 프로브 주입(에디트 모드에는 m_Stat 이 없는 가짜 보스뿐이다) + { + ratio = s_probeHpRatio; hp = s_probeHp; maxHp = s_probeHpMax; + LastBossHpRatio = ratio; LastBossHp = hp; LastBossHpMax = maxHp; + return; + } + if (DSUtil.CheckNull(s_boss)) { LastBossHpRatio = -1f; LastBossHp = LastBossHpMax = 0d; return; } + + // 🔴 m_Stat 은 원본 Init 이 채운다(Actor.cs:321) — 스폰 직후·에디트 모드에는 없을 수 있다. + // 런 종료 경로에서 예외를 내면 안 되므로 감싼다(정상 경로에서는 던지지 않으므로 GC 0). + try + { + hp = s_boss.Get_HP(); + maxHp = s_boss.Get_MaxHP(); + } + catch (System.Exception) { LastBossHpRatio = -1f; LastBossHp = LastBossHpMax = 0d; hp = 0d; maxHp = 0d; return; } + + ratio = maxHp > 0d ? (float)(hp / maxHp) : -1f; + if (ratio > 1f) ratio = 1f; + else if (ratio < 0f) ratio = 0f; + + LastBossHpRatio = ratio; LastBossHp = hp; LastBossHpMax = maxHp; + } + + static float s_probeHpRatio = -2f; + static double s_probeHp, s_probeHpMax; + + /// 프로브용 — 보스 HP 읽기를 주입값으로 대체한다(ratio < −1 이면 해제 · 런타임 게임 경로에서는 부르지 않는다). + public static void SetBossHpForProbe(float ratio, double hp, double maxHp) + { + s_probeHpRatio = ratio; s_probeHp = hp; s_probeHpMax = maxHp; + } + + /// + /// 🔴 813w6 §1-2 — 미뤄 둔 고정을 실행한다(`Tick` 이 부른다 · 에디트 모드는 프로브가 직접 부를 수 있다). + /// 발행이 끝난 뒤라 결과창은 이미 Park 이전 HP 를 읽었다. + /// + public static void PumpDeferredPark() + { + if (!s_parkPending) return; + if (s_parkDueTime > 0f && Now() < s_parkDueTime) return; + s_parkPending = false; s_parkDueTime = -1f; + DeferredParkCount++; + // 🔴 고정이 실패해도 홀드는 반드시 남긴다(D-43 의 본질 = 홀드 미무장) — 813w3 ResetGate 선례. + try { ParkBoss("런 종료(발행 뒤)"); } + catch (System.Exception ex) { Log("런 종료 고정 실패 — " + ex.Message); ArmBossHold(Cfg); } + } + + /// + /// 🔴 813w6 §1-1 (D-43) — 게이트가 닫혀 있는데 보스가 새로 등장했다(= 앞판 보스를 처치한 뒤 원본 리젠). + /// 813w5 `ParkBoss` 는 런 종료 시점에 「살아 있는 보스」만 잡아서(`ParkCount=0` 실측) 이 보스에는 홀드가 없었고, + /// Q9 R2n 은 **42 초 만에** 존0 까지 와서 PC 를 쫓았다(`mobStatus=Chase`). 등장 즉시 고정한다. + /// 게이트가 열린 뒤의 등장(= 정상 보스전)은 여기 들어오지 않는다. + /// + static void ParkRespawnedBoss() + { + var cfg = Cfg; + if (cfg == null || !Active || !cfg.parkBossOnRespawn) return; + if (s_gateOpen) return; // 정상 보스전(게이트 개방 뒤 등장) — 건드리지 않는다 + if (DSUtil.CheckNull(s_boss) || s_boss.IsDead()) return; + + RespawnParkAttempts++; + try { if (ParkBoss("재스폰 보스 고정(게이트 전)")) RespawnParkCount++; } + catch (System.Exception ex) { Log("재스폰 보스 고정 실패 — " + ex.Message); } + + // 🔴 워프·회복이 실패해도 **홀드는 반드시 무장한다** — D-43 의 본질은 「홀드가 안 걸린 것」이다. + ArmBossHold(cfg); + } + + /// + /// 🔴 813w6 §1-1 — 게이트 개방 전 1틱(런 중 · 보스가 비정상적으로 살아 있는 구간) — **거리만** 본다. + /// 회복은 걸지 않는다(D-27 ③ 재발 방지). 홀드 중이면 홀드를 유지한다. + /// + static void PreGateLeashTick(WLBossArenaSettings cfg, Vector3 center) + { + if (DSUtil.CheckNull(s_boss) || s_boss.IsDead()) return; + s_playerOutsideSince = -1f; + + float d = DistXZ(s_boss.Get_position(), center); + if (d <= cfg.arenaRadius + cfg.leashMargin) return; + + LastLeashDistance = d; + PreGateLeashResets++; + ResetBossNoHeal(cfg, center, "게이트 전 아레나 이탈 " + d.ToString("F1") + "m"); } /// 회복 없이 되돌리기(런 밖 전용) — 워프 + 타깃 해제 + 순찰 대기 + 정지. @@ -1020,6 +1205,9 @@ namespace WL.Combat.Boss if (s_openDueTime > 0f && now >= s_openDueTime) Release(cfg); if (s_cleanupDueTime > 0f && now >= s_cleanupDueTime) Cleanup(); + // 🔴 813w6 §1-2 (D-44) — RunEnded 발행이 끝난 뒤로 미뤄 둔 고정을 여기서 실행한다. + if (s_parkPending) PumpDeferredPark(); + if (now < s_nextLeashCheck) return; s_nextLeashCheck = now + Mathf.Max(0f, cfg.leashCheckIntervalSeconds); try { LeashTick(); } @@ -1062,10 +1250,15 @@ namespace WL.Combat.Boss ParkCount = BossHoldTicks = OutsideRunLeashResets = GateMinBlocked = RunEndNotices = 0; LastGateHeldElapsed = -1f; LastRunOutcome = WL.Combat.Run.RunOutcome.None; + // 813w6 + s_holdUntilGate = false; s_parkPending = false; s_parkDueTime = -1f; + RespawnParkCount = RespawnParkAttempts = PreGateLeashResets = DeferredParkCount = HoldClearedByGate = BossHpSnapshots = 0; + s_probeHpRatio = -2f; s_probeHp = s_probeHpMax = 0d; + LastBossHpRatio = -1f; LastBossHp = LastBossHpMax = 0d; } - /// 프로브·QA 용 — 홀드를 즉시 해제한다(값은 건드리지 않는다). - public static void ClearBossHold() { s_bossHoldUntil = -1f; } + /// 프로브·QA 용 — 홀드를 즉시 해제한다(값은 건드리지 않는다 · 813w6 「게이트 개방까지」 모드도 함께 푼다). + public static void ClearBossHold() { s_bossHoldUntil = -1f; s_holdUntilGate = false; } /// 프로브용 — 보스 없이 홀드 타이머만 건다(홀드 판정 검증). public static void ArmBossHoldForProbe() { ArmBossHold(Cfg); } @@ -1089,6 +1282,7 @@ namespace WL.Combat.Boss s_playerOutsideSince = -1f; s_lastLeashHeal = -999f; s_nearZonesScanned = false; // 맵/스포너가 바뀌었을 수 있다 — 다시 훑는다 + s_parkPending = false; s_parkDueTime = -1f; // 813w6 — 여기서 보스를 직접 처리하므로 미뤄 둔 고정은 버린다 // ① 게이트 조기 개방(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 0cdd6c929..11880553e 100644 --- a/Assets/WL/Combat/Boss/WLBossArenaSettings.cs +++ b/Assets/WL/Combat/Boss/WLBossArenaSettings.cs @@ -275,5 +275,38 @@ namespace WL.Combat.Boss [Tooltip("위 시간 전이라도 「진짜 존 전멸」(RunGateReason.ZonesCleared)이면 게이트를 연다. " + "끄면 존을 다 밀어도 gateMinSeconds 전에는 안 열린다.")] public bool gateMinAllowsZonesCleared = true; + + // ─────────────────────────────────────────── 813w6 §1-1 재스폰 보스 고정(D-43) + [Header("재스폰 보스 고정 (813w6 · D-43 처치한 런 뒤 새 보스가 42s 만에 존0 추격)")] + [Tooltip("🔴 게이트가 닫혀 있는 동안 새로 등장한 보스(Spawned(isBoss))를 즉시 아레나 중심에 고정한다 " + + "(워프·만피·타깃 해제·홀드). 813w5 ParkBoss 는 「런 종료 시 살아 있는 보스」만 잡아서, " + + "앞판 보스를 처치한 뒤 원본 리젠으로 새로 스폰된 보스에는 홀드가 안 걸렸다(D-43 · ParkCount=0). " + + "끄면 813w5 그대로. 게이트가 열린 뒤의 등장(= 정상 보스전)은 이 스위치와 무관하게 고정하지 않는다.")] + public bool parkBossOnRespawn = true; + + [Tooltip("🔴 홀드를 nextRunBossHoldSeconds 대신 「다음 런의 게이트 개방까지」 유지한다(기본 on = 존0 추격 원천 차단). " + + "끄면 시간제(nextRunBossHoldSeconds)만 쓴다 = 813w5 그대로(30 s 뒤 보스가 다시 걸어 나온다). " + + "게이트가 열리는 순간(Release) 홀드는 해제되어 정상 보스전이 된다.")] + public bool bossHoldUntilGateOpen = true; + + [Tooltip("🔴 런 중이라도 게이트가 열리기 전에는 리쉬를 「거리 판정만」으로 돌린다 — 아레나를 벗어나면 회복 없이 중심으로. " + + "게이트 전에 보스가 살아 있는 것 자체가 비정상(원본 리젠)이라, 여기서 회복을 걸면 D-27 ③ 이 재발한다. " + + "끄면 813w5 그대로(런 중이면 회복 포함 리쉬).")] + public bool preGateLeashDistanceOnly = true; + + // ─────────────────────────────────────────── 813w6 §1-2 결과 HP 스냅샷 순서(D-44) + [Header("결과창 보스 HP (813w6 · D-44 「HP 남음」이 항상 100 %)")] + [Tooltip("🔴 RunEnded 발행이 끝난 뒤에 ParkBoss(만피)를 돌린다 — 순서 = HP 스냅샷 → RunEnded → Park. " + + "813w5 는 RunEnded 핸들러 안에서 즉시 만피로 되돌려, 같은 발행을 구독하는 결과창이 만피를 읽었다(D-44). " + + "끄면 813w5 그대로(즉시 고정).")] + public bool parkBossOnRunEndDeferred = true; + + [Tooltip("위 지연 시간(초 · unscaled). 0 이면 「다음 프레임 Tick」 = 발행이 끝난 직후. " + + "길게 줄수록 결과창 동안 보스가 자유롭게 움직이므로 D-39 가 되살아난다(0 권장).")] + public float parkBossOnRunEndDelaySeconds = 0f; + + [Tooltip("🔴 런 종료 순간(Park 이전)의 보스 HP 를 RunResult.bossHpRatio/bossHp/bossHpMax 에 담는다. " + + "끄면 −1/0/0 = 813p 계약 그대로(필드는 있지만 비어 있다).")] + public bool snapshotBossHpOnRunEnd = true; } } diff --git a/Assets/WL/Combat/Run/RunDirector.cs b/Assets/WL/Combat/Run/RunDirector.cs index 4a11a4b53..9226a4887 100644 --- a/Assets/WL/Combat/Run/RunDirector.cs +++ b/Assets/WL/Combat/Run/RunDirector.cs @@ -456,6 +456,10 @@ namespace WL.Combat.Run for (int i = 0; i < s_zoneCount; i++) s_zoneKills[i] = ZoneProgress(i); + // 🔴 #813w6 D-44 — 보스 HP 는 BossArena.ParkBoss(만피 복구) **이전**에 뜬다(스냅샷 → 발행 → Park). + float bossHpRatio; double bossHp, bossHpMax; + BossArena.SnapshotBossHp(out bossHpRatio, out bossHp, out bossHpMax); + var c = Cfg; LastResult = new RunResult { @@ -470,6 +474,9 @@ namespace WL.Combat.Run bossGateSec = s_bossGateSec, bossStartSec = s_bossStartSec, bossClearSec = s_bossClearSec, + bossHpRatio = bossHpRatio, + bossHp = bossHp, + bossHpMax = bossHpMax, kills = s_kills, bossKills = s_bossKills, maxChain = s_maxChain, diff --git a/Assets/WL/Combat/Run/RunEvents.cs b/Assets/WL/Combat/Run/RunEvents.cs index c837800ae..ba117d992 100644 --- a/Assets/WL/Combat/Run/RunEvents.cs +++ b/Assets/WL/Combat/Run/RunEvents.cs @@ -180,6 +180,15 @@ namespace WL.Combat.Run /// 보스 게이트 개방 시각 · 보스 등장 시각 · 보스 처치 시각(런 시작 후 초 · 미발생 −1). public float bossGateSec, bossStartSec, bossClearSec; + /// + /// 🔴 813w6(D-44) — 런 종료 순간의 보스 HP 비율(0~1). 보스가 없거나 못 읽으면 **−1**. + /// **BossArena.ParkBoss(만피 복구) 이전** 값이다(순서 = 스냅샷 → RunEnded 발행 → Park). + /// 결과창 「보스 처치 : 미처치 (HP 남음 N %)」는 이 값을 쓰면 된다(813q2 는 UI 자체 실측이라 만피를 읽었다). + /// + public float bossHpRatio; + /// 런 종료 순간의 보스 HP · 최대 HP(못 읽으면 0 · 813w6). + public double bossHp, bossHpMax; + /// 런 중 처치 수(보스 포함) · 그중 보스 처치 수. public int kills, bossKills; /// 런 중 최대 연쇄 처치 수(813e KillChain). diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLBossArenaSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLBossArenaSettings.asset index 772b500c8..8d672deea 100644 --- a/Assets/WL/Combat/Settings/Resources/WL/WLBossArenaSettings.asset +++ b/Assets/WL/Combat/Settings/Resources/WL/WLBossArenaSettings.asset @@ -66,3 +66,9 @@ MonoBehaviour: leashOutsideRunDistanceOnly: 1 gateMinSeconds: 60 gateMinAllowsZonesCleared: 1 + parkBossOnRespawn: 1 + bossHoldUntilGateOpen: 1 + preGateLeashDistanceOnly: 1 + parkBossOnRunEndDeferred: 1 + parkBossOnRunEndDelaySeconds: 0 + snapshotBossHpOnRunEnd: 1