Merge branch 'wl/gameplay/WL-813w7-nextrun-zone-rearm'

This commit is contained in:
깃 관리자 2026-09-09 20:55:32 +09:00
commit e94f823628
9 changed files with 1153 additions and 7 deletions

View File

@ -0,0 +1,95 @@
# WL813w7 Probe 2026-09-09 20:49:10 (edit mode · Play 0 · 로그인 0)
playMode=False
── ⓪ 준비
[PASS] WLBossArenaSettings 로드 · Active=True
[PASS] WLRunGuideSettings 로드 · Enabled=True
[PASS] WLRunSettings 로드 · Enabled=True
[PASS] RunGuide.Subscribed(RunEvents)
[PASS] HoldSpawn(FieldBossData) → 게이트 무장(armed=True open=False)
── ① 에셋 (813w7 신규 12필드 · 값 전부 SO = C45)
RunGuide: holdPcOnRunEnd=True runEndHoldMaxSeconds=120 runEndHoldClearsTarget=True runEndHoldStopsAgent=True
[PASS] holdPcOnRunEnd=1 (발주서 §1-2 ① 기본값)
[PASS] runEndHoldMaxSeconds>0 (결과창을 닫고 방치해도 스스로 풀린다)
[PASS] runEndHoldClearsTarget=1 (자동 스킬 루프 차단 · PCActor.cs:93 isTarget 조건)
[PASS] runEndHoldStopsAgent=1 (잔여 경로 제거)
BossArena: holdZoneSpawnOnlyAfterGate=True holdZoneSpawnSkipsRunZones=True rearmZoneSpawnsEnabled=True rearmZoneEmptySeconds=8 rearmZoneCheckSeconds=1 rearmZoneRegenMax=15 rearmZoneRunZonesOnly=True rearmZoneSpawnsOnRestart=True
[PASS] holdZoneSpawnOnlyAfterGate=1 (차단을 보스전으로 좁힌다)
[PASS] holdZoneSpawnSkipsRunZones=1 (런 사냥 존은 대상 제외)
[PASS] rearmZoneSpawnsEnabled=1
[PASS] rearmZoneSpawnsOnRestart=1 ([다음 런] 즉시 1회)
[PASS] rearmZoneRegenMax>0 (테이블 실패 시 폴백·상한)
[PASS] 🔴 rearmZoneEmptySeconds(8s) > 원본 리듬 5s — 멀쩡한 스포너와 겹치지 않는다(이중 스폰 방지)
[PASS] 감시 주기(1s) < 임계(8s)
[PASS] 813w3 holdZoneSpawnNearArena=1 유지(차단 경로 자체는 그대로 · 조건만 좁혔다)
── ② 🔴 D-45 원인 산식 (Q10: R1n 0/18 · R2n 1/18 PC (112.93,0.06,16.60) 고정 무피해 · R3n 14/18 정지)
아레나 중심 (139.00, 0.80, -20.00) · 차단 반경 = arenaRadius 20 + zoneSpawnHoldMargin 10 = 30 m
존0(id 813001) 앵커 (115.00, 0.39, -22.00) → 중심 24.1 m · 차단 사정권 안 🔴
존1(id 813002) 앵커 (147.00, 2.59, 30.00) → 중심 50.6 m · 차단 사정권 밖
[PASS] 🔴 존0 이 차단 사정권 안(24.1 m ≤ 30 m) = D-45 의 직접 원인
[PASS] Q10 R2n PC 좌표 (112.93, 0.06, -16.60) ↔ 존0 앵커 5.8 m ≤ zoneArriveRadius 6 = **도착(Hold)했는데 때릴 몹이 없었다**
차단 방식 = MobControlMgr.StopAllCoroutines() → PhaseAllKill 의 `killmobcount = 0`(MobControlMgr.cs:218)이
함께 끊겨 카운터가 n_MaxMonsterCount 에 굳는다 → `==` 판정(:214)이 두 번 다시 참이 안 된다 = 영구 소진.
(MyCoroutine.Set_Coroutine = StartCoroutine(MyCoroutine.cs) 이므로 StopAllCoroutines 에 걸린다 · 실측)
── ③ 수리 ① 결과창 중 PC 정지 (D-45 원인 = 결과창 100 s 사이 z0 처치 26 → 47)
[PASS] 가짜 RunEnded(Win) → 정지 시작(RunEndHeld=True RunEndHolds=1)
[PASS] 가이드는 꺼진다(State=Off Dest=None) — 813w6 계약 유지
[PASS] 🔴 정지 틱 → JoystickStatus=1(1 = 원본 자동 교전 접근 루프 차단 · PCActor.cs:139) Steering=True
[PASS] 🔴 정지 틱 → 타깃 해제(isTarget=False RunEndTargetClears=1) = 자동 스킬 루프(PCActor.cs:93)도 돌지 않는다
[PASS] 정지가 이어진다(틱 3 · 상한 전)
[PASS] 🔴 가짜 RunStarted("restart") → 정지 해제(RunEndHeld=False 해제 1 사유 '런 시작')
[PASS] 🔴 조종 원복 JoystickStatus=0(0 = 원본 그대로) Steering=False
[PASS] 813w4 재유도는 그대로 산다(RestartGuides=1) — 정지 해제가 유도보다 앞
[PASS] 🔴 상한 만료 자동 해제(199 s 유지 → 201 s 해제 · 사유 '상한 만료')
[PASS] 만료 해제에서도 조종 원복(JoystickStatus=0)
[PASS] JoystickStatus=2(스킬 이동 잠금)는 건드리지 않는다(=2 Steering=False)
── ④ 수리 ②-a 리젠 차단 판정(ShouldHoldZoneSpawn · 순수 함수)
[PASS] IsRunZoneSpawner(813001)=true (런 사냥 존)
[PASS] IsRunZoneSpawner(813005)=false (런 밖 존)
[PASS] 🔴 게이트 **닫힘** + 런 밖 존 → 차단 안 함(= 813w6 이 만든 「다음 런 내내 차단」이 사라진다)
[PASS] 🔴 게이트 열림 + **런 사냥 존** → 차단 안 함(D-45 재발 차단)
[PASS] 게이트 열림 + 런 밖 존 → 차단(813w3 잡몹 유입 차단 의도 유지)
[PASS] C8: 두 스위치 0 → 게이트 전 + 런 존도 차단 = 813w3 동작 그대로
[PASS] C8: holdZoneSpawnNearArena=0 → 언제나 차단 0
── ⑤ 수리 ②-b 존 재무장(원본 리셋 API 없음 → MobActor.On_Regen = MobControlMgr.cs:221 과 같은 호출)
감시 대상 2개: 813001 / 813002
[PASS] 대상 = WLRunSettings.zoneSpawnerIds 전부(rearmZoneRunZonesOnly=1)
[PASS] 첫 대상 = 존0(813001)
[PASS] WaveSize(813001)=15 (테이블 n_MaxMonsterCount → 없으면 rearmZoneRegenMax 15 · 상한도 그 값)
[PASS] alive=1 → 빈 시각 미기록(-1)
[PASS] alive=0 → 빈 시각 100 기록
[PASS] 임계(8s) 전 → 트리거 없음(빈 시각 그대로 100)
[PASS] 🔴 임계 경과 → 재무장 트리거(빈 시각이 108.5 로 갱신 = RearmZone 호출)
(에디트 모드는 ActorInfo.Ins 가 없어 실제 On_Regen 은 0마리 — 「언제 트리거되는가」까지 증명 · §미확인)
[PASS] 런 밖(결과창)에서는 감시 정지(빈 시각 108.5 불변)
[PASS] RearmZoneSpawns() 진입점 동작(재등장 0마리 · 에디트 모드라 0 · 목록 2개 재구축)
── ⑥ 수리 ③ 런 밖 처치가 존 카운터에 안 실린다(Q10: 결과창 동안 z0 26 → 47)
[PASS] BossArena 누적은 계속 센다(1 → 22) = 813p 계약(리셋 금지) 유지
[PASS] 런 밖 게이트 평가는 건너뛴다(GateSuspendedEvals 0 → 21)
[PASS] 🔴 다음 런 시작 직후 존0 진행 = 0/18 — 런 밖 21처치가 한 건도 안 실렸다(StartRun 스냅샷 차감)
[PASS] 런 안 처치 1건은 정상 반영(1/18)
── ⑦ GC 0 (틱 경로 · 각 100회)
[PASS] 정지 틱 ×100 = 0 B
[PASS] 빈 존 감시 ×100 = 0 B
[PASS] 차단 판정 ×100 = 0 B (acc=False)
[PASS] 런 존 판정 ×100 = 0 B (acc=False)
── ⑧ C8 롤백 (신규 스위치 0 → 813w6 동작 그대로)
[PASS] holdPcOnRunEnd=0 → 정지 0(RunEndHeld=False RunEndHolds=0)
[PASS] 조종도 잡지 않는다(JoystickStatus=0) = 813w6 그대로
[PASS] 정지 중 스위치 off → 즉시 해제(사유 '스위치 off')
[PASS] rearmZoneSpawnsEnabled=0 → 감시 무동작(빈 시각 -1)
[PASS] RearmZoneSpawns() 도 0
[PASS] rearmZoneSpawnsOnRestart=0 → [다음 런] 즉시 재무장 0
[PASS] RunGuide RuntimeDisabled → Active=false
[PASS] Tick 이 정지를 풀고 원본으로 돌려준다(RunEndHeld=False)
[PASS] BossArena RuntimeDisabled → 감시 무동작
RESULT PASS

View File

