// PD 지시 #813 · 발주서 WL-813h §1-4 — 보스 패턴 로더 · 페이즈 통지 에디트 모드 프로브 (읽기 전용) // // 실행 (CLAUDE.md §1: using 금지 · 네임스페이스 풀어 쓰기 · 첫 Roslyn 컴파일은 --timeout 60) // unity command run_script --file AgentScripts/WL813h_Probe.cs --entry WL813h_Probe.Run --timeout 180 // // 무엇을 확인하나 (발주서 §3 ⓒ ⓓ ⓔ) // ① 로더 상태(JSON 행 수 · 파싱) 와 12행 임계 조회표 // ② Anubis(10006) 프리팹 인스턴스에 행 값이 적용되는지 — 적용 전/후 필드 덤프 // ③ 행 없음(가짜 ID) → 값 무변경(원본 유지) // ④ BossPhase 통지 — 가짜 HP 100 → 70 → 40 % 에서 이벤트 3회 + 페이로드 // ⑤ 디스패치 GC 할당(1000회 Raise 전후 GC.GetTotalMemory 차) // ⑥ C8 — RuntimeDisabled = true 면 로더/통지 무동작 // // Play 하지 않는다. 프리팹은 LoadPrefabContents → (저장 없이) UnloadPrefabContents 라 에셋 무변경. // 수치는 전부 JSON/프리팹에서 읽는다(C45). 프로브의 반복 횟수·가짜 HP 는 "측정 조건"이지 게임 데이터가 아니다. public static class WL813h_Probe { const string kAnubisPrefab = "Assets/Res_Addr/Mobs/FieldBoss/Anubis.prefab"; const int kAnubisId = 10006; const int kMissingId = 99999; // JSON 에 없는 ID (행 없음 경로 검증) const int kGcIterations = 1000; // 측정 조건 const string kLog = "AgentScripts/WL813h_PROBE.txt"; // 발주서 §4 허용 경로(AgentScripts/WL813h_*) static System.Text.StringBuilder s; static int s_eventCount; static string s_eventLog; public static string Run() { s = new System.Text.StringBuilder(); s_eventCount = 0; s_eventLog = ""; L("===== WL-813h 보스 패턴 로더 · 페이즈 통지 프로브 ====="); 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.BossPatternTable.ClearCache(); WL.Combat.Boss.BossEvents.ResetDiagnostics(); Section1_Table(); Section2_Apply(); // ②③ 안에서 ④⑤(페이즈·GC)까지 같은 인스턴스로 이어서 돈다 Section4_Rollback(); L(""); L("===== 끝 ====="); return Flush(); } // ───────────────────────────────────────── ① 로더 상태 + 임계 조회표 static void Section1_Table() { L(""); L("── ① 로더 상태"); L(" 코어 Enabled = " + WL.Combat.Core.WLCombatCoreSettings.Enabled); L(" 로더 Enabled = " + WL.Combat.Boss.BossPatternTable.Enabled); L(" RowCount = " + WL.Combat.Boss.BossPatternTable.RowCount); L(" ParseFailed = " + WL.Combat.Boss.BossPatternTable.ParseFailed + (WL.Combat.Boss.BossPatternTable.LoadError.Length > 0 ? (" (" + WL.Combat.Boss.BossPatternTable.LoadError + ")") : "")); L(""); L("── ① 임계 조회표 (skill1 / skill2 = 해금 임계 · phases = 페이즈 임계)"); L(" ID 이름 skill1 skill2 phases 필드수"); var f = WL.Combat.Boss.BossPatternTable.File; if (f == null || f.rows == null) { L(" (JSON 없음)"); return; } for (int i = 0; i < f.rows.Count; i++) { var r = f.rows[i]; float t1, t2; WL.Combat.Boss.BossPatternTable.GetSkillThresholds(r.id, out t1, out t2); var th = WL.Combat.Boss.BossPatternTable.GetPhaseThresholds(r.id); L(string.Format(" {0,-7} {1,-20} {2,-7} {3,-7} {4,-21} {5}", r.id, r.name, t1.ToString("0.###"), t2.ToString("0.###"), "[" + string.Join(", ", System.Array.ConvertAll(th, x => x.ToString("0.##"))) + "]", r.fields != null ? r.fields.Count : 0)); } } // ───────────────────────────────────────── ② 행 적용 · ③ 행 없음 static void Section2_Apply() { L(""); L("── ② Anubis(10006) 프리팹 인스턴스에 행 적용 (LoadPrefabContents · 저장 없음)"); var root = UnityEditor.PrefabUtility.LoadPrefabContents(kAnubisPrefab); if (root == null) { L(" [에러] 프리팹 로드 실패: " + kAnubisPrefab); return; } try { var boss = root.GetComponentInChildren(true); if (boss == null) { L(" [에러] BossMobActor 컴포넌트 없음"); return; } L(" 대상 = " + boss.GetType().Name + " on '" + boss.gameObject.name + "'"); var before = Dump(boss); int applied = WL.Combat.Boss.BossPatternTable.ApplyById(boss, kAnubisId); var after = Dump(boss); L(" ApplyById(10006) → 적용 필드 " + applied + "개 · 미지원 키 " + WL.Combat.Boss.BossPatternTable.LastUnknownKeys + "개"); L(""); L(" 필드 프리팹값 → 적용후 (변화)"); foreach (var kv in before) { string a = after.ContainsKey(kv.Key) ? after[kv.Key] : "?"; L(string.Format(" {0,-24} {1,-9} → {2,-9} {3}", kv.Key, kv.Value, a, kv.Value == a ? "" : "<<< 변경")); } L(""); L("── ③ 행 없음(ID " + kMissingId + ") → 값 유지 검증"); int reverted = WL.Combat.Boss.BossPatternTable.Revert(boss, kAnubisId); L(" Revert(10006) = " + reverted + "개 되돌림 (프리팹 원본 값으로 복구)"); var baseline = Dump(boss); int applied2 = WL.Combat.Boss.BossPatternTable.ApplyById(boss, kMissingId); var afterMissing = Dump(boss); int changed = 0; foreach (var kv in baseline) if (!afterMissing.ContainsKey(kv.Key) || afterMissing[kv.Key] != kv.Value) changed++; L(" ApplyById(" + kMissingId + ") = " + applied2 + " (−1 = 무동작) · 변경된 필드 " + changed + "개"); L(" 판정 = " + (applied2 == -1 && changed == 0 ? "PASS (행 없으면 프리팹 값 그대로)" : "FAIL")); // 페이즈 검사는 같은 인스턴스로 이어서 한다. s_probeBoss = boss; Section3_PhaseInner(); } finally { s_probeBoss = null; UnityEditor.PrefabUtility.UnloadPrefabContents(root); // 저장하지 않으므로 프리팹 에셋 무변경 } } static BossMobActor s_probeBoss; // ───────────────────────────────────────── ④ 페이즈 통지 · ⑤ GC static void Section3_PhaseInner() { var boss = s_probeBoss; L(""); L("── ④ BossPhase 통지 (가짜 HP 100 → 70 → 40 %)"); WL.Combat.Core.CombatHandler h = OnPhase; WL.Combat.Boss.BossEvents.BossPhase.Add(h); WL.Combat.Boss.BossEvents.ResetDiagnostics(); s_eventCount = 0; s_eventLog = ""; try { WL.Combat.Boss.BossPatternTable.ApplyById(boss, kAnubisId); // 스폰 = 페이즈 1 WL.Combat.Boss.BossPatternTable.UpdatePhaseById(boss, kAnubisId, 1.00f); WL.Combat.Boss.BossPatternTable.UpdatePhaseById(boss, kAnubisId, 0.85f); WL.Combat.Boss.BossPatternTable.UpdatePhaseById(boss, kAnubisId, 0.70f); WL.Combat.Boss.BossPatternTable.UpdatePhaseById(boss, kAnubisId, 0.55f); WL.Combat.Boss.BossPatternTable.UpdatePhaseById(boss, kAnubisId, 0.40f); WL.Combat.Boss.BossPatternTable.UpdatePhaseById(boss, kAnubisId, 0.90f); // 회복 — 되돌아가지 않아야 한다 WL.Combat.Boss.BossPatternTable.UpdatePhaseById(boss, kAnubisId, 0.05f); L(s_eventLog); L(" 이벤트 " + s_eventCount + "회 · Raised=" + WL.Combat.Boss.BossEvents.BossPhase.Raised + " Dispatched=" + WL.Combat.Boss.BossEvents.BossPhase.Dispatched + " 최종 페이즈=" + WL.Combat.Boss.BossPatternTable.CurrentPhase(boss)); L(" 판정 = " + (s_eventCount == 3 ? "PASS (100→70→40 · 3회 · 회복 시 되돌림 없음)" : "FAIL (기대 3회)")); L(""); L("── ④ 스킬 해금 임계 (원본 0.9 / 0.7 · 행이 값을 주면 그 값)"); bool c1 = false, c2 = false; float[] hps = { 1.00f, 0.95f, 0.90f, 0.80f, 0.70f, 0.50f }; for (int i = 0; i < hps.Length; i++) { WL.Combat.Boss.BossPatternTable.ApplySkillGatesById(boss, kAnubisId, hps[i], ref c1, ref c2); L(string.Format(" HP {0,5:P0} → CanUseSkill1={1,-5} CanUseSkill2={2}", hps[i], c1, c2)); } L(""); L("── ⑤ 디스패치 GC (Raise " + kGcIterations + "회 · 구독자는 카운터만 = 로그 문자열 할당 배제)"); WL.Combat.Boss.BossEvents.BossPhase.Remove(h); WL.Combat.Core.CombatHandler hc = OnPhaseCount; WL.Combat.Boss.BossEvents.BossPhase.Add(hc); s_gcCount = 0; System.GC.Collect(); long m0 = System.GC.GetTotalMemory(true); for (int i = 0; i < kGcIterations; i++) WL.Combat.Boss.BossEvents.RaiseBossPhase(boss, kAnubisId, 1, 2, 3, 0.7f, 0.7f, false); long m1 = System.GC.GetTotalMemory(false); WL.Combat.Boss.BossEvents.BossPhase.Remove(hc); L(" 구독자 1명 · 수신 " + s_gcCount + "회 · 할당 델타 = " + (m1 - m0) + " byte (회당 " + ((m1 - m0) / (double)kGcIterations).ToString("0.##") + " byte)"); } finally { WL.Combat.Boss.BossEvents.BossPhase.Remove(h); } } static int s_gcCount; /// GC 측정용 — 할당이 전혀 없는 구독자(문자열 조립 없음). static void OnPhaseCount(in WL.Combat.Boss.BossPhaseEvent e) { s_gcCount++; } static void OnPhase(in WL.Combat.Boss.BossPhaseEvent e) { s_eventCount++; s_eventLog += string.Format(" #{0} phase {1}→{2}/{3} id={4} hp={5:P0} 임계={6:0.##} spawn={7}\n", s_eventCount, e.prevPhase, e.phase, e.phaseCount, e.monsterId, e.hpPercent, e.threshold, e.isSpawn); } // ───────────────────────────────────────── ⑥ C8 롤백 static void Section4_Rollback() { L(""); L("── ⑥ C8 롤백 (RuntimeDisabled = true → 로더·통지 무동작)"); var root = UnityEditor.PrefabUtility.LoadPrefabContents(kAnubisPrefab); try { var boss = root.GetComponentInChildren(true); WL.Combat.Core.WLCombatCoreSettings.RuntimeDisabled = true; WL.Combat.Boss.BossEvents.ResetDiagnostics(); var before = Dump(boss); int applied = WL.Combat.Boss.BossPatternTable.ApplyById(boss, kAnubisId); var after = Dump(boss); int changed = 0; foreach (var kv in before) if (after[kv.Key] != kv.Value) changed++; bool c1 = false, c2 = false; WL.Combat.Boss.BossPatternTable.ApplySkillGatesById(boss, kAnubisId, 0.10f, ref c1, ref c2); int ph = WL.Combat.Boss.BossPatternTable.UpdatePhaseById(boss, kAnubisId, 0.10f); L(" 로더 Enabled=" + WL.Combat.Boss.BossPatternTable.Enabled + " · ApplyById=" + applied + " · 변경 필드=" + changed + " · 게이트(HP 10%)=" + c1 + "/" + c2 + " · UpdatePhase=" + ph + " · 이벤트=" + WL.Combat.Boss.BossEvents.TotalRaised); L(" 판정 = " + (applied == -1 && changed == 0 && !c1 && !c2 && ph == 0 && WL.Combat.Boss.BossEvents.TotalRaised == 0 ? "PASS (프리팹 값 · 원본 게이트 · 통지 0)" : "FAIL")); } finally { UnityEditor.PrefabUtility.UnloadPrefabContents(root); WL.Combat.Core.WLCombatCoreSettings.RuntimeDisabled = false; WL.Combat.Boss.BossPatternTable.ClearCache(); } } // ───────────────────────────────────────── 유틸 static System.Collections.Generic.Dictionary Dump(BossMobActor boss) { var d = new System.Collections.Generic.Dictionary(); var fields = boss.GetType().GetFields(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.DeclaredOnly); for (int i = 0; i < fields.Length; i++) { var t = fields[i].FieldType; if (t == typeof(float) || t == typeof(int) || t == typeof(bool) || t == typeof(string)) d[fields[i].Name] = System.Convert.ToString(fields[i].GetValue(boss)); } return d; } static void L(string line) { s.Append(line).Append("\n"); } static string Flush() { var text = s.ToString(); try { var dir = System.IO.Path.GetDirectoryName(kLog); if (!System.IO.Directory.Exists(dir)) System.IO.Directory.CreateDirectory(dir); System.IO.File.WriteAllText(kLog, text, new System.Text.UTF8Encoding(false)); } catch (System.Exception ex) { text += "\n[로그 저장 실패] " + ex.Message; } return text; } }