Merge branch 'wl/gameplay/WL-813w6-respawn-park-hp'

This commit is contained in:
깃 관리자 2026-09-09 19:02:50 +09:00
commit aa25fb8e3b
7 changed files with 913 additions and 9 deletions

View File

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

View File

@ -0,0 +1,573 @@
// WL813w6_Probe.cs — #813w6(재스폰 보스 고정 D-43 · 결과 HP 스냅샷 순서 D-44) 프로브
// 에디트 모드 전용 · Play 불필요 · 로그인 0 · 씬/에셋 무수정(임시 GameObject·임시 SO 는 즉시 파괴/회수)
//
// 상주 에디터:
// unity command run_script --project-path <wt> --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<RunResult> 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); }
/// <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 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;
}
/// <summary>가짜 보스(살아 있는 것으로 보이는 MobActor · m_Stat 은 없다).</summary>
static MobActor MakeBoss(string name)
{
var go = new GameObject(name);
var m = go.AddComponent<MobActor>();
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<PCActor>();
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<FieldBossData>();
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) 동작 그대로다");
}
}

View File

@ -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;
/// <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>
/// 지금 보스가 「이동·타깃 금지」 홀드 중인가.
/// 🔴 813w6 — ⓐ 「게이트 개방까지」 홀드(`bossHoldUntilGateOpen`) ⓑ 시간제 홀드(`nextRunBossHoldSeconds`)의 **OR**.
/// 813w5 는 ⓑ 뿐이라 30 s 뒤 보스가 다시 걸어 나왔다(Q9 R1n t≈30~38 해제 → t=77 중심 21.89 m).
/// </summary>
public static bool BossHeld
{
get
{
if (s_holdUntilGate && !s_gateOpen) return true;
return s_bossHoldUntil > 0f && Now() < s_bossHoldUntil;
}
}
/// <summary>RunEvents.RunEnded 구독 여부(프로브).</summary>
public static bool RunSubscribed { get { return s_runSubscribed; } }
// ───────────────────────────────────────── 813w6 진단(D-43 · D-44)
/// <summary>홀드가 「게이트 개방까지」 모드인가(진단).</summary>
public static bool HoldUntilGate { get { return s_holdUntilGate; } }
/// <summary>런 종료 뒤로 미뤄 둔 고정이 남아 있는가 · 그 실행 예정 시각(진단).</summary>
public static bool ParkPending { get { return s_parkPending; } }
public static float ParkDueTime { get { return s_parkDueTime; } }
/// <summary>재스폰 보스를 즉시 고정한 횟수(D-43) · 게이트 전 거리 리셋 수 · 미뤄서 실행한 고정 수 · 게이트 개방으로 홀드를 푼 횟수.</summary>
public static int RespawnParkCount, RespawnParkAttempts, PreGateLeashResets, DeferredParkCount, HoldClearedByGate;
/// <summary>마지막 런 종료 스냅샷(D-44 · Park 이전 값 · 보스가 없으면 1 / 0 / 0).</summary>
public static float LastBossHpRatio = -1f;
public static double LastBossHp, LastBossHpMax;
/// <summary>SnapshotBossHp 호출 수(진단).</summary>
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;
}
/// <summary>홀드 타이머를 지금부터 다시 건다(0 이면 홀드 없음 = 813w3 그대로).</summary>
/// <summary>
/// 홀드를 지금부터 다시 건다(시간제 0 이면 시간 홀드 없음 = 813w3 그대로).
/// 🔴 813w6 — `bossHoldUntilGateOpen` 이면 「다음 런의 게이트 개방까지」 모드도 함께 켠다(둘은 OR).
/// </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"));
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)
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// 🔴 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)는 공개).
/// </summary>
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;
/// <summary>프로브용 — 보스 HP 읽기를 주입값으로 대체한다(ratio &lt; 1 이면 해제 · 런타임 게임 경로에서는 부르지 않는다).</summary>
public static void SetBossHpForProbe(float ratio, double hp, double maxHp)
{
s_probeHpRatio = ratio; s_probeHp = hp; s_probeHpMax = maxHp;
}
/// <summary>
/// 🔴 813w6 §1-2 — 미뤄 둔 고정을 실행한다(`Tick` 이 부른다 · 에디트 모드는 프로브가 직접 부를 수 있다).
/// 발행이 끝난 뒤라 결과창은 이미 Park 이전 HP 를 읽었다.
/// </summary>
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); }
}
/// <summary>
/// 🔴 813w6 §1-1 (D-43) — 게이트가 닫혀 있는데 보스가 새로 등장했다(= 앞판 보스를 처치한 뒤 원본 리젠).
/// 813w5 `ParkBoss` 는 런 종료 시점에 「살아 있는 보스」만 잡아서(`ParkCount=0` 실측) 이 보스에는 홀드가 없었고,
/// Q9 R2n 은 **42 초 만에** 존0 까지 와서 PC 를 쫓았다(`mobStatus=Chase`). 등장 즉시 고정한다.
/// 게이트가 열린 뒤의 등장(= 정상 보스전)은 여기 들어오지 않는다.
/// </summary>
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);
}
/// <summary>
/// 🔴 813w6 §1-1 — 게이트 개방 전 1틱(런 중 · 보스가 비정상적으로 살아 있는 구간) — **거리만** 본다.
/// 회복은 걸지 않는다(D-27 ③ 재발 방지). 홀드 중이면 홀드를 유지한다.
/// </summary>
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");
}
/// <summary>회복 없이 되돌리기(런 밖 전용) — 워프 + 타깃 해제 + 순찰 대기 + 정지.</summary>
@ -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;
}
/// <summary>프로브·QA 용 — 홀드를 즉시 해제한다(값은 건드리지 않는다).</summary>
public static void ClearBossHold() { s_bossHoldUntil = -1f; }
/// <summary>프로브·QA 용 — 홀드를 즉시 해제한다(값은 건드리지 않는다 · 813w6 「게이트 개방까지」 모드도 함께 푼다).</summary>
public static void ClearBossHold() { s_bossHoldUntil = -1f; s_holdUntilGate = false; }
/// <summary>프로브용 — 보스 없이 홀드 타이머만 건다(홀드 판정 검증).</summary>
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();