@ -0,0 +1,588 @@
// WL813w7_Probe.cs — #813w7(다음 런 존 재무장 · 결과창 중 PC 정지 D-45) 프로브
// 에디트 모드 전용 · Play 불필요 · 로그인 0 · 씬/에셋 무수정(임시 GameObject·임시 SO 는 즉시 파괴/회수)
//
// 상주 에디터:
// unity command run_script --project-path <wt> --file AgentScripts/WL813w7_Probe.cs --entry WL813w7_Probe.RunAll --timeout 300
// 결과: Console + AgentScripts/WL813w7_PROBE.txt (RESULT PASS / RESULT FAIL n)
//
// 무엇을 증명하나 (발주서 WL-813w7 §1 · 근거 = qa/W2-10_재채점_v8.md D-45)
// ① 에셋·SOT — 813w7 신규 12필드 값 실측 · 이중 스폰 방지 부등식 · 코드 상수 0
// ② 원인 산식 — 존0 앵커 ↔ 아레나 중심 24.1 m ≤ arenaRadius+zoneSpawnHoldMargin 30 m
// = 813w3 리젠 차단의 사정권 안이다(D-45 의 직접 원인 · 값으로 재현)
// ③ 수리 ① — 가짜 `RunEnded` → PC 조종 잡힘(JoystickStatus 1) · 타깃 해제 ·
// 가짜 `RunStarted("restart")` → 조종 원복(0) · 상한 만료 자동 해제
// ④ 수리 ②-a — 리젠 차단 판정(ShouldHoldZoneSpawn) 진리표: 게이트 전 0 · 런 존 0 · 그 밖 1
// ⑤ 수리 ②-b — 빈 존 감시 타이밍(8 s) · Restart 즉시 재무장 · 1웨이브 마리 수(WaveSize)
// ⑥ 수리 ③ — 런 밖 처치는 다음 런 존 진행에 안 센다(StartRun 스냅샷 차감)
// ⑦ GC 0 — 정지 틱 ×100 · 빈 존 감시 ×100 · 차단 판정 ×100 · 런 존 판정 ×100
// ⑧ C8 롤백 — 신규 스위치 0 → 813w6 동작 그대로 · RuntimeDisabled → 무동작
//
// 에디트 모드 한계(보고서 「미확인」)
// · `ActorInfo.Ins` 도 `table_monsterappear.Ins` 도 없다(플레이 모드 전용) → 실제 `On_Regen()` 재무장은
// 못 돌린다. 「언제 재무장을 트리거하는가」와 「1웨이브 마리 수 산정」까지를 증명한다.
// · `Time.unscaledTime` 이 스크립트 실행 중 멈춰 있다 → 시간 판정은 전부 인자로 먹인다.
// · PC 에 살아 있는 타깃(m_Target)을 붙일 수 없다(Set_Outline 이 렌더러를 만진다) → 타깃 해제는
// 「isTarget 플래그가 내려가는가」로 증명한다.
using System;
using System.IO;
using System.Text;
using UnityEngine;
using WL.Combat.Boss;
using WL.Combat.Core;
using WL.Combat.Run;
public static class WL813w7_Probe
{
const string OutCommit = "AgentScripts/WL813w7_PROBE.txt";
static StringBuilder sb;
static int s_fail;
static GameObject s_pcGo, s_fbdGo;
static PCActor s_pc, s_prevMyPc;
static FieldBossData s_fbd;
public static object RunAll()
{
sb = new StringBuilder();
s_fail = 0;
sb.AppendLine("# WL813w7 Probe " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " (edit mode · Play 0 · 로그인 0)");
sb.AppendLine("playMode=" + Application.isPlaying);
try
{
Setup();
Sec1_Asset();
Sec2_Cause();
Sec3_RunEndHold();
Sec4_HoldDecision();
Sec5_ZoneRearm();
Sec6_OutsideRunKills();
Sec7_GC();
Sec8_C8();
}
catch (Exception ex) { L("[FAIL] 예외 — " + ex.GetType().Name + ": " + ex.Message); L(ex.StackTrace); s_fail++; }
finally { Teardown(); }
L("");
L(s_fail == 0 ? "RESULT PASS" : "RESULT FAIL " + s_fail);
var text = sb.ToString();
try { File.WriteAllText(OutCommit, text, new UTF8Encoding(false)); } catch { }
Debug.Log(text);
return text;
}
// ───────────────────────────────────────── 유틸
static void L(string m) { sb.AppendLine(m); }
static string P(bool ok) { if (!ok) s_fail++; return ok ? "[PASS]" : "[FAIL]"; }
static long Alloc() { return GC.GetTotalMemory(false); }
static WLBossArenaSettings TmpB()
{
var t = UnityEngine.Object.Instantiate(WLBossArenaSettings.Instance);
t.hideFlags = HideFlags.HideAndDontSave;
return t;
}
static WLRunGuideSettings TmpG()
{
var t = UnityEngine.Object.Instantiate(WLRunGuideSettings.Instance);
t.hideFlags = HideFlags.HideAndDontSave;
return t;
}
static void UseTmpB(WLBossArenaSettings t) { WLBossArenaSettings.SetInstanceForProbe(t); }
static void UseRealB() { WLBossArenaSettings.SetInstanceForProbe(null); }
static void UseTmpG(WLRunGuideSettings t) { WLRunGuideSettings.SetInstanceForProbe(t); }
static void UseRealG() { WLRunGuideSettings.SetInstanceForProbe(null); }
static float DistXZ(Vector3 a, Vector3 b)
{
float dx = a.x - b.x, dz = a.z - b.z;
return Mathf.Sqrt(dx * dx + dz * dz);
}
static void Setup()
{
s_prevMyPc = MyValue.MyPC;
s_pcGo = new GameObject("__WL813w7_PC");
s_pc = s_pcGo.AddComponent<PCActor>();
s_pc.m_Role = eRole.PC; s_pc.m_SubRole = eSubRol.None;
MyValue.MyPC = s_pc;
WLBossArenaSettings.RuntimeDisabled = false;
WLRunGuideSettings.RuntimeDisabled = false;
BossArena.ResetAll();
BossArena.EnsureSubscribed();
RunDirector.ResetAll();
RunGuide.EnsureSubscribed();
RunGuide.ResetDiagnostics();
// 게이트 무장 — 원본 훅과 같은 진입점(HoldSpawn)으로 Arm 한다(s_zones 에 존 id 가 들어간다).
var cfg = WLBossArenaSettings.Instance;
s_fbdGo = new GameObject("__WL813w7_FBD");
s_fbd = s_fbdGo.AddComponent<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(WLRunGuideSettings.Instance != null) + " WLRunGuideSettings 로드 · Enabled=" + WLRunGuideSettings.Enabled);
L(" " + P(WLRunSettings.Instance != null) + " WLRunSettings 로드 · Enabled=" + WLRunSettings.Enabled);
L(" " + P(RunGuide.Subscribed) + " RunGuide.Subscribed(RunEvents)");
L(" " + P(held && BossArena.GateArmed && !BossArena.GateOpen) +
" HoldSpawn(FieldBossData) → 게이트 무장(armed=" + BossArena.GateArmed + " open=" + BossArena.GateOpen + ")");
}
static void NoopResume() { }
static void Teardown()
{
try
{
UseRealB(); UseRealG();
BossArena.SetProbeTime(-1f);
BossArena.SetRunActiveForProbe(0);
BossArena.SetBossForProbe(null);
BossArena.ResetAll();
RunGuide.EndRunEndHold("teardown");
RunGuide.ResetDiagnostics();
RunDirector.ResetAll();
WLBossArenaSettings.RuntimeDisabled = false;
WLRunGuideSettings.RuntimeDisabled = false;
MyValue.MyPC = s_prevMyPc;
if (s_pcGo != null) UnityEngine.Object.DestroyImmediate(s_pcGo);
if (s_fbdGo != null) UnityEngine.Object.DestroyImmediate(s_fbdGo);
}
catch { }
}
// ─────────────────────────────────────────────────────────────────────
// ① 에셋 · SOT (C45 — 값은 전부 .asset)
// ─────────────────────────────────────────────────────────────────────
static void Sec1_Asset()
{
L("");
L("── ① 에셋 (813w7 신규 12필드 · 값 전부 SO = C45)");
var g = WLRunGuideSettings.Instance;
var b = WLBossArenaSettings.Instance;
if (g == null || b == null) { L(" [FAIL] 에셋 없음"); s_fail++; return; }
L(" RunGuide: holdPcOnRunEnd=" + g.holdPcOnRunEnd +
" runEndHoldMaxSeconds=" + g.runEndHoldMaxSeconds +
" runEndHoldClearsTarget=" + g.runEndHoldClearsTarget +
" runEndHoldStopsAgent=" + g.runEndHoldStopsAgent);
L(" " + P(g.holdPcOnRunEnd) + " holdPcOnRunEnd=1 (발주서 §1-2 ① 기본값)");
L(" " + P(g.runEndHoldMaxSeconds > 0f) + " runEndHoldMaxSeconds>0 (결과창을 닫고 방치해도 스스로 풀린다)");
L(" " + P(g.runEndHoldClearsTarget) + " runEndHoldClearsTarget=1 (자동 스킬 루프 차단 · PCActor.cs:93 isTarget 조건)");
L(" " + P(g.runEndHoldStopsAgent) + " runEndHoldStopsAgent=1 (잔여 경로 제거)");
L(" BossArena: holdZoneSpawnOnlyAfterGate=" + b.holdZoneSpawnOnlyAfterGate +
" holdZoneSpawnSkipsRunZones=" + b.holdZoneSpawnSkipsRunZones +
" rearmZoneSpawnsEnabled=" + b.rearmZoneSpawnsEnabled +
" rearmZoneEmptySeconds=" + b.rearmZoneEmptySeconds +
" rearmZoneCheckSeconds=" + b.rearmZoneCheckSeconds +
" rearmZoneRegenMax=" + b.rearmZoneRegenMax +
" rearmZoneRunZonesOnly=" + b.rearmZoneRunZonesOnly +
" rearmZoneSpawnsOnRestart=" + b.rearmZoneSpawnsOnRestart);
L(" " + P(b.holdZoneSpawnOnlyAfterGate) + " holdZoneSpawnOnlyAfterGate=1 (차단을 보스전으로 좁힌다)");
L(" " + P(b.holdZoneSpawnSkipsRunZones) + " holdZoneSpawnSkipsRunZones=1 (런 사냥 존은 대상 제외)");
L(" " + P(b.rearmZoneSpawnsEnabled) + " rearmZoneSpawnsEnabled=1");
L(" " + P(b.rearmZoneSpawnsOnRestart) + " rearmZoneSpawnsOnRestart=1 ([다음 런] 즉시 1회)");
L(" " + P(b.rearmZoneRegenMax > 0) + " rearmZoneRegenMax>0 (테이블 실패 시 폴백·상한)");
// 🔴 이중 스폰 방지 부등식 — 원본 리듬 = 시체 3.0 s(Actor.cs:631) + f_SpawnerDelay 2.0 s(813e2 표 A) = 5.0 s
const float kOriginalRegenRhythm = 5.0f; // 게임 수치가 아니라 813e2 실측 리듬(검증 기준값)
L(" " + P(b.rearmZoneEmptySeconds > kOriginalRegenRhythm) +
" 🔴 rearmZoneEmptySeconds(" + b.rearmZoneEmptySeconds + "s) > 원본 리듬 " + kOriginalRegenRhythm +
"s — 멀쩡한 스포너와 겹치지 않는다(이중 스폰 방지)");
L(" " + P(b.rearmZoneCheckSeconds > 0f && b.rearmZoneCheckSeconds < b.rearmZoneEmptySeconds) +
" 감시 주기(" + b.rearmZoneCheckSeconds + "s) < 임계(" + b.rearmZoneEmptySeconds + "s)");
L(" " + P(b.holdZoneSpawnNearArena) + " 813w3 holdZoneSpawnNearArena=1 유지(차단 경로 자체는 그대로 · 조건만 좁혔다)");
}
// ─────────────────────────────────────────────────────────────────────
// ② 원인 산식 — 존0 이 리젠 차단 사정권 안이다
// ─────────────────────────────────────────────────────────────────────
static void Sec2_Cause()
{
L("");
L("── ② 🔴 D-45 원인 산식 (Q10: R1n 0/18 · R2n 1/18 PC (112.93,0.06,16.60) 고정 무피해 · R3n 14/18 정지)");
var b = WLBossArenaSettings.Instance;
var g = WLRunGuideSettings.Instance;
var rs = WLRunSettings.Instance;
if (b == null || g == null || rs == null) { L(" [FAIL] 에셋 없음"); s_fail++; return; }
Vector3 center = b.arenaCenter;
float lim = b.arenaRadius + b.zoneSpawnHoldMargin;
L(" 아레나 중심 " + center.ToString("F2") + " · 차단 반경 = arenaRadius " + b.arenaRadius +
" + zoneSpawnHoldMargin " + b.zoneSpawnHoldMargin + " = " + lim.ToString("F0") + " m");
var fb = g.zoneAnchorFallbacks;
for (int i = 0; i < rs.zoneSpawnerIds.Length; i++)
{
if (fb == null || i >= fb.Length) continue;
float d = DistXZ(fb[i], center);
L(" 존" + i + "(id " + rs.zoneSpawnerIds[i] + ") 앵커 " + fb[i].ToString("F2") +
" → 중심 " + d.ToString("F1") + " m · 차단 사정권 " + (d <= lim ? "안 🔴" : "밖"));
}
float d0 = fb != null && fb.Length > 0 ? DistXZ(fb[0], center) : -1f;
L(" " + P(d0 >= 0f && d0 <= lim) +
" 🔴 존0 이 차단 사정권 안(" + d0.ToString("F1") + " m ≤ " + lim.ToString("F0") + " m) = D-45 의 직접 원인");
// PC 고정 좌표(Q10 R2n 실측)가 존0 앵커에 도착한 자리인가 = 「PC 는 존에 있는데 몹이 없다」
var pcSeen = new Vector3(112.93f, 0.06f, -16.60f);
float dPc = fb != null && fb.Length > 0 ? DistXZ(pcSeen, fb[0]) : -1f;
L(" " + P(dPc >= 0f && dPc <= g.zoneArriveRadius) +
" Q10 R2n PC 좌표 " + pcSeen.ToString("F2") + " ↔ 존0 앵커 " + dPc.ToString("F1") +
" m ≤ zoneArriveRadius " + g.zoneArriveRadius + " = **도착(Hold)했는데 때릴 몹이 없었다**");
L(" 차단 방식 = MobControlMgr.StopAllCoroutines() → PhaseAllKill 의 `killmobcount = 0`(MobControlMgr.cs:218)이");
L(" 함께 끊겨 카운터가 n_MaxMonsterCount 에 굳는다 → `==` 판정(:214)이 두 번 다시 참이 안 된다 = 영구 소진.");
L(" (MyCoroutine.Set_Coroutine = StartCoroutine(MyCoroutine.cs) 이므로 StopAllCoroutines 에 걸린다 · 실측)");
}
// ─────────────────────────────────────────────────────────────────────
// ③ 수리 ① — 결과창 중 PC 정지
// ─────────────────────────────────────────────────────────────────────
static void Sec3_RunEndHold()
{
L("");
L("── ③ 수리 ① 결과창 중 PC 정지 (D-45 원인 = 결과창 100 s 사이 z0 처치 26 → 47)");
var g = WLRunGuideSettings.Instance;
RunGuide.ResetDiagnostics();
s_pc.Set_JoystickStatus(3);
s_pc.isTarget = false;
int holds0 = RunGuide.RunEndHolds;
RunGuide.ProbeRunEnded(RunOutcome.Win);
RunGuide.SetRunEndHoldUntilForProbe(-1f); // 에디트 모드는 Time 이 안 흘러 상한 판정을 따로 검증한다(아래 ⓑ)
L(" " + P(RunGuide.RunEndHeld && RunGuide.RunEndHolds == holds0 + 1) +
" 가짜 RunEnded(Win) → 정지 시작(RunEndHeld=" + RunGuide.RunEndHeld + " RunEndHolds=" + RunGuide.RunEndHolds + ")");
L(" " + P(RunGuide.State == RunGuideState.Off && RunGuide.DestKind == RunGuideDest.None) +
" 가이드는 꺼진다(State=" + RunGuide.State + " Dest=" + RunGuide.DestKind + ") — 813w6 계약 유지");
// 정지 1틱 — 조종을 잡고 타깃을 뗀다
s_pc.isTarget = true;
int clears0 = RunGuide.RunEndTargetClears;
RunGuide.RunEndHoldTick(g, s_pc, 100f);
L(" " + P(s_pc.JoystickStatus == 1 && RunGuide.Steering) +
" 🔴 정지 틱 → JoystickStatus=" + s_pc.JoystickStatus + "(1 = 원본 자동 교전 접근 루프 차단 · PCActor.cs:139) Steering=" + RunGuide.Steering);
L(" " + P(!s_pc.isTarget && RunGuide.RunEndTargetClears == clears0 + 1) +
" 🔴 정지 틱 → 타깃 해제(isTarget=" + s_pc.isTarget + " RunEndTargetClears=" + RunGuide.RunEndTargetClears +
") = 자동 스킬 루프(PCActor.cs:93)도 돌지 않는다");
int ticks0 = RunGuide.RunEndHoldTicks;
RunGuide.RunEndHoldTick(g, s_pc, 101f);
RunGuide.RunEndHoldTick(g, s_pc, 102f);
L(" " + P(RunGuide.RunEndHoldTicks == ticks0 + 2 && RunGuide.RunEndHeld) +
" 정지가 이어진다(틱 " + RunGuide.RunEndHoldTicks + " · 상한 전)");
// 복귀 ⓐ — 다음 런 시작(RunDirector.Restart → StartRun("restart"))
int rel0 = RunGuide.RunEndHoldReleases;
RunGuide.ProbeRunStarted(2, "restart");
L(" " + P(!RunGuide.RunEndHeld && RunGuide.RunEndHoldReleases == rel0 + 1) +
" 🔴 가짜 RunStarted(\"restart\") → 정지 해제(RunEndHeld=" + RunGuide.RunEndHeld +
" 해제 " + RunGuide.RunEndHoldReleases + " 사유 '" + RunGuide.LastRunEndHoldReason + "')");
L(" " + P(s_pc.JoystickStatus == 0 && !RunGuide.Steering) +
" 🔴 조종 원복 JoystickStatus=" + s_pc.JoystickStatus + "(0 = 원본 그대로) Steering=" + RunGuide.Steering);
L(" " + P(RunGuide.RestartGuides >= 1) +
" 813w4 재유도는 그대로 산다(RestartGuides=" + RunGuide.RestartGuides + ") — 정지 해제가 유도보다 앞");
// 복귀 ⓑ — 상한 만료(결과창을 [닫기]로 닫고 런을 다시 시작하지 않는 경우)
RunGuide.StopGuide("프로브");
RunGuide.BeginRunEndHold("상한 시험");
RunGuide.SetRunEndHoldUntilForProbe(200f);
RunGuide.RunEndHoldTick(g, s_pc, 199f);
bool stillHeld = RunGuide.RunEndHeld;
RunGuide.RunEndHoldTick(g, s_pc, 201f);
L(" " + P(stillHeld && !RunGuide.RunEndHeld) +
" 🔴 상한 만료 자동 해제(199 s 유지 → 201 s 해제 · 사유 '" + RunGuide.LastRunEndHoldReason + "')");
L(" " + P(s_pc.JoystickStatus == 0) + " 만료 해제에서도 조종 원복(JoystickStatus=" + s_pc.JoystickStatus + ")");
// 이미 스킬 이동 잠금(2)이면 건드리지 않는다
RunGuide.ResetDiagnostics();
s_pc.Set_JoystickStatus(2);
RunGuide.BeginRunEndHold("잠금 시험");
RunGuide.SetRunEndHoldUntilForProbe(-1f);
RunGuide.RunEndHoldTick(g, s_pc, 300f);
L(" " + P(s_pc.JoystickStatus == 2 && !RunGuide.Steering) +
" JoystickStatus=2(스킬 이동 잠금)는 건드리지 않는다(=" + s_pc.JoystickStatus + " Steering=" + RunGuide.Steering + ")");
RunGuide.EndRunEndHold("프로브 정리");
s_pc.Set_JoystickStatus(3);
RunGuide.ResetDiagnostics();
}
// ─────────────────────────────────────────────────────────────────────
// ④ 수리 ②-a — 리젠 차단 판정 진리표
// ─────────────────────────────────────────────────────────────────────
static void Sec4_HoldDecision()
{
L("");
L("── ④ 수리 ②-a 리젠 차단 판정(ShouldHoldZoneSpawn · 순수 함수)");
var rs = WLRunSettings.Instance;
int runZone = rs != null && rs.zoneSpawnerIds != null && rs.zoneSpawnerIds.Length > 0 ? rs.zoneSpawnerIds[0] : 813001;
int other = 813005;
L(" " + P(BossArena.IsRunZoneSpawner(runZone)) + " IsRunZoneSpawner(" + runZone + ")=true (런 사냥 존)");
L(" " + P(!BossArena.IsRunZoneSpawner(other)) + " IsRunZoneSpawner(" + other + ")=false (런 밖 존)");
var t = TmpB();
try
{
UseTmpB(t);
t.holdZoneSpawnNearArena = true;
t.holdZoneSpawnOnlyAfterGate = true;
t.holdZoneSpawnSkipsRunZones = true;
L(" " + P(!BossArena.ShouldHoldZoneSpawn(t, other, false)) +
" 🔴 게이트 **닫힘** + 런 밖 존 → 차단 안 함(= 813w6 이 만든 「다음 런 내내 차단」이 사라진다)");
L(" " + P(!BossArena.ShouldHoldZoneSpawn(t, runZone, true)) +
" 🔴 게이트 열림 + **런 사냥 존** → 차단 안 함(D-45 재발 차단)");
L(" " + P(BossArena.ShouldHoldZoneSpawn(t, other, true)) +
" 게이트 열림 + 런 밖 존 → 차단(813w3 잡몹 유입 차단 의도 유지)");
// C8 — 두 스위치를 끄면 813w3 그대로(보스 생존 = 상시 차단)
t.holdZoneSpawnOnlyAfterGate = false;
t.holdZoneSpawnSkipsRunZones = false;
L(" " + P(BossArena.ShouldHoldZoneSpawn(t, runZone, false)) +
" C8: 두 스위치 0 → 게이트 전 + 런 존도 차단 = 813w3 동작 그대로");
t.holdZoneSpawnNearArena = false;
L(" " + P(!BossArena.ShouldHoldZoneSpawn(t, other, true)) + " C8: holdZoneSpawnNearArena=0 → 언제나 차단 0");
}
finally { UseRealB(); UnityEngine.Object.DestroyImmediate(t); }
}
// ─────────────────────────────────────────────────────────────────────
// ⑤ 수리 ②-b — 빈 존 감시 · 재무장
// ─────────────────────────────────────────────────────────────────────
static void Sec5_ZoneRearm()
{
L("");
L("── ⑤ 수리 ②-b 존 재무장(원본 리셋 API 없음 → MobActor.On_Regen = MobControlMgr.cs:221 과 같은 호출)");
var b = WLBossArenaSettings.Instance;
var rs = WLRunSettings.Instance;
int z0 = rs != null && rs.zoneSpawnerIds != null && rs.zoneSpawnerIds.Length > 0 ? rs.zoneSpawnerIds[0] : 813001;
// 감시 목록 — 런 존만
BossArena.RebuildRearmZonesForProbe();
L(" 감시 대상 " + BossArena.RearmZoneCount + "개: " +
(BossArena.RearmZoneCount > 0 ? BossArena.RearmZoneIdAt(0).ToString() : "-") +
(BossArena.RearmZoneCount > 1 ? " / " + BossArena.RearmZoneIdAt(1) : ""));
L(" " + P(rs != null && BossArena.RearmZoneCount == rs.zoneSpawnerIds.Length) +
" 대상 = WLRunSettings.zoneSpawnerIds 전부(rearmZoneRunZonesOnly=1)");
L(" " + P(BossArena.RearmZoneCount > 0 && BossArena.RearmZoneIdAt(0) == z0) + " 첫 대상 = 존0(" + z0 + ")");
// 1웨이브 마리 수 — 에디트 모드엔 table_monsterappear 가 없어 SO 폴백을 쓴다
int wave = BossArena.WaveSize(b, z0);
L(" " + P(wave > 0 && wave <= b.rearmZoneRegenMax) +
" WaveSize(" + z0 + ")=" + wave + " (테이블 n_MaxMonsterCount → 없으면 rearmZoneRegenMax " + b.rearmZoneRegenMax + " · 상한도 그 값)");
// 타이밍 — 살아 있으면 타이머가 안 돈다 / 비면 임계까지 기다렸다 트리거
BossArena.SetRunActiveForProbe(1);
BossArena.NoteSpawn(z0, false); // alive 1
BossArena.ZoneRearmTick(b, 100f);
L(" " + P(BossArena.AliveOf(z0) > 0 && BossArena.RearmZoneEmptySinceAt(0) < 0f) +
" alive=" + BossArena.AliveOf(z0) + " → 빈 시각 미기록(" + BossArena.RearmZoneEmptySinceAt(0) + ")");
BossArena.NoteKill(z0, false); // alive 0
BossArena.ZoneRearmTick(b, 100f);
L(" " + P(BossArena.AliveOf(z0) == 0 && Mathf.Approximately(BossArena.RearmZoneEmptySinceAt(0), 100f)) +
" alive=0 → 빈 시각 " + BossArena.RearmZoneEmptySinceAt(0) + " 기록");
BossArena.ZoneRearmTick(b, 100f + b.rearmZoneEmptySeconds - 1f);
L(" " + P(Mathf.Approximately(BossArena.RearmZoneEmptySinceAt(0), 100f)) +
" 임계(" + b.rearmZoneEmptySeconds + "s) 전 → 트리거 없음(빈 시각 그대로 " + BossArena.RearmZoneEmptySinceAt(0) + ")");
float fire = 100f + b.rearmZoneEmptySeconds + 0.5f;
BossArena.ZoneRearmTick(b, fire);
L(" " + P(Mathf.Approximately(BossArena.RearmZoneEmptySinceAt(0), fire)) +
" 🔴 임계 경과 → 재무장 트리거(빈 시각이 " + BossArena.RearmZoneEmptySinceAt(0) + " 로 갱신 = RearmZone 호출)");
L(" (에디트 모드는 ActorInfo.Ins 가 없어 실제 On_Regen 은 0마리 — 「언제 트리거되는가」까지 증명 · §미확인)");
// 런 밖에서는 감시하지 않는다(결과창 동안 몹을 우리가 늘리지 않는다)
BossArena.SetRunActiveForProbe(-1);
float before = BossArena.RearmZoneEmptySinceAt(0);
BossArena.ZoneRearmTick(b, fire + 100f);
L(" " + P(Mathf.Approximately(BossArena.RearmZoneEmptySinceAt(0), before)) +
" 런 밖(결과창)에서는 감시 정지(빈 시각 " + BossArena.RearmZoneEmptySinceAt(0) + " 불변)");
BossArena.SetRunActiveForProbe(1);
// [다음 런] 즉시 재무장 진입점
int n = BossArena.RearmZoneSpawns("프로브 다음 런");
L(" " + P(BossArena.RearmZoneCount > 0) +
" RearmZoneSpawns() 진입점 동작(재등장 " + n + "마리 · 에디트 모드라 0 · 목록 " + BossArena.RearmZoneCount + "개 재구축)");
BossArena.SetRunActiveForProbe(0);
}
// ─────────────────────────────────────────────────────────────────────
// ⑥ 수리 ③ — 런 밖 처치는 다음 런 존 진행에 안 센다
// ─────────────────────────────────────────────────────────────────────
static void Sec6_OutsideRunKills()
{
L("");
L("── ⑥ 수리 ③ 런 밖 처치가 존 카운터에 안 실린다(Q10: 결과창 동안 z0 26 → 47)");
var rs = WLRunSettings.Instance;
int z0 = rs.zoneSpawnerIds[0];
RunDirector.ResetAll();
RunDirector.EnsureSubscribed();
BossArena.SetRunActiveForProbe(-1); // 런 밖(결과창)
int arena0 = BossArena.KillsOf(z0);
int gateEvals0 = BossArena.GateSuspendedEvals;
for (int i = 0; i < 21; i++) // Q10 실측과 같은 21마리
{
RunDirector.NoteKill(z0, false); // 런 밖 → IsRunning=false → 무시
BossArena.NoteKill(z0, false); // 누적값은 813p 계약대로 계속 센다
}
L(" " + P(BossArena.KillsOf(z0) == arena0 + 21) +
" BossArena 누적은 계속 센다(" + arena0 + " → " + BossArena.KillsOf(z0) + ") = 813p 계약(리셋 금지) 유지");
L(" " + P(BossArena.GateSuspendedEvals > gateEvals0) +
" 런 밖 게이트 평가는 건너뛴다(GateSuspendedEvals " + gateEvals0 + " → " + BossArena.GateSuspendedEvals + ")");
BossArena.SetRunActiveForProbe(1);
RunDirector.StartRun("probe restart"); // ← 여기서 s_zoneBase 를 다시 잡는다
L(" " + P(RunDirector.ZoneProgress(0) == 0) +
" 🔴 다음 런 시작 직후 존0 진행 = " + RunDirector.ZoneProgress(0) + "/" + RunDirector.ZoneRequired(0) +
" — 런 밖 21처치가 한 건도 안 실렸다(StartRun 스냅샷 차감)");
RunDirector.NoteKill(z0, false);
L(" " + P(RunDirector.ZoneProgress(0) == 1) +
" 런 안 처치 1건은 정상 반영(" + RunDirector.ZoneProgress(0) + "/" + RunDirector.ZoneRequired(0) + ")");
RunDirector.ResetAll();
BossArena.SetRunActiveForProbe(0);
RunGuide.ResetDiagnostics();
}
// ─────────────────────────────────────────────────────────────────────
// ⑦ GC 0
// ─────────────────────────────────────────────────────────────────────
static void Sec7_GC()
{
L("");
L("── ⑦ GC 0 (틱 경로 · 각 100회)");
var g = WLRunGuideSettings.Instance;
var b = WLBossArenaSettings.Instance;
var rs = WLRunSettings.Instance;
int z0 = rs.zoneSpawnerIds[0];
int other = 813005;
// ⓐ 결과창 정지 틱
RunGuide.ResetDiagnostics();
s_pc.Set_JoystickStatus(3);
RunGuide.BeginRunEndHold("GC");
RunGuide.SetRunEndHoldUntilForProbe(-1f);
RunGuide.RunEndHoldTick(g, s_pc, 10f); // 워밍업(조종 획득 1회)
long a0 = Alloc();
for (int i = 0; i < 100; i++) RunGuide.RunEndHoldTick(g, s_pc, 10f + i);
long a1 = Alloc();
L(" " + P(a1 - a0 <= 0) + " 정지 틱 ×100 = " + (a1 - a0) + " B");
RunGuide.EndRunEndHold("GC 끝");
s_pc.Set_JoystickStatus(3);
// ⓑ 빈 존 감시
BossArena.SetRunActiveForProbe(1);
BossArena.ZoneRearmTick(b, 500f);
long b0 = Alloc();
for (int i = 0; i < 100; i++) BossArena.ZoneRearmTick(b, 500f + i * 0.01f);
long b1 = Alloc();
L(" " + P(b1 - b0 <= 0) + " 빈 존 감시 ×100 = " + (b1 - b0) + " B");
BossArena.SetRunActiveForProbe(0);
// ⓒ 차단 판정 · 런 존 판정
long c0 = Alloc();
bool acc = false;
for (int i = 0; i < 100; i++) acc ^= BossArena.ShouldHoldZoneSpawn(b, i % 2 == 0 ? z0 : other, i % 2 == 0);
long c1 = Alloc();
L(" " + P(c1 - c0 <= 0) + " 차단 판정 ×100 = " + (c1 - c0) + " B (acc=" + acc + ")");
long d0 = Alloc();
for (int i = 0; i < 100; i++) acc ^= BossArena.IsRunZoneSpawner(i % 2 == 0 ? z0 : other);
long d1 = Alloc();
L(" " + P(d1 - d0 <= 0) + " 런 존 판정 ×100 = " + (d1 - d0) + " B (acc=" + acc + ")");
RunGuide.ResetDiagnostics();
}
// ─────────────────────────────────────────────────────────────────────
// ⑧ C8 롤백
// ─────────────────────────────────────────────────────────────────────
static void Sec8_C8()
{
L("");
L("── ⑧ C8 롤백 (신규 스위치 0 → 813w6 동작 그대로)");
// ⓐ holdPcOnRunEnd = 0 → 런 종료 정지 경로가 통째로 죽는다
var tg = TmpG();
try
{
UseTmpG(tg);
tg.holdPcOnRunEnd = false;
RunGuide.ResetDiagnostics();
s_pc.Set_JoystickStatus(3);
RunGuide.ProbeRunEnded(RunOutcome.Timeout);
L(" " + P(!RunGuide.RunEndHeld && RunGuide.RunEndHolds == 0) +
" holdPcOnRunEnd=0 → 정지 0(RunEndHeld=" + RunGuide.RunEndHeld + " RunEndHolds=" + RunGuide.RunEndHolds + ")");
L(" " + P(s_pc.JoystickStatus == 0) + " 조종도 잡지 않는다(JoystickStatus=" + s_pc.JoystickStatus + ") = 813w6 그대로");
// 정지 중에 스위치를 끄면 그 자리에서 풀린다
tg.holdPcOnRunEnd = true;
RunGuide.BeginRunEndHold("C8");
tg.holdPcOnRunEnd = false;
RunGuide.RunEndHoldTick(tg, s_pc, 900f);
L(" " + P(!RunGuide.RunEndHeld) + " 정지 중 스위치 off → 즉시 해제(사유 '" + RunGuide.LastRunEndHoldReason + "')");
}
finally { UseRealG(); UnityEngine.Object.DestroyImmediate(tg); RunGuide.ResetDiagnostics(); }
// ⓑ rearmZoneSpawnsEnabled = 0 → 감시·재무장 경로가 통째로 죽는다
var tb = TmpB();
try
{
UseTmpB(tb);
BossArena.SetRunActiveForProbe(1);
BossArena.RebuildRearmZonesForProbe();
tb.rearmZoneSpawnsEnabled = false;
BossArena.ZoneRearmTick(tb, 1000f);
L(" " + P(BossArena.RearmZoneEmptySinceAt(0) < 0f) +
" rearmZoneSpawnsEnabled=0 → 감시 무동작(빈 시각 " + BossArena.RearmZoneEmptySinceAt(0) + ")");
L(" " + P(BossArena.RearmZoneSpawns("C8") == 0) + " RearmZoneSpawns() 도 0");
tb.rearmZoneSpawnsEnabled = true;
tb.rearmZoneSpawnsOnRestart = false;
L(" " + P(BossArena.RearmZoneSpawns("C8") == 0) + " rearmZoneSpawnsOnRestart=0 → [다음 런] 즉시 재무장 0");
}
finally { UseRealB(); BossArena.SetRunActiveForProbe(0); UnityEngine.Object.DestroyImmediate(tb); }
// ⓒ RuntimeDisabled — 축 전체 off
WLRunGuideSettings.RuntimeDisabled = true;
RunGuide.ResetDiagnostics();
RunGuide.BeginRunEndHold("RuntimeDisabled");
RunGuide.ForceTick();
L(" " + P(!WLRunGuideSettings.Enabled && !RunGuide.Active) + " RunGuide RuntimeDisabled → Active=false");
L(" " + P(!RunGuide.RunEndHeld) + " Tick 이 정지를 풀고 원본으로 돌려준다(RunEndHeld=" + RunGuide.RunEndHeld + ")");
WLRunGuideSettings.RuntimeDisabled = false;
WLBossArenaSettings.RuntimeDisabled = true;
var b2 = WLBossArenaSettings.Instance;
BossArena.SetRunActiveForProbe(1);
BossArena.RebuildRearmZonesForProbe();
BossArena.ZoneRearmTick(b2, 2000f);
L(" " + P(BossArena.RearmZoneEmptySinceAt(0) < 0f) + " BossArena RuntimeDisabled → 감시 무동작");
WLBossArenaSettings.RuntimeDisabled = false;
BossArena.SetRunActiveForProbe(0);
RunGuide.ResetDiagnostics();
}
}

