// PD 지시 #813 · 발주서 WL-813d §2 ⓒ — 보스전 게이트 · 아레나 에디트 모드 프로브 // // 실행 (CLAUDE.md §1: using 금지 · 네임스페이스 풀어 쓰기 · 첫 Roslyn 컴파일은 --timeout 60) // unity command run_script --file AgentScripts/WL813d_Probe.cs --entry WL813d_Probe.Run --timeout 300 // // 무엇을 확인하나 (발주서 §2 ⓒ ⓓ) // ① 설정 에셋 로드 · Active · 값 전수 덤프 // ② 게이트 A — 가짜 Spawned/Killed 로 존 카운트 → 게이트 존(813004·813005) 전멸 → resume() 호출(= 보스 활성) // ③ 게이트 B — 누적 처치 ≥ requiredKills 경로(리젠형 존에서 전멸이 안 잡힐 때) // ④ 추적 반경 데이터화 — MonsterList 10006 행(실 JSON 값)에 SO 값이 실제로 쓰이는지(원 코드 경로) // ⑤ 리쉬 판정표 — 보스 이탈 · PC 이탈(유예 포함) 가짜 좌표 // ⑥ 보스 처치 → 정리 — RegenTime 코루틴 차단 · 반경 원복 // ⑦ C8 — RuntimeDisabled / 코어 off 면 HoldSpawn 이 false(= 813e 상시 배치 100%) // // Play 하지 않는다. 프리팹·씬·에셋을 저장하지 않는다(만든 GameObject 는 DestroyImmediate 로 되돌린다). // 수치는 전부 .asset / MonsterList.json / MonsterAppear.json 에서 읽는다(C45). public static class WL813d_Probe { const string kLog = "AgentScripts/WL813d_PROBE.txt"; const string kMonsterList = "Assets/ResWork/Table/Export/MonsterList.json"; const string kMonsterAppear = "Assets/ResWork/Table/Export/MonsterAppear.json"; static System.Text.StringBuilder s; static int s_resumeCount; static UnityEngine.GameObject s_fbdGo, s_tableGo; static FieldBossData s_fbd; public static string Run() { s = new System.Text.StringBuilder(); s_resumeCount = 0; L("===== WL-813d 보스전 게이트 · 아레나 프로브 ====="); L("playMode=" + UnityEngine.Application.isPlaying + " time=" + System.DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); WL.Combat.Core.WLCombatCoreSettings.RuntimeDisabled = false; WL.Combat.Boss.WLBossArenaSettings.RuntimeDisabled = false; WL.Combat.Boss.WLBossArenaSettings.ClearCache(); WL.Combat.Boss.BossArena.ResetAll(); try { Setup(); Section1_Settings(); Section2_GateZoneCleared(); Section3_GateKillCount(); Section5_Leash(); Section6_Cleanup(); Section7_Rollback(); } catch (System.Exception ex) { L("!! 예외: " + ex); } finally { Teardown(); } L(""); L("===== 끝 ====="); return Flush(); } // ───────────────────────────────────────── 준비 static void Setup() { // 가짜 FieldBossData — 실제 훅과 같은 계약으로 HoldSpawn 을 부른다(원본 Start 는 부르지 않는다). var cfg = WL.Combat.Boss.WLBossArenaSettings.Instance; s_fbdGo = new UnityEngine.GameObject("WL813d_FakeFieldBoss"); s_fbdGo.transform.position = cfg != null ? cfg.arenaCenter : UnityEngine.Vector3.zero; s_fbd = s_fbdGo.AddComponent(); s_fbd.isChapterBoss = false; s_fbd.n_MonsterID = cfg != null ? cfg.bossMonsterId : 10006; s_fbd.n_SpawnerId = cfg != null ? cfg.bossSpawnerId : 813010; s_fbd.RegenTime = 60f; // 가짜 MonsterList 싱글턴 — 실 JSON 의 10006 행 값을 그대로 넣어 「원 코드 경로」로 반경 적용을 측정한다. // (에디트 모드에는 table_monsterlist 인스턴스가 없다. Ins 는 public static, dic_Data 만 private 이라 리플렉션 1회.) float chaseFromJson = ReadFloatField(kMonsterList, "n_MonsterID", s_fbd.n_MonsterID.ToString(), "f_BaseChaseRange", -1f); var row = new MonsterTableData(); row.n_MonsterID = s_fbd.n_MonsterID; row.f_BaseChaseRange = chaseFromJson; var dic = new System.Collections.Generic.Dictionary(); dic.Add(row.n_MonsterID, row); s_tableGo = new UnityEngine.GameObject("WL813d_FakeMonsterList"); var tbl = s_tableGo.AddComponent(); var fi = typeof(table_monsterlist).GetField("dic_Data", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); if (fi != null) fi.SetValue(tbl, dic); table_monsterlist.Ins = tbl; L(""); L("── ⓪ 준비"); L(" 가짜 FieldBossData = spawnerId " + s_fbd.n_SpawnerId + " · monsterId " + s_fbd.n_MonsterID + " · pos " + s_fbdGo.transform.position.ToString("F1") + " · RegenTime " + s_fbd.RegenTime); L(" MonsterList 10006 행 f_BaseChaseRange (실 JSON) = " + chaseFromJson.ToString("F1") + " dic_Data 주입 = " + (fi != null)); } static void Teardown() { WL.Combat.Boss.BossArena.Unsubscribe(); WL.Combat.Boss.BossArena.ResetAll(); WL.Combat.Boss.WLBossArenaSettings.RuntimeDisabled = false; WL.Combat.Core.WLCombatCoreSettings.RuntimeDisabled = false; table_monsterlist.Ins = null; if (s_fbdGo != null) UnityEngine.Object.DestroyImmediate(s_fbdGo); if (s_tableGo != null) UnityEngine.Object.DestroyImmediate(s_tableGo); } // ───────────────────────────────────────── ① 설정 static void Section1_Settings() { var cfg = WL.Combat.Boss.WLBossArenaSettings.Instance; L(""); L("── ① 설정 에셋"); L(" Instance = " + (cfg != null ? "OK" : "null (Resources/WL/WLBossArenaSettings 없음)")); L(" 코어 Enabled = " + WL.Combat.Core.WLCombatCoreSettings.Enabled); L(" 게이트 Active = " + WL.Combat.Boss.WLBossArenaSettings.Active); if (cfg == null) return; L(" enabled/verboseLog = " + cfg.enabled + " / " + cfg.verboseLog); L(" bossSpawnerId/Monster = " + cfg.bossSpawnerId + " / " + cfg.bossMonsterId); L(" gateEnabled/mode = " + cfg.gateEnabled + " / " + cfg.gateMode); L(" zoneSpawnerIds = " + Join(cfg.zoneSpawnerIds)); L(" gateZoneSpawnerIds = " + Join(cfg.gateZoneSpawnerIds)); L(" requiredKills = " + cfg.requiredKills + " requireZoneSpawnedOnce=" + cfg.requireZoneSpawnedOnce); L(" gateOpenDelaySeconds = " + cfg.gateOpenDelaySeconds); L(" arena center/radius = " + (cfg.useFieldBossPositionAsCenter ? "FieldBossData 좌표" : cfg.arenaCenter.ToString("F1")) + " / " + cfg.arenaRadius); L(" bossChaseRange = " + cfg.bossChaseRange + " restoreOnCleanup=" + cfg.restoreChaseRangeOnCleanup); L(" leash margin/PC margin= " + cfg.leashMargin + " / " + cfg.playerLeaveMargin + " grace=" + cfg.playerLeaveGraceSeconds + "s interval=" + cfg.leashCheckIntervalSeconds + "s"); L(" resetHeals/rate = " + cfg.resetHealsBoss + " / " + cfg.resetHealRate); L(" cleanup delay/regen = " + cfg.cleanupDelaySeconds + "s / suppressRegen=" + cfg.suppressRegenAfterBossKill); L(" deactivateZones/rearm = " + cfg.deactivateZonesAfterBossKill + " / " + cfg.rearmGateAfterBossKill); } // ───────────────────────────────────────── ② 게이트 A (존 전멸) static void Section2_GateZoneCleared() { var cfg = WL.Combat.Boss.WLBossArenaSettings.Instance; L(""); L("── ② 게이트 A — 게이트 존 전멸 → 보스 등장"); if (cfg == null) { L(" 설정 없음 — 건너뜀"); return; } WL.Combat.Boss.BossArena.ResetAll(); s_resumeCount = 0; int killedBefore = WL.Combat.Core.CombatEvents.Killed.Count; int spawnedBefore = WL.Combat.Core.CombatEvents.Spawned.Count; bool held = WL.Combat.Boss.BossArena.HoldSpawn(s_fbd, FakeResume); L(" HoldSpawn() = " + held + " (true = 원본 Start 가 return · 보스 미등장)"); L(" GateArmed/Subscribed = " + WL.Combat.Boss.BossArena.GateArmed + " / " + WL.Combat.Boss.BossArena.Subscribed); L(" CombatEvents 구독 수 = Killed " + killedBefore + "→" + WL.Combat.Core.CombatEvents.Killed.Count + " · Spawned " + spawnedBefore + "→" + WL.Combat.Core.CombatEvents.Spawned.Count); L(" resume 호출 수 = " + s_resumeCount + " (게이트가 닫혀 있으므로 0 이어야 한다)"); L(" 반경 적용 여부 = " + WL.Combat.Boss.BossArena.ChaseRangeApplied + " (등장 직전에만 적용)"); // 존 스폰 — 마릿수는 MonsterAppear.json 의 n_MaxMonsterCount 실값 L(""); L(" [스폰] 존별 n_MaxMonsterCount 만큼 가짜 Spawned"); for (int i = 0; i < cfg.zoneSpawnerIds.Length; i++) { int id = cfg.zoneSpawnerIds[i]; int n = (int)ReadFloatField(kMonsterAppear, "n_SpawnerID", id.ToString(), "n_MaxMonsterCount", 0f); for (int k = 0; k < n; k++) WL.Combat.Boss.BossArena.NoteSpawn(id, false); L(" 존 " + id + " : 스폰 " + n + " → alive " + WL.Combat.Boss.BossArena.AliveOf(id)); } L(" GateOpen = " + WL.Combat.Boss.BossArena.GateOpen + " (아직 닫혀 있어야 한다)"); // 게이트 존만 전멸시킨다(비게이트 존은 살려 둔다 → ZoneCleared 경로만 검증) L(""); L(" [처치] 게이트 존만 전멸 — 비게이트 존은 살려 둔다(KillCount 경로와 분리)"); for (int i = 0; i < cfg.gateZoneSpawnerIds.Length; i++) { int id = cfg.gateZoneSpawnerIds[i]; int alive = WL.Combat.Boss.BossArena.AliveOf(id); for (int k = 0; k < alive; k++) { WL.Combat.Boss.BossArena.NoteKill(id, false); if (k == alive - 1) L(" 존 " + id + " 마지막 처치 → alive " + WL.Combat.Boss.BossArena.AliveOf(id) + " · 누적처치 " + WL.Combat.Boss.BossArena.TotalZoneKills + "/" + cfg.requiredKills + " · GateOpen " + WL.Combat.Boss.BossArena.GateOpen + " · resume " + s_resumeCount); else if (k == 0) L(" 존 " + id + " 첫 처치 → alive " + WL.Combat.Boss.BossArena.AliveOf(id) + " · GateOpen " + WL.Combat.Boss.BossArena.GateOpen); } } L(""); L(" 결과 GateOpen = " + WL.Combat.Boss.BossArena.GateOpen); L(" 결과 resume 호출 수 = " + s_resumeCount + " (1 = 원본 FieldBossData.Start 재진입 = 보스 활성)"); L(" ReleaseCount = " + WL.Combat.Boss.BossArena.ReleaseCount); L(" 누적 처치 = " + WL.Combat.Boss.BossArena.TotalZoneKills + " / requiredKills " + cfg.requiredKills + " → KillCount 조건은 아직 미충족(= ZoneCleared 단독 개방)"); L(" LastLog = " + WL.Combat.Boss.BossArena.LastLog); // ④ 추적 반경 — Release 안에서 원 코드 경로로 적용된다 L(""); L("── ④ 추적 반경 데이터화 (등장 직전 · MonsterList 행)"); L(" ChaseRangeApplied = " + WL.Combat.Boss.BossArena.ChaseRangeApplied); L(" before → after = " + WL.Combat.Boss.BossArena.ChaseRangeBefore.ToString("F1") + " → " + WL.Combat.Boss.BossArena.ChaseRangeAfter.ToString("F1") + " (SO bossChaseRange=" + cfg.bossChaseRange + ")"); var live = table_monsterlist.Ins != null ? table_monsterlist.Ins.Get_Data_orNull(cfg.bossMonsterId) : null; L(" 테이블 행 실측 = " + (live != null ? live.f_BaseChaseRange.ToString("F1") : "행 없음") + " (MobActor.cs:92 Check_Battle · :356 Check_Patrol 이 이 값을 읽는다)"); // 두 번째 진입(resume → Start 재진입)에서 통과하는지 bool second = WL.Combat.Boss.BossArena.HoldSpawn(s_fbd, FakeResume); L(" 재진입 HoldSpawn() = " + second + " (false = 원본 스폰 코드 진행 · 이중 스폰 없음)"); L(" 재진입 후 resume 호출 = " + s_resumeCount + " (증가하지 않아야 한다)"); } // ───────────────────────────────────────── ③ 게이트 B (누적 처치) static void Section3_GateKillCount() { var cfg = WL.Combat.Boss.WLBossArenaSettings.Instance; L(""); L("── ③ 게이트 B — 누적 처치 ≥ requiredKills (리젠형 존에서 전멸이 안 잡힐 때)"); if (cfg == null) { L(" 설정 없음 — 건너뜀"); return; } WL.Combat.Boss.BossArena.ResetAll(); s_resumeCount = 0; WL.Combat.Boss.BossArena.HoldSpawn(s_fbd, FakeResume); // 모든 존을 스폰시켜 두되, 게이트 존은 계속 리젠되는 상황을 흉내낸다(처치할 때마다 다시 스폰). int zone0 = cfg.zoneSpawnerIds.Length > 0 ? cfg.zoneSpawnerIds[0] : 813001; for (int i = 0; i < cfg.zoneSpawnerIds.Length; i++) { int id = cfg.zoneSpawnerIds[i]; int n = (int)ReadFloatField(kMonsterAppear, "n_SpawnerID", id.ToString(), "n_MaxMonsterCount", 0f); for (int k = 0; k < n; k++) WL.Combat.Boss.BossArena.NoteSpawn(id, false); } int guard = 0; while (!WL.Combat.Boss.BossArena.GateOpen && guard++ < 500) { WL.Combat.Boss.BossArena.NoteKill(zone0, false); WL.Combat.Boss.BossArena.NoteSpawn(zone0, false); // 리젠 — alive 가 0 이 되지 않는다 } L(" 리젠 흉내 (존 " + zone0 + " 처치↔리젠 반복 · 어떤 존도 전멸하지 않음)"); L(" 게이트 존 alive = " + Join(cfg.gateZoneSpawnerIds) + " → " + AliveList(cfg.gateZoneSpawnerIds)); L(" 누적 처치 / 임계 = " + WL.Combat.Boss.BossArena.TotalZoneKills + " / " + cfg.requiredKills); L(" GateOpen / resume = " + WL.Combat.Boss.BossArena.GateOpen + " / " + s_resumeCount); L(" 반복 횟수(안전 상한 500) = " + guard); L(" LastLog = " + WL.Combat.Boss.BossArena.LastLog); } // ───────────────────────────────────────── ⑤ 리쉬 static void Section5_Leash() { var cfg = WL.Combat.Boss.WLBossArenaSettings.Instance; L(""); L("── ⑤ 리쉬 판정표 (EvaluateLeash · 순수 함수 · Actor 접근 0)"); if (cfg == null) { L(" 설정 없음 — 건너뜀"); return; } var c = cfg.arenaCenter; float r = cfg.arenaRadius; L(" 중심 " + c.ToString("F1") + " · 반경 " + r + " · leashMargin " + cfg.leashMargin + " · playerLeaveMargin " + cfg.playerLeaveMargin + " · grace " + cfg.playerLeaveGraceSeconds + "s"); L(" " + Pad("보스 거리", 12) + Pad("PC 거리", 10) + Pad("경과(s)", 9) + "판정"); LeashCase(c, r, 0f, 0f, 0f); // 둘 다 안 LeashCase(c, r, r - 1f, 0f, 0f); // 보스 경계 안 LeashCase(c, r, r + cfg.leashMargin + 1f, 0f, 0f); // 보스 이탈 LeashCase(c, r, 0f, r + cfg.playerLeaveMargin + 1f, 0f); // PC 이탈 · 유예 전 LeashCase(c, r, 0f, r + cfg.playerLeaveMargin + 1f, cfg.playerLeaveGraceSeconds + 0.1f); // PC 이탈 · 유예 후 WL.Combat.Boss.BossArena.ResetLeashState(); LeashCase(c, r, 0f, r + cfg.playerLeaveMargin - 1f, 0f); // PC 여유 안 (이탈 아님) L(" ※ 실제 리셋 실행(Del_Target · On_Regen(reincarnation:true) · Set_Warp)은 살아 있는 MobActor 가 필요해"); L(" 에디트 모드에서 측정 불가 = 「미확인」. Boss 참조 없음 → LeashTick() 는 무동작:"); WL.Combat.Boss.BossArena.SetBossForProbe(null); int before = WL.Combat.Boss.BossArena.ResetCount; WL.Combat.Boss.BossArena.LeashTick(); L(" LeashTick() ResetCount " + before + " → " + WL.Combat.Boss.BossArena.ResetCount); } static void LeashCase(UnityEngine.Vector3 c, float r, float bossDist, float pcDist, float elapsed) { WL.Combat.Boss.BossArena.ResetLeashState(); var bossPos = c + new UnityEngine.Vector3(bossDist, 0f, 0f); var pcPos = c + new UnityEngine.Vector3(pcDist, 0f, 0f); bool hasPc = pcDist > 0f; int v = WL.Combat.Boss.BossArena.EvaluateLeash(bossPos, pcPos, hasPc, c, 0f); if (v == WL.Combat.Boss.BossArena.kLeashNone && elapsed > 0f) v = WL.Combat.Boss.BossArena.EvaluateLeash(bossPos, pcPos, hasPc, c, elapsed); // 유예 경과 후 재판정 L(" " + Pad(bossDist.ToString("F1") + "m", 12) + Pad(hasPc ? pcDist.ToString("F1") + "m" : "-", 10) + Pad(elapsed.ToString("F1"), 9) + Verdict(v)); } static string Verdict(int v) { if (v == WL.Combat.Boss.BossArena.kLeashPlayerOut) return "리셋 (PC 이탈)"; if (v == WL.Combat.Boss.BossArena.kLeashBossOut) return "리셋 (보스 이탈)"; return "유지"; } // ───────────────────────────────────────── ⑥ 처치 후 정리 static void Section6_Cleanup() { var cfg = WL.Combat.Boss.WLBossArenaSettings.Instance; L(""); L("── ⑥ 보스 처치 → 정리"); if (cfg == null) { L(" 설정 없음 — 건너뜀"); return; } WL.Combat.Boss.BossArena.ResetAll(); s_resumeCount = 0; WL.Combat.Boss.BossArena.HoldSpawn(s_fbd, FakeResume); WL.Combat.Boss.BossArena.ForceOpenGate(); L(" 강제 개방 후 resume = " + s_resumeCount + " · 반경 " + WL.Combat.Boss.BossArena.ChaseRangeBefore.ToString("F1") + " → " + WL.Combat.Boss.BossArena.ChaseRangeAfter.ToString("F1")); WL.Combat.Boss.BossArena.NoteSpawn(cfg.bossSpawnerId, true); L(" 보스 Spawned(isBoss) → BossSpawned=" + WL.Combat.Boss.BossArena.BossSpawned + " (811d 보스 캠 · 813c HP 바 · 813t 배너가 구독하는 그 이벤트)"); WL.Combat.Boss.BossArena.NoteKill(cfg.bossSpawnerId, true); L(" 보스 Killed(Boss) → BossKilled=" + WL.Combat.Boss.BossArena.BossKilled + " · CleanupDone=" + WL.Combat.Boss.BossArena.CleanupDone + " (에디트 모드라 cleanupDelaySeconds 무시 · 즉시 실행)"); var live = table_monsterlist.Ins != null ? table_monsterlist.Ins.Get_Data_orNull(cfg.bossMonsterId) : null; L(" 반경 원복 후 테이블 값 = " + (live != null ? live.f_BaseChaseRange.ToString("F1") : "행 없음") + " restoreChaseRangeOnCleanup=" + cfg.restoreChaseRangeOnCleanup); L(" ChaseRangeApplied = " + WL.Combat.Boss.BossArena.ChaseRangeApplied); L(" LastLog = " + WL.Combat.Boss.BossArena.LastLog); L(" ※ 리젠 차단은 FieldBossData.StopAllCoroutines() — 코루틴은 Play 에서만 돌아 「미확인」."); L(" 가짜 FieldBossData 생존 = " + (s_fbd != null) + " (정리가 오브젝트를 파괴하지 않는다)"); } // ───────────────────────────────────────── ⑦ C8 static void Section7_Rollback() { var cfg = WL.Combat.Boss.WLBossArenaSettings.Instance; L(""); L("── ⑦ C8 롤백 — 어느 스위치든 끄면 813e 상시 배치 100%"); if (cfg == null) { L(" 설정 없음 — 건너뜀"); return; } // ⑦-1 게이트 SO 런타임 스위치 WL.Combat.Boss.BossArena.ResetAll(); s_resumeCount = 0; WL.Combat.Boss.WLBossArenaSettings.RuntimeDisabled = true; bool a = WL.Combat.Boss.BossArena.HoldSpawn(s_fbd, FakeResume); L(" WLBossArenaSettings.RuntimeDisabled=true → HoldSpawn=" + a + " · Active=" + WL.Combat.Boss.WLBossArenaSettings.Active + " · PassThrough=" + WL.Combat.Boss.BossArena.PassThroughCount); WL.Combat.Boss.WLBossArenaSettings.RuntimeDisabled = false; // ⑦-2 코어 off — 이벤트가 0 이라 게이트를 걸면 보스가 영영 안 나온다 → 걸지 않는다 WL.Combat.Boss.BossArena.ResetAll(); WL.Combat.Core.WLCombatCoreSettings.RuntimeDisabled = true; bool b = WL.Combat.Boss.BossArena.HoldSpawn(s_fbd, FakeResume); L(" WLCombatCoreSettings.RuntimeDisabled=true → HoldSpawn=" + b + " · 코어 Enabled=" + WL.Combat.Core.WLCombatCoreSettings.Enabled + " · 게이트 Active=" + WL.Combat.Boss.WLBossArenaSettings.Active); WL.Combat.Core.WLCombatCoreSettings.RuntimeDisabled = false; // ⑦-3 다른 FieldBossData(다른 spawnerId)는 손대지 않는다 WL.Combat.Boss.BossArena.ResetAll(); var otherGo = new UnityEngine.GameObject("WL813d_OtherFieldBoss"); var other = otherGo.AddComponent(); other.n_MonsterID = 10001; other.n_SpawnerId = 999999; bool c = WL.Combat.Boss.BossArena.HoldSpawn(other, FakeResume); L(" 다른 FieldBossData(spawnerId 999999) → HoldSpawn=" + c + " (false = 원본 즉시 배치 그대로)"); UnityEngine.Object.DestroyImmediate(otherGo); // ⑦-4 gateEnabled=false 는 에셋 값이라 여기서 바꾸지 않는다(에셋 무변경) — 코드 경로만 명시 L(" gateEnabled=false / enabled=false / 에셋 삭제도 같은 경로(HoldSpawn 첫 3줄) — 에셋을 건드리지 않아 미실행"); L(" resume 호출 총합 = " + s_resumeCount + " (C8 구간에서 0 이어야 한다)"); } // ───────────────────────────────────────── 유틸 static void FakeResume() { s_resumeCount++; L(" ▶ resume() 호출 = 원본 FieldBossData.Start 재진입(보스 활성) #" + s_resumeCount); } /// Export 테이블(1줄 minified · 값은 문자열)에서 keyField=keyValue 인 행의 field 를 float 로 읽는다. static float ReadFloatField(string path, string keyField, string keyValue, string field, float fallback) { try { string txt = System.IO.File.ReadAllText(path); string anchor = "\"" + keyField + "\": \"" + keyValue + "\""; int at = txt.IndexOf(anchor, System.StringComparison.Ordinal); if (at < 0) return fallback; int end = txt.IndexOf('}', at); if (end < 0) end = txt.Length; string row = txt.Substring(at, end - at); var m = System.Text.RegularExpressions.Regex.Match(row, "\"" + field + "\"\\s*:\\s*\"([^\"]*)\""); if (!m.Success) return fallback; float v; return float.TryParse(m.Groups[1].Value, out v) ? v : fallback; } catch (System.Exception) { return fallback; } } static string AliveList(int[] ids) { if (ids == null) return ""; var sb = new System.Text.StringBuilder(); for (int i = 0; i < ids.Length; i++) { if (i > 0) sb.Append(", "); sb.Append(WL.Combat.Boss.BossArena.AliveOf(ids[i])); } return sb.ToString(); } static string Join(int[] a) { if (a == null) return "(null)"; var sb = new System.Text.StringBuilder(); for (int i = 0; i < a.Length; i++) { if (i > 0) sb.Append(", "); sb.Append(a[i]); } return sb.ToString(); } static string Pad(string t, int n) { return t.Length >= n ? t + " " : t + new string(' ', n - t.Length); } static void L(string line) { s.AppendLine(line); } static string Flush() { try { System.IO.File.WriteAllText(kLog, s.ToString(), new System.Text.UTF8Encoding(true)); } catch (System.Exception ex) { s.AppendLine("!! 로그 저장 실패: " + ex.Message); } return s.ToString(); } }