View File

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

View File

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

View File

@ -180,6 +180,15 @@ namespace WL.Combat.Run
/// <summary>보스 게이트 개방 시각 · 보스 등장 시각 · 보스 처치 시각(런 시작 후 초 · 미발생 1).</summary>
public float bossGateSec, bossStartSec, bossClearSec;
/// <summary>
/// 🔴 813w6(D-44) — 런 종료 순간의 보스 HP 비율(0~1). 보스가 없거나 못 읽으면 **1**.
/// **BossArena.ParkBoss(만피 복구) 이전** 값이다(순서 = 스냅샷 → RunEnded 발행 → Park).
/// 결과창 「보스 처치 : 미처치 (HP 남음 N %)」는 이 값을 쓰면 된다(813q2 는 UI 자체 실측이라 만피를 읽었다).
/// </summary>
public float bossHpRatio;
/// <summary>런 종료 순간의 보스 HP · 최대 HP(못 읽으면 0 · 813w6).</summary>
public double bossHp, bossHpMax;
/// <summary>런 중 처치 수(보스 포함) · 그중 보스 처치 수.</summary>
public int kills, bossKills;
/// <summary>런 중 최대 연쇄 처치 수(813e KillChain).</summary>

View File

@ -66,3 +66,9 @@ MonoBehaviour:
leashOutsideRunDistanceOnly: 1
gateMinSeconds: 60
gateMinAllowsZonesCleared: 1
parkBossOnRespawn: 1
bossHoldUntilGateOpen: 1
preGateLeashDistanceOnly: 1
parkBossOnRunEndDeferred: 1
parkBossOnRunEndDelaySeconds: 0
snapshotBossHpOnRunEnd: 1