View File

@ -115,6 +115,24 @@
// → 수리 순서 = **HP 스냅샷(`SnapshotBossHp` · RunDirector.EndRun) → `RunEnded` 발행 → Park**.
// `parkBossOnRunEndDeferred`(기본 on)가 고정을 **발행 뒤(다음 Tick)** 로 미루고, `RunResult.bossHpRatio/bossHp/bossHpMax`
// 가 Park 이전 값을 싣는다. **Park 뒤 만피는 그대로 유지**한다(D-27 ③ 방지 로직 불변).
//
// ■ WL-813w7 (Q10 D-45 · 2026-09-09) — 813w3 §1-3 의 잡몹 유입 차단이 813w6 과 만나 존을 영구 소진시킨다
// 🔴 원인(코드로 확정 · 발주서 §1-1 「원인 실측」)
// ⓐ `HoldNearbyZoneSpawns`(이 파일)는 **보스가 살아 있는 동안** 아레나 중심 `arenaRadius + zoneSpawnHoldMargin`
// (= 20 + 10 = **30 m**) 안의 존 스포너에 `StopAllCoroutines()` 를 건다.
// ⓑ 존0(813001) 앵커 `(115, 0.39, 22)` ↔ 아레나 중심 `(139, 0.8, 20)` = **24.1 m** → **차단 대상이다.**
// ⓒ 원본 `MobControlMgr` 의 PhaseAllKill 리젠은 **그 코루틴 안에서** `killmobcount = 0` 을 한다
// (MobControlMgr.cs:216-222). 코루틴이 끊기면 `killmobcount` 가 `n_MaxMonsterCount` 에 굳고,
// 판정이 `==`(MobControlMgr.cs:214)라 다음 사망부터 Max+1, Max+2… → **두 번 다시 참이 되지 않는다.**
// = 그 스포너는 맵 세션이 끝날 때까지 **영구 소진**된다(리셋 API 없음 · 전부 private).
// ⓓ 813w6 이 재스폰 보스를 **게이트 개방까지** 살려 두면서, 차단이 「보스전 동안」이 아니라
// **다음 런 내내** 걸렸다 → R1n 0/18 · R2n 1/18(PC 는 존0 앵커에서 5.8 m 에 서 있었다) · R3n 14/18 정지.
// ■ 수리 ① `holdZoneSpawnOnlyAfterGate`(기본 on) — 차단을 **게이트 개방 뒤**(= 진짜 보스전)로 좁힌다.
// ② `holdZoneSpawnSkipsRunZones`(기본 on) — 런이 쓰는 존(WLRunSettings.zoneSpawnerIds)은 아예 대상에서 뺀다.
// ③ `rearmZoneSpawnsEnabled` — 존이 `rearmZoneEmptySeconds`(8 s > 원본 리듬 5.0 s) 넘게 비면
// **원본과 같은 공개 경로**(`MobActor.On_Regen()` = MobControlMgr.cs:221 과 동일한 호출)로 1웨이브를 되살린다.
// `RunDirector.Restart()` 도 즉시 1회 부른다(`rearmZoneSpawnsOnRestart` · 이미 굳은 세션 구제).
// ■ C8 롤백 — 위 4개 스위치를 전부 끄면 813w6 동작 그대로다.
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections.Generic;
@ -167,6 +185,13 @@ namespace WL.Combat.Boss
static bool s_parkPending; // RunEnded 발행이 끝난 뒤로 미뤄 둔 고정이 있는가
static float s_parkDueTime = -1f; // 그 고정을 실행할 시각(unscaled · 0 지연이면 다음 Tick)
// 813w7 §1-2 ② — 존 스포너 재무장(D-45). 감시 대상 id 와 「빈 지 얼마나 됐나」를 사전 할당 배열로 든다(GC 0).
const int kMaxRearmZones = 16; // 버퍼 크기지 게임 수치가 아니다
static readonly int[] s_rearmIds = new int[kMaxRearmZones];
static readonly float[] s_rearmEmptySince = new float[kMaxRearmZones];
static int s_rearmCount = -1; // 1 = 아직 목록을 안 잡았다
static float s_nextZoneRearmCheck;
// ───────────────────────────────────────── 진단(프로브·QA 가 읽는다)
public static bool GateArmed { get { return s_armed; } }
public static bool GateOpen { get { return s_gateOpen; } }
@ -192,6 +217,8 @@ namespace WL.Combat.Boss
public static int LeashSuspendedTicks, GateSuspendedEvals;
/// <summary>존 스포너 리젠 예약을 끊은 횟수 · 대상 스포너 수(§1-3).</summary>
public static int ZoneSpawnHoldTicks, ZoneSpawnHoldTargets;
/// <summary>813w7 §1-2 ②: 차단을 건너뛴 횟수(게이트 전 · 런 존 제외) — D-45 수리가 실제로 도는지의 증거.</summary>
public static int ZoneSpawnHoldSkipped;
/// <summary>이 런의 게이트 판정 기준 처치 수(= TotalZoneKills 런 시작 스냅샷).</summary>
public static int GateKills { get { int v = TotalZoneKills - s_totalKillBase; return v > 0 ? v : 0; } }
@ -1021,16 +1048,46 @@ namespace WL.Combat.Boss
{
if (cfg == null || !Active || !cfg.holdZoneSpawnNearArena || cfg.zoneSpawnHoldMargin < 0f) return;
if (DSUtil.CheckNull(s_boss) || s_boss.IsDead()) return;
// 🔴 813w7 §1-2 ②(D-45) — 「보스 생존 중」이 아니라 「게이트 개방 뒤」로 좁힌다.
// 813w6 이 재스폰 보스를 **게이트 개방까지** 살려 두면서, 이 차단이 다음 런 내내 걸렸다.
// 차단은 `StopAllCoroutines` 라 PhaseAllKill 의 `killmobcount = 0`(MobControlMgr.cs:218)까지 함께
// 끊어 카운터를 `n_MaxMonsterCount` 에 굳힌다 → `== 비교`(:214) 가 영영 참이 안 된다(영구 소진).
if (cfg.holdZoneSpawnOnlyAfterGate && !s_gateOpen) { ZoneSpawnHoldSkipped++; return; }
if (!s_nearZonesScanned) ScanNearZones(cfg);
for (int i = 0; i < s_nearZones.Count; i++)
{
var m = s_nearZones[i];
if (DSUtil.CheckNull(m) || !m.Get_LoadComplete()) continue;
// 🔴 런이 쓰는 존(= 다음 런의 사냥터)은 건드리지 않는다 — 여기서 굳으면 D-45 가 그대로 재발한다.
if (!ShouldHoldZoneSpawn(cfg, m.SpawnerID, s_gateOpen)) { ZoneSpawnHoldSkipped++; continue; }
m.StopAllCoroutines();
ZoneSpawnHoldTicks++;
}
}
/// <summary>
/// 813w7 §1-2 ② — 이 스포너의 리젠 예약을 끊어도 되는가(순수 함수 · Actor 접근 0 · 프로브가 그대로 부른다 · C8 증거).
/// 🔴 「끊는다 = 그 스포너의 PhaseAllKill 카운터를 영구히 굳힌다」이므로 판정은 보수적이어야 한다.
/// </summary>
public static bool ShouldHoldZoneSpawn(WLBossArenaSettings cfg, int spawnerId, bool gateOpen)
{
if (cfg == null || !cfg.holdZoneSpawnNearArena) return false;
if (cfg.holdZoneSpawnOnlyAfterGate && !gateOpen) return false;
if (cfg.holdZoneSpawnSkipsRunZones && IsRunZoneSpawner(spawnerId)) return false;
return true;
}
/// <summary>이 스포너가 런 축(WLRunSettings.zoneSpawnerIds)의 사냥 존인가(순수 · 프로브가 그대로 부른다).</summary>
public static bool IsRunZoneSpawner(int spawnerId)
{
var rs = WL.Combat.Run.WLRunSettings.Instance;
if (rs == null || rs.zoneSpawnerIds == null) return false;
for (int i = 0; i < rs.zoneSpawnerIds.Length; i++) if (rs.zoneSpawnerIds[i] == spawnerId) return true;
return false;
}
/// <summary>대상 존 스포너를 1회 훑는다(런당 몇 회 · 매 틱 아님).</summary>
static void ScanNearZones(WLBossArenaSettings cfg)
{
@ -1053,6 +1110,171 @@ namespace WL.Combat.Boss
Log("존 스포너 유입 차단 대상 " + s_nearZones.Count + "개(중심 " + center.ToString("F1") + " 반경 " + lim.ToString("F0") + "m)");
}
// ─────────────────────────────────────────────────────────────────────
// 813w7 §1-2 ② — 존 스포너 재무장 (D-45 · 다음 런 사냥터가 비어 있다)
// ─────────────────────────────────────────────────────────────────────
//
// 🔴 원본에 「스포너 리셋 API」는 없다(실측) — `MobControlMgr` 의 공개 멤버는
// `SpawnerID` · `MobGenRange` · `text_lv` · `Get_LoadComplete()` 뿐이고,
// `killmobcount` · `orderIndex` · `dic_actors` 는 전부 private 이다.
// 그래서 **원본이 스스로 쓰는 공개 경로**를 그대로 다시 태운다 —
// 813e2 표 A 의 `MobControlMgr.cs:220-221` `dic_actors[orderIndex][i].On_Regen()` 와
// 같은 호출을, 그 스포너에 등록된 비활성 몹(`ActorInfo.Get_Actors`)에 대해 한다.
// `MobActor.On_Regen()` 은 원본 그대로 `Set_Warp(OriginalPos)` + 스폰 이펙트 +
// `CombatEvents.RaiseSpawned` 까지 하므로 존 카운터(z.alive/z.spawns)도 정상으로 붙는다.
//
// 🔴 이중 스폰 방지 = `rearmZoneEmptySeconds`(기본 8 s) > 원본 리듬 5.0 s(시체 3.0 + 리젠 2.0 · 813e2 표 A).
// 스포너가 멀쩡하면 5.0 s 에 스스로 되살아나 alive > 0 이 되므로 이 경로는 아예 들어오지 않는다.
/// <summary>감시 대상 존 id 목록을 다시 잡는다(런 존만 / 아레나 존 전체 · SO).</summary>
static void BuildRearmZones(WLBossArenaSettings cfg)
{
s_rearmCount = 0;
int[] src = null;
if (cfg.rearmZoneRunZonesOnly)
{
var rs = WL.Combat.Run.WLRunSettings.Instance;
if (rs != null) src = rs.zoneSpawnerIds;
}
if (src == null) src = cfg.zoneSpawnerIds;
if (src == null) return;
for (int i = 0; i < src.Length && s_rearmCount < kMaxRearmZones; i++)
{
s_rearmIds[s_rearmCount] = src[i];
s_rearmEmptySince[s_rearmCount] = -1f;
s_rearmCount++;
}
}
/// <summary>
/// 빈 존 감시 1틱 — 살아 있는 몹이 0 인 상태가 `rearmZoneEmptySeconds` 이상이면 그 존을 재무장한다.
/// 🔴 폴링 자체는 카운터만 본다 = 할당 0. 실제 재무장(드문 경로)만 원본 등록부를 훑는다.
/// 프로브가 가짜 시각으로 그대로 부를 수 있다.
/// </summary>
public static void ZoneRearmTick(WLBossArenaSettings cfg, float now)
{
if (cfg == null || !Active || !cfg.rearmZoneSpawnsEnabled || !s_armed) return;
if (cfg.rearmZoneEmptySeconds <= 0f) return;
if (!RunActive) return; // 결과창·다음 런 대기 중에는 손대지 않는다
if (s_rearmCount < 0) BuildRearmZones(cfg);
for (int i = 0; i < s_rearmCount; i++)
{
if (AliveOf(s_rearmIds[i]) > 0) { s_rearmEmptySince[i] = -1f; continue; }
if (s_rearmEmptySince[i] < 0f) { s_rearmEmptySince[i] = now; continue; }
if (now - s_rearmEmptySince[i] < cfg.rearmZoneEmptySeconds) continue;
s_rearmEmptySince[i] = now; // 성공·실패와 무관하게 다음 주기를 다시 잰다
RearmZone(cfg, s_rearmIds[i], "빈 존 " + cfg.rearmZoneEmptySeconds.ToString("F0") + "s");
}
}
/// <summary>
/// 대상 존을 전부 즉시 1회 재무장한다(「다음 런」 진입점 · `RunDirector.Restart()` 가 부른다).
/// 이미 살아 있는 몹이 있으면 그 존은 건너뛴다.
/// </summary>
public static int RearmZoneSpawns(string reason)
{
var cfg = Cfg;
if (cfg == null || !Active || !cfg.rearmZoneSpawnsEnabled || !cfg.rearmZoneSpawnsOnRestart) return 0;
BuildRearmZones(cfg); // 런 존 목록이 바뀌었을 수 있다
s_nextZoneRearmCheck = 0f;
int total = 0;
for (int i = 0; i < s_rearmCount; i++)
{
s_rearmEmptySince[i] = Now();
if (AliveOf(s_rearmIds[i]) > 0) continue;
total += RearmZone(cfg, s_rearmIds[i], reason);
}
if (total > 0) Log("존 재무장(" + reason + ") — " + total + "마리 재등장");
return total;
}
/// <summary>
/// 존 하나를 재무장한다 — 그 스포너의 **비활성** 몹을 1웨이브분만큼 `On_Regen()` 한다.
/// 반환 = 실제로 되살린 마리 수. 원본 등록부(`ActorInfo`)가 없거나 대상이 없으면 0.
/// </summary>
public static int RearmZone(WLBossArenaSettings cfg, int spawnerId, string reason)
{
if (cfg == null || !Application.isPlaying) return 0;
var info = ActorInfo.Ins;
if (DSUtil.CheckNull(info)) return 0;
int want = WaveSize(cfg, spawnerId);
if (want <= 0) return 0;
// 🔴 `ActorInfo.Get_Actors` 는 dic_actor[role][spawnerId] 를 직접 인덱싱한다 —
// 등록이 없는 스포너면 KeyNotFoundException 이 난다. 재무장 실패가 런을 죽이면 안 되므로 감싼다.
List<Actor> actors = null;
int done = 0;
try
{
actors = info.Get_Actors(eRole.Mob, spawnerId, true);
if (actors == null) return 0;
// 이미 살아 있는 개체가 있으면(카운터 드리프트) 재무장하지 않는다 — 이중 스폰 방지.
for (int i = 0; i < actors.Count; i++)
{
var a = actors[i];
if (!DSUtil.CheckNull(a) && a.isActiveAndEnabled && !a.IsDead()) return 0;
}
for (int i = 0; i < actors.Count && done < want; i++)
{
var mob = actors[i] as MobActor;
if (DSUtil.CheckNull(mob) || mob.isActiveAndEnabled) continue;
mob.On_Regen(); // 원본 웨이브와 같은 호출(MobControlMgr.cs:221) — 좌표·이펙트·Spawned 이벤트까지 원본 그대로
done++;
}
}
catch (System.Exception ex) { Log("존 " + spawnerId + " 재무장 실패 — " + ex.Message); }
finally { if (actors != null) info.Release_Actors(actors); }
if (done > 0)
{
ZoneRearmCount++;
ZoneRearmMobs += done;
LastRearmZoneId = spawnerId;
Log("존 " + spawnerId + " 재무장(" + reason + ") — " + done + "/" + want + "마리");
}
return done;
}
/// <summary>
/// 1웨이브 마리 수 — 원본 테이블 `n_MaxMonsterCount`(= `MobControlMgr` 이 쓰는 그 값)를 읽고,
/// 못 읽으면 SO `rearmZoneRegenMax`. SO 값은 언제나 상한으로 쓴다(안전).
/// </summary>
public static int WaveSize(WLBossArenaSettings cfg, int spawnerId)
{
int cap = cfg != null && cfg.rearmZoneRegenMax > 0 ? cfg.rearmZoneRegenMax : 0;
int n = cap;
// 🔴 table_monsterappear.Get_Data_orNull 은 dic_Data[id] 직접 인덱싱이라 없는 행이면 던진다.
try
{
var tbl = table_monsterappear.Ins;
if (!DSUtil.CheckNull(tbl))
{
var row = tbl.Get_Data_orNull(spawnerId);
if (row != null && row.n_MaxMonsterCount > 0) n = row.n_MaxMonsterCount;
}
}
catch (System.Exception) { n = cap; }
if (cap > 0 && n > cap) n = cap;
return n;
}
/// <summary>813w7 진단 — 재무장을 실행한 존 수 · 되살린 총 마리 수 · 마지막 대상 존 id.</summary>
public static int ZoneRearmCount, ZoneRearmMobs;
public static int LastRearmZoneId;
/// <summary>감시 대상 존 수(진단 · 아직 목록을 안 잡았으면 1).</summary>
public static int RearmZoneCount { get { return s_rearmCount; } }
/// <summary>감시 대상 i 의 스포너 id · 「비었다」로 잡힌 시각(unscaled · 안 비었으면 1).</summary>
public static int RearmZoneIdAt(int i) { return i >= 0 && i < s_rearmCount ? s_rearmIds[i] : 0; }
public static float RearmZoneEmptySinceAt(int i) { return i >= 0 && i < s_rearmCount ? s_rearmEmptySince[i] : -1f; }
/// <summary>프로브용 — 감시 목록을 다시 잡는다.</summary>
public static void RebuildRearmZonesForProbe() { var c = Cfg; if (c != null) BuildRearmZones(c); }
/// <summary>마지막 부분 회복 시각(unscaled · 쿨다운 판정).</summary>
static float s_lastLeashHeal = -999f;
/// <summary>리쉬 회복을 실제로 넣은 횟수 · 쿨다운으로 건너뛴 횟수(진단).</summary>
@ -1208,6 +1430,14 @@ namespace WL.Combat.Boss
// 🔴 813w6 §1-2 (D-44) — RunEnded 발행이 끝난 뒤로 미뤄 둔 고정을 여기서 실행한다.
if (s_parkPending) PumpDeferredPark();
// 🔴 813w7 §1-2 ② (D-45) — 빈 존 감시(자체 주기 · 리쉬 주기와 독립).
if (cfg.rearmZoneSpawnsEnabled && now >= s_nextZoneRearmCheck)
{
s_nextZoneRearmCheck = now + Mathf.Max(0.1f, cfg.rearmZoneCheckSeconds);
try { ZoneRearmTick(cfg, now); }
catch (System.Exception ex) { Log("존 재무장 감시 실패 — " + ex.Message); }
}
if (now < s_nextLeashCheck) return;
s_nextLeashCheck = now + Mathf.Max(0f, cfg.leashCheckIntervalSeconds);
try { LeashTick(); }
@ -1245,6 +1475,10 @@ namespace WL.Combat.Boss
s_totalKillBase = 0; s_runActiveOverride = 0;
RearmCount = LivingBossResetCount = DeadBossRegenCount = 0;
LeashSuspendedTicks = GateSuspendedEvals = ZoneSpawnHoldTicks = ZoneSpawnHoldTargets = 0;
// 813w7
ZoneSpawnHoldSkipped = ZoneRearmCount = ZoneRearmMobs = LastRearmZoneId = 0;
s_rearmCount = -1; s_nextZoneRearmCheck = 0f;
for (int i = 0; i < kMaxRearmZones; i++) s_rearmEmptySince[i] = -1f;
// 813w5
s_bossHoldUntil = -1f; s_probeTime = -1f;
ParkCount = BossHoldTicks = OutsideRunLeashResets = GateMinBlocked = RunEndNotices = 0;
@ -1283,6 +1517,7 @@ namespace WL.Combat.Boss
s_lastLeashHeal = -999f;
s_nearZonesScanned = false; // 맵/스포너가 바뀌었을 수 있다 — 다시 훑는다
s_parkPending = false; s_parkDueTime = -1f; // 813w6 — 여기서 보스를 직접 처리하므로 미뤄 둔 고정은 버린다
s_rearmCount = -1; // 813w7 — 존 감시 목록도 다시 잡는다(런 존이 바뀌었을 수 있다)
// ① 게이트 조기 개방(D-27) — 이 런 기준선으로 재스냅샷
if (cfg != null && cfg.restartRebasesGateKills) RebaseGateCounters();

