// 발주서 WL-813f2 — 전리품 등급 하한 백(엘리트 ≥3 · 보스 ≥5) 검증 프로브 // // 실행 (CLAUDE.md §1: using 금지 · 네임스페이스 풀어 쓰기) // 에디트 모드 : unity command run_script --file AgentScripts/WL813f2_Probe.cs --entry WL813f2_Probe.Edit --timeout 600 // // Edit() 가 재는 것 — 씬/프리팹/에셋 변경 0(가짜 오브젝트·가짜 테이블은 전부 회수): // (a) WLLootSettings 값 — eliteBagId · eliteFloorDrops · bossBagId · beamMinGrade // (b) 테이블 부트스트랩 — table_randombag / table_itemlist 를 TextAsset 에서 에디트 모드에 실제로 올린다 // (813f 는 싱글턴이 없어 백 추첨을 못 쟀다. 여기서는 비활성 GO + 리플렉션 Awake/Start 로 실제 경로를 태운다) // (c) RollBag(8130002) ×100 → ResolveGrade 등급 분포 · 등급 ≥3 비율 · 빈손 횟수 // (d) RollBag(8130003) ×100 → 등급 ≥5 비율 · 빔(BeamPrefab) 뜨는 비율 // (e) RollBag(8130001) ×N → 존 잡몹 백 등급 분포(등급 1~2 비율) — 기준서 §E 4-1 「등급 색 구분」 근거 // (f) 엘리트 버스트 시뮬 ×200 — BurstCount(Elite) 건수마다 BagForBurst → 8130002 가 정확히 몇 건인가 // (g) 보스 버스트 시뮬 ×200 — 전 건 8130003 인가 // (h) 가짜 Killed(Elite/Boss) → 구독·BurstPlanned 동작 // (i) GC — RollBag 1000회 · BagForBurst 1000회 할당 바이트 // (j) C8 — eliteBagId/bossBagId 0 이면 존 백 그대로(기존 동작) // (k) 데이터 — RandomBag 행 수 · 새 백 2개 행 전수 · 기존 202행 무변경 확인 // // 수치는 전부 SO/테이블에서 읽는다(C45). 프로브 상수는 "어디를 보나" 뿐이다. public static class WL813f2_Probe { const string kBagJson = "Assets/ResWork/Table/Export/RandomBag.json"; const string kItemJson = "Assets/ResWork/Table/Export/ItemList.json"; const string kOutDir = "AgentScripts/staging/WL813f2"; const int kZoneBag = 8130001; const int kEliteBag = 8130002; const int kBossBag = 8130003; const int kRolls = 100; static System.Text.StringBuilder s; static void L(string t) { s.AppendLine(t); UnityEngine.Debug.Log("[WL813f2] " + t); } static string Abs(string rel) { return System.IO.Path.Combine(System.IO.Directory.GetCurrentDirectory(), rel.Replace('/', System.IO.Path.DirectorySeparatorChar)); } static string Flush(string name) { try { System.IO.Directory.CreateDirectory(Abs(kOutDir)); System.IO.File.WriteAllText(Abs(kOutDir + "/" + name), s.ToString(), new System.Text.UTF8Encoding(true)); } catch (System.Exception e) { UnityEngine.Debug.LogWarning("[WL813f2] 로그 저장 실패: " + e.Message); } return s.ToString(); } // ── 테이블 부트스트랩 ──────────────────────────────────────────────────── // table_missionbase.Awake() 가 json_last = m_json.text 를 읽고, 파생 Start() 가 역직렬화한다. // 에디트 모드에서는 Awake/Start 가 자동으로 안 불리므로 비활성 GO 에 붙이고 리플렉션으로 직접 부른다. static UnityEngine.GameObject s_tableHost; // Awake 는 m_json(TextAsset · 인스펙터 참조)을 읽으므로 태우지 않는다. // 대신 protected json_last 에 파일 본문을 직접 넣고 Ins 를 세운 뒤 Start() 만 실제로 호출한다. static T Boot(string jsonPath) where T : UnityEngine.MonoBehaviour { if (s_tableHost == null) { s_tableHost = new UnityEngine.GameObject("WL813f2_TableHost"); s_tableHost.SetActive(false); // 비활성 = Awake 자동 호출 없음 s_tableHost.hideFlags = UnityEngine.HideFlags.HideAndDontSave; } var comp = s_tableHost.AddComponent(); var fj = typeof(table_missionbase).GetField("json_last", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); if (fj == null) { L(" ! json_last 필드 없음"); return null; } fj.SetValue(comp, System.IO.File.ReadAllText(Abs(jsonPath))); // BOM 은 StreamReader 가 벗긴다 var fi = typeof(T).GetField("Ins", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static); if (fi == null) { L(" ! Ins 정적 필드 없음: " + typeof(T).Name); return null; } fi.SetValue(null, comp); Call(comp, "Start"); return comp; } static void Call(object o, string name) { var m = o.GetType().GetMethod(name, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public); if (m == null) { L(" ! 메서드 없음: " + name); return; } m.Invoke(o, null); } static int BagRows(int bagId) { try { var l = table_randombag.Ins.Get_DataList(bagId); return l == null ? -1 : l.Count; } catch (System.Exception) { return -1; } // Get_DataList 는 없는 백이면 KeyNotFound 를 던진다 } static Actor MakeFakeMob(string name, eSubRol subRole) { var go = new UnityEngine.GameObject(name); go.hideFlags = UnityEngine.HideFlags.HideAndDontSave; go.SetActive(false); // 비활성 = Awake/Start 안 탐 var a = go.AddComponent(); a.m_SubRole = subRole; return a; } static void Teardown() { table_randombag.Ins = null; table_itemlist.Ins = null; if (s_tableHost != null) UnityEngine.Object.DestroyImmediate(s_tableHost); s_tableHost = null; } // ── 분포 집계 ─────────────────────────────────────────────────────────── static string GradeTable(int[] hist, int total, int empty) { var sb = new System.Text.StringBuilder(); for (int g = 1; g <= 9; g++) if (hist[g] > 0) sb.Append("g" + g + ":" + hist[g] + "(" + (100f * hist[g] / total).ToString("0.0") + "%) "); sb.Append("| 빈손 " + empty); return sb.ToString(); } static int RollGrades(int bagId, int rolls, int[] hist, out int empty, out int beam) { empty = 0; beam = 0; var st = WL.Combat.Loot.WLLootSettings.Instance; int min = 99; for (int i = 0; i < rolls; i++) { int itemId, amount; if (!WL.Combat.Loot.LootBurst.RollBag(bagId, out itemId, out amount)) { empty++; min = 0; continue; } int g = WL.Combat.Loot.LootBurst.ResolveGrade(itemId); if (g >= 1 && g <= 9) hist[g]++; if (g < min) min = g; if (g >= st.beamMinGrade && WL.Combat.Loot.LootBurst.BeamPrefab(st, g) != null) beam++; } return min; } public static string Edit() { s = new System.Text.StringBuilder(); L("═══ WL-813f2 등급 하한 백 프로브 (edit mode) " + System.DateTime.Now.ToString("HH:mm:ss") + " ═══"); WL.Combat.Loot.WLLootSettings.ClearCache(); var st = WL.Combat.Loot.WLLootSettings.Instance; if (st == null) { L("!! WLLootSettings 로드 실패"); return Flush("EDIT.txt"); } WL.Combat.Loot.LootBurst.EnsureSubscribed(); // (a) 값 L(""); L("[a] WLLootSettings — enabled=" + st.enabled + " burstEnabled=" + st.burstEnabled); L(" eliteBagId=" + st.eliteBagId + " eliteFloorDrops=" + st.eliteFloorDrops + " bossBagId=" + st.bossBagId); L(" eliteBurst=" + st.eliteBurstMin + "~" + st.eliteBurstMax + " bossBurst=" + st.bossBurstMin + "~" + st.bossBurstMax); L(" beamMinGrade=" + st.beamMinGrade + " burstRollsBag=" + st.burstRollsBag + " burstGoldShare=" + st.burstGoldShare); // (b) 테이블 부트스트랩 L(""); L("[b] 테이블 부트스트랩"); L(" 부트 전: table_randombag.Ins=" + (table_randombag.Ins != null) + " table_itemlist.Ins=" + (table_itemlist.Ins != null)); try { Boot(kBagJson); Boot(kItemJson); } catch (System.Exception e) { L(" !! 부트 실패: " + e.GetType().Name + " " + e.Message); } bool booted = table_randombag.Ins != null && table_itemlist.Ins != null; L(" 부트 후: table_randombag.Ins=" + (table_randombag.Ins != null) + " table_itemlist.Ins=" + (table_itemlist.Ins != null)); if (booted) { L(" 행 수: 8130001=" + BagRows(kZoneBag) + " 8130002=" + BagRows(kEliteBag) + " 8130003=" + BagRows(kBossBag) + " 전체=" + table_randombag.Ins.Get_DataList().Count); L(" ResolveGrade 표본: 10101012=" + WL.Combat.Loot.LootBurst.ResolveGrade(10101012) + " 10101017=" + WL.Combat.Loot.LootBurst.ResolveGrade(10101017) + " 10101022=" + WL.Combat.Loot.LootBurst.ResolveGrade(10101022) + " 10101023=" + WL.Combat.Loot.LootBurst.ResolveGrade(10101023) + " 10101027=" + WL.Combat.Loot.LootBurst.ResolveGrade(10101027) + " 10101032=" + WL.Combat.Loot.LootBurst.ResolveGrade(10101032)); } if (booted) { // (c) 엘리트 백 L(""); var h2 = new int[10]; int e2, b2; int min2 = RollGrades(kEliteBag, kRolls, h2, out e2, out b2); L("[c] RollBag(8130002) ×" + kRolls + " — " + GradeTable(h2, kRolls, e2)); L(" 최소 등급 " + min2 + " · 등급 ≥3 " + (kRolls - e2 - h2[1] - h2[2]) + "/" + kRolls + " · 빔(≥" + st.beamMinGrade + ") " + b2 + "/" + kRolls + " (" + (100f * b2 / kRolls).ToString("0.0") + "%)"); // (d) 보스 백 L(""); var h3 = new int[10]; int e3, b3; int min3 = RollGrades(kBossBag, kRolls, h3, out e3, out b3); int ge5 = h3[5] + h3[6] + h3[7] + h3[8] + h3[9]; L("[d] RollBag(8130003) ×" + kRolls + " — " + GradeTable(h3, kRolls, e3)); L(" 최소 등급 " + min3 + " · 등급 ≥5 " + ge5 + "/" + kRolls + " · 빔(≥" + st.beamMinGrade + ") " + b3 + "/" + kRolls + " (" + (100f * b3 / kRolls).ToString("0.0") + "%)"); // (e) 존 잡몹 백 — 색 구분 근거 L(""); const int kZoneRolls = 4000; var h1 = new int[10]; int e1, b1; RollGrades(kZoneBag, kZoneRolls, h1, out e1, out b1); int dropped = kZoneRolls - e1; L("[e] RollBag(8130001) ×" + kZoneRolls + " — " + GradeTable(h1, kZoneRolls, e1)); L(" 드랍 " + dropped + "/" + kZoneRolls + " = " + (100f * dropped / kZoneRolls).ToString("0.00") + "% (기준서 15~25%)"); if (dropped > 0) L(" 드랍 조건부 분포: g1 " + (100f * h1[1] / dropped).ToString("0.0") + "% · g2 " + (100f * h1[2] / dropped).ToString("0.0") + "% · g3 " + (100f * h1[3] / dropped).ToString("0.0") + "% · g4 " + (100f * h1[4] / dropped).ToString("0.0") + "% · g5 " + (100f * h1[5] / dropped).ToString("0.0") + "% → 등급 1~2 = " + (100f * (h1[1] + h1[2]) / dropped).ToString("0.0") + "%"); L(" 빔(≥" + st.beamMinGrade + ") " + b1 + "/" + kZoneRolls + " = " + (100f * b1 / kZoneRolls).ToString("0.000") + "% (전 처치 대비)"); } else L("[c][d][e] 건너뜀 — 테이블 부트 실패"); // (f)(g) 버스트 시뮬 — 실제 BurstCount + BagForBurst 경로 L(""); const int kSim = 200; int eliteMinFloor = 99, eliteMaxFloor = 0, eliteZero = 0, eliteTotal = 0; for (int t = 0; t < kSim; t++) { int n = WL.Combat.Loot.LootBurst.BurstCount(st, eSubRol.Elite); eliteTotal += n; int floor = 0; for (int i = 0; i < n; i++) if (WL.Combat.Loot.LootBurst.BagForBurst(st, eSubRol.Elite, i, kZoneBag) == kEliteBag) floor++; if (floor < eliteMinFloor) eliteMinFloor = floor; if (floor > eliteMaxFloor) eliteMaxFloor = floor; if (floor == 0) eliteZero++; } L("[f] 엘리트 버스트 ×" + kSim + " — 총 " + eliteTotal + "건 · 8130002 건수 min " + eliteMinFloor + " max " + eliteMaxFloor + " · 0건인 처치 " + eliteZero + "회 (요구: 0)"); int bossOther = 0, bossTotal = 0; for (int t = 0; t < kSim; t++) { int n = WL.Combat.Loot.LootBurst.BurstCount(st, eSubRol.Boss); bossTotal += n; for (int i = 0; i < n; i++) if (WL.Combat.Loot.LootBurst.BagForBurst(st, eSubRol.Boss, i, kZoneBag) != kBossBag) bossOther++; } L("[g] 보스 버스트 ×" + kSim + " — 총 " + bossTotal + "건 · 8130003 이 아닌 건 " + bossOther + " (요구: 0)"); L(" 일반 잡몹: BagForBurst(None,0,zone)=" + WL.Combat.Loot.LootBurst.BagForBurst(st, eSubRol.None, 0, kZoneBag) + " · normalBurstCount=" + st.normalBurstCount); // (h) 가짜 Killed L(""); var mobE = MakeFakeMob("__f2Elite", eSubRol.Elite); var mobB = MakeFakeMob("__f2Boss", eSubRol.Boss); var mobN = MakeFakeMob("__f2None", eSubRol.None); WL.Combat.Loot.LootBurst.ResetDiagnostics(); try { WL.Combat.Core.CombatEvents.RaiseKilled(mobE); int cE = WL.Combat.Loot.LootBurst.LastBurstCount; WL.Combat.Core.CombatEvents.RaiseKilled(mobB); int cB = WL.Combat.Loot.LootBurst.LastBurstCount; WL.Combat.Core.CombatEvents.RaiseKilled(mobN); int cN = WL.Combat.Loot.LootBurst.LastBurstCount; L("[h] 가짜 Killed 3건 — core Enabled=" + WL.Combat.Core.WLCombatCoreSettings.Enabled + " 구독=" + WL.Combat.Loot.LootBurst.Subscribed + " 처리=" + WL.Combat.Loot.LootBurst.KilledHandled + " · elite 계획 " + cE + " · boss " + cB + " · none " + cN + " · 누적 BurstPlanned " + WL.Combat.Loot.LootBurst.BurstPlanned + " · 실드랍 " + WL.Combat.Loot.LootBurst.BurstDropped + "(DropItemInfo.Ins=" + (DropItemInfo.Ins != null) + ")"); } catch (System.Exception e) { L("[h] !! RaiseKilled 실패: " + e.GetType().Name + " " + e.Message); } UnityEngine.Object.DestroyImmediate(mobE.gameObject); UnityEngine.Object.DestroyImmediate(mobB.gameObject); UnityEngine.Object.DestroyImmediate(mobN.gameObject); // (i) GC L(""); long g0 = System.GC.GetTotalMemory(false); for (int i = 0; i < 1000; i++) WL.Combat.Loot.LootBurst.BagForBurst(st, eSubRol.Elite, i & 3, kZoneBag); long g1 = System.GC.GetTotalMemory(false); long r0 = System.GC.GetTotalMemory(false); if (booted) { int a, b; for (int i = 0; i < 1000; i++) WL.Combat.Loot.LootBurst.RollBag(kEliteBag, out a, out b); } long r1 = System.GC.GetTotalMemory(false); L("[i] GC — BagForBurst ×1000 델타 " + (g1 - g0) + " B · RollBag ×1000 델타 " + (r1 - r0) + " B"); // (j) C8 L(""); int se = st.eliteBagId, sb = st.bossBagId; st.eliteBagId = 0; st.bossBagId = 0; L("[j] C8 — eliteBagId/bossBagId 0 → Elite i0=" + WL.Combat.Loot.LootBurst.BagForBurst(st, eSubRol.Elite, 0, kZoneBag) + " Boss i0=" + WL.Combat.Loot.LootBurst.BagForBurst(st, eSubRol.Boss, 0, kZoneBag) + " (요구: 둘 다 " + kZoneBag + ")"); st.eliteBagId = se; st.bossBagId = sb; L(" 복원: eliteBagId=" + st.eliteBagId + " bossBagId=" + st.bossBagId + " (메모리만 · 에셋 저장 0)"); L(" RuntimeDisabled 경로는 813f 프로브 [j] 에서 이미 실측(훅 전체 무동작)"); // (k) 데이터 원문 L(""); L("[k] " + kBagJson); try { string txt = System.IO.File.ReadAllText(Abs(kBagJson)); int rows = 0; for (int i = 0; i < txt.Length; i++) if (txt[i] == '{') rows++; L(" 총 행 " + rows + " · 줄바꿈 " + (txt.IndexOf('\n') >= 0 ? "있음" : "없음(한 줄 JSON)")); int at = txt.IndexOf("\"8130002\""); if (at > 0) L(" 새 백 원문 시작 오프셋 " + at + " / 길이 " + txt.Length + " (= 뒤에 붙임 · 앞 행 무변경)"); L(" 8130002/8130003 원문:"); int pos = at; while (pos > 0) { int e = txt.IndexOf('}', pos); if (e < 0) break; L(" " + txt.Substring(txt.LastIndexOf('{', pos), e - txt.LastIndexOf('{', pos) + 1)); pos = txt.IndexOf("81300", e); if (pos < 0) break; } } catch (System.Exception e) { L(" !! 읽기 실패: " + e.Message); } Teardown(); L(""); L("═══ 종료 · 부트한 테이블 회수 완료(Ins=null · 호스트 GO 파괴) ═══"); return Flush("EDIT.txt"); } }