[WL-813w5] 다음 런 앞판 보스 정리 · 게이트 조기 개방 방지 (#813)

This commit is contained in:
깃 관리자 2026-09-09 17:44:39 +09:00
parent a7423cd431
commit 292ccf674f
6 changed files with 991 additions and 6 deletions

View File

@ -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

View File

@ -0,0 +1,542 @@
// WL813w5_Probe.cs — #813w5(「다음 런」 앞판 보스 정리 · 게이트 조기 개방 방지) 프로브
// 에디트 모드 전용 · Play 불필요 · 로그인 0 · 씬/에셋 무수정(임시 GameObject·임시 SO 는 즉시 파괴/회수)
//
// 상주 에디터:
// unity command run_script --project-path <wt> --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); }
/// <summary>실에셋을 복사한 임시 SO(실에셋은 절대 안 건드린다). 쓰고 나면 DestroyImmediate + 캐시 원복.</summary>
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<PCActor>();
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<MobActor>();
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();
}
}

View File

@ -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
/// <summary>이 런의 게이트 판정 기준 처치 수(= TotalZoneKills 런 시작 스냅샷).</summary>
public static int GateKills { get { int v = TotalZoneKills - s_totalKillBase; return v > 0 ? v : 0; } }
// ───────────────────────────────────────── 813w5 진단(D-39)
/// <summary>보스를 아레나에 고정한 횟수(런 종료 + 다음 런) · 홀드 틱 수 · 런 밖 거리 리셋 수 · 게이트 조기 개방 차단 수.</summary>
public static int ParkCount, BossHoldTicks, OutsideRunLeashResets, GateMinBlocked;
/// <summary>RunEnded 를 받아 「보스 고정」 경로에 들어간 횟수(보스 유무와 무관 · 구독·스위치 증거).</summary>
public static int RunEndNotices;
/// <summary>마지막 RunEnded 의 결과(진단).</summary>
public static WL.Combat.Run.RunOutcome LastRunOutcome;
/// <summary>마지막으로 게이트를 막았을 때의 런 경과 초(진단).</summary>
public static float LastGateHeldElapsed = -1f;
/// <summary>홀드 종료 시각(unscaled · 홀드 중이 아니면 1).</summary>
public static float BossHoldUntil { get { return s_bossHoldUntil; } }
/// <summary>지금 보스가 「이동·타깃 금지」 홀드 중인가.</summary>
public static bool BossHeld { get { return s_bossHoldUntil > 0f && Now() < s_bossHoldUntil; } }
/// <summary>RunEvents.RunEnded 구독 여부(프로브).</summary>
public static bool RunSubscribed { get { return s_runSubscribed; } }
static WLBossArenaSettings Cfg { get { return WLBossArenaSettings.Instance; } }
static bool Active { get { return WLBossArenaSettings.Active; } }
/// <summary>시간 축 하나(프로브가 가짜 시각을 넣을 수 있다 · 에디트 모드는 0).</summary>
static float Now() { return s_probeTime >= 0f ? s_probeTime : (Application.isPlaying ? Time.unscaledTime : 0f); }
/// <summary>프로브용 — 가짜 시각을 넣는다(음수면 실시간으로 되돌린다).</summary>
public static void SetProbeTime(float t) { s_probeTime = t; }
/// <summary>
/// 813w3 §1-4 ③ — 런이 진행 중인가. 런 밖(RunEnded 뒤 · 결과창)에서는 리쉬·게이트 평가를 쉰다.
/// 🔴 C8 — 런 축(WLRunSettings)이 꺼졌거나 런이 한 번도 시작된 적 없으면 언제나 true(813d 그대로).
@ -247,6 +309,7 @@ namespace WL.Combat.Boss
/// <summary>CombatEvents 구독(중복 구독은 CombatEventList.Add 가 막는다).</summary>
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
/// <summary>구독 해제(프로브·롤백용).</summary>
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<WL.Combat.Run.RunResult> h_runEnded = OnRunEnded;
/// <summary>RunEvents.RunEnded 구독(중복 안전 · 813w5).</summary>
public static void EnsureRunSubscribed()
{
if (s_runSubscribed) return;
WL.Combat.Run.RunEvents.RunEnded.Add(h_runEnded);
s_runSubscribed = true;
}
/// <summary>RunEnded 구독 해제(프로브·롤백용).</summary>
public static void UnsubscribeRun()
{
if (!s_runSubscribed) return;
WL.Combat.Run.RunEvents.RunEnded.Remove(h_runEnded);
s_runSubscribed = false;
}
/// <summary>
/// 813w5 §1-2 — 런 종료(Win/Timeout/Abandon) 시 살아 있는 보스를 아레나 중심에 고정한다.
/// 🔴 D-39: 813w3 은 [다음 런]을 누른 순간에만 되돌렸다 → 결과창 동안 보스가 존까지 걸어 나갔다.
/// </summary>
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;
}
/// <summary>
/// 813w5 §1-2 ③ — 「런 시작 뒤 gateMinSeconds 전인가」. true 면 게이트를 열지 않는다(D-39).
/// 🔴 예외 = ⓐ 진짜 존 전멸(`ZonesCleared` · SO 로 끌 수 있다) ⓑ 시간제한(`TimeLimit`) ⓒ 외부 호출(`External` · QA·치트).
/// 🔴 C8 — `gateMinSeconds = 0` · 런 축 off · 런이 한 번도 시작 안 됨 · 런 밖 → 언제나 false(813p/813w3 그대로).
/// `RunDirector` 는 이 판정을 부르는 2줄만 바뀐다(발주서 「RunDirector ≤10줄」).
/// </summary>
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;
}
/// <summary>게이트를 강제로 연다(QA·프로브용 · 조건 무시).</summary>
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)
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// 런 밖(결과창·다음 런 대기)의 1틱 — **거리만** 본다. PC 이탈 판정은 하지 않고(결과창에서 의미가 없다),
/// 보스가 아레나(반경 + leashMargin)를 벗어나면 **회복 없이** 중심으로 되돌린다.
/// 홀드 중이면 되돌리기 대신 홀드를 유지한다.
/// </summary>
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");
}
/// <summary>
/// 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 — 문자열·배열 할당 없음(로그는 홀드 시작/해제에서만 남긴다).
/// </summary>
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);
}
/// <summary>보스 쪽 타깃(+ 옵션으로 PC 쪽 락·타깃)을 뗀다.</summary>
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);
}
/// <summary>
/// 원본 `Actor.StopMove_Imm`(protected · Actor.cs:2146) 본체를 공개 `Get_Agent()`(Actor.cs:900)로 재현한다.
/// `isOnNavMesh` 가드는 원본과 #813x 선례 그대로 — NavMesh 밖/비활성 에이전트에 쓰면 예외가 난다.
/// </summary>
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;
}
/// <summary>
/// 813w5 §1-2 — 살아 있는 보스를 아레나 중심에 「고정」한다(런 종료 · 다음 런 공통 진입점).
/// ① 워프 → ② 만피 → ③ 타깃·어그로 해제 → ④ nextRunBossHoldSeconds 동안 이동·타깃 금지.
/// 보스가 없거나 죽어 있으면 false.
/// </summary>
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;
}
/// <summary>홀드 타이머를 지금부터 다시 건다(0 이면 홀드 없음 = 813w3 그대로).</summary>
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"));
}
/// <summary>회복 없이 되돌리기(런 밖 전용) — 워프 + 타깃 해제 + 순찰 대기 + 정지.</summary>
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;
}
/// <summary>프로브·QA 용 — 홀드를 즉시 해제한다(값은 건드리지 않는다).</summary>
public static void ClearBossHold() { s_bossHoldUntil = -1f; }
/// <summary>프로브용 — 보스 없이 홀드 타이머만 건다(홀드 판정 검증).</summary>
public static void ArmBossHoldForProbe() { ArmBossHold(Cfg); }
/// <summary>프로브용 — 런 밖 1틱을 직접 태운다(보스·설정은 호출측이 준비한다).</summary>
public static void OutsideRunLeashTickForProbe() { var cfg = Cfg; if (cfg != null) OutsideRunLeashTick(cfg); }
/// <summary>
/// 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

View File

@ -78,6 +78,17 @@ namespace WL.Combat.Boss
/// <summary>테스트용: 캐시를 비워 다음 접근 때 다시 Resources.Load 한다(에셋 제거/복구 실험).</summary>
public static void ClearCache() { s_cached = null; s_lookupDone = false; }
/// <summary>
/// 813w5 프로브용 — 임시 SO 를 잠시 Instance 자리에 끼운다(C8 진리표 · **실에셋은 읽기만** 한다).
/// null 을 넣으면 캐시가 비워져 다음 접근에서 실에셋을 다시 로드한다(= ClearCache).
/// 🔴 런타임 게임 경로에서는 절대 부르지 않는다.
/// </summary>
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;
}
}

View File

@ -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;

View File

@ -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