View File

@ -308,5 +308,46 @@ namespace WL.Combat.Boss
[Tooltip("🔴 런 종료 순간(Park 이전)의 보스 HP 를 RunResult.bossHpRatio/bossHp/bossHpMax 에 담는다. "
+ "끄면 1/0/0 = 813p 계약 그대로(필드는 있지만 비어 있다).")]
public bool snapshotBossHpOnRunEnd = true;
// ─────────────────────────────────────────── 813w7 §1-2 ② 존 스포너 재무장(D-45)
[Header("존 스포너 재무장 (813w7 · D-45 다음 런 사냥터가 비어 있다)")]
[Tooltip("🔴 존 스포너 리젠 차단(holdZoneSpawnNearArena)을 **게이트가 열린 뒤**(= 진짜 보스전)에만 한다. "
+ "813w3 은 「보스가 살아 있는 동안」이 조건이었는데, 813w6 이 재스폰 보스를 게이트 개방까지 살려 두면서 "
+ "다음 런 내내 차단이 걸렸다 → 존0 이 통째로 얼어붙는다(D-45 · R2n 1/18). "
+ "끄면 813w3 그대로(보스 생존 = 상시 차단).")]
public bool holdZoneSpawnOnlyAfterGate = true;
[Tooltip("🔴 리젠 차단 대상에서 **런이 쓰는 존**(WLRunSettings.zoneSpawnerIds)은 뺀다. "
+ "차단은 원본 `MobControlMgr` 의 예약 코루틴을 StopAllCoroutines 로 끊는 방식인데, "
+ "PhaseAllKill 은 그 코루틴 안에서 `killmobcount = 0` 을 하므로(MobControlMgr.cs:218) "
+ "끊기면 카운터가 `n_MaxMonsterCount` 에 굳어 `== 비교`(:214)가 두 번 다시 참이 되지 않는다 = 영구 소진. "
+ "끄면 813w3 그대로(런 존도 차단 대상).")]
public bool holdZoneSpawnSkipsRunZones = true;
[Tooltip("🔴 존 스포너가 비어 있는 채로 굳으면 WL 이 원본과 같은 경로(MobActor.On_Regen)로 한 웨이브를 다시 깨운다. "
+ "원본 리셋 API 가 없어(MobControlMgr 공개 멤버 = SpawnerID·MobGenRange·text_lv·Get_LoadComplete 뿐) "
+ "813e2 PhaseAllKill 웨이브(:220-221 `dic_actors[orderIndex][i].On_Regen()`)와 같은 호출로 대신한다. "
+ "끄면 재무장 경로가 통째로 죽는다(C8).")]
public bool rearmZoneSpawnsEnabled = true;
[Tooltip("존의 살아 있는 몹이 0 인 상태가 이 시간(초 · unscaled) 이상 이어지면 재무장한다. "
+ "🔴 원본 리듬(시체 3.0 s + f_SpawnerDelay 2.0 s = 5.0 s · 813e2 표 A)보다 **길게** 둘 것 — "
+ "짧으면 멀쩡한 스포너의 웨이브와 겹쳐 이중 스폰이 된다.")]
public float rearmZoneEmptySeconds = 8f;
[Tooltip("빈 존 감시 주기(초 · unscaled). 이 주기의 폴링 자체는 할당 0(카운터만 본다).")]
public float rearmZoneCheckSeconds = 1f;
[Tooltip("한 번에 되살릴 최대 마리 수. 기본은 테이블 `n_MaxMonsterCount`(= 1웨이브)를 쓰고, "
+ "테이블을 못 읽으면 이 값을 쓴다. 테이블 값이 이 값보다 크면 이 값으로 자른다(안전 상한).")]
public int rearmZoneRegenMax = 15;
[Tooltip("재무장 대상 = 런이 쓰는 존(WLRunSettings.zoneSpawnerIds)만. "
+ "끄면 이 에셋의 zoneSpawnerIds 전체(게이트 존 포함)를 대상으로 한다.")]
public bool rearmZoneRunZonesOnly = true;
[Tooltip("🔴 [다음 런](RunDirector.Restart) 1 "
+ "다음 런 시작 시점에 되살린다(D-45 의 직접 처방). 끄면 위의 빈 존 감시로만 되살아난다.")]
public bool rearmZoneSpawnsOnRestart = true;
}
}

