[WL-813p] 3분 런 구조: RunEvents · RunDirector · 집계 · 재시작 (#813)
- Assets/WL/Combat/Run/ 신규 4파일: RunEvents(계약 허브) · RunDirector(상태 머신·집계) · RunDirectorRunner(숨김 GO 1개) · WLRunSettings(값 전부 SO) - 런 = 현행 필드 맵 오버레이(새 던전 타입·DungeonInfo·*MapConfig 무수정) 진입 → 존 813001~813004 → 보스 게이트 → Anubis 처치=Win · runSeconds 초과=Timeout · 이탈=Abandon - 존 진행 = BossArena.KillsOf 누적값의 런 시작 스냅샷 차감(BossArena 값 리셋 0) - 원본 Assets/Script/** 0줄 · Core 0 · BossArena.cs +9(ResetGate) · AutoCombatOnEnter.cs +5(경계 원천 읽기 노출·동작 변경 0) - C8: WLRunSettings.enabled=0 또는 코어 off 면 러너·구독 0 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
3f87ed684a
commit
ee37211b18
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,545 @@
|
||||||
|
// WL813p_Probe.cs — #813p(3분 런 구조 · RunEvents · 집계 · 다음 런) 에디트 모드 검증 프로브
|
||||||
|
// 에디트 모드 전용 · Play 불필요 · 로그인 0 · 씬/에셋 무수정(임시 GameObject 는 즉시 파괴)
|
||||||
|
//
|
||||||
|
// 상주 에디터: unity command run_script --project-path <wt> --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<MobActor>();
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>MonsterAppear.json 의 한 행에서 정수 필드를 읽는다(코드 상수 0 · 실측 대조용).</summary>
|
||||||
|
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<FieldBossData>();
|
||||||
|
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<Actor>();
|
||||||
|
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); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -138,6 +138,11 @@ namespace WL.Combat.Auto
|
||||||
ArmIcon(cfg);
|
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)
|
static void ArmIcon(WLCombatCoreSettings cfg)
|
||||||
{
|
{
|
||||||
if (cfg.autoCombatIconRetrySeconds > 0f)
|
if (cfg.autoCombatIconRetrySeconds > 0f)
|
||||||
|
|
|
||||||
|
|
@ -576,6 +576,17 @@ namespace WL.Combat.Boss
|
||||||
LastLog = "";
|
LastLog = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>813p 「다음 런」 — 게이트만 다시 잠근다. 존 누적 카운터(s_zones)·구독·보류 목록은 건드리지 않는다.</summary>
|
||||||
|
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 다음 런)");
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>프로브용 — 보스 참조를 직접 넣는다(에디트 모드에는 실제 스폰이 없다).</summary>
|
/// <summary>프로브용 — 보스 참조를 직접 넣는다(에디트 모드에는 실제 스폰이 없다).</summary>
|
||||||
public static void SetBossForProbe(MobActor boss) { s_boss = boss; }
|
public static void SetBossForProbe(MobActor boss) { s_boss = boss; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 8d13d0110a1b4c5d8e9f0a1b2c3d4e50
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -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
|
||||||
|
{
|
||||||
|
/// <summary>3분 런 상태 머신. 정적 · 새 Manager/Singleton 0(런너는 숨김 GameObject 1개).</summary>
|
||||||
|
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 가 읽는다)
|
||||||
|
/// <summary>현재 단계.</summary>
|
||||||
|
public static RunPhase Phase { get { return s_phase; } }
|
||||||
|
/// <summary>이 세션에서 몇 번째 런인가(1부터 · 아직 없으면 0).</summary>
|
||||||
|
public static int RunIndex { get { return s_runIndex; } }
|
||||||
|
/// <summary>런이 진행 중인가(Zones · BossGate · Boss).</summary>
|
||||||
|
public static bool IsRunning { get { return s_phase == RunPhase.Zones || s_phase == RunPhase.BossGate || s_phase == RunPhase.Boss; } }
|
||||||
|
/// <summary>구독 여부 — 집계가 실제로 이벤트에 붙었는지 확인용.</summary>
|
||||||
|
public static bool Subscribed { get { return s_subscribed; } }
|
||||||
|
/// <summary>보스 게이트가 열렸는가 · 보스가 등장했는가.</summary>
|
||||||
|
public static bool GateOpened { get { return s_gateOpened; } }
|
||||||
|
public static bool BossStarted { get { return s_bossStarted; } }
|
||||||
|
/// <summary>존 수 · 클리어한 존 수.</summary>
|
||||||
|
public static int ZoneCount { get { return s_zoneCount; } }
|
||||||
|
public static int ZonesCleared { get { return s_zonesCleared; } }
|
||||||
|
/// <summary>마지막으로 발행한 RunResult(결과 화면이 늦게 떠도 읽을 수 있게 남긴다).</summary>
|
||||||
|
public static RunResult LastResult;
|
||||||
|
/// <summary>진단 카운터.</summary>
|
||||||
|
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; } }
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// 시계
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>런 시작 후 경과 초(세계 정지 시간 제외 옵션 반영). 런이 없으면 0.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>남은 초(0 미만으로 내려가지 않는다).</summary>
|
||||||
|
public static float Remaining
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var c = Cfg;
|
||||||
|
float r = (c != null ? c.runSeconds : 0f) - Elapsed;
|
||||||
|
return r > 0f ? r : 0f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// 구독
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>집계 구독(중복 구독은 CombatEventList.Add 가 막는다).</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>구독 해제(프로브·롤백용).</summary>
|
||||||
|
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 선례)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>처치 1건 반영 + 존 진행·승리 재판정.</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>보스가 월드에 등장했다(런당 1회 통지).</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>연쇄 처치 수 갱신(최대만 남긴다).</summary>
|
||||||
|
public static void NoteChain(int chainKills) { if (IsRunning && chainKills > s_maxChain) s_maxChain = chainKills; }
|
||||||
|
|
||||||
|
/// <summary>피해 갱신 — single = 단일 타격 · burst = 합산(0 이면 무시).</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>레벨업 반영(levelFrom 은 런 중 첫 레벨업의 이전 레벨로 보정한다).</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>획득 1건 반영. grade 는 LootBurst.ResolveGrade 결과(1~GradeSlots).</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>사망 1건(런을 끝내지 않는다 · 813i 부활 유지).</summary>
|
||||||
|
public static void NoteDeath() { if (IsRunning) s_deaths++; }
|
||||||
|
/// <summary>물약 1개 사용.</summary>
|
||||||
|
public static void NotePotion() { if (IsRunning) s_potions++; }
|
||||||
|
/// <summary>메인 PC 스킬 시전 1회.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>존 i 의 진행률 — BossArena 누적값 − 런 시작 스냅샷(무장 시) 과 자체 카운터 중 큰 값.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>존 i 의 클리어 임계(SO).</summary>
|
||||||
|
public static int ZoneRequired(int i) { return i >= 0 && i < s_zoneCount ? s_zoneNeed[i] : 0; }
|
||||||
|
|
||||||
|
/// <summary>아직 클리어하지 않은 첫 존(전부 클리어면 zoneCount).</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>보스 게이트 개방(런당 1회). 813d BossArena.ForceOpenGate() 를 부르고 통지한다.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// 런 시작 · 종료 · 재시작
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>런 시작(진행 중이면 먼저 Abandon 으로 닫는다).</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>런 종료 1회(RunEnded 발행). 이미 끝났거나 시작 전이면 아무 일도 하지 않는다.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// "다음 런" — 집계 리셋 · runIndex+1 · PC 를 존 시작점으로 · 보스 게이트 재무장.
|
||||||
|
/// 813q 결과 화면의 [다시 하기] 버튼이 부르는 유일한 공개 API.
|
||||||
|
/// </summary>
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// 틱
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>런너가 매 프레임 부른다(에디트 모드에서는 프로브가 ForceTick 으로 부른다).</summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>진입 경계 — 813x AutoCombatOnEnter 와 같은 조건(원천 읽기는 그 파일 · 판정은 여기).</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// 준비 · 진단
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>존 배열을 SO 길이에 맞춘다(길이가 같으면 재할당 0 = GC 0).</summary>
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>런 시작 시점 레벨(플레이 모드에서만 · 없으면 0).</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>프로브용: 상태·카운터를 전부 비운다(구독은 Unsubscribe 로 따로 끊는다).</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>프로브용: 러너 없이 1회 틱(폴링 간격 무시).</summary>
|
||||||
|
public static void ForceTick() { s_nextPoll = 0f; Tick(); }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 프로브용: 런 시계를 강제로 되감는다(초 단위로 「경과했다」를 만든다).
|
||||||
|
/// 틱·폴링 예약 시각도 같이 당겨서, 에디트 모드에서도 tickIntervalSeconds 스케줄러를 그대로 측정할 수 있게 한다.
|
||||||
|
/// </summary>
|
||||||
|
public static void ShiftClockForProbe(float seconds)
|
||||||
|
{
|
||||||
|
s_startTime -= seconds;
|
||||||
|
s_nextTick -= seconds;
|
||||||
|
s_nextPoll -= seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>마지막 게이트 개방 사유(진단).</summary>
|
||||||
|
public static RunGateReason GateReason { get { return s_gateReason; } }
|
||||||
|
|
||||||
|
/// <summary>등장한 보스(진단 · 미등장이면 null).</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 8d13d0112c3d4e5f6a7b8c9d0e1f2a52
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -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
|
||||||
|
{
|
||||||
|
/// <summary>RunDirector 를 매 프레임 틱한다(폴링 간격은 SO pollSeconds 가 결정).</summary>
|
||||||
|
internal sealed class RunDirectorRunner : MonoBehaviour
|
||||||
|
{
|
||||||
|
static RunDirectorRunner s_runner;
|
||||||
|
|
||||||
|
/// <summary>런너 1개 보장(플레이 모드 전용 · 에디트 모드에서는 만들지 않는다).</summary>
|
||||||
|
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<RunDirectorRunner>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>런너가 살아 있는가(프로브가 읽는다).</summary>
|
||||||
|
internal static bool Exists { get { return s_runner != null; } }
|
||||||
|
|
||||||
|
void Update() { RunDirector.Tick(); }
|
||||||
|
|
||||||
|
void OnDestroy() { if (ReferenceEquals(s_runner, this)) s_runner = null; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 8d13d0113d4e5f6a7b8c9d0e1f2a3b53
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -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<T> 재사용 — 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
|
||||||
|
{
|
||||||
|
/// <summary>런 종료 사유.</summary>
|
||||||
|
public enum RunOutcome
|
||||||
|
{
|
||||||
|
/// <summary>아직 끝나지 않았다(진행 중 스냅샷).</summary>
|
||||||
|
None = 0,
|
||||||
|
/// <summary>보스(Anubis) 처치 — 승리.</summary>
|
||||||
|
Win = 1,
|
||||||
|
/// <summary>runSeconds 초과 — 보스 미처치.</summary>
|
||||||
|
Timeout = 2,
|
||||||
|
/// <summary>런 미완 이탈(로비 복귀 · 맵 이동 · PC 소실).</summary>
|
||||||
|
Abandon = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>런 진행 단계.</summary>
|
||||||
|
public enum RunPhase
|
||||||
|
{
|
||||||
|
/// <summary>런 없음(타이틀 · 로비 · 종료 후 대기).</summary>
|
||||||
|
Idle = 0,
|
||||||
|
/// <summary>존 진행 중(게이트 미개방).</summary>
|
||||||
|
Zones = 1,
|
||||||
|
/// <summary>게이트는 열렸으나 보스가 아직 월드에 없다.</summary>
|
||||||
|
BossGate = 2,
|
||||||
|
/// <summary>보스전 진행 중.</summary>
|
||||||
|
Boss = 3,
|
||||||
|
/// <summary>종료(RunEnded 발행 완료 · 다음 런 대기).</summary>
|
||||||
|
Ended = 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>보스 게이트를 연 이유.</summary>
|
||||||
|
public enum RunGateReason
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
/// <summary>존을 전부 클리어했다(openGateOnZonesCleared).</summary>
|
||||||
|
ZonesCleared = 1,
|
||||||
|
/// <summary>openGateAtSec 경과(존이 남았어도 연다).</summary>
|
||||||
|
TimeLimit = 2,
|
||||||
|
/// <summary>외부에서 RunDirector.OpenBossGate() 를 직접 불렀다(QA·치트·813q 버튼).</summary>
|
||||||
|
External = 3,
|
||||||
|
/// <summary>보스가 게이트보다 먼저 월드에 있었다(BossArena off · 813e 상시 배치).</summary>
|
||||||
|
AlreadySpawned = 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────── 페이로드 (struct · 발생 시점 스냅샷 · GC 0)
|
||||||
|
|
||||||
|
/// <summary>런 시작.</summary>
|
||||||
|
public struct RunStartedEvent
|
||||||
|
{
|
||||||
|
/// <summary>이 세션에서 몇 번째 런인가(1부터). Restart 마다 +1.</summary>
|
||||||
|
public int runIndex;
|
||||||
|
/// <summary>런이 시작된 시각(Time.unscaledTime).</summary>
|
||||||
|
public float startTime;
|
||||||
|
/// <summary>제한 시간(초 · SO runSeconds) — UI 타이머 최대값.</summary>
|
||||||
|
public float runSeconds;
|
||||||
|
/// <summary>이 런의 존 수(SO zoneSpawnerIds 길이) — UI 존 게이지 개수.</summary>
|
||||||
|
public int zoneCount;
|
||||||
|
/// <summary>시작 사유("map 1" · "pc" · "restart" · "manual").</summary>
|
||||||
|
public string reason;
|
||||||
|
public float time;
|
||||||
|
public int frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>존 1개 클리어(누적 처치가 임계에 도달한 순간 · 존당 1회).</summary>
|
||||||
|
public struct ZoneClearedEvent
|
||||||
|
{
|
||||||
|
/// <summary>zoneSpawnerIds 안의 인덱스(0부터).</summary>
|
||||||
|
public int zoneIndex;
|
||||||
|
/// <summary>그 존의 SpawnerID(813001~).</summary>
|
||||||
|
public int spawnerId;
|
||||||
|
/// <summary>런 시작 후 경과 초.</summary>
|
||||||
|
public float elapsedSec;
|
||||||
|
/// <summary>런 시작 시점 대비 그 존의 처치 수 · 임계값.</summary>
|
||||||
|
public int kills, required;
|
||||||
|
/// <summary>이번 클리어로 모든 존이 끝났는가.</summary>
|
||||||
|
public bool allZonesCleared;
|
||||||
|
public float time;
|
||||||
|
public int frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>보스 게이트 개방(런당 1회).</summary>
|
||||||
|
public struct BossGateOpenedEvent
|
||||||
|
{
|
||||||
|
/// <summary>런 시작 후 경과 초.</summary>
|
||||||
|
public float elapsedSec;
|
||||||
|
/// <summary>왜 열렸는가.</summary>
|
||||||
|
public RunGateReason reason;
|
||||||
|
/// <summary>보스 스포너 ID(SO bossSpawnerId).</summary>
|
||||||
|
public int bossSpawnerId;
|
||||||
|
/// <summary>BossArena.ForceOpenGate() 가 실제로 먹혔는가(게이트가 무장돼 있었는가). false 면 통지만 났다.</summary>
|
||||||
|
public bool arenaGateArmed;
|
||||||
|
public float time;
|
||||||
|
public int frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>보스가 월드에 등장(런당 1회).</summary>
|
||||||
|
public struct BossStartedEvent
|
||||||
|
{
|
||||||
|
/// <summary>런 시작 후 경과 초.</summary>
|
||||||
|
public float elapsedSec;
|
||||||
|
/// <summary>등장한 보스의 SpawnerID.</summary>
|
||||||
|
public int spawnerId;
|
||||||
|
/// <summary>등장한 보스(BossArena.Boss · 폴백 경로로 잡았으면 null 일 수 있다 — 반드시 null 검사).</summary>
|
||||||
|
public Actor boss;
|
||||||
|
public float time;
|
||||||
|
public int frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>런 타이머 틱(기본 1 Hz · UI 타이머·존 게이지 갱신용).</summary>
|
||||||
|
public struct RunTickEvent
|
||||||
|
{
|
||||||
|
/// <summary>런 시작 후 경과 초 · 남은 초(0 미만으로 내려가지 않는다).</summary>
|
||||||
|
public float elapsedSec, remainingSec;
|
||||||
|
/// <summary>현재 존 인덱스 = 아직 클리어하지 않은 첫 존(전부 클리어면 zoneCount).</summary>
|
||||||
|
public int zoneIndex;
|
||||||
|
/// <summary>현재 존의 처치 수 · 임계값(전부 클리어면 0/0).</summary>
|
||||||
|
public int zoneKills, zoneRequired;
|
||||||
|
/// <summary>지금까지 클리어한 존 수 · 전체 존 수.</summary>
|
||||||
|
public int zonesCleared, zoneCount;
|
||||||
|
/// <summary>현재 단계.</summary>
|
||||||
|
public RunPhase phase;
|
||||||
|
/// <summary>이 세션에서 몇 번째 런인가.</summary>
|
||||||
|
public int runIndex;
|
||||||
|
public float time;
|
||||||
|
public int frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 런 결과 — 813q 결과 화면이 이 하나로 전부를 그린다.
|
||||||
|
/// 🔴 배열 3종은 RunDirector 가 재사용한다. 값을 즉시 읽어 복사할 것(참조 보관 금지).
|
||||||
|
/// </summary>
|
||||||
|
public struct RunResult
|
||||||
|
{
|
||||||
|
/// <summary>종료 사유.</summary>
|
||||||
|
public RunOutcome outcome;
|
||||||
|
/// <summary>이 세션에서 몇 번째 런인가(1부터).</summary>
|
||||||
|
public int runIndex;
|
||||||
|
/// <summary>런 전체 경과 초(pauseTimerWhileWorldHeld 면 부활 팝업 시간은 뺀다).</summary>
|
||||||
|
public float totalSec;
|
||||||
|
/// <summary>제한 시간(초 · SO runSeconds).</summary>
|
||||||
|
public float runSeconds;
|
||||||
|
|
||||||
|
/// <summary>존별 클리어 시각(런 시작 후 초). 미클리어는 −1. 길이 = zoneCount.</summary>
|
||||||
|
public float[] zoneClearSec;
|
||||||
|
/// <summary>존별 처치 수(런 시작 시점 대비). 길이 = zoneCount.</summary>
|
||||||
|
public int[] zoneKills;
|
||||||
|
/// <summary>존 수 · 클리어한 존 수.</summary>
|
||||||
|
public int zoneCount, zonesCleared;
|
||||||
|
|
||||||
|
/// <summary>보스 게이트 개방 시각 · 보스 등장 시각 · 보스 처치 시각(런 시작 후 초 · 미발생 −1).</summary>
|
||||||
|
public float bossGateSec, bossStartSec, bossClearSec;
|
||||||
|
|
||||||
|
/// <summary>런 중 처치 수(보스 포함) · 그중 보스 처치 수.</summary>
|
||||||
|
public int kills, bossKills;
|
||||||
|
/// <summary>런 중 최대 연쇄 처치 수(813e KillChain).</summary>
|
||||||
|
public int maxChain;
|
||||||
|
/// <summary>단일 타격 최대 피해 · 합산(DamageMergeEvent.total) 최대 피해.</summary>
|
||||||
|
public double maxDamage, maxDamageBurst;
|
||||||
|
|
||||||
|
/// <summary>런 시작·종료 시점 레벨(813k GrowthEvents · 플레이 모드가 아니면 0).</summary>
|
||||||
|
public int levelFrom, levelTo;
|
||||||
|
|
||||||
|
/// <summary>등급별 획득 수 — **인덱스 = 등급(1~WLLootSettings.GradeSlots)** · [0] 미사용. 길이 = GradeSlots+1.</summary>
|
||||||
|
public int[] lootByGrade;
|
||||||
|
/// <summary>획득 총 개수(골드 제외) · 획득 골드 총량.</summary>
|
||||||
|
public int lootTotal;
|
||||||
|
public long gold;
|
||||||
|
|
||||||
|
/// <summary>사망 수(813i DeathFlow · 런을 끝내지 않는다) · 물약 사용 수 · 스킬 시전 수(메인 PC).</summary>
|
||||||
|
public int deaths, potionsUsed, skillCasts;
|
||||||
|
|
||||||
|
public float time;
|
||||||
|
public int frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>런 이벤트 허브. 정적 · GC 0 · 구독은 각 목록의 Add / Remove.</summary>
|
||||||
|
public static class RunEvents
|
||||||
|
{
|
||||||
|
/// <summary>런 시작.</summary>
|
||||||
|
public static readonly CombatEventList<RunStartedEvent> RunStarted = new CombatEventList<RunStartedEvent>(4);
|
||||||
|
/// <summary>존 클리어(존당 1회).</summary>
|
||||||
|
public static readonly CombatEventList<ZoneClearedEvent> ZoneCleared = new CombatEventList<ZoneClearedEvent>(4);
|
||||||
|
/// <summary>보스 게이트 개방(런당 1회).</summary>
|
||||||
|
public static readonly CombatEventList<BossGateOpenedEvent> BossGateOpened = new CombatEventList<BossGateOpenedEvent>(4);
|
||||||
|
/// <summary>보스 등장(런당 1회).</summary>
|
||||||
|
public static readonly CombatEventList<BossStartedEvent> BossStarted = new CombatEventList<BossStartedEvent>(4);
|
||||||
|
/// <summary>런 종료 — 페이로드가 곧 RunResult.</summary>
|
||||||
|
public static readonly CombatEventList<RunResult> RunEnded = new CombatEventList<RunResult>(4);
|
||||||
|
/// <summary>런 타이머 틱(기본 1 Hz).</summary>
|
||||||
|
public static readonly CombatEventList<RunTickEvent> RunTick = new CombatEventList<RunTickEvent>(4);
|
||||||
|
|
||||||
|
/// <summary>런 축이 살아 있는가 = 에셋 · enabled · 런타임 스위치 · 코어(C8).</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>프로브용: 카운터 초기화(구독은 유지).</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 8d13d0111b2c4d5e6f7a8b9c0d1e2f51
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -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<WLRunSettings>("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;
|
||||||
|
|
||||||
|
/// <summary>설정 에셋(1회 로드 후 캐시). 없으면 null.</summary>
|
||||||
|
public static WLRunSettings Instance
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (!s_lookupDone)
|
||||||
|
{
|
||||||
|
s_cached = Resources.Load<WLRunSettings>(ResourcesPath);
|
||||||
|
s_lookupDone = true;
|
||||||
|
}
|
||||||
|
return s_cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>진단·A/B 전용 런타임 스위치. 에셋을 건드리지 않고 런 구조만 끈다(도메인 리로드로 자동 false).</summary>
|
||||||
|
public static bool RuntimeDisabled;
|
||||||
|
|
||||||
|
/// <summary>런 구조가 살아 있는가 = 에셋 존재 · enabled · 런타임 스위치 off · 코어 on.</summary>
|
||||||
|
public static bool Enabled
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var i = Instance;
|
||||||
|
return i != null && i.enabled && !RuntimeDisabled && WLCombatCoreSettings.Enabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>테스트용: 캐시를 비워 다음 접근 때 다시 Resources.Load 한다(에셋 제거/복구 실험).</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 8d13d0114e5f6a7b8c9d0e1f2a3b4c54
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 8d13d0115f6a7b8c9d0e1f2a3b4c5d55
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 11400000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
Loading…
Reference in New Issue