diff --git a/AgentScripts/WL813p_PROBE.txt b/AgentScripts/WL813p_PROBE.txt new file mode 100644 index 000000000..9baff11cc --- /dev/null +++ b/AgentScripts/WL813p_PROBE.txt @@ -0,0 +1,93 @@ +# WL813p Probe 2026-09-09 08:35:32 (edit mode · Play 0 · 로그인 0) +playMode=False + +── ⓪ 준비 + BossArena.HoldSpawn=True GateArmed=True 존 4개 · 보스 813010 + [PASS] 813d 게이트 무장(존 누적 카운터 사용 가능) + [PASS] 가짜 메인 PC IsMainPC()=true + +── ① 에셋 · SOT (WLRunSettings) + [PASS] Resources.Load("WL/WLRunSettings") + [PASS] Enabled(에셋·enabled·RuntimeDisabled·코어) = True (core=True) + 값: enabled=True verboseLog=False runSeconds=180 tickIntervalSeconds=1 pollSeconds=0.1 pauseTimerWhileWorldHeld=True + 값: bossSpawnerId=813010 openGateOnZonesCleared=True openGateAtSec=120 autoStartOnMapEnter=True skipLobby=True endOnMapLeave=True + 값: restartWarpsPlayer=True restartHealRate=1 restartRearmsBossGate=True restartResetsPotions=True + [PASS] zoneSpawnerIds/zoneClearKills 길이 일치 = 4 / 4 + [PASS] 존0 id=813001 zoneClearKills=24 MonsterAppear n_MaxMonsterCount=12 ×2=24 + [PASS] 존1 id=813002 zoneClearKills=30 MonsterAppear n_MaxMonsterCount=15 ×2=30 + [PASS] 존2 id=813003 zoneClearKills=28 MonsterAppear n_MaxMonsterCount=14 ×2=28 + [PASS] 존3 id=813004 zoneClearKills=16 MonsterAppear n_MaxMonsterCount=8 ×2=16 + [PASS] 보스 스포너 813010 n_MaxMonsterCount=1 (MonsterAppear 실측) + lootByGrade 길이 = WLLootSettings.GradeSlots(9) + 1 = 10 ※ 발주서 §1 은 [6] 이라 적혀 있으나 실측 등급 슬롯은 9 이라 그쪽에 맞췄다 + +── ② 계약 — RunEvents 6종 · RunDirector 가 무는 소스 11종 + RunEvents 구독자(프로브 6개): RunStarted=1 ZoneCleared=1 BossGateOpened=1 BossStarted=1 RunEnded=1 RunTick=1 + [PASS] 813q 가 구독할 6종이 전부 살아 있다 + [PASS] EnsureSubscribed ×2 → 소스 11종 각각 +1 (중복 구독 0) · Subscribed=True + +── ③ 런 1회(승리) — 존 4 클리어 → 게이트 → 보스 등장 → 처치 + [PASS] RunStarted 1회 · phase=Zones runIndex=1 · 시작 즉시 RunTick 1회 + [PASS] 스냅샷 차감 — BossArena.KillsOf(813001)=5 인데 런 진행률=0 (0 이어야 한다) + [PASS] ZoneCleared 4회 / 존 4개 · 마지막 zoneIndex=3 · ZonesCleared=4 + [PASS] BossGateOpened 1회 reason=ZonesCleared · BossArena.GateOpen=True resume 호출=1 + [PASS] BossStarted 1회 · phase=Boss + [PASS] RunEnded 1회 outcome=Win phase=Ended + 집계 대조 (입력 → RunResult) + [PASS] kills 99 / 기대 99 + [PASS] bossKills 1 / 기대 1 + [PASS] maxChain 9 / 기대 9 + [PASS] maxDamage 1234.5 / 기대 1234.5 + [PASS] maxDamageBurst 8888.5 / 기대 8888.5 + [PASS] level 11→14 / 기대 11→14 + [PASS] deaths 2 / 기대 2 + [PASS] potionsUsed 3 / 기대 3 + [PASS] skillCasts 7 / 기대 7 + [PASS] lootTotal 45 / 기대 45 + [PASS] gold 777 / 기대 777 + [PASS] lootByGrade 길이 10 / 기대 10 + [PASS] lootByGrade[0..9] = 0,1,2,3,4,5,6,7,8,9 / 기대 0,1,2,3,…,9 + [PASS] zonesCleared 4/4 + [PASS] zoneClearSec 전 존 기록됨 + [PASS] zoneKills = zoneClearKills(스냅샷 차감 후) · 존0 = 24 + [PASS] bossGateSec=0.00 bossStartSec=0.00 bossClearSec=0.00 + [PASS] runIndex=1 runSeconds=180 + BossArena 누적 보존 — KillsOf(813001)=29 (런 전 5 + 이번 런 24) + [PASS] BossArena 값 리셋 0(발주서 §1) + [PASS] 종료 후 처치는 집계에 안 들어간다(phase=Ended) + +── ④ 시간 종료 — openGateAtSec → 게이트 · runSeconds → Timeout + [PASS] 존 미달(12/24) → ZoneCleared 0 · BossGateOpened 0 + [PASS] openGateAtSec(120s) 경과 → BossGateOpened reason=TimeLimit phase=BossGate + [PASS] RunEnded outcome=Timeout totalSec=300.5 (runSeconds=180) + [PASS] 보스 미처치 — bossKills=0 bossClearSec=-1 · zonesCleared=0/4 + [PASS] runIndex=2 (두 번째 런) + +── ⑤ 다음 런 — RunDirector.Restart() + [PASS] runIndex 2 → 3 + [PASS] RunStarted 1회 · phase=Zones + [PASS] 집계 리셋 — zonesCleared=0 zone0 진행=0 + [PASS] 게이트·보스 상태 리셋 — GateOpened=False BossStarted=False + [PASS] BossArena.ResetGate() — GateOpen=False GateArmed=True BossKilled=False + [PASS] BossArena 존 누적 보존 42 → 42 (리셋 금지 조항) + [PASS] 재시작 후 존0 재클리어 ZoneCleared=1 (진행 1/4) + ※ Restart() 의 PC 워프(MyActor.On_Regen → Set_Warp(Get_ReincanationPos))는 에디트 모드에 PC·LoadMapMgr 가 없어 미측정 =「미확인」 + +── ⑥ RunTick 주기 (SO tickIntervalSeconds) + [PASS] 같은 프레임 강제 틱 100회 → RunTick +0 (간격 미만이면 0 이어야 한다) + [PASS] 절반(0.5s) 경과 → +0 + [PASS] 나머지 절반 경과 → +1 = 1 Hz(tickIntervalSeconds=1) + [PASS] 10초 진행 → RunTick +10 (기대 10) + +── ⑦ GC — 틱·집계 100회 + 본시험 ΔGetTotalMemory=0 B · ΔMonoUsed=0 B · gc0 253→253 + 대조군 ΔGetTotalMemory=4096 B · ΔMonoUsed=4096 B (문자열 100개 · junk[100]) + [PASS] 틱·집계 100회 할당 0 B + +── ⑧ C8 롤백 + [PASS] RuntimeDisabled=1 → Enabled=False · StartRun 무동작 · phase=Idle + [PASS] 코어 off → Enabled=False · 런 구조도 안 걸린다(집계 소스가 죽으므로) + [PASS] 복구 후 Enabled=True + +── ⑨ 정리 — 임시 GameObject 파괴 · 구독 해제 · 런타임 스위치 복구 (에셋/씬 저장 0) + RunEvents.TotalRaised=133 last=RunEnded +RESULT PASS diff --git a/AgentScripts/WL813p_Probe.cs b/AgentScripts/WL813p_Probe.cs new file mode 100644 index 000000000..5c5419347 --- /dev/null +++ b/AgentScripts/WL813p_Probe.cs @@ -0,0 +1,545 @@ +// WL813p_Probe.cs — #813p(3분 런 구조 · RunEvents · 집계 · 다음 런) 에디트 모드 검증 프로브 +// 에디트 모드 전용 · Play 불필요 · 로그인 0 · 씬/에셋 무수정(임시 GameObject 는 즉시 파괴) +// +// 상주 에디터: unity command run_script --project-path --file AgentScripts/WL813p_Probe.cs --entry WL813p_Probe.RunAll --timeout 300 +// 결과: Console + AgentScripts/WL813p_PROBE.txt (RESULT PASS / RESULT FAIL n) +// +// 검사 (발주서 WL-813p §2 ⓒ ⓓ) +// ① 에셋·SOT: WLRunSettings 로드 · 값 전수 덤프 · zoneSpawnerIds/zoneClearKills 를 MonsterAppear.json 실측과 대조 +// ② 계약: RunEvents 6종 구독자 목록 · RunDirector 가 무는 11개 소스 이벤트(EnsureSubscribed 전/후 Count +1) +// ③ 런 1회(승리): 존 4 클리어 → 게이트(ZonesCleared) → 보스 등장 → 보스 처치 → RunEnded(Win) +// · 가짜 Killed/Spawned/SkillCast/LootPicked + Note* 로 넣은 값과 RunResult 덤프가 일치하는지 전수 비교 +// · 런 시작 전 존 누적 처치(스냅샷 차감)가 진행률에 섞이지 않는지 +// ④ 시간 종료: openGateAtSec 경과 → BossGateOpened(TimeLimit) · runSeconds 경과 → RunEnded(Timeout) +// ⑤ 다음 런: RunDirector.Restart() → runIndex+1 · 집계 0 · 존 클리어 0 · BossArena 게이트 재무장(누적 카운터는 보존) +// ⑥ RunTick 1 Hz: 같은 프레임 100회 강제 틱 = +0 · 0.5 s 씩 두 번 = +1 · SO tickIntervalSeconds 반영 +// ⑦ GC: 틱·집계 100회 반복 0 B(대조군 = 의도적 할당) +// ⑧ C8: RuntimeDisabled / enabled=0 이면 StartRun·Tick 이 아무 일도 하지 않는다 +// +// 에디트 모드 한계: 실제 스폰·PC·서버 데이터가 없다 → Restart() 의 On_Regen(존 시작점 워프)·levelFrom(ServerInfo) +// 은 여기서 측정할 수 없다(플레이 모드 · 병합 후 QA). RunDirector 가 Application.isPlaying 으로 스스로 막는다. + +using System; +using System.IO; +using System.Reflection; +using System.Text; +using UnityEngine; +using WL.Combat.Boss; +using WL.Combat.Core; +using WL.Combat.Loot; +using WL.Combat.Run; +using WL.Combat.Survival; + +public static class WL813p_Probe +{ + const string OutCommit = "AgentScripts/WL813p_PROBE.txt"; + const string MonsterAppear = "Assets/ResWork/Table/Export/MonsterAppear.json"; + const BindingFlags NP = BindingFlags.NonPublic | BindingFlags.Instance; + + static StringBuilder sb; + static int s_fail; + + static GameObject s_fbdGo, s_pcGo, s_bossGo; + static GameObject[] s_zoneGo; + static MobActor[] s_zoneMob; + static MobActor s_bossMob; + static Actor s_pc; + static int s_resume; + + // ── 마지막으로 받은 RunEvents 페이로드(구독 검증용) + static int s_evStart, s_evZone, s_evGate, s_evBoss, s_evEnd, s_evTick; + static RunResult s_lastEnd; + static RunGateReason s_lastGateReason; + static int s_lastZoneIndex = -1; + + public static object RunAll() + { + sb = new StringBuilder(); + s_fail = 0; + sb.AppendLine("# WL813p 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_Contract(); + Sec3_WinRun(); + Sec4_Timeout(); + Sec5_Restart(); + Sec6_TickRate(); + Sec7_Gc(); + Sec8_Rollback(); + } + catch (Exception ex) { s_fail++; L("[FAIL] 예외 " + ex); } + finally { Teardown(); } + + sb.AppendLine(s_fail == 0 ? "RESULT PASS" : "RESULT FAIL " + s_fail); + string s = sb.ToString(); + Debug.Log(s); + try { File.WriteAllText(OutCommit, s, new UTF8Encoding(false)); } catch { } + return s; + } + + // ───────────────────────────────────────── 도구 + static void L(string m) { sb.AppendLine(m); } + static string P(bool ok) { if (!ok) s_fail++; return ok ? "[PASS]" : "[FAIL]"; } + static bool Near(double a, double b) { return Math.Abs(a - b) < 1e-3; } + static long Alloc() { return GC.GetTotalMemory(false); } + static long Mono() { return UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong(); } + + static MobActor MakeMob(string name, int spawnerId, eSubRol sub) + { + var go = new GameObject("__WL813p_" + name); + var m = go.AddComponent(); + m.m_Role = eRole.Mob; + m.m_SubRole = sub; + var f = typeof(MobActor).GetField("m_SpawnerId", BindingFlags.NonPublic | BindingFlags.Instance); + if (f != null) f.SetValue(m, spawnerId); + return m; + } + + static void Kill(params UnityEngine.Object[] objs) + { + for (int i = 0; i < objs.Length; i++) + if (objs[i] != null) UnityEngine.Object.DestroyImmediate(objs[i]); + } + + /// MonsterAppear.json 의 한 행에서 정수 필드를 읽는다(코드 상수 0 · 실측 대조용). + static int ReadAppear(int spawnerId, string field, int fallback) + { + try + { + string txt = File.ReadAllText(MonsterAppear); + int at = txt.IndexOf("\"n_SpawnerID\": \"" + spawnerId + "\"", StringComparison.Ordinal); + if (at < 0) return fallback; + int end = txt.IndexOf('}', at); + int k = txt.IndexOf("\"" + field + "\": \"", at, StringComparison.Ordinal); + if (k < 0 || k > end) return fallback; + k += field.Length + 5; + int q = txt.IndexOf('"', k); + int v; + return int.TryParse(txt.Substring(k, q - k), out v) ? v : fallback; + } + catch { return fallback; } + } + + // ───────────────────────────────────────── ⓪ 준비 + static void Setup() + { + WLCombatCoreSettings.RuntimeDisabled = false; + WLBossArenaSettings.RuntimeDisabled = false; + WLRunSettings.RuntimeDisabled = false; + WLRunSettings.ClearCache(); + WLBossArenaSettings.ClearCache(); + + BossArena.ResetAll(); + RunDirector.ResetAll(); + RunDirector.Unsubscribe(); + RunEvents.ResetDiagnostics(); + CombatEvents.ResetDiagnostics(); + + // 프로브가 RunEvents 를 실제로 구독한다(813q 가 할 일과 같은 계약). + RunEvents.RunStarted.Add(OnRunStarted); + RunEvents.ZoneCleared.Add(OnZoneCleared); + RunEvents.BossGateOpened.Add(OnGateOpened); + RunEvents.BossStarted.Add(OnBossStarted); + RunEvents.RunEnded.Add(OnRunEnded); + RunEvents.RunTick.Add(OnRunTick); + + var cfg = WLRunSettings.Instance; + var bcfg = WLBossArenaSettings.Instance; + + // 가짜 FieldBossData 로 813d 게이트를 무장시킨다(존 카운터 KillsOf 가 살아야 스냅샷 차감을 측정할 수 있다). + s_fbdGo = new GameObject("__WL813p_FieldBoss"); + s_fbdGo.transform.position = bcfg != null ? bcfg.arenaCenter : Vector3.zero; + var fbd = s_fbdGo.AddComponent(); + fbd.isChapterBoss = false; + fbd.n_MonsterID = bcfg != null ? bcfg.bossMonsterId : 10006; + fbd.n_SpawnerId = bcfg != null ? bcfg.bossSpawnerId : 813010; + fbd.RegenTime = 60f; + BossArena.EnsureSubscribed(); + bool held = BossArena.HoldSpawn(fbd, delegate { s_resume++; }); + + // 가짜 존 몹 · 보스 · 메인 PC + int zc = cfg != null && cfg.zoneSpawnerIds != null ? cfg.zoneSpawnerIds.Length : 0; + s_zoneGo = new GameObject[zc]; + s_zoneMob = new MobActor[zc]; + for (int i = 0; i < zc; i++) + { + s_zoneMob[i] = MakeMob("zone" + i, cfg.zoneSpawnerIds[i], eSubRol.None); + s_zoneGo[i] = s_zoneMob[i].gameObject; + } + s_bossMob = MakeMob("boss", cfg != null ? cfg.bossSpawnerId : 813010, eSubRol.Boss); + s_bossGo = s_bossMob.gameObject; + + s_pcGo = new GameObject("__WL813p_PC"); + s_pc = s_pcGo.AddComponent(); + s_pc.m_Role = eRole.PC; s_pc.m_SubRole = eSubRol.None; // m_Enemy 기본 false → IsMainPC() true + + L(""); + L("── ⓪ 준비"); + L(" BossArena.HoldSpawn=" + held + " GateArmed=" + BossArena.GateArmed + " 존 " + zc + "개 · 보스 " + (cfg != null ? cfg.bossSpawnerId : 0)); + L(" " + P(BossArena.GateArmed) + " 813d 게이트 무장(존 누적 카운터 사용 가능)"); + L(" " + P(s_pc.IsMainPC()) + " 가짜 메인 PC IsMainPC()=true"); + } + + static void OnRunStarted(in RunStartedEvent e) { s_evStart++; } + static void OnZoneCleared(in ZoneClearedEvent e) { s_evZone++; s_lastZoneIndex = e.zoneIndex; } + static void OnGateOpened(in BossGateOpenedEvent e) { s_evGate++; s_lastGateReason = e.reason; } + static void OnBossStarted(in BossStartedEvent e) { s_evBoss++; } + static void OnRunEnded(in RunResult e) { s_evEnd++; s_lastEnd = e; } + static void OnRunTick(in RunTickEvent e) { s_evTick++; } + + // ───────────────────────────────────────── ① 에셋 · SOT + static void Sec1_Asset() + { + L(""); + L("── ① 에셋 · SOT (WLRunSettings)"); + var c = WLRunSettings.Instance; + L(" " + P(c != null) + " Resources.Load(\"" + WLRunSettings.ResourcesPath + "\")"); + if (c == null) return; + + L(" " + P(WLRunSettings.Enabled) + " Enabled(에셋·enabled·RuntimeDisabled·코어) = " + WLRunSettings.Enabled + + " (core=" + WLCombatCoreSettings.Enabled + ")"); + L(" 값: enabled=" + c.enabled + " verboseLog=" + c.verboseLog + " runSeconds=" + c.runSeconds + + " tickIntervalSeconds=" + c.tickIntervalSeconds + " pollSeconds=" + c.pollSeconds + + " pauseTimerWhileWorldHeld=" + c.pauseTimerWhileWorldHeld); + L(" 값: bossSpawnerId=" + c.bossSpawnerId + " openGateOnZonesCleared=" + c.openGateOnZonesCleared + + " openGateAtSec=" + c.openGateAtSec + " autoStartOnMapEnter=" + c.autoStartOnMapEnter + + " skipLobby=" + c.skipLobby + " endOnMapLeave=" + c.endOnMapLeave); + L(" 값: restartWarpsPlayer=" + c.restartWarpsPlayer + " restartHealRate=" + c.restartHealRate + + " restartRearmsBossGate=" + c.restartRearmsBossGate + " restartResetsPotions=" + c.restartResetsPotions); + + bool lenOk = c.zoneSpawnerIds != null && c.zoneClearKills != null && c.zoneSpawnerIds.Length == c.zoneClearKills.Length; + L(" " + P(lenOk) + " zoneSpawnerIds/zoneClearKills 길이 일치 = " + + (c.zoneSpawnerIds != null ? c.zoneSpawnerIds.Length : -1) + " / " + (c.zoneClearKills != null ? c.zoneClearKills.Length : -1)); + + if (!lenOk) return; + for (int i = 0; i < c.zoneSpawnerIds.Length; i++) + { + int max = ReadAppear(c.zoneSpawnerIds[i], "n_MaxMonsterCount", -1); + bool ok = max > 0 && c.zoneClearKills[i] == max * 2; + L(" " + P(ok) + " 존" + i + " id=" + c.zoneSpawnerIds[i] + " zoneClearKills=" + c.zoneClearKills[i] + + " MonsterAppear n_MaxMonsterCount=" + max + " ×2=" + (max * 2)); + } + int bossMax = ReadAppear(c.bossSpawnerId, "n_MaxMonsterCount", -1); + L(" " + P(bossMax == 1) + " 보스 스포너 " + c.bossSpawnerId + " n_MaxMonsterCount=" + bossMax + " (MonsterAppear 실측)"); + L(" lootByGrade 길이 = WLLootSettings.GradeSlots(" + WLLootSettings.GradeSlots + ") + 1 = " + (WLLootSettings.GradeSlots + 1) + + " ※ 발주서 §1 은 [6] 이라 적혀 있으나 실측 등급 슬롯은 " + WLLootSettings.GradeSlots + " 이라 그쪽에 맞췄다"); + } + + // ───────────────────────────────────────── ② 계약(구독) + static void Sec2_Contract() + { + L(""); + L("── ② 계약 — RunEvents 6종 · RunDirector 가 무는 소스 11종"); + L(" RunEvents 구독자(프로브 6개): RunStarted=" + RunEvents.RunStarted.Count + " ZoneCleared=" + RunEvents.ZoneCleared.Count + + " BossGateOpened=" + RunEvents.BossGateOpened.Count + " BossStarted=" + RunEvents.BossStarted.Count + + " RunEnded=" + RunEvents.RunEnded.Count + " RunTick=" + RunEvents.RunTick.Count); + bool six = RunEvents.RunStarted.Count == 1 && RunEvents.ZoneCleared.Count == 1 && RunEvents.BossGateOpened.Count == 1 && + RunEvents.BossStarted.Count == 1 && RunEvents.RunEnded.Count == 1 && RunEvents.RunTick.Count == 1; + L(" " + P(six) + " 813q 가 구독할 6종이 전부 살아 있다"); + + int k0 = CombatEvents.Killed.Count, s0 = CombatEvents.Spawned.Count, c0 = CombatEvents.SkillCast.Count, + h0 = CombatEvents.HitConfirmed.Count, d0 = WL.Combat.Reaction.DamageAggregator.Flushed.Count, + t0 = WL.Combat.Reaction.KillChain.TierReached.Count, e0 = WL.Combat.Reaction.KillChain.Ended.Count, + g0 = WL.Combat.Growth.GrowthEvents.LevelUp.Count, l0 = LootBurst.Picked.Count, + x0 = DeathFlow.Died.Count, p0 = PotionUse.Used.Count; + + RunDirector.EnsureSubscribed(); + RunDirector.EnsureSubscribed(); // 중복 구독 방어 확인 + + bool add = CombatEvents.Killed.Count == k0 + 1 && CombatEvents.Spawned.Count == s0 + 1 && + CombatEvents.SkillCast.Count == c0 + 1 && CombatEvents.HitConfirmed.Count == h0 + 1 && + WL.Combat.Reaction.DamageAggregator.Flushed.Count == d0 + 1 && + WL.Combat.Reaction.KillChain.TierReached.Count == t0 + 1 && WL.Combat.Reaction.KillChain.Ended.Count == e0 + 1 && + WL.Combat.Growth.GrowthEvents.LevelUp.Count == g0 + 1 && LootBurst.Picked.Count == l0 + 1 && + DeathFlow.Died.Count == x0 + 1 && PotionUse.Used.Count == p0 + 1; + L(" " + P(add) + " EnsureSubscribed ×2 → 소스 11종 각각 +1 (중복 구독 0) · Subscribed=" + RunDirector.Subscribed); + } + + // ───────────────────────────────────────── ③ 런 1회(승리) + static void Sec3_WinRun() + { + L(""); + L("── ③ 런 1회(승리) — 존 4 클리어 → 게이트 → 보스 등장 → 처치"); + var c = WLRunSettings.Instance; + if (c == null) { s_fail++; L(" [FAIL] 설정 없음"); return; } + + // 런 시작 「전」 누적 처치 — 스냅샷 차감이 먹는지 보려고 존0 에 미리 5킬을 쌓는다. + const int kPre = 5; + for (int i = 0; i < kPre; i++) { BossArena.NoteSpawn(c.zoneSpawnerIds[0], false); BossArena.NoteKill(c.zoneSpawnerIds[0], false); } + int preArena = BossArena.KillsOf(c.zoneSpawnerIds[0]); + + s_evStart = s_evZone = s_evGate = s_evBoss = s_evEnd = s_evTick = 0; + RunDirector.StartRun("probe-win"); + L(" " + P(s_evStart == 1 && RunDirector.Phase == RunPhase.Zones) + " RunStarted 1회 · phase=" + RunDirector.Phase + + " runIndex=" + RunDirector.RunIndex + " · 시작 즉시 RunTick " + s_evTick + "회"); + L(" " + P(RunDirector.ZoneProgress(0) == 0) + " 스냅샷 차감 — BossArena.KillsOf(" + c.zoneSpawnerIds[0] + ")=" + preArena + + " 인데 런 진행률=" + RunDirector.ZoneProgress(0) + " (0 이어야 한다)"); + + // 존별 처치 — 진짜 CombatEvents.Killed 로 넣는다(BossArena 카운터 + RunDirector 자체 카운터 둘 다 탄다). + int totalZoneKills = 0; + for (int z = 0; z < c.zoneSpawnerIds.Length; z++) + { + int need = c.zoneClearKills[z]; + for (int i = 0; i < need; i++) + { + CombatEvents.RaiseSpawned(s_zoneMob[z]); + CombatEvents.RaiseKilled(s_zoneMob[z]); + totalZoneKills++; + } + } + L(" " + P(s_evZone == c.zoneSpawnerIds.Length) + " ZoneCleared " + s_evZone + "회 / 존 " + c.zoneSpawnerIds.Length + + "개 · 마지막 zoneIndex=" + s_lastZoneIndex + " · ZonesCleared=" + RunDirector.ZonesCleared); + L(" " + P(s_evGate == 1 && s_lastGateReason == RunGateReason.ZonesCleared) + " BossGateOpened " + s_evGate + + "회 reason=" + s_lastGateReason + " · BossArena.GateOpen=" + BossArena.GateOpen + " resume 호출=" + s_resume); + + // 보스 등장 + CombatEvents.RaiseSpawned(s_bossMob); + L(" " + P(s_evBoss == 1 && RunDirector.Phase == RunPhase.Boss) + " BossStarted " + s_evBoss + "회 · phase=" + RunDirector.Phase); + + // 나머지 집계 — 스킬(진짜 이벤트) · 연쇄 · 피해 · 레벨 · 전리품 · 사망 · 물약 + const int kSkill = 7, kChain = 9, kDeath = 2, kPotion = 3; + for (int i = 0; i < kSkill; i++) CombatEvents.RaiseSkillCast(s_pc, null); + RunDirector.NoteChain(4); RunDirector.NoteChain(kChain); RunDirector.NoteChain(3); // 최대만 남아야 한다 + RunDirector.NoteDamage(1234.5d, 0d); RunDirector.NoteDamage(999d, 8888.5d); RunDirector.NoteDamage(50d, 100d); + RunDirector.NoteLevel(11, 12); RunDirector.NoteLevel(12, 14); + for (int i = 0; i < kDeath; i++) RunDirector.NoteDeath(); + for (int i = 0; i < kPotion; i++) RunDirector.NotePotion(); + + // 전리품 — 등급 1 은 진짜 LootBurst 경로(구독 배선 증명) · 나머지 등급은 Note 이음매 + int gradeSlots = WLLootSettings.GradeSlots; + LootBurst.OnDropPicked(null, 1001, 1, null); // table_itemlist 없음 → ResolveGrade = 1 + int lootTotal = 1, expect1 = 1; + for (int g = 2; g <= gradeSlots; g++) { RunDirector.NoteLoot(g, g, false); lootTotal += g; } + RunDirector.NoteLoot(LootBurst.ResolveGrade(LootBurst.GoldItemId), 777, true); // 골드는 lootTotal 에 들어가지 않는다 + + // 보스 처치 = 승리 + CombatEvents.RaiseKilled(s_bossMob); + var r = s_lastEnd; + L(" " + P(s_evEnd == 1 && r.outcome == RunOutcome.Win) + " RunEnded " + s_evEnd + "회 outcome=" + r.outcome + + " phase=" + RunDirector.Phase); + + int expectKills = totalZoneKills + 1; // 존 처치 + 보스 1 + L(" 집계 대조 (입력 → RunResult)"); + L(" " + P(r.kills == expectKills) + " kills " + r.kills + " / 기대 " + expectKills); + L(" " + P(r.bossKills == 1) + " bossKills " + r.bossKills + " / 기대 1"); + L(" " + P(r.maxChain == kChain) + " maxChain " + r.maxChain + " / 기대 " + kChain); + L(" " + P(Near(r.maxDamage, 1234.5d)) + " maxDamage " + r.maxDamage + " / 기대 1234.5"); + L(" " + P(Near(r.maxDamageBurst, 8888.5d)) + " maxDamageBurst " + r.maxDamageBurst + " / 기대 8888.5"); + L(" " + P(r.levelFrom == 11 && r.levelTo == 14) + " level " + r.levelFrom + "→" + r.levelTo + " / 기대 11→14"); + L(" " + P(r.deaths == kDeath) + " deaths " + r.deaths + " / 기대 " + kDeath); + L(" " + P(r.potionsUsed == kPotion) + " potionsUsed " + r.potionsUsed + " / 기대 " + kPotion); + L(" " + P(r.skillCasts == kSkill) + " skillCasts " + r.skillCasts + " / 기대 " + kSkill); + L(" " + P(r.lootTotal == lootTotal) + " lootTotal " + r.lootTotal + " / 기대 " + lootTotal); + L(" " + P(r.gold == 777) + " gold " + r.gold + " / 기대 777"); + L(" " + P(r.lootByGrade != null && r.lootByGrade.Length == gradeSlots + 1) + " lootByGrade 길이 " + + (r.lootByGrade != null ? r.lootByGrade.Length : -1) + " / 기대 " + (gradeSlots + 1)); + if (r.lootByGrade != null && r.lootByGrade.Length == gradeSlots + 1) + { + bool gradeOk = r.lootByGrade[0] == 0 && r.lootByGrade[1] == expect1; + for (int g = 2; g <= gradeSlots; g++) if (r.lootByGrade[g] != g) gradeOk = false; + var line = new StringBuilder(); + for (int g = 0; g <= gradeSlots; g++) { if (g > 0) line.Append(','); line.Append(r.lootByGrade[g]); } + L(" " + P(gradeOk) + " lootByGrade[0.." + gradeSlots + "] = " + line + " / 기대 0,1,2,3,…," + gradeSlots); + } + L(" " + P(r.zonesCleared == r.zoneCount && r.zoneCount == c.zoneSpawnerIds.Length) + " zonesCleared " + + r.zonesCleared + "/" + r.zoneCount); + bool zsecOk = r.zoneClearSec != null && r.zoneClearSec.Length == r.zoneCount; + if (zsecOk) for (int i = 0; i < r.zoneCount; i++) if (r.zoneClearSec[i] < 0f) zsecOk = false; + L(" " + P(zsecOk) + " zoneClearSec 전 존 기록됨"); + bool zkOk = r.zoneKills != null && r.zoneKills.Length == r.zoneCount; + if (zkOk) for (int i = 0; i < r.zoneCount; i++) if (r.zoneKills[i] != c.zoneClearKills[i]) zkOk = false; + L(" " + P(zkOk) + " zoneKills = zoneClearKills(스냅샷 차감 후) · 존0 = " + (r.zoneKills != null && r.zoneKills.Length > 0 ? r.zoneKills[0] : -1)); + L(" " + P(r.bossGateSec >= 0f && r.bossStartSec >= 0f && r.bossClearSec >= 0f) + " bossGateSec=" + + r.bossGateSec.ToString("F2") + " bossStartSec=" + r.bossStartSec.ToString("F2") + " bossClearSec=" + r.bossClearSec.ToString("F2")); + L(" " + P(r.runIndex == 1 && Near(r.runSeconds, c.runSeconds)) + " runIndex=" + r.runIndex + " runSeconds=" + r.runSeconds); + L(" BossArena 누적 보존 — KillsOf(" + c.zoneSpawnerIds[0] + ")=" + BossArena.KillsOf(c.zoneSpawnerIds[0]) + + " (런 전 " + preArena + " + 이번 런 " + c.zoneClearKills[0] + ")"); + L(" " + P(BossArena.KillsOf(c.zoneSpawnerIds[0]) == preArena + c.zoneClearKills[0]) + " BossArena 값 리셋 0(발주서 §1)"); + + // 종료 뒤에는 아무것도 세지 않는다 + int killsAfter = r.kills; + CombatEvents.RaiseKilled(s_zoneMob[0]); + L(" " + P(RunDirector.LastResult.kills == killsAfter) + " 종료 후 처치는 집계에 안 들어간다(phase=" + RunDirector.Phase + ")"); + } + + // ───────────────────────────────────────── ④ 시간 종료 + static void Sec4_Timeout() + { + L(""); + L("── ④ 시간 종료 — openGateAtSec → 게이트 · runSeconds → Timeout"); + var c = WLRunSettings.Instance; + BossArena.ResetGate(); + s_evStart = s_evZone = s_evGate = s_evBoss = s_evEnd = s_evTick = 0; + RunDirector.StartRun("probe-timeout"); + + // 존은 일부만 밀어 둔다(클리어 미달) + int part = Math.Max(1, c.zoneClearKills[0] / 2); + for (int i = 0; i < part; i++) CombatEvents.RaiseKilled(s_zoneMob[0]); + L(" " + P(s_evZone == 0 && s_evGate == 0) + " 존 미달(" + RunDirector.ZoneProgress(0) + "/" + c.zoneClearKills[0] + + ") → ZoneCleared " + s_evZone + " · BossGateOpened " + s_evGate); + + RunDirector.ShiftClockForProbe(c.openGateAtSec + 0.5f); + RunDirector.ForceTick(); + L(" " + P(s_evGate == 1 && s_lastGateReason == RunGateReason.TimeLimit) + " openGateAtSec(" + c.openGateAtSec + + "s) 경과 → BossGateOpened reason=" + s_lastGateReason + " phase=" + RunDirector.Phase); + + RunDirector.ShiftClockForProbe(c.runSeconds); + RunDirector.ForceTick(); + var r = s_lastEnd; + L(" " + P(s_evEnd == 1 && r.outcome == RunOutcome.Timeout) + " RunEnded outcome=" + r.outcome + + " totalSec=" + r.totalSec.ToString("F1") + " (runSeconds=" + c.runSeconds + ")"); + L(" " + P(r.bossKills == 0 && r.bossClearSec < 0f) + " 보스 미처치 — bossKills=" + r.bossKills + + " bossClearSec=" + r.bossClearSec + " · zonesCleared=" + r.zonesCleared + "/" + r.zoneCount); + L(" " + P(r.runIndex == 2) + " runIndex=" + r.runIndex + " (두 번째 런)"); + } + + // ───────────────────────────────────────── ⑤ 다음 런 + static void Sec5_Restart() + { + L(""); + L("── ⑤ 다음 런 — RunDirector.Restart()"); + var c = WLRunSettings.Instance; + int arenaBefore = BossArena.KillsOf(c.zoneSpawnerIds[0]); + int idxBefore = RunDirector.RunIndex; + + s_evStart = s_evZone = s_evGate = s_evBoss = s_evEnd = s_evTick = 0; + RunDirector.Restart(); + + L(" " + P(RunDirector.RunIndex == idxBefore + 1) + " runIndex " + idxBefore + " → " + RunDirector.RunIndex); + L(" " + P(s_evStart == 1 && RunDirector.Phase == RunPhase.Zones) + " RunStarted 1회 · phase=" + RunDirector.Phase); + L(" " + P(RunDirector.ZonesCleared == 0 && RunDirector.ZoneProgress(0) == 0) + " 집계 리셋 — zonesCleared=" + + RunDirector.ZonesCleared + " zone0 진행=" + RunDirector.ZoneProgress(0)); + L(" " + P(!RunDirector.GateOpened && !RunDirector.BossStarted) + " 게이트·보스 상태 리셋 — GateOpened=" + + RunDirector.GateOpened + " BossStarted=" + RunDirector.BossStarted); + L(" " + P(!BossArena.GateOpen && BossArena.GateArmed) + " BossArena.ResetGate() — GateOpen=" + BossArena.GateOpen + + " GateArmed=" + BossArena.GateArmed + " BossKilled=" + BossArena.BossKilled); + L(" " + P(BossArena.KillsOf(c.zoneSpawnerIds[0]) == arenaBefore) + " BossArena 존 누적 보존 " + arenaBefore + + " → " + BossArena.KillsOf(c.zoneSpawnerIds[0]) + " (리셋 금지 조항)"); + + // 재시작 뒤 다시 존을 밀면 정상 진행되는가(스냅샷 재설정 확인) + for (int i = 0; i < c.zoneClearKills[0]; i++) CombatEvents.RaiseKilled(s_zoneMob[0]); + L(" " + P(s_evZone == 1) + " 재시작 후 존0 재클리어 ZoneCleared=" + s_evZone + " (진행 " + RunDirector.ZonesCleared + "/" + RunDirector.ZoneCount + ")"); + L(" ※ Restart() 의 PC 워프(MyActor.On_Regen → Set_Warp(Get_ReincanationPos))는 에디트 모드에 PC·LoadMapMgr 가 없어 미측정 =「미확인」"); + } + + // ───────────────────────────────────────── ⑥ RunTick 1 Hz + static void Sec6_TickRate() + { + L(""); + L("── ⑥ RunTick 주기 (SO tickIntervalSeconds)"); + var c = WLRunSettings.Instance; + int t0 = s_evTick; + for (int i = 0; i < 100; i++) RunDirector.ForceTick(); + int t1 = s_evTick; + L(" " + P(t1 == t0) + " 같은 프레임 강제 틱 100회 → RunTick +" + (t1 - t0) + " (간격 미만이면 0 이어야 한다)"); + + RunDirector.ShiftClockForProbe(c.tickIntervalSeconds * 0.5f); RunDirector.ForceTick(); + int t2 = s_evTick; + L(" " + P(t2 == t1) + " 절반(" + (c.tickIntervalSeconds * 0.5f) + "s) 경과 → +" + (t2 - t1)); + RunDirector.ShiftClockForProbe(c.tickIntervalSeconds * 0.5f + 0.01f); RunDirector.ForceTick(); + int t3 = s_evTick; + L(" " + P(t3 == t2 + 1) + " 나머지 절반 경과 → +" + (t3 - t2) + " = 1 Hz(tickIntervalSeconds=" + c.tickIntervalSeconds + ")"); + + int t4 = s_evTick; + for (int i = 0; i < 10; i++) { RunDirector.ShiftClockForProbe(c.tickIntervalSeconds); RunDirector.ForceTick(); } + L(" " + P(s_evTick == t4 + 10) + " 10초 진행 → RunTick +" + (s_evTick - t4) + " (기대 10)"); + } + + // ───────────────────────────────────────── ⑦ GC + static void Sec7_Gc() + { + L(""); + L("── ⑦ GC — 틱·집계 100회"); + var c = WLRunSettings.Instance; + RunDirector.EndRun(RunOutcome.Abandon); + BossArena.ResetGate(); + RunDirector.StartRun("probe-gc"); + + // 워밍업(JIT) + for (int i = 0; i < 20; i++) { RunDirector.NoteChain(i); RunDirector.NoteDamage(i, i); RunDirector.NoteLoot(1, 1, false); RunDirector.ForceTick(); } + + int g0 = GC.CollectionCount(0); long a0 = Alloc(), m0 = Mono(); + for (int i = 0; i < 100; i++) + { + RunDirector.NoteKill(c.zoneSpawnerIds[0], false); + RunDirector.NoteChain(i); + RunDirector.NoteDamage(i, i * 2); + RunDirector.NoteLoot(1, 1, false); + RunDirector.NoteDeath(); RunDirector.NotePotion(); RunDirector.NoteSkillCast(); + RunDirector.ShiftClockForProbe(c.tickIntervalSeconds); + RunDirector.ForceTick(); + } + long a1 = Alloc(), m1 = Mono(); int g1 = GC.CollectionCount(0); + + // 대조군 — 의도적 할당(측정 감도 증명) + long b0 = Alloc(), n0 = Mono(); + var junk = new string[100]; + for (int i = 0; i < 100; i++) junk[i] = "wl813p-" + i; + long b1 = Alloc(), n1 = Mono(); + + bool zero = (a1 - a0) <= 0 && (m1 - m0) <= 0; + L(" 본시험 ΔGetTotalMemory=" + (a1 - a0) + " B · ΔMonoUsed=" + (m1 - m0) + " B · gc0 " + g0 + "→" + g1); + L(" 대조군 ΔGetTotalMemory=" + (b1 - b0) + " B · ΔMonoUsed=" + (n1 - n0) + " B (문자열 100개 · junk[" + junk.Length + "])"); + L(" " + P(zero) + " 틱·집계 100회 할당 0 B"); + RunDirector.EndRun(RunOutcome.Abandon); + } + + // ───────────────────────────────────────── ⑧ C8 + static void Sec8_Rollback() + { + L(""); + L("── ⑧ C8 롤백"); + RunDirector.ResetAll(); + WLRunSettings.RuntimeDisabled = true; + s_evStart = s_evTick = 0; + RunDirector.StartRun("probe-c8"); + RunDirector.ForceTick(); + L(" " + P(!WLRunSettings.Enabled && s_evStart == 0 && RunDirector.Phase == RunPhase.Idle) + + " RuntimeDisabled=1 → Enabled=" + WLRunSettings.Enabled + " · StartRun 무동작 · phase=" + RunDirector.Phase); + WLRunSettings.RuntimeDisabled = false; + + WLCombatCoreSettings.RuntimeDisabled = true; + RunDirector.StartRun("probe-c8-core"); + L(" " + P(!WLRunSettings.Enabled && RunDirector.Phase == RunPhase.Idle) + + " 코어 off → Enabled=" + WLRunSettings.Enabled + " · 런 구조도 안 걸린다(집계 소스가 죽으므로)"); + WLCombatCoreSettings.RuntimeDisabled = false; + + L(" " + P(WLRunSettings.Enabled) + " 복구 후 Enabled=" + WLRunSettings.Enabled); + } + + // ───────────────────────────────────────── ⑨ 정리 + static void Teardown() + { + try + { + RunEvents.RunStarted.Remove(OnRunStarted); + RunEvents.ZoneCleared.Remove(OnZoneCleared); + RunEvents.BossGateOpened.Remove(OnGateOpened); + RunEvents.BossStarted.Remove(OnBossStarted); + RunEvents.RunEnded.Remove(OnRunEnded); + RunEvents.RunTick.Remove(OnRunTick); + + RunDirector.EndRun(RunOutcome.Abandon); + RunDirector.Unsubscribe(); + RunDirector.ResetAll(); + BossArena.Unsubscribe(); + BossArena.ResetAll(); + WLRunSettings.RuntimeDisabled = false; + WLCombatCoreSettings.RuntimeDisabled = false; + WLBossArenaSettings.RuntimeDisabled = false; + + if (s_zoneGo != null) for (int i = 0; i < s_zoneGo.Length; i++) Kill(s_zoneGo[i]); + Kill(s_bossGo, s_pcGo, s_fbdGo); + s_zoneGo = null; s_zoneMob = null; s_bossGo = s_pcGo = s_fbdGo = null; + + L(""); + L("── ⑨ 정리 — 임시 GameObject 파괴 · 구독 해제 · 런타임 스위치 복구 (에셋/씬 저장 0)"); + L(" RunEvents.TotalRaised=" + RunEvents.TotalRaised + " last=" + RunEvents.LastEvent); + } + catch (Exception ex) { L(" [WARN] 정리 예외 " + ex.Message); } + } +} diff --git a/Assets/WL/Combat/Auto/AutoCombatOnEnter.cs b/Assets/WL/Combat/Auto/AutoCombatOnEnter.cs index b9bbb44ac..fd19fc469 100644 --- a/Assets/WL/Combat/Auto/AutoCombatOnEnter.cs +++ b/Assets/WL/Combat/Auto/AutoCombatOnEnter.cs @@ -138,6 +138,11 @@ namespace WL.Combat.Auto ArmIcon(cfg); } + internal static bool ReadBoundarySources(out PCActor pc, out int mapId, out bool inLobby) { // 813p 공용 — 원천 값만 읽는다(판정·소비 0 · 동작 변경 0) + pc = DSUtil.CheckNull(MyValue.MyPC) ? null : MyValue.MyPC; var info = InGameInfo.Ins; + mapId = MyValue.MyChoiceMapData != null ? MyValue.MyChoiceMapData.n_MapID : int.MinValue; + inLobby = !DSUtil.CheckNull(info) && info.Get_GameMode() == eGameMode.Lobby; return !DSUtil.CheckNull(info); } + static void ArmIcon(WLCombatCoreSettings cfg) { if (cfg.autoCombatIconRetrySeconds > 0f) diff --git a/Assets/WL/Combat/Boss/BossArena.cs b/Assets/WL/Combat/Boss/BossArena.cs index becacb301..2d8ab3824 100644 --- a/Assets/WL/Combat/Boss/BossArena.cs +++ b/Assets/WL/Combat/Boss/BossArena.cs @@ -576,6 +576,17 @@ namespace WL.Combat.Boss LastLog = ""; } + /// 813p 「다음 런」 — 게이트만 다시 잠근다. 존 누적 카운터(s_zones)·구독·보류 목록은 건드리지 않는다. + public static void ResetGate() + { + if (!s_armed) return; + s_gateOpen = false; + BossSpawned = BossKilled = CleanupDone = false; + s_openDueTime = s_cleanupDueTime = -1f; + s_playerOutsideSince = -1f; + Log("게이트 재무장(813p 다음 런)"); + } + /// 프로브용 — 보스 참조를 직접 넣는다(에디트 모드에는 실제 스폰이 없다). public static void SetBossForProbe(MobActor boss) { s_boss = boss; } diff --git a/Assets/WL/Combat/Run.meta b/Assets/WL/Combat/Run.meta new file mode 100644 index 000000000..dec1f9f3e --- /dev/null +++ b/Assets/WL/Combat/Run.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8d13d0110a1b4c5d8e9f0a1b2c3d4e50 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Run/RunDirector.cs b/Assets/WL/Combat/Run/RunDirector.cs new file mode 100644 index 000000000..fefbd498a --- /dev/null +++ b/Assets/WL/Combat/Run/RunDirector.cs @@ -0,0 +1,697 @@ +// ───────────────────────────────────────────────────────────────────────────── +// RunDirector.cs — 3분 런 상태 머신 · 집계 · "다음 런" 재시작 +// +// PD 지시 #813 · 발주서 WL-813p §1 (2026-09-09) +// 규격 = 기준서 §D-2 813p 행(3분 런 · 존 4~5 → 보스 → 결과 · 런 상태 이벤트) + §B 요소 1·5·6 + Q3 6-3 +// +// ■ 런의 정의 (발주서 §1 · Lead 설계 결정) +// 런 = **현행 필드 맵(WL_Nature) 위의 오버레이**다. 새 던전 타입·DungeonInfo·BattleMapConfig· +// DungeonMapConfig 는 건드리지 않는다(핫스팟 · 바퀴 4 이월). +// 진입(시작점) → 존 813001~813004 진행 → 보스 게이트 → Anubis 처치 = 승리 +// 런 타이머 runSeconds(SO · 180) 초과 = 시간 종료(보스 미처치) +// 사망은 런을 끝내지 않고 카운트만 한다(813i 부활 유지) +// 로비 복귀·맵 이동 = RunEnded(Abandon) 1회 후 정지 +// +// ■ 진입 경계 (원본 훅 0 · 폴링) +// 813x `AutoCombatOnEnter` 와 **같은 조건**을 본다 — 메인 PC 교체 또는 MyValue.MyChoiceMapData.n_MapID 변경. +// 조건을 복제하지 않으려고 그 파일의 원천 읽기를 `AutoCombatOnEnter.ReadBoundarySources`(internal · 5줄)로 +// 노출해 여기서 부른다. 판정·소비는 전부 이 파일이 한다(그 파일의 동작 변경 0). +// +// ■ 존 진행 실측 소스 (발주서 §1) +// 813d `BossArena.KillsOf(spawnerId)` 의 **누적값**. BossArena 값은 절대 리셋하지 않고, +// 런 시작 시점 값을 스냅샷(s_zoneBase)해 **차감**한다. 그래서 같은 맵 세션에서 런을 몇 번 돌려도 +// 813d 의 게이트 판정(누적 처치 ≥ requiredKills)이 망가지지 않는다. +// BossArena 가 무장돼 있지 않을 때(게이트 off · FieldBossData 없음)를 대비해 이 파일도 Killed 를 +// 스포너별로 직접 세고 **둘 중 큰 값**을 진행률로 쓴다(폴백 · 813d 무수정). +// +// ■ 집계 소스 (전부 기존 이벤트 구독 · 원본 훅 0) +// kills·bossKills CombatEvents.Killed(subRole == Boss) +// skillCasts CombatEvents.SkillCast(actor.IsMainPC()) +// maxDamage CombatEvents.HitConfirmed(byMainPC) · DamageAggregator.Flushed(maxSingle) +// maxDamageBurst DamageAggregator.Flushed(total · 813e 합산 데미지) +// maxChain KillChain.TierReached / KillChain.Ended (813e) +// levelFrom/To GrowthEvents.LevelUp (813k) +// lootByGrade·gold LootBurst.Picked (813f · grade 는 LootBurst.ResolveGrade 가 이미 확정) +// deaths DeathFlow.Died (813i) +// potionsUsed PotionUse.Used (813i) +// +// ■ GC 0 +// 틱 경로에서 할당하지 않는다 — 페이로드는 struct(`in` 전달), 결과 배열 3종은 런 사이에 재사용, +// 로그 문자열은 **만들기 전에** verboseLog 로 막는다(811b FIX-3 규약). +// +// ■ C8 롤백 +// WLRunSettings.Enabled(에셋 없음 / enabled=0 / RuntimeDisabled / 코어 off)가 false 면 +// 러너가 뜨지 않고 Tick 이 즉시 반환한다 = 런 구조 이전 100%. +// +// 🔴 UI 코드·프리팹은 이 발주 범위 밖(813q 몫). 이 파일은 RunEvents 발행까지만 한다. +// 🔴 Debug.LogError / Debug.LogException 을 쓰지 않는다 — ErrorLogHookManager 가 그것을 플레이어 팝업으로 띄운다. +// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것. +// ───────────────────────────────────────────────────────────────────────────── + +using UnityEngine; +using WL.Combat.Auto; +using WL.Combat.Boss; +using WL.Combat.Core; +using WL.Combat.Growth; +using WL.Combat.Loot; +using WL.Combat.Reaction; +using WL.Combat.Survival; + +namespace WL.Combat.Run +{ + /// 3분 런 상태 머신. 정적 · 새 Manager/Singleton 0(런너는 숨김 GameObject 1개). + public static class RunDirector + { + // ───────────────────────────────────────── 상태 + static RunPhase s_phase = RunPhase.Idle; + static int s_runIndex; + static float s_startTime, s_pausedTotal, s_pauseStart = -1f; + static float s_nextPoll, s_nextTick; + static bool s_subscribed, s_faulted, s_gateOpened, s_bossStarted; + static RunGateReason s_gateReason; + + // ── 존 진행(길이 = s_zoneCount · 배열은 재사용) + static int[] s_zoneIds, s_zoneNeed, s_zoneBase, s_zoneOwn, s_zoneKills; + static float[] s_zoneClearSec; + static int s_zoneCount, s_zonesCleared; + + // ── 집계 + static int s_kills, s_bossKills, s_maxChain, s_deaths, s_potions, s_skillCasts, s_lootTotal; + static double s_maxDamage, s_maxDamageBurst; + static long s_gold; + static int s_levelFrom, s_levelTo; + static int[] s_lootByGrade; + static float s_bossGateSec = -1f, s_bossStartSec = -1f, s_bossClearSec = -1f; + static Actor s_boss; + + // ── 진입 경계(AutoCombatOnEnter 와 같은 조건 · 판정은 여기서) + static PCActor s_lastPc; + static int s_lastMapId = int.MinValue; + + // ───────────────────────────────────────── 진단(프로브·QA 가 읽는다) + /// 현재 단계. + public static RunPhase Phase { get { return s_phase; } } + /// 이 세션에서 몇 번째 런인가(1부터 · 아직 없으면 0). + public static int RunIndex { get { return s_runIndex; } } + /// 런이 진행 중인가(Zones · BossGate · Boss). + public static bool IsRunning { get { return s_phase == RunPhase.Zones || s_phase == RunPhase.BossGate || s_phase == RunPhase.Boss; } } + /// 구독 여부 — 집계가 실제로 이벤트에 붙었는지 확인용. + public static bool Subscribed { get { return s_subscribed; } } + /// 보스 게이트가 열렸는가 · 보스가 등장했는가. + public static bool GateOpened { get { return s_gateOpened; } } + public static bool BossStarted { get { return s_bossStarted; } } + /// 존 수 · 클리어한 존 수. + public static int ZoneCount { get { return s_zoneCount; } } + public static int ZonesCleared { get { return s_zonesCleared; } } + /// 마지막으로 발행한 RunResult(결과 화면이 늦게 떠도 읽을 수 있게 남긴다). + public static RunResult LastResult; + /// 진단 카운터. + public static int StartCount, EndCount, RestartCount, TickCount, BoundaryCount; + public static string LastLog = ""; + + static WLRunSettings Cfg { get { return WLRunSettings.Instance; } } + static bool Verbose { get { var c = Cfg; return c != null && c.verboseLog; } } + + // ───────────────────────────────────────────────────────────────────── + // 시계 + // ───────────────────────────────────────────────────────────────────── + + /// 런 시작 후 경과 초(세계 정지 시간 제외 옵션 반영). 런이 없으면 0. + public static float Elapsed + { + get + { + if (s_phase == RunPhase.Idle) return 0f; + float now = Time.unscaledTime; + float paused = s_pausedTotal + (s_pauseStart >= 0f ? now - s_pauseStart : 0f); + float e = now - s_startTime - paused; + return e > 0f ? e : 0f; + } + } + + /// 남은 초(0 미만으로 내려가지 않는다). + public static float Remaining + { + get + { + var c = Cfg; + float r = (c != null ? c.runSeconds : 0f) - Elapsed; + return r > 0f ? r : 0f; + } + } + + // ───────────────────────────────────────────────────────────────────── + // 구독 + // ───────────────────────────────────────────────────────────────────── + + /// 집계 구독(중복 구독은 CombatEventList.Add 가 막는다). + public static void EnsureSubscribed() + { + if (s_subscribed) return; + CombatEvents.Killed.Add(OnKilled); + CombatEvents.Spawned.Add(OnSpawned); + CombatEvents.SkillCast.Add(OnSkillCast); + CombatEvents.HitConfirmed.Add(OnHitConfirmed); + DamageAggregator.Flushed.Add(OnDamageMerged); + KillChain.TierReached.Add(OnChainTier); + KillChain.Ended.Add(OnChainEnded); + GrowthEvents.LevelUp.Add(OnLevelUp); + LootBurst.Picked.Add(OnLootPicked); + DeathFlow.Died.Add(OnDied); + PotionUse.Used.Add(OnPotionUsed); + s_subscribed = true; + } + + /// 구독 해제(프로브·롤백용). + public static void Unsubscribe() + { + if (!s_subscribed) return; + CombatEvents.Killed.Remove(OnKilled); + CombatEvents.Spawned.Remove(OnSpawned); + CombatEvents.SkillCast.Remove(OnSkillCast); + CombatEvents.HitConfirmed.Remove(OnHitConfirmed); + DamageAggregator.Flushed.Remove(OnDamageMerged); + KillChain.TierReached.Remove(OnChainTier); + KillChain.Ended.Remove(OnChainEnded); + GrowthEvents.LevelUp.Remove(OnLevelUp); + LootBurst.Picked.Remove(OnLootPicked); + DeathFlow.Died.Remove(OnDied); + PotionUse.Used.Remove(OnPotionUsed); + s_subscribed = false; + } + + // ── 핸들러: 이벤트 → Note* 이음매 하나로 모은다(프로브가 같은 경로를 직접 부른다) + static void OnKilled(in KilledEvent e) + { + if (!IsRunning) return; + NoteKill(DSUtil.CheckNull(e.victim) ? 0 : e.victim.Get_Group(), e.subRole == eSubRol.Boss); + } + + static void OnSpawned(in SpawnedEvent e) + { + if (!IsRunning || !e.isBoss) return; + int sid = DSUtil.CheckNull(e.actor) ? 0 : e.actor.Get_Group(); + if (IsBossSpawner(sid)) NoteBossSpawned(sid, e.actor); + } + + static void OnSkillCast(in SkillCastEvent e) + { + if (!IsRunning || DSUtil.CheckNull(e.actor) || !e.actor.IsMainPC()) return; + NoteSkillCast(); + } + + static void OnHitConfirmed(in HitConfirmedEvent e) + { + if (!IsRunning || !e.byMainPC) return; + NoteDamage(e.damage, 0d); + } + + static void OnDamageMerged(in DamageMergeEvent e) + { + if (!IsRunning || !e.byMainPC) return; + NoteDamage(e.maxSingle, e.total); + } + + static void OnChainTier(in KillChainTierEvent e) { if (IsRunning) NoteChain(e.kills); } + static void OnChainEnded(in KillChainEndedEvent e) { if (IsRunning) NoteChain(e.kills); } + static void OnLevelUp(in PlayerLevelUpEvent e) { if (IsRunning) NoteLevel(e.prevLevel, e.newLevel); } + static void OnLootPicked(in LootPickedEvent e) { if (IsRunning) NoteLoot(e.grade, e.amount, e.isGold); } + static void OnDied(in DeathEvent e) { if (IsRunning) NoteDeath(); } + static void OnPotionUsed(in PotionEvent e) { if (IsRunning) NotePotion(); } + + // ───────────────────────────────────────────────────────────────────── + // 집계 이음매 (프로브가 가짜 값을 넣을 때도 이 경로를 쓴다 — 813d NoteKill 선례) + // ───────────────────────────────────────────────────────────────────── + + /// 처치 1건 반영 + 존 진행·승리 재판정. + public static void NoteKill(int spawnerId, bool isBoss) + { + if (!IsRunning) return; + s_kills++; + + for (int i = 0; i < s_zoneCount; i++) + if (s_zoneIds[i] == spawnerId) { s_zoneOwn[i]++; break; } + + if (isBoss && IsBossSpawner(spawnerId)) + { + s_bossKills++; + s_bossClearSec = Elapsed; + EndRun(RunOutcome.Win); + return; + } + EvaluateZones(); + } + + /// 보스가 월드에 등장했다(런당 1회 통지). + public static void NoteBossSpawned(int spawnerId, Actor boss) + { + if (!IsRunning || s_bossStarted) return; + s_boss = boss; + s_bossStarted = true; + s_bossStartSec = Elapsed; + if (!s_gateOpened) OpenBossGate(RunGateReason.AlreadySpawned); + s_phase = RunPhase.Boss; + + var e = new BossStartedEvent + { + elapsedSec = s_bossStartSec, spawnerId = spawnerId, boss = boss, + time = Time.unscaledTime, frame = Time.frameCount + }; + RunEvents.Stamp("BossStarted"); + RunEvents.BossStarted.Dispatch(in e); + if (Verbose) Log("보스 등장 — " + s_bossStartSec.ToString("F1") + "s spawnerId=" + spawnerId); + } + + /// 연쇄 처치 수 갱신(최대만 남긴다). + public static void NoteChain(int chainKills) { if (IsRunning && chainKills > s_maxChain) s_maxChain = chainKills; } + + /// 피해 갱신 — single = 단일 타격 · burst = 합산(0 이면 무시). + public static void NoteDamage(double single, double burst) + { + if (!IsRunning) return; + if (single > s_maxDamage) s_maxDamage = single; + if (burst > s_maxDamageBurst) s_maxDamageBurst = burst; + } + + /// 레벨업 반영(levelFrom 은 런 중 첫 레벨업의 이전 레벨로 보정한다). + public static void NoteLevel(int prevLevel, int newLevel) + { + if (!IsRunning) return; + if (s_levelFrom <= 0 || prevLevel < s_levelFrom) s_levelFrom = prevLevel; + if (newLevel > s_levelTo) s_levelTo = newLevel; + } + + /// 획득 1건 반영. grade 는 LootBurst.ResolveGrade 결과(1~GradeSlots). + public static void NoteLoot(int grade, int amount, bool isGold) + { + if (!IsRunning) return; + if (isGold) { s_gold += amount; return; } + s_lootTotal += amount; + if (grade >= 0 && grade < s_lootByGrade.Length) s_lootByGrade[grade] += amount; + } + + /// 사망 1건(런을 끝내지 않는다 · 813i 부활 유지). + public static void NoteDeath() { if (IsRunning) s_deaths++; } + /// 물약 1개 사용. + public static void NotePotion() { if (IsRunning) s_potions++; } + /// 메인 PC 스킬 시전 1회. + public static void NoteSkillCast() { if (IsRunning) s_skillCasts++; } + + // ───────────────────────────────────────────────────────────────────── + // 존 진행 · 게이트 + // ───────────────────────────────────────────────────────────────────── + + static bool IsBossSpawner(int spawnerId) + { + var c = Cfg; + return c != null && (c.bossSpawnerId == 0 || spawnerId == c.bossSpawnerId); + } + + /// 존 i 의 진행률 — BossArena 누적값 − 런 시작 스냅샷(무장 시) 과 자체 카운터 중 큰 값. + public static int ZoneProgress(int i) + { + if (i < 0 || i >= s_zoneCount) return 0; + int own = s_zoneOwn[i]; + if (!BossArena.GateArmed) return own; + int arena = BossArena.KillsOf(s_zoneIds[i]) - s_zoneBase[i]; + return arena > own ? arena : own; + } + + /// 존 i 의 클리어 임계(SO). + public static int ZoneRequired(int i) { return i >= 0 && i < s_zoneCount ? s_zoneNeed[i] : 0; } + + /// 아직 클리어하지 않은 첫 존(전부 클리어면 zoneCount). + public static int CurrentZone + { + get + { + for (int i = 0; i < s_zoneCount; i++) if (s_zoneClearSec[i] < 0f) return i; + return s_zoneCount; + } + } + + static void EvaluateZones() + { + for (int i = 0; i < s_zoneCount; i++) + { + if (s_zoneClearSec[i] >= 0f) continue; + int kills = ZoneProgress(i); + s_zoneKills[i] = kills; + if (s_zoneNeed[i] > 0 && kills < s_zoneNeed[i]) continue; + + s_zoneClearSec[i] = Elapsed; + s_zonesCleared++; + bool all = s_zonesCleared >= s_zoneCount; + + var e = new ZoneClearedEvent + { + zoneIndex = i, spawnerId = s_zoneIds[i], elapsedSec = s_zoneClearSec[i], + kills = kills, required = s_zoneNeed[i], allZonesCleared = all, + time = Time.unscaledTime, frame = Time.frameCount + }; + RunEvents.Stamp("ZoneCleared"); + RunEvents.ZoneCleared.Dispatch(in e); + if (Verbose) Log("존 클리어 #" + i + " id=" + s_zoneIds[i] + " " + kills + "/" + s_zoneNeed[i] + + " @" + s_zoneClearSec[i].ToString("F1") + "s all=" + all); + } + + var c = Cfg; + if (!s_gateOpened && c != null && c.openGateOnZonesCleared && s_zonesCleared >= s_zoneCount && s_zoneCount > 0) + OpenBossGate(RunGateReason.ZonesCleared); + } + + /// 보스 게이트 개방(런당 1회). 813d BossArena.ForceOpenGate() 를 부르고 통지한다. + public static void OpenBossGate(RunGateReason reason) + { + if (!IsRunning || s_gateOpened) return; + var c = Cfg; + s_gateOpened = true; + s_gateReason = reason; + s_bossGateSec = Elapsed; + bool armed = BossArena.GateArmed; + if (armed && !BossArena.GateOpen) BossArena.ForceOpenGate(); + if (s_phase == RunPhase.Zones) s_phase = RunPhase.BossGate; + + var e = new BossGateOpenedEvent + { + elapsedSec = s_bossGateSec, reason = reason, + bossSpawnerId = c != null ? c.bossSpawnerId : 0, arenaGateArmed = armed, + time = Time.unscaledTime, frame = Time.frameCount + }; + RunEvents.Stamp("BossGateOpened"); + RunEvents.BossGateOpened.Dispatch(in e); + if (Verbose) Log("보스 게이트 개방 — " + reason + " @" + s_bossGateSec.ToString("F1") + "s armed=" + armed); + } + + // ───────────────────────────────────────────────────────────────────── + // 런 시작 · 종료 · 재시작 + // ───────────────────────────────────────────────────────────────────── + + /// 런 시작(진행 중이면 먼저 Abandon 으로 닫는다). + public static void StartRun(string reason) + { + var c = Cfg; + if (c == null || !WLRunSettings.Enabled) return; + if (IsRunning) EndRun(RunOutcome.Abandon); + + EnsureSubscribed(); + EnsureZoneArrays(c); + + s_runIndex++; + StartCount++; + s_startTime = Time.unscaledTime; + s_pausedTotal = 0f; s_pauseStart = -1f; + s_nextTick = s_startTime + (c.tickIntervalSeconds > 0f ? c.tickIntervalSeconds : 0f); // 시작 틱은 아래에서 직접 1회 + s_phase = RunPhase.Zones; + s_gateOpened = s_bossStarted = false; + s_gateReason = RunGateReason.None; + s_bossGateSec = s_bossStartSec = s_bossClearSec = -1f; + s_boss = null; + s_zonesCleared = 0; + s_kills = s_bossKills = s_maxChain = s_deaths = s_potions = s_skillCasts = s_lootTotal = 0; + s_maxDamage = s_maxDamageBurst = 0d; + s_gold = 0L; + s_levelFrom = s_levelTo = CurrentLevel(); + + for (int i = 0; i < s_zoneCount; i++) + { + s_zoneBase[i] = BossArena.KillsOf(s_zoneIds[i]); // 🔴 스냅샷 차감 — BossArena 값은 리셋하지 않는다 + s_zoneOwn[i] = 0; + s_zoneKills[i] = 0; + s_zoneClearSec[i] = -1f; + } + for (int i = 0; i < s_lootByGrade.Length; i++) s_lootByGrade[i] = 0; + + var e = new RunStartedEvent + { + runIndex = s_runIndex, startTime = s_startTime, runSeconds = c.runSeconds, + zoneCount = s_zoneCount, reason = reason, + time = s_startTime, frame = Time.frameCount + }; + RunEvents.Stamp("RunStarted"); + RunEvents.RunStarted.Dispatch(in e); + if (Verbose) Log("런 #" + s_runIndex + " 시작(" + reason + ") — " + c.runSeconds.ToString("F0") + + "s · 존 " + s_zoneCount + " · 존 스냅샷 차감"); + RaiseTick(); + } + + /// 런 종료 1회(RunEnded 발행). 이미 끝났거나 시작 전이면 아무 일도 하지 않는다. + public static void EndRun(RunOutcome outcome) + { + if (!IsRunning) return; + float total = Elapsed; + s_phase = RunPhase.Ended; + EndCount++; + + for (int i = 0; i < s_zoneCount; i++) s_zoneKills[i] = ZoneProgress(i); + + var c = Cfg; + LastResult = new RunResult + { + outcome = outcome, + runIndex = s_runIndex, + totalSec = total, + runSeconds = c != null ? c.runSeconds : 0f, + zoneClearSec = s_zoneClearSec, + zoneKills = s_zoneKills, + zoneCount = s_zoneCount, + zonesCleared = s_zonesCleared, + bossGateSec = s_bossGateSec, + bossStartSec = s_bossStartSec, + bossClearSec = s_bossClearSec, + kills = s_kills, + bossKills = s_bossKills, + maxChain = s_maxChain, + maxDamage = s_maxDamage, + maxDamageBurst = s_maxDamageBurst, + levelFrom = s_levelFrom, + levelTo = s_levelTo, + lootByGrade = s_lootByGrade, + lootTotal = s_lootTotal, + gold = s_gold, + deaths = s_deaths, + potionsUsed = s_potions, + skillCasts = s_skillCasts, + time = Time.unscaledTime, + frame = Time.frameCount, + }; + RunEvents.Stamp("RunEnded"); + RunEvents.RunEnded.Dispatch(in LastResult); + if (Verbose) Log("런 #" + s_runIndex + " 종료 — " + outcome + " " + total.ToString("F1") + "s kills=" + s_kills + + " boss=" + s_bossKills + " chain=" + s_maxChain + " deaths=" + s_deaths); + } + + /// + /// "다음 런" — 집계 리셋 · runIndex+1 · PC 를 존 시작점으로 · 보스 게이트 재무장. + /// 813q 결과 화면의 [다시 하기] 버튼이 부르는 유일한 공개 API. + /// + public static void Restart() + { + var c = Cfg; + if (c == null || !WLRunSettings.Enabled) return; + RestartCount++; + + if (IsRunning) EndRun(RunOutcome.Abandon); + + try + { + if (c.restartWarpsPlayer) + { + var pc = MyValue.MyPC; + if (!DSUtil.CheckNull(pc)) + { + DeathFlow.Abort("run restart"); // 부활 팝업이 떠 있으면 세계 정지부터 푼다 + pc.On_Regen(false, c.restartHealRate); // MyActor.On_Regen = 전회복 + Set_Warp(Get_ReincanationPos) + } + } + if (c.restartRearmsBossGate) BossArena.ResetGate(); // 존 누적 카운터는 건드리지 않는다 + if (c.restartResetsPotions) PotionUse.ResetRun("run restart"); + } + catch (System.Exception ex) { Log("재시작 일부 실패 — " + ex.Message); } + + StartRun("restart"); + } + + // ───────────────────────────────────────────────────────────────────── + // 틱 + // ───────────────────────────────────────────────────────────────────── + + /// 런너가 매 프레임 부른다(에디트 모드에서는 프로브가 ForceTick 으로 부른다). + internal static void Tick() + { + if (s_faulted || !WLRunSettings.Enabled) return; + var c = Cfg; + float now = Time.unscaledTime; + if (now < s_nextPoll) return; + s_nextPoll = now + Mathf.Max(0.02f, c.pollSeconds); + + // 🔴 Debug.LogException 을 쓰지 않는다 — ErrorLogHookManager 가 그것을 플레이어 팝업으로 띄운다. + try + { + PollBoundary(c); + if (!IsRunning) return; + PollPause(c, now); + EvaluateZones(); + + float el = Elapsed; + if (!s_gateOpened && c.openGateAtSec > 0f && el >= c.openGateAtSec) OpenBossGate(RunGateReason.TimeLimit); + if (!s_bossStarted && s_gateOpened && BossArena.BossSpawned) + NoteBossSpawned(c.bossSpawnerId, BossArena.Boss); // 폴백 — Spawned 를 놓쳤을 때(구독 이전 등장 등) + + if (c.runSeconds > 0f && el >= c.runSeconds) { EndRun(RunOutcome.Timeout); return; } + + float step = c.tickIntervalSeconds; + if (now >= s_nextTick) { s_nextTick = now + (step > 0f ? step : 0f); RaiseTick(); } + } + catch (System.Exception ex) { s_faulted = true; Log("중단(예외) — " + ex.Message); } + } + + static void PollPause(WLRunSettings c, float now) + { + if (!c.pauseTimerWhileWorldHeld) { if (s_pauseStart >= 0f) { s_pausedTotal += now - s_pauseStart; s_pauseStart = -1f; } return; } + bool held = DeathFlow.WorldHeld; + if (held && s_pauseStart < 0f) s_pauseStart = now; + else if (!held && s_pauseStart >= 0f) { s_pausedTotal += now - s_pauseStart; s_pauseStart = -1f; } + } + + /// 진입 경계 — 813x AutoCombatOnEnter 와 같은 조건(원천 읽기는 그 파일 · 판정은 여기). + static void PollBoundary(WLRunSettings c) + { + PCActor pc; int map; bool lobby; + if (!AutoCombatOnEnter.ReadBoundarySources(out pc, out map, out lobby)) return; // InGameInfo 없음 = 경계를 소비하지 않는다 + + bool pcChanged = !ReferenceEquals(pc, s_lastPc); + bool mapChanged = map != s_lastMapId; + if (!pcChanged && !mapChanged) return; + + s_lastPc = pc; s_lastMapId = map; + BoundaryCount++; + + if (pc == null || (c.skipLobby && lobby)) + { + if (c.endOnMapLeave && IsRunning) EndRun(RunOutcome.Abandon); + if (Verbose) Log("경계 — " + (pc == null ? "PC 없음" : "로비") + " map=" + map); + return; + } + + if (c.endOnMapLeave && IsRunning) EndRun(RunOutcome.Abandon); + if (c.autoStartOnMapEnter) StartRun(pcChanged ? "pc" : ("map " + map)); + } + + static void RaiseTick() + { + int zi = CurrentZone; + var e = new RunTickEvent + { + elapsedSec = Elapsed, remainingSec = Remaining, + zoneIndex = zi, + zoneKills = zi < s_zoneCount ? ZoneProgress(zi) : 0, + zoneRequired = zi < s_zoneCount ? s_zoneNeed[zi] : 0, + zonesCleared = s_zonesCleared, zoneCount = s_zoneCount, + phase = s_phase, runIndex = s_runIndex, + time = Time.unscaledTime, frame = Time.frameCount + }; + TickCount++; + RunEvents.Stamp("RunTick"); + RunEvents.RunTick.Dispatch(in e); + } + + // ───────────────────────────────────────────────────────────────────── + // 준비 · 진단 + // ───────────────────────────────────────────────────────────────────── + + /// 존 배열을 SO 길이에 맞춘다(길이가 같으면 재할당 0 = GC 0). + static void EnsureZoneArrays(WLRunSettings c) + { + int ids = c.zoneSpawnerIds != null ? c.zoneSpawnerIds.Length : 0; + int need = c.zoneClearKills != null ? c.zoneClearKills.Length : 0; + int n = ids < need ? ids : need; // 짧은 쪽 기준(SO 오설정 방어) + if (s_zoneIds == null || s_zoneIds.Length != n) + { + s_zoneIds = new int[n]; s_zoneNeed = new int[n]; s_zoneBase = new int[n]; + s_zoneOwn = new int[n]; s_zoneKills = new int[n]; s_zoneClearSec = new float[n]; + } + for (int i = 0; i < n; i++) { s_zoneIds[i] = c.zoneSpawnerIds[i]; s_zoneNeed[i] = c.zoneClearKills[i]; } + s_zoneCount = n; + + int slots = WLLootSettings.GradeSlots + 1; // 인덱스 = 등급(1~GradeSlots) · [0] 미사용 + if (s_lootByGrade == null || s_lootByGrade.Length != slots) s_lootByGrade = new int[slots]; + } + + /// 런 시작 시점 레벨(플레이 모드에서만 · 없으면 0). + static int CurrentLevel() + { + // 🔴 ServerInfo.Ins 는 없으면 GameObject 를 만든다(ServerInfo.cs:19) — 에디트 모드를 오염시키지 않는다. + if (!Application.isPlaying) return 0; + var si = ServerInfo.Ins; + if (DSUtil.CheckNull(si) || si.m_ServerData == null || si.m_ServerData.PC == null) return 0; + return si.m_ServerData.PC.Lv; + } + + static void Log(string msg) + { + LastLog = msg; + var c = Cfg; + if (c != null && c.verboseLog) Debug.Log("[RunDirector] " + msg); + } + + /// 프로브용: 상태·카운터를 전부 비운다(구독은 Unsubscribe 로 따로 끊는다). + public static void ResetAll() + { + s_phase = RunPhase.Idle; + s_runIndex = 0; + s_startTime = s_pausedTotal = 0f; s_pauseStart = -1f; + s_nextPoll = s_nextTick = 0f; + s_faulted = s_gateOpened = s_bossStarted = false; + s_gateReason = RunGateReason.None; + s_zoneCount = s_zonesCleared = 0; + s_kills = s_bossKills = s_maxChain = s_deaths = s_potions = s_skillCasts = s_lootTotal = 0; + s_maxDamage = s_maxDamageBurst = 0d; + s_gold = 0L; + s_levelFrom = s_levelTo = 0; + s_bossGateSec = s_bossStartSec = s_bossClearSec = -1f; + s_boss = null; + s_lastPc = null; s_lastMapId = int.MinValue; + StartCount = EndCount = RestartCount = TickCount = BoundaryCount = 0; + LastLog = ""; + LastResult = default(RunResult); + } + + /// 프로브용: 러너 없이 1회 틱(폴링 간격 무시). + public static void ForceTick() { s_nextPoll = 0f; Tick(); } + + /// + /// 프로브용: 런 시계를 강제로 되감는다(초 단위로 「경과했다」를 만든다). + /// 틱·폴링 예약 시각도 같이 당겨서, 에디트 모드에서도 tickIntervalSeconds 스케줄러를 그대로 측정할 수 있게 한다. + /// + public static void ShiftClockForProbe(float seconds) + { + s_startTime -= seconds; + s_nextTick -= seconds; + s_nextPoll -= seconds; + } + + /// 마지막 게이트 개방 사유(진단). + public static RunGateReason GateReason { get { return s_gateReason; } } + + /// 등장한 보스(진단 · 미등장이면 null). + public static Actor Boss { get { return s_boss; } } + + // ───────────────────────────────────────────────────────────────────── + // 런너 + // ───────────────────────────────────────────────────────────────────── + + internal static void EnsureRunner() + { + if (Application.isPlaying) RunDirectorRunner.Ensure(); + } + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] + static void Boot() + { + if (!WLRunSettings.Enabled) return; // 꺼져 있으면 오브젝트 0 · 구독 0(C8) + EnsureSubscribed(); + EnsureRunner(); + } + } +} diff --git a/Assets/WL/Combat/Run/RunDirector.cs.meta b/Assets/WL/Combat/Run/RunDirector.cs.meta new file mode 100644 index 000000000..21a0db145 --- /dev/null +++ b/Assets/WL/Combat/Run/RunDirector.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8d13d0112c3d4e5f6a7b8c9d0e1f2a52 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Run/RunDirectorRunner.cs b/Assets/WL/Combat/Run/RunDirectorRunner.cs new file mode 100644 index 000000000..052652a47 --- /dev/null +++ b/Assets/WL/Combat/Run/RunDirectorRunner.cs @@ -0,0 +1,38 @@ +// ───────────────────────────────────────────────────────────────────────────── +// RunDirectorRunner.cs — RunDirector 의 시간 축(숨김 GameObject 1개 · 코루틴 0) +// +// PD 지시 #813 · 발주서 WL-813p §2 (2026-09-09) +// +// 새 Manager/Singleton 을 만들지 않는다 — BossArenaRunner · AutoCombatOnEnterRunner · SurvivalRunner 와 +// 같은 방식이다. HideAndDontSave 라 씬 전환에도 살아남고(DontDestroyOnLoad 불필요) 씬·프리팹에 저장되지 않는다. +// WLRunSettings.Enabled 가 false 면 RunDirector.Boot 이 이 오브젝트를 아예 만들지 않는다(C8). +// +// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것. +// ───────────────────────────────────────────────────────────────────────────── + +using UnityEngine; + +namespace WL.Combat.Run +{ + /// RunDirector 를 매 프레임 틱한다(폴링 간격은 SO pollSeconds 가 결정). + internal sealed class RunDirectorRunner : MonoBehaviour + { + static RunDirectorRunner s_runner; + + /// 런너 1개 보장(플레이 모드 전용 · 에디트 모드에서는 만들지 않는다). + internal static void Ensure() + { + if (!Application.isPlaying || s_runner != null) return; + var go = new GameObject("[WL813p] RunDirectorRunner"); + go.hideFlags = HideFlags.HideAndDontSave; + s_runner = go.AddComponent(); + } + + /// 런너가 살아 있는가(프로브가 읽는다). + internal static bool Exists { get { return s_runner != null; } } + + void Update() { RunDirector.Tick(); } + + void OnDestroy() { if (ReferenceEquals(s_runner, this)) s_runner = null; } + } +} diff --git a/Assets/WL/Combat/Run/RunDirectorRunner.cs.meta b/Assets/WL/Combat/Run/RunDirectorRunner.cs.meta new file mode 100644 index 000000000..7e3b785e3 --- /dev/null +++ b/Assets/WL/Combat/Run/RunDirectorRunner.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8d13d0113d4e5f6a7b8c9d0e1f2a3b53 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Run/RunEvents.cs b/Assets/WL/Combat/Run/RunEvents.cs new file mode 100644 index 000000000..c837800ae --- /dev/null +++ b/Assets/WL/Combat/Run/RunEvents.cs @@ -0,0 +1,249 @@ +// ───────────────────────────────────────────────────────────────────────────── +// RunEvents.cs — WL 3분 런 통지 허브(정적) · 813q(결과 화면 UI)의 유일한 구독 지점 +// +// PD 지시 #813 · 발주서 WL-813p §1 (2026-09-09) +// +// ■ 왜 별도 파일인가 +// 코어 허브 `Assets/WL/Combat/Core/CombatEvents.cs` 는 811d~j 가 동시에 물고 있어 수정 금지다. +// 「런」은 811 12종·813h BossEvents·813k GrowthEvents 에 없던 새 축이므로 같은 규약으로 따로 둔다. +// · 정적 클래스(새 Manager/Singleton 0) · 구조체 페이로드를 `in` 으로 전달(복사·박싱 0) +// · 구독자 = 사전 할당 배열(코어의 CombatEventList 재사용 — Assembly-CSharp 단일 어셈블리) +// · 구독자 예외는 try/catch 로 격리(CombatEventList.Dispatch 안) → 런 흐름을 끊지 않는다 +// · 발행은 전부 RunDirector 안(이 파일은 계약 + 구독자 목록만 들고 있다) +// +// ■ 813q(UI)가 구독할 것 — 발주서 §4 계약표와 1:1 +// RunStarted 런 시작(HUD 타이머 리셋 · 존 게이지 리셋) +// RunTick 1 Hz(SO tickIntervalSeconds) — 남은 시간 · 현재 존 진행률 +// ZoneCleared 존 1개 클리어(존 게이지 완료 연출) +// BossGateOpened 보스 게이트 개방(배너 · "보스 등장" 안내) +// BossStarted 보스가 실제로 월드에 등장(보스 HP 바 · 보스 타이머 시작) +// RunEnded 런 종료 — RunResult 1개로 결과 화면 전부를 그린다 +// +// 🔴 RunResult 안의 배열(zoneClearSec · zoneKills · lootByGrade)은 RunDirector 가 **재사용**한다. +// 구독자는 값을 즉시 읽어 자기 버퍼에 복사할 것 — 참조를 보관하면 다음 런에서 덮어써진다(GC 0 대가). +// +// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것. +// ───────────────────────────────────────────────────────────────────────────── + +using UnityEngine; +using WL.Combat.Core; + +namespace WL.Combat.Run +{ + /// 런 종료 사유. + public enum RunOutcome + { + /// 아직 끝나지 않았다(진행 중 스냅샷). + None = 0, + /// 보스(Anubis) 처치 — 승리. + Win = 1, + /// runSeconds 초과 — 보스 미처치. + Timeout = 2, + /// 런 미완 이탈(로비 복귀 · 맵 이동 · PC 소실). + Abandon = 3, + } + + /// 런 진행 단계. + public enum RunPhase + { + /// 런 없음(타이틀 · 로비 · 종료 후 대기). + Idle = 0, + /// 존 진행 중(게이트 미개방). + Zones = 1, + /// 게이트는 열렸으나 보스가 아직 월드에 없다. + BossGate = 2, + /// 보스전 진행 중. + Boss = 3, + /// 종료(RunEnded 발행 완료 · 다음 런 대기). + Ended = 4, + } + + /// 보스 게이트를 연 이유. + public enum RunGateReason + { + None = 0, + /// 존을 전부 클리어했다(openGateOnZonesCleared). + ZonesCleared = 1, + /// openGateAtSec 경과(존이 남았어도 연다). + TimeLimit = 2, + /// 외부에서 RunDirector.OpenBossGate() 를 직접 불렀다(QA·치트·813q 버튼). + External = 3, + /// 보스가 게이트보다 먼저 월드에 있었다(BossArena off · 813e 상시 배치). + AlreadySpawned = 4, + } + + // ───────────────────────────────────────────── 페이로드 (struct · 발생 시점 스냅샷 · GC 0) + + /// 런 시작. + public struct RunStartedEvent + { + /// 이 세션에서 몇 번째 런인가(1부터). Restart 마다 +1. + public int runIndex; + /// 런이 시작된 시각(Time.unscaledTime). + public float startTime; + /// 제한 시간(초 · SO runSeconds) — UI 타이머 최대값. + public float runSeconds; + /// 이 런의 존 수(SO zoneSpawnerIds 길이) — UI 존 게이지 개수. + public int zoneCount; + /// 시작 사유("map 1" · "pc" · "restart" · "manual"). + public string reason; + public float time; + public int frame; + } + + /// 존 1개 클리어(누적 처치가 임계에 도달한 순간 · 존당 1회). + public struct ZoneClearedEvent + { + /// zoneSpawnerIds 안의 인덱스(0부터). + public int zoneIndex; + /// 그 존의 SpawnerID(813001~). + public int spawnerId; + /// 런 시작 후 경과 초. + public float elapsedSec; + /// 런 시작 시점 대비 그 존의 처치 수 · 임계값. + public int kills, required; + /// 이번 클리어로 모든 존이 끝났는가. + public bool allZonesCleared; + public float time; + public int frame; + } + + /// 보스 게이트 개방(런당 1회). + public struct BossGateOpenedEvent + { + /// 런 시작 후 경과 초. + public float elapsedSec; + /// 왜 열렸는가. + public RunGateReason reason; + /// 보스 스포너 ID(SO bossSpawnerId). + public int bossSpawnerId; + /// BossArena.ForceOpenGate() 가 실제로 먹혔는가(게이트가 무장돼 있었는가). false 면 통지만 났다. + public bool arenaGateArmed; + public float time; + public int frame; + } + + /// 보스가 월드에 등장(런당 1회). + public struct BossStartedEvent + { + /// 런 시작 후 경과 초. + public float elapsedSec; + /// 등장한 보스의 SpawnerID. + public int spawnerId; + /// 등장한 보스(BossArena.Boss · 폴백 경로로 잡았으면 null 일 수 있다 — 반드시 null 검사). + public Actor boss; + public float time; + public int frame; + } + + /// 런 타이머 틱(기본 1 Hz · UI 타이머·존 게이지 갱신용). + public struct RunTickEvent + { + /// 런 시작 후 경과 초 · 남은 초(0 미만으로 내려가지 않는다). + public float elapsedSec, remainingSec; + /// 현재 존 인덱스 = 아직 클리어하지 않은 첫 존(전부 클리어면 zoneCount). + public int zoneIndex; + /// 현재 존의 처치 수 · 임계값(전부 클리어면 0/0). + public int zoneKills, zoneRequired; + /// 지금까지 클리어한 존 수 · 전체 존 수. + public int zonesCleared, zoneCount; + /// 현재 단계. + public RunPhase phase; + /// 이 세션에서 몇 번째 런인가. + public int runIndex; + public float time; + public int frame; + } + + /// + /// 런 결과 — 813q 결과 화면이 이 하나로 전부를 그린다. + /// 🔴 배열 3종은 RunDirector 가 재사용한다. 값을 즉시 읽어 복사할 것(참조 보관 금지). + /// + public struct RunResult + { + /// 종료 사유. + public RunOutcome outcome; + /// 이 세션에서 몇 번째 런인가(1부터). + public int runIndex; + /// 런 전체 경과 초(pauseTimerWhileWorldHeld 면 부활 팝업 시간은 뺀다). + public float totalSec; + /// 제한 시간(초 · SO runSeconds). + public float runSeconds; + + /// 존별 클리어 시각(런 시작 후 초). 미클리어는 −1. 길이 = zoneCount. + public float[] zoneClearSec; + /// 존별 처치 수(런 시작 시점 대비). 길이 = zoneCount. + public int[] zoneKills; + /// 존 수 · 클리어한 존 수. + public int zoneCount, zonesCleared; + + /// 보스 게이트 개방 시각 · 보스 등장 시각 · 보스 처치 시각(런 시작 후 초 · 미발생 −1). + public float bossGateSec, bossStartSec, bossClearSec; + + /// 런 중 처치 수(보스 포함) · 그중 보스 처치 수. + public int kills, bossKills; + /// 런 중 최대 연쇄 처치 수(813e KillChain). + public int maxChain; + /// 단일 타격 최대 피해 · 합산(DamageMergeEvent.total) 최대 피해. + public double maxDamage, maxDamageBurst; + + /// 런 시작·종료 시점 레벨(813k GrowthEvents · 플레이 모드가 아니면 0). + public int levelFrom, levelTo; + + /// 등급별 획득 수 — **인덱스 = 등급(1~WLLootSettings.GradeSlots)** · [0] 미사용. 길이 = GradeSlots+1. + public int[] lootByGrade; + /// 획득 총 개수(골드 제외) · 획득 골드 총량. + public int lootTotal; + public long gold; + + /// 사망 수(813i DeathFlow · 런을 끝내지 않는다) · 물약 사용 수 · 스킬 시전 수(메인 PC). + public int deaths, potionsUsed, skillCasts; + + public float time; + public int frame; + } + + /// 런 이벤트 허브. 정적 · GC 0 · 구독은 각 목록의 Add / Remove. + public static class RunEvents + { + /// 런 시작. + public static readonly CombatEventList RunStarted = new CombatEventList(4); + /// 존 클리어(존당 1회). + public static readonly CombatEventList ZoneCleared = new CombatEventList(4); + /// 보스 게이트 개방(런당 1회). + public static readonly CombatEventList BossGateOpened = new CombatEventList(4); + /// 보스 등장(런당 1회). + public static readonly CombatEventList BossStarted = new CombatEventList(4); + /// 런 종료 — 페이로드가 곧 RunResult. + public static readonly CombatEventList RunEnded = new CombatEventList(4); + /// 런 타이머 틱(기본 1 Hz). + public static readonly CombatEventList RunTick = new CombatEventList(4); + + /// 런 축이 살아 있는가 = 에셋 · enabled · 런타임 스위치 · 코어(C8). + public static bool Enabled { get { return WLRunSettings.Enabled; } } + + // ── 진단(프로브가 읽는다) + public static int TotalRaised; + public static string LastEvent = ""; + public static float LastEventTime; + + internal static void Stamp(string name) + { + TotalRaised++; + LastEvent = name; + LastEventTime = Time.unscaledTime; + } + + /// 프로브용: 카운터 초기화(구독은 유지). + public static void ResetDiagnostics() + { + TotalRaised = 0; LastEvent = ""; LastEventTime = 0f; + RunStarted.Raised = RunStarted.Dispatched = 0; + ZoneCleared.Raised = ZoneCleared.Dispatched = 0; + BossGateOpened.Raised = BossGateOpened.Dispatched = 0; + BossStarted.Raised = BossStarted.Dispatched = 0; + RunEnded.Raised = RunEnded.Dispatched = 0; + RunTick.Raised = RunTick.Dispatched = 0; + } + } +} diff --git a/Assets/WL/Combat/Run/RunEvents.cs.meta b/Assets/WL/Combat/Run/RunEvents.cs.meta new file mode 100644 index 000000000..f7f9d8166 --- /dev/null +++ b/Assets/WL/Combat/Run/RunEvents.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8d13d0111b2c4d5e6f7a8b9c0d1e2f51 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Run/WLRunSettings.cs b/Assets/WL/Combat/Run/WLRunSettings.cs new file mode 100644 index 000000000..ad5c39e4a --- /dev/null +++ b/Assets/WL/Combat/Run/WLRunSettings.cs @@ -0,0 +1,134 @@ +// ───────────────────────────────────────────────────────────────────────────── +// WLRunSettings.cs — 3분 런 구조 값의 단일 출처(SOT · C45) +// +// PD 지시 #813 · 발주서 WL-813p §1 (2026-09-09) +// +// 에셋 경로(고정): Assets/WL/Combat/Settings/Resources/WL/WLRunSettings.asset +// → Resources.Load("WL/WLRunSettings") 로 1회 로드 후 캐시 +// (WLBossArenaSettings·WLLootSettings 등 기존 설정 SO 관례 그대로). +// +// ■ C8 롤백 (하나라도 false 면 RunDirector 는 아무 일도 하지 않는다 = 런 구조 이전 100%) +// ① 에셋이 없다 → Enabled false +// ② enabled = false → Enabled false +// ③ RuntimeDisabled = true → Enabled false (진단·A/B 전용 · 도메인 리로드로 자동 해제) +// ④ WLCombatCoreSettings.Enabled = false → Enabled false +// 🔴 ④ 가 필요한 이유: 런 집계·승리 판정은 전부 CombatEvents(Killed/Spawned/SkillCast) 구독이다. +// 코어가 꺼진 채 런을 걸면 처치가 0 으로 보여 「무조건 시간 종료」가 된다. +// 그래서 "코어가 꺼지면 런 구조도 안 건다"(= 결과 화면도 안 뜬다)로 떨어뜨린다. +// +// ■ 아래 필드 기본값은 "SOT 부재 시의 실패 처리"가 아니라 에셋 생성 시 초기값이다. +// 실제로 읽히는 값은 항상 .asset 이며, 코드에는 게임 수치 상수를 두지 않는다(C45). +// +// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것(Actor/PCActor 가 Assembly-CSharp 에 있다). +// ───────────────────────────────────────────────────────────────────────────── + +using UnityEngine; +using WL.Combat.Core; + +namespace WL.Combat.Run +{ + [CreateAssetMenu(fileName = "WLRunSettings", menuName = "WL/Run Settings", order = 327)] + public sealed class WLRunSettings : ScriptableObject + { + public const string ResourcesPath = "WL/WLRunSettings"; + + static WLRunSettings s_cached; + static bool s_lookupDone; + + /// 설정 에셋(1회 로드 후 캐시). 없으면 null. + public static WLRunSettings Instance + { + get + { + if (!s_lookupDone) + { + s_cached = Resources.Load(ResourcesPath); + s_lookupDone = true; + } + return s_cached; + } + } + + /// 진단·A/B 전용 런타임 스위치. 에셋을 건드리지 않고 런 구조만 끈다(도메인 리로드로 자동 false). + public static bool RuntimeDisabled; + + /// 런 구조가 살아 있는가 = 에셋 존재 · enabled · 런타임 스위치 off · 코어 on. + public static bool Enabled + { + get + { + var i = Instance; + return i != null && i.enabled && !RuntimeDisabled && WLCombatCoreSettings.Enabled; + } + } + + /// 테스트용: 캐시를 비워 다음 접근 때 다시 Resources.Load 한다(에셋 제거/복구 실험). + public static void ClearCache() { s_cached = null; s_lookupDone = false; } + + // ─────────────────────────────────────────── 전체 스위치 + [Header("전체 스위치")] + [Tooltip("끄면 런 상태 머신·집계·RunEvents 가 전부 비활성 — 맵은 기존 필드 맵 그대로다. 롤백 1순위.")] + public bool enabled = true; + + [Tooltip("런 시작·존 클리어·게이트·종료를 Console 에 남긴다(검증용 · 기본 off).")] + public bool verboseLog = false; + + // ─────────────────────────────────────────── 런 길이 + [Header("런 길이 (기준서 §D-2 3분 런)")] + [Tooltip("런 제한 시간(초). 이 시간을 넘기면 보스를 못 잡았어도 Timeout 으로 끝난다.")] + public float runSeconds = 180f; + + [Tooltip("RunTick 발행 주기(초 · unscaled). 1 = 1 Hz(UI 타이머). 0 이하면 매 프레임(권장하지 않는다).")] + public float tickIntervalSeconds = 1f; + + [Tooltip("진입 경계·타이머 폴링 주기(초 · unscaled). AutoCombatOnEnter 와 같은 폴링 방식이다.")] + public float pollSeconds = 0.1f; + + [Tooltip("813i DeathFlow 가 세계를 멈춘 동안(부활 팝업)은 런 시계를 세운다. 끄면 실시간으로 계속 흐른다.")] + public bool pauseTimerWhileWorldHeld = true; + + // ─────────────────────────────────────────── 존 진행 + [Header("존 진행 (813e 존 스포너 · MonsterAppear.json 실측 ID)")] + [Tooltip("런이 진행 순서로 보는 존 SpawnerID. zoneClearKills 와 길이가 같아야 한다(짧은 쪽 기준으로 잘린다).")] + public int[] zoneSpawnerIds = new int[] { 813001, 813002, 813003, 813004 }; + + [Tooltip("존 클리어 임계 — 런 시작 시점 대비 그 스포너의 누적 처치 수. 기본 = MonsterAppear 의 n_MaxMonsterCount × 2.")] + public int[] zoneClearKills = new int[] { 24, 30, 28, 16 }; + + // ─────────────────────────────────────────── 보스 게이트 + [Header("보스 게이트 (813d BossArena)")] + [Tooltip("보스 FieldBossData 의 n_SpawnerId(MonsterAppear 실측 813010). 0 이면 모든 보스 스폰을 이 런의 보스로 본다.")] + public int bossSpawnerId = 813010; + + [Tooltip("존을 전부 클리어하면 BossArena.ForceOpenGate() 로 게이트를 연다.")] + public bool openGateOnZonesCleared = true; + + [Tooltip("경과 시간이 이 값(초)을 넘으면 존이 남았어도 게이트를 연다(3분 안에 보스를 만나게 하는 안전판). 0 이하면 시간 개방 없음.")] + public float openGateAtSec = 120f; + + // ─────────────────────────────────────────── 런 시작 · 종료 + [Header("런 시작 · 종료 경계")] + [Tooltip("맵(런) 진입 경계에서 런을 자동 시작한다. 끄면 RunDirector.StartRun() 을 직접 불러야 한다(813q 버튼 등).")] + public bool autoStartOnMapEnter = true; + + [Tooltip("로비에서는 런을 시작하지 않는다(AutoCombatOnEnter 와 같은 판정).")] + public bool skipLobby = true; + + [Tooltip("런 도중 맵을 뜨거나 로비로 돌아가면 RunEnded(Abandon) 1회 후 정지한다.")] + public bool endOnMapLeave = true; + + // ─────────────────────────────────────────── 다음 런(재시작) + [Header("다음 런 (RunDirector.Restart)")] + [Tooltip("재시작 때 PC 를 존 시작점으로 되돌린다 — MyActor.On_Regen(false, rate) = 전회복 + Set_Warp(Get_ReincanationPos) 경로(813i DeathFlow 와 같다).")] + public bool restartWarpsPlayer = true; + + [Tooltip("재시작 회복 비율(1 = 풀피). restartWarpsPlayer 가 꺼져 있으면 쓰이지 않는다.")] + public float restartHealRate = 1f; + + [Tooltip("재시작 때 BossArena 게이트를 다시 잠근다(BossArena.ResetGate · 존 누적 카운터는 건드리지 않는다).")] + public bool restartRearmsBossGate = true; + + [Tooltip("재시작 때 813i 물약 잔량을 런 기준으로 되돌린다(PotionUse.ResetRun).")] + public bool restartResetsPotions = true; + } +} diff --git a/Assets/WL/Combat/Run/WLRunSettings.cs.meta b/Assets/WL/Combat/Run/WLRunSettings.cs.meta new file mode 100644 index 000000000..90869ffa9 --- /dev/null +++ b/Assets/WL/Combat/Run/WLRunSettings.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8d13d0114e5f6a7b8c9d0e1f2a3b4c54 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLRunSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLRunSettings.asset new file mode 100644 index 000000000..451d13343 --- /dev/null +++ b/Assets/WL/Combat/Settings/Resources/WL/WLRunSettings.asset @@ -0,0 +1,40 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 8d13d0114e5f6a7b8c9d0e1f2a3b4c54, type: 3} + m_Name: WLRunSettings + m_EditorClassIdentifier: Assembly-CSharp::WL.Combat.Run.WLRunSettings + enabled: 1 + verboseLog: 0 + runSeconds: 180 + tickIntervalSeconds: 1 + pollSeconds: 0.1 + pauseTimerWhileWorldHeld: 1 + zoneSpawnerIds: + - 813001 + - 813002 + - 813003 + - 813004 + zoneClearKills: + - 24 + - 30 + - 28 + - 16 + bossSpawnerId: 813010 + openGateOnZonesCleared: 1 + openGateAtSec: 120 + autoStartOnMapEnter: 1 + skipLobby: 1 + endOnMapLeave: 1 + restartWarpsPlayer: 1 + restartHealRate: 1 + restartRearmsBossGate: 1 + restartResetsPotions: 1 diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLRunSettings.asset.meta b/Assets/WL/Combat/Settings/Resources/WL/WLRunSettings.asset.meta new file mode 100644 index 000000000..7315cde4d --- /dev/null +++ b/Assets/WL/Combat/Settings/Resources/WL/WLRunSettings.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8d13d0115f6a7b8c9d0e1f2a3b4c5d55 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: