Project_WL/AgentScripts/WL813d_Probe.cs

434 lines
24 KiB
C#

// 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<FieldBossData>();
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<int, MonsterTableData>();
dic.Add(row.n_MonsterID, row);
s_tableGo = new UnityEngine.GameObject("WL813d_FakeMonsterList");
var tbl = s_tableGo.AddComponent<table_monsterlist>();
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<FieldBossData>();
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);
}
/// <summary>Export 테이블(1줄 minified · 값은 문자열)에서 keyField=keyValue 인 행의 field 를 float 로 읽는다.</summary>
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();
}
}