View File

@ -523,6 +523,7 @@ namespace WL.Combat.Run
}
}
if (c.restartRearmsBossGate) BossArena.ResetGate(); // 존 누적 카운터는 건드리지 않는다
BossArena.RearmZoneSpawns("다음 런"); // 🔴 #813w7 D-45 — 굳은 존 스포너를 1웨이브 되살린다(SO 로 끈다)
if (c.restartResetsPotions) PotionUse.ResetRun("run restart");
}
catch (System.Exception ex) { Log("재시작 일부 실패 — " + ex.Message); }

View File

@ -104,6 +104,24 @@
// ■ C8 롤백 — `warpBackEnabled=0` 이면 워프 경로가 통째로 죽고 813w2 동작 그대로다.
// `exitDiagEnabled=0` 이면 진단 로그도 0.
// ─────────────────────────────────────────────────────────────────────────────
// WL-813w7 (2026-09-09 · PD 지시 #813) — 근거 = qa/W2-10_재채점_v8.md **D-45**
//
// 🔴 실측 결함 — 「다음 런」의 사냥터가 비어 있다(R1n 0/18 · R2n 1/18 · R3n 14/18 정지).
// R2n 은 PC 좌표 `(112.93, 0.06, 16.60)` = 존0 앵커 `(115, 0.39, 22)` 에서 5.8 m,
// 즉 **PC 는 존0 에 도착해 Hold 로 서 있는데 때릴 몹이 없다**(HP 310/310 무피해).
//
// ■ 이 파일이 맡은 몫 = ① 결과창 동안 PC 를 세운다(§1-2 ①)
// 813w6 까지는 `OnRunEnded` 가 `StopGuide` 만 했다 = 가이드를 끄고 **원본 자동전투 100%** 로 돌려줬다.
// Q10 실측: 결과창을 100 초 띄워 둔 사이 존0 처치가 **26 → 47**(21마리)로 늘었다.
// 이제 런이 끝나면 다음 런이 시작될 때까지(또는 `runEndHoldMaxSeconds`) PC 를 붙잡는다 —
// ⓐ `Set_JoystickStatus(1)` → PCActor.cs:139 이 `base.Update()`(자동 교전 접근 루프)를 건너뛴다
// ⓑ `Del_Target(true)` 매 폴링 → PCActor.cs:93 자동 스킬 루프는 `isTarget` 이 있어야만 돈다
// ⓒ `NavMeshAgent.ResetPath()` → 런 종료 순간의 잔여 경로로 계속 걸어가지 않는다
// 복귀 = 다음 `RunStarted`(= `RunDirector.Restart()`) 또는 상한 만료 또는 PC 사망.
// 🔴 UI 0 — RunGuide 는 결과창을 보지 않는다. 「결과창 닫힘」은 상한으로만 판정한다(§4 미확인).
//
// ■ C8 롤백 — `holdPcOnRunEnd=0` 이면 이 경로가 통째로 죽고 813w6 동작 그대로다.
// ─────────────────────────────────────────────────────────────────────────────
//
// ■ 값(C45) 전부 WLRunGuideSettings. enabled = 0 이면 러너가 뜨지 않고 구독도 하지 않는다(C8).
//
@ -217,6 +235,16 @@ namespace WL.Combat.Run
/// <summary>마지막 이탈 진단 줄 원문(프로브·QA 가 읽는다 · 콘솔에 나간 그 문자열).</summary>
public static string LastExitLog = "";
// ───────────────────────────────────────── 813w7 진단 (D-45 · §1-2 ①)
/// <summary>813w7 §1-2 ①: 런 종료 정지를 건 횟수 · 푼 횟수 · 정지 폴링 틱 수 · 정지 중 뗀 타깃 수.</summary>
public static int RunEndHolds, RunEndHoldReleases, RunEndHoldTicks, RunEndTargetClears;
/// <summary>지금 「결과창 중 PC 정지」 상태인가(프로브·QA 가 읽는다).</summary>
public static bool RunEndHeld { get { return s_runEndHold; } }
/// <summary>정지 상한 만료 시각(unscaled · 무제한이면 1).</summary>
public static float RunEndHoldUntil { get { return s_runEndHoldUntil; } }
/// <summary>마지막으로 정지를 푼 사유(진단).</summary>
public static string LastRunEndHoldReason = "";
/// <summary>진단 링버퍼 칸 수. 게임 수치가 아니라 버퍼 크기다(발주서 §1-1 「링버퍼 32」).</summary>
const int kExitLogCapacity = 32;
static readonly ArenaExitRecord[] s_exitLog = new ArenaExitRecord[kExitLogCapacity];
@ -257,6 +285,10 @@ namespace WL.Combat.Run
static float s_idleSince = -1f, s_lastStallGuide = -999f;
static int s_lastRestartCount;
// 813w7 §1-2 ① 결과창 중 PC 정지 — 정지 중인가 · 상한 만료 시각(1 = 무제한)
static bool s_runEndHold;
static float s_runEndHoldUntil = -1f;
// 813w4 §1-2 엘리트 등록부 — 사전 할당 배열(GC 0 · 버퍼 크기지 게임 수치가 아니다)
const int kMaxElites = 16;
static readonly Actor[] s_elites = new Actor[kMaxElites];
@ -321,6 +353,8 @@ namespace WL.Combat.Run
static void OnRunStarted(in RunStartedEvent e)
{
// 🔴 813w7 §1-2 ① — 런이 다시 시작됐다 = 결과창이 끝났다. PC 정지를 먼저 푼다(StopGuide 보다 앞).
EndRunEndHold("런 시작");
StopGuide("런 시작");
s_boss = null;
s_anchorCount = 0; // 맵이 바뀌었을 수 있다 — 앵커를 다시 읽는다
@ -403,7 +437,9 @@ namespace WL.Combat.Run
BeginBoss("보스 등장");
}
static void OnRunEnded(in RunResult e) { StopGuide("런 종료"); }
// 🔴 813w7 §1-2 ① (D-45) — 813w6 까지는 여기서 가이드만 껐다 = 결과창 동안 원본 자동전투 100%.
// Q10 실측: 결과창 100 s 사이 존0 처치 26 → 47 → 다음 런 시작 시 사냥터가 비었다.
static void OnRunEnded(in RunResult e) { StopGuide("런 종료"); BeginRunEndHold("런 종료"); }
static void OnDamaged(in DamagedEvent e)
{
@ -463,6 +499,9 @@ namespace WL.Combat.Run
"(쿨 " + WarpsSkippedCooldown + " 보스곁 " + WarpsSkippedNearBoss + " 맵밖 " + WarpsFailedNavMesh + ")" +
// 813w4: 「다음 런」 재유도 · 정지 감시 · 엘리트 우선 타깃(D-34 · D-20 을 이 줄로 판정한다)
" · 재유도 " + RestartGuides + "/정지 " + StallGuides +
// 813w7 §1-2 ①: 결과창 중 PC 정지(D-45) — QA 가 이 값으로 「빈 사냥터」 수리를 판정한다
" · 결과정지 " + RunEndHolds + "→" + RunEndHoldReleases +
"(틱 " + RunEndHoldTicks + " 타깃 " + RunEndTargetClears + ")" +
" · 엘리트 " + EliteCount + "(고정 " + (DSUtil.CheckNull(s_lockedElite) ? "-" : LastEliteName) +
" 락 " + EliteLocks + " 재타깃 " + EliteRetargets + " 해제 " + EliteReleases + ")" +
" · 존 " + e.zonesCleared + "/" + e.zoneCount + " " + e.zoneKills + "/" + e.zoneRequired;
@ -542,7 +581,7 @@ namespace WL.Combat.Run
internal static void Tick()
{
if (s_faulted) return;
if (!Active) { if (s_steering) ReleaseSteer(); return; }
if (!Active) { if (s_runEndHold) EndRunEndHold("가이드 off"); else if (s_steering) ReleaseSteer(); return; }
var cfg = Cfg;
float now = Time.unscaledTime;
@ -563,6 +602,10 @@ namespace WL.Combat.Run
var pc = MyValue.MyPC;
if (DSUtil.CheckNull(pc) || pc.IsDead()) { ReleaseSteer(); s_lastPosTime = -1f; return; }
// 🔴 813w7 §1-2 ①(D-45) — 결과창 동안 PC 정지. 목적지 판정보다 **앞**에 둔다
// (이 구간에는 목적지가 없고, 원본 자동전투가 존 몹을 소진하는 것을 막는 것이 전부다).
if (s_runEndHold) { RunEndHoldTick(cfg, pc, now); return; }
// 🔴 813w4 §1-1(D-34) 안전망 — 목적지가 없는 동안에도 「런은 도는데 PC 가 멈춰 있는지」를 본다.
if (s_destKind == RunGuideDest.None && TrackIdleStall(cfg, pc, pc.Get_position(), now)) return;
@ -654,6 +697,80 @@ namespace WL.Combat.Run
Log((isBoss ? "아레나" : "존 " + s_zoneIndex) + " " + why);
}
// ─────────────────────────────────────────────────────────────────────
// 813w7 §1-2 ① — 결과창 중 PC 정지 (D-45)
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// 런 종료(결과창) 정지를 건다. 다음 `RunStarted`·상한 만료·가이드 off 에서 풀린다.
/// `holdPcOnRunEnd` 가 꺼져 있으면 아무 일도 하지 않는다(C8 = 813w6 그대로).
/// 프로브가 직접 부를 수 있다.
/// </summary>
public static void BeginRunEndHold(string reason)
{
var cfg = Cfg;
if (cfg == null || !cfg.holdPcOnRunEnd) return;
if (s_runEndHold) return; // 중복 안전(RunEnded 는 런당 1회지만 Abandon 경로가 겹칠 수 있다)
s_runEndHold = true;
s_runEndHoldUntil = cfg.runEndHoldMaxSeconds > 0f ? Time.unscaledTime + cfg.runEndHoldMaxSeconds : -1f;
RunEndHolds++;
Log("결과창 중 PC 정지 시작(" + reason + ") — 상한 " +
(s_runEndHoldUntil > 0f ? cfg.runEndHoldMaxSeconds.ToString("F0") + "s" : "무제한"));
}
/// <summary>런 종료 정지를 푼다(조종 원복 포함). 프로브가 직접 부를 수 있다.</summary>
public static void EndRunEndHold(string reason)
{
if (!s_runEndHold) { s_runEndHoldUntil = -1f; return; }
s_runEndHold = false;
s_runEndHoldUntil = -1f;
LastRunEndHoldReason = reason;
RunEndHoldReleases++;
// 🔴 정지 중 세워 둔 에이전트를 반드시 다시 풀어 준다 — 안 그러면 다음 런에서 PC 가 못 걷는다.
var pc0 = MyValue.MyPC;
if (!DSUtil.CheckNull(pc0))
{
var ag = pc0.Get_Agent();
if (ag != null && ag.enabled && ag.isOnNavMesh) ag.isStopped = false;
}
ReleaseSteer(); // JoystickStatus 를 우리가 1 로 만든 경우에만 3(=0)으로 되돌린다
Log("결과창 중 PC 정지 해제 — " + reason);
}
/// <summary>
/// 정지 1틱 — 조종을 잡고(자동 교전 접근 루프 차단) 타깃을 떼고(자동 스킬 차단) 잔여 경로를 지운다.
/// 🔴 GC 0 — 문자열·배열 할당 없음(로그는 시작·해제에서만).
/// 프로브가 가짜 시각으로 그대로 부를 수 있다(pc 는 살아 있는 참조여야 한다).
/// </summary>
public static void RunEndHoldTick(WLRunGuideSettings cfg, PCActor pc, float now)
{
if (cfg == null || !cfg.holdPcOnRunEnd) { EndRunEndHold("스위치 off"); return; }
if (s_runEndHoldUntil > 0f && now >= s_runEndHoldUntil) { EndRunEndHold("상한 만료"); return; }
if (DSUtil.CheckNull(pc)) return;
RunEndHoldTicks++;
// ⓐ 자동 교전 접근 루프 차단(PCActor.cs:139) — 우리가 0 → 1 로 만든 경우에만.
AcquireSteer(pc);
// ⓑ 자동 스킬 루프 차단(PCActor.cs:93 `isTarget` 조건) — 매 폴링 뗀다.
if (cfg.runEndHoldClearsTarget && pc.isTarget)
{
var t = pc.Get_Target();
if (!DSUtil.CheckNull(t)) { LastIgnoredTarget = t.name; pc.Del_Target(true); }
else pc.isTarget = false; // 참조 없이 플래그만 남은 상태 — Del_Target 은 여기서 m_Target 을 NRE 로 만진다
RunEndTargetClears++;
}
// ⓒ 런 종료 순간의 잔여 경로로 계속 걸어가지 않게 한다.
if (!cfg.runEndHoldStopsAgent) return;
var agent = pc.Get_Agent();
if (agent == null || !agent.enabled || !agent.isOnNavMesh) return;
if (agent.hasPath) agent.ResetPath();
agent.isStopped = true;
}
// ─────────────────────────────────────────────────────────────────────
// 813w4 §1-1 — 정지 감시(D-34 안전망)
// ─────────────────────────────────────────────────────────────────────
@ -1004,11 +1121,7 @@ namespace WL.Combat.Run
if (anim == eAnim.Dodge || anim >= eAnim.Attack || pc.IsCC()) return; // 공격·스킬·회피·CC 는 원본 우선
if (pc.JoystickStatus == 2) return; // 스킬 이동 잠금(Whirlwind 등)
if (!s_steering && pc.JoystickStatus == 0)
{
pc.Set_JoystickStatus(1); // 원본 자동 교전 접근 루프를 비켜 세운다(PCActor.cs:139)
s_steering = true;
}
AcquireSteer(pc);
// 🔴 813w2: 타깃이 남아 있으면 Play_Run() 이 `Set_Path(타깃)`(MyActor.cs:335)으로 경로를 덮어쓴다.
// 보통은 바로 위 ClearTravelTarget 이 타깃을 비웠으므로 그런 일이 없지만, 원본이 같은 프레임에
@ -1027,6 +1140,19 @@ namespace WL.Combat.Run
}
}
/// <summary>
/// 조종을 잡는다 — 원본 자동 교전 접근 루프를 비켜 세운다(PCActor.cs:139).
/// 🔴 우리가 **0 → 1** 로 만든 경우에만 잡는다. 2(Whirlwind 등 스킬 이동 잠금)는 건드리지 않는다.
/// 813w7 §1-2 ①: 이동(Steer)과 결과창 정지(RunEndHoldTick)가 같은 진입점을 쓴다.
/// </summary>
static void AcquireSteer(PCActor pc)
{
if (s_steering || DSUtil.CheckNull(pc)) return;
if (pc.JoystickStatus != 0) return;
pc.Set_JoystickStatus(1);
s_steering = true;
}
/// <summary>조종을 놓는다(우리가 1 로 만든 경우에만 0 으로 되돌린다).</summary>
static void ReleaseSteer()
{
@ -1267,6 +1393,21 @@ namespace WL.Combat.Run
for (int i = 0; i < kMaxElites; i++) s_elites[i] = null;
s_idleSince = -1f; s_lastStallGuide = -999f; s_idlePos = Vector3.zero;
s_lastRestartCount = RunDirector.RestartCount;
// 813w7
EndRunEndHold("진단 초기화");
RunEndHolds = RunEndHoldReleases = RunEndHoldTicks = RunEndTargetClears = 0;
LastRunEndHoldReason = "";
}
// ── 813w7 프로브 API
/// <summary>프로브용 — 정지 상한 만료 시각을 직접 밀어 넣는다(에디트 모드에서는 Time 이 흐르지 않는다).</summary>
public static void SetRunEndHoldUntilForProbe(float t) { s_runEndHoldUntil = t; }
/// <summary>프로브용 — 진짜 RunEnded 페이로드로 핸들러를 그대로 태운다(Play 불필요).</summary>
public static void ProbeRunEnded(RunOutcome outcome)
{
var e = default(RunResult);
e.outcome = outcome; e.time = Time.unscaledTime; e.frame = Time.frameCount;
OnRunEnded(in e);
}
// ── 813w4 프로브 API

View File

@ -63,6 +63,17 @@ namespace WL.Combat.Run
/// <summary>테스트용: 캐시를 비워 다음 접근 때 다시 Resources.Load 한다.</summary>
public static void ClearCache() { s_cached = null; s_lookupDone = false; }
/// <summary>
/// 813w7 프로브용 — 임시 SO 를 잠시 Instance 자리에 끼운다(C8 진리표 · **실에셋은 읽기만** 한다).
/// null 을 넣으면 캐시가 비워져 다음 접근에서 실에셋을 다시 로드한다(= ClearCache).
/// 🔴 런타임 게임 경로에서는 절대 부르지 않는다(WLBossArenaSettings.SetInstanceForProbe 선례 그대로).
/// </summary>
public static void SetInstanceForProbe(WLRunGuideSettings so)
{
s_cached = so;
s_lookupDone = so != null;
}
// ─────────────────────────────────────────── 전체 스위치
[Header("전체 스위치")]
[Tooltip("끄면 존 이동 가이드·보스 우선 타깃이 전부 비활성 — 원본 자동전투 100%(Q4b 상태 그대로). 롤백 1순위.")]
@ -263,5 +274,27 @@ namespace WL.Combat.Run
[Tooltip("이탈이 이어지는 동안 이 주기(초)마다 진단 줄을 더 남긴다. 0 이면 이탈 시작·워프 때만(기본).")]
public float exitDiagRepeatSeconds = 0f;
// ─────────────────────────────────────────── 813w7 §1-2 ① 결과창 중 PC 정지(D-45)
[Header("결과창 중 PC 정지 (813w7 · D-45 결과창 동안 PC 가 존 몹을 소진)")]
[Tooltip("🔴 런이 끝나면(RunEnded) 다음 런이 시작될 때까지 PC 를 세운다 — 813w 과 같은 "
+ "Set_JoystickStatus(1)(PCActor.cs:139 이 base.Update() = 자동 교전 접근 루프를 통째로 건너뛴다) + "
+ "타깃 해제(PCActor.cs:93 자동 스킬 루프는 isTarget 이 있어야만 돈다). "
+ "Q10 D-45 실측: 결과창 100 초 동안 z0 처치가 26 → 47 로 늘어 다음 런의 사냥터가 비었다. "
+ "끄면 813w6 그대로(런 종료 = 가이드만 끄고 원본 자동전투 100%).")]
public bool holdPcOnRunEnd = true;
[Tooltip("정지 상한(초 · unscaled). 결과창을 [닫기] PC "
+ "이 시간이 지나면 스스로 푼다. 0 이면 무제한(다음 RunStarted 까지). "
+ "🔴 RunGuide 는 UI 를 보지 않는다(UI 0) — 「결과창 닫힘」은 이 상한과 다음 런 시작으로만 판정한다.")]
public float runEndHoldMaxSeconds = 120f;
[Tooltip("정지 중 매 폴링마다 PC 타깃을 뗀다(Actor.Del_Target(true)). "
+ "🔴 이것이 꺼지면 조이스틱 잠금만으로는 자동 스킬(PCActor.cs:93)이 계속 돌아 몹이 계속 죽는다.")]
public bool runEndHoldClearsTarget = true;
[Tooltip("정지 중 NavMeshAgent 의 남은 경로를 지운다(ResetPath · isStopped). 런 종료 순간의 잔여 경로로 "
+ "PC 가 계속 걸어가는 것을 막는다. 끄면 좌표만 그대로 두고 조종만 잡는다.")]
public bool runEndHoldStopsAgent = true;
}
}

View File

@ -72,3 +72,11 @@ MonoBehaviour:
parkBossOnRunEndDeferred: 1
parkBossOnRunEndDelaySeconds: 0
snapshotBossHpOnRunEnd: 1
holdZoneSpawnOnlyAfterGate: 1
holdZoneSpawnSkipsRunZones: 1
rearmZoneSpawnsEnabled: 1
rearmZoneEmptySeconds: 8
rearmZoneCheckSeconds: 1
rearmZoneRegenMax: 15
rearmZoneRunZonesOnly: 1
rearmZoneSpawnsOnRestart: 1

View File

@ -62,3 +62,7 @@ MonoBehaviour:
bossAwayFromCenterRadius: 6
exitDiagEnabled: 1
exitDiagRepeatSeconds: 0
holdPcOnRunEnd: 1
runEndHoldMaxSeconds: 120
runEndHoldClearsTarget: 1
runEndHoldStopsAgent: 1