Project_WL/AgentScripts/WL813s_Probe.cs

522 lines
33 KiB
C#
Raw Normal View History

[WL-813s] 엘리트 몹: 존별 배치 · 마커 · 드랍 (#813) MonsterAppear.json 에 엘리트 스포너 새 행 4개(813021~813024) 추가 — 기존 52행 바이트 무변경 (813u 가 같은 파일의 존 행을 고치므로 「기존 행 값 수정 0」 · 단일 라인 JSON 이라 rebase 에서 전체 충돌 1회 → main(813u) 값을 기준으로 새 행만 재부착). 존1~4 에 Elite 1종씩 · 동시 1마리 · 리젠 25 s. 🔴 HP 는 발주서 제안값(120~180 · Q3 보스 단일표적 DPS 15/s 기준)이 아니라 **470/540/610/680** 이다. 발주 시점 뒤에 813u 가 존 잡몹 HP 를 300/450/600/800 → 190/270/350/430 으로 내려서, 120~180 이면 엘리트가 같은 존 잡몹보다 약해진다. 813u 실측 존 총 DPS 171.8 HP/s 를 기준서 §E 3-2 규격(1발 명중 ≥3마리)으로 나눈 단일 표적 57.3 HP/s 기준 → 8.2/9.4/10.6/11.9 s = 기준서 「엘리트 8~12 s」. 잡몹 대비 2.47/2.00/1.74/1.58배. ATK 는 813u 존 값과 동률 (6/8/10/12 · Q4-a D-1 사망 이슈 · ATK 밸런스는 813u 소유). WL_Nature.prefab: maplv/e1~e4 스포너 노드 4기 신규(존 스포너와 같은 좌표 · MobGenRange 6 · Lv 라벨 렌더러 끔) — 기존 노드·좌표 무변경(+924 / −0). EliteMarker.cs(신규 · 정적 · 러너 0 · GC 0): Spawned(isElite) → localScale = 테이블 기본 스케일 × 1.3(멱등 · NavMesh 반경 무변경) + 상시 색 등록, Killed(Elite) → 색 해제 + 처치 시간(TTK) 로그. 아웃라인은 QuickOutline 이 프로젝트에 없고 MK Toon 의 _RimColor 가 _MK_RIM_* shader_feature 게이팅이라 MPB 로 못 켠다 → 발주서 대체안대로 MPB 상시 색 (_EmissionColor 주 · _AlbedoColor 보조 · 새 셰이더/머티리얼 0). MobHitFlash 가 렌더러 MPB 의 단일 소유자라 합성 지점(SetEliteTint/ClearEliteTint)만 그 파일에 더했다(우선순위 = 점멸 > 상시). 킬캠(811d)·임팩트 티어(811gh)는 이미 Elite 분기가 열려 있어 코드 0(killCamOnElite=1 · bossKillIncludesElite=1 실측). 드랍은 WLLootSettings.asset 값만 eliteBurst 1~2 → 2~3. 검증: 게이트 PASS(oneshot · error CS 0줄) · 프로브 AgentScripts/WL813s_Probe.cs (새 행 4/4 · 스포너 노드 4/4 · 크기 2.30→2.99 멱등 · 상시 색 O · 일반 몹 미적용 · 킬캠 Elite 1 / 일반 0 · 드랍 버스트 2~3 · 0건 0회 · GC 0 B · C8 PASS · 원본 문자열 0). Play 실측(인게임 화면)은 미확인 — 로그인 금지라 병합 후 QA. xlsm 동기화 안 함(백로그). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 00:25:41 +00:00
// PD 지시 #813 · 발주서 WL-813s — 엘리트 몹(존별 배치 · 크기 1.3× · 상시 색 · HP 8~12 s · 킬캠 · 드랍) 검증 프로브
// (에디트 모드 전용 · Play 0 · 로그인 0 · 에셋 무수정)
//
// 실행 (CLAUDE.md §1: using 금지 · 네임스페이스 풀어 쓰기)
// unity command run_script --file AgentScripts/WL813s_Probe.cs --entry WL813s_Probe.Edit --timeout 900
//
// 무엇을 재는가
// A. 데이터 — MonsterAppear.json 새 행 813021~813024 인식 · 몹 ID 가 MonsterList 의 Elite 인지 ·
// 실 HP(f_BaseHPRate × n_MonsterHP) → Q3 실측 DPS 15/s 기준 처치 시간 · 몹/투사체 프리팹 실존 ·
// WL_Nature.prefab 의 스포너 전수(존 5 + 엘리트 4 + 레거시 14) 연결 · text_lv null 0
// B. 마커 — 진짜 엘리트 프리팹으로 가짜 Spawned(Elite) → 스케일 1.3× · MPB 색 부착 · **멱등**(2회) ·
// 가짜 Killed(Elite) → 색 뗌 · 처치 시간(TTK) 측정 · RestoreAll 로 원본 스케일 복귀
// C. 연계 — 킬캠(811d): CombatCameraDirector 를 리플렉션으로 구독시키고 진짜 Killed 를 올려 Elite/일반 대조 ·
// 드랍(813f): LootBurst.BurstCount(SO, Elite) 실행 · 임팩트 티어(811gh): bossKillIncludesElite 실값
// D. GC — Spawned/Killed 각 100회의 GC.GetAllocatedBytesForCurrentThread() 증가분(0 B 목표)
// E. C8 — WLEliteSettings.RuntimeDisabled = true(= 에셋 enabled 0 과 같은 Enabled 게이트)
// → 크기 원본 · 색 0 · MPB 잔류 0
// F. 원본 — Assets/Script/** · Core · UI 에 813s 문자열 0 · MobHitFlash 합성 배선 확인
//
// 에디트 모드 주의
// · [RuntimeInitializeOnLoadMethod] 는 Play 에서만 돈다 → EliteMarker.Probe* 헬퍼로 핸들러를 직접 부른다.
// · Time.unscaledTime 은 한 호출 안에서 멈춰 있다 → TTK 는 ImpactRunner.ForceTick 으로 시뮬 시계를 민다(811gh 방식).
// · 프로브가 만드는 오브젝트는 전부 HideAndDontSave + DestroyImmediate. 어떤 에셋도 쓰지 않는다.
public static class WL813s_Probe
{
const string kOut = "AgentScripts/staging/WL813s/EDIT_PROBE.txt";
const string kAppear = "Assets/ResWork/Table/Export/MonsterAppear.json";
const string kList = "Assets/ResWork/Table/Export/MonsterList.json";
const string kMap = "Assets/Res_Addr/Map/WL_Nature.prefab";
const string kFlash = "Assets/WL/Combat/Reaction/MobHitFlash.cs";
const float kDps = 15f; // Q3 실측(보스 9000 → 6247 / 3분) · 813u 발주서 근거
static readonly int[] kEliteSpawners = new int[] { 813021, 813022, 813023, 813024 };
static readonly int[] kZoneSpawners = new int[] { 813001, 813002, 813003, 813004 };
static System.Collections.Generic.List<string> s_log = new System.Collections.Generic.List<string>();
static bool s_hooked;
static void OnLog(string cond, string stack, UnityEngine.LogType t)
{
if (t == UnityEngine.LogType.Error || t == UnityEngine.LogType.Exception || t == UnityEngine.LogType.Assert)
s_log.Add(t + " | " + cond);
}
static void Hook() { if (!s_hooked) { UnityEngine.Application.logMessageReceived += OnLog; s_hooked = true; } }
static void Unhook() { if (s_hooked) { UnityEngine.Application.logMessageReceived -= OnLog; s_hooked = false; } }
static UnityEngine.GameObject s_root, s_eliteGo, s_normalGo;
static MobActor s_elite, s_normal;
static float s_eliteBaseScale = -1f;
public static string Edit()
{
var sb = new System.Text.StringBuilder();
sb.Append("# WL-813s 프로브 (에디트 모드 · Play 0 · 로그인 0)\n");
sb.Append("unity=").Append(UnityEngine.Application.unityVersion)
.Append(" isPlaying=").Append(UnityEngine.Application.isPlaying)
.Append(" utc=").Append(System.DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss")).Append("\n\n");
Hook(); s_log.Clear();
try
{
Section0(sb);
SectionA(sb);
BuildFakes(sb);
SectionB(sb);
SectionC(sb);
SectionD(sb);
SectionE(sb);
SectionF(sb);
}
catch (System.Exception ex) { sb.Append("\n🔴 예외: ").Append(ex.ToString()).Append("\n"); }
finally { Cleanup(sb); Unhook(); }
sb.Append("\n## 프로브 중 Error/Exception: ").Append(s_log.Count).Append("\n");
for (int i = 0; i < s_log.Count && i < 30; i++) sb.Append(" ").Append(s_log[i]).Append("\n");
var dir = System.IO.Path.GetDirectoryName(kOut);
if (!System.IO.Directory.Exists(dir)) System.IO.Directory.CreateDirectory(dir);
System.IO.File.WriteAllText(kOut, sb.ToString(), new System.Text.UTF8Encoding(false));
return sb.ToString();
}
// ═══════════════════════════════════════════════ 0. 환경 · SO 실값
static void Section0(System.Text.StringBuilder sb)
{
sb.Append("## 0. SO 실값 (전부 에셋에서 읽은 값 · 코드 상수 0)\n");
var el = WL.Combat.Reaction.WLEliteSettings.Instance;
sb.Append("WLEliteSettings: ").Append(el == null ? "🔴 에셋 없음" : "로드 O").Append("\n");
if (el != null)
sb.Append(" enabled=").Append(el.enabled)
.Append(" scaleEnabled=").Append(el.scaleEnabled)
.Append(" scaleMultiplier=").Append(el.scaleMultiplier.ToString("F2"))
.Append(" scaleMaxAbsolute=").Append(el.scaleMaxAbsolute.ToString("F1"))
.Append(" tintEnabled=").Append(el.tintEnabled)
.Append("\n tintAlbedo=").Append(el.tintAlbedo.ToString("F2"))
.Append(" tintEmission=").Append(el.tintEmission.ToString("F2"))
.Append(" 목표 처치 ").Append(el.killTimeTargetMin.ToString("F0")).Append("~").Append(el.killTimeTargetMax.ToString("F0")).Append(" s\n");
var loot = WL.Combat.Loot.WLLootSettings.Instance;
sb.Append("WLLootSettings: ").Append(loot == null ? "🔴 없음" : "burstEnabled=" + loot.burstEnabled
+ " elite=" + loot.eliteBurstMin + "~" + loot.eliteBurstMax
+ " normal=" + loot.normalBurstCount + " boss=" + loot.bossBurstMin + "~" + loot.bossBurstMax
+ " burstRollsBag=" + loot.burstRollsBag + " burstGoldShare=" + loot.burstGoldShare.ToString("F2")).Append("\n");
var cam = WL.CombatCamera.WLCombatCameraSettings.Instance;
sb.Append("WLCombatCameraSettings: ").Append(cam == null ? "🔴 없음" : "killCamEnabled=" + cam.killCamEnabled
+ " onElite=" + cam.killCamOnElite + " onBoss=" + cam.killCamOnBoss + " onNormal=" + cam.killCamOnNormal
+ " directHitOnly=" + cam.killCamDirectHitOnly + " maxPerRun=" + cam.killCamMaxPerRun).Append("\n");
var tier = WL.Combat.Reaction.WLImpactTierSettings.Instance;
sb.Append("WLImpactTierSettings: ").Append(tier == null ? "🔴 없음" : "tierEnabled=" + tier.tierEnabled
+ " bossKillIncludesElite=" + tier.bossKillIncludesElite).Append("\n");
var blink = WL.Combat.Reaction.WLHitBlinkSettings.Instance;
sb.Append("WLHitBlinkSettings: ").Append(blink == null ? "🔴 없음"
: "colorProperties=[" + string.Join(",", blink.colorProperties) + "] emissionProperties=["
+ string.Join(",", blink.emissionProperties) + "] dissolveElite hdr=" + blink.dissolveEdgeHdrElite).Append("\n");
sb.Append("코어 CombatEvents.Enabled=").Append(WL.Combat.Core.CombatEvents.Enabled).Append("\n\n");
}
// ═══════════════════════════════════════════════ A. 데이터
static void SectionA(System.Text.StringBuilder sb)
{
sb.Append("## A. 데이터 — MonsterAppear 새 행 · MonsterList 대조 · 자산 실존 · 스포너 연결\n");
string appear = System.IO.File.ReadAllText(kAppear);
string list = System.IO.File.ReadAllText(kList);
sb.Append("| 스포너 | Lv | ATK | HP(행) | 몹ID | e_MonsterType | f_BaseHPRate | 실 HP | DPS15 처치 s | f_DefaultScale | ×1.3 | 리젠 s | 동시 | 몹 프리팹 | 투사체 |\n");
sb.Append("|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|\n");
int ok = 0;
var el = WL.Combat.Reaction.WLEliteSettings.Instance;
float mul = el != null ? el.scaleMultiplier : 1.3f;
for (int i = 0; i < kEliteSpawners.Length; i++)
{
string row = Row(appear, "n_SpawnerID", kEliteSpawners[i].ToString());
if (row == null) { sb.Append("| ").Append(kEliteSpawners[i]).Append(" | 🔴 행 없음 |\n"); continue; }
string mobId = F(row, "s_AppearMonster");
string hp = F(row, "n_MonsterHP");
string mrow = Row(list, "n_MonsterID", mobId);
string type = mrow != null ? F(mrow, "e_MonsterType") : "?";
float hpRate = mrow != null ? Num(F(mrow, "f_BaseHPRate")) : 0f;
float scale = mrow != null ? Num(F(mrow, "f_DefaultScale")) : 0f;
string prefab = mrow != null ? F(mrow, "s_MonsterPrefab") : "";
string proj = mrow != null ? F(mrow, "s_Porjectile1") : "";
float realHp = hpRate * Num(hp);
bool mobOk = Exists("Assets/Res_Addr/Mobs/" + prefab + ".prefab");
bool projOk = Exists("Assets/Res_Addr/Projectile/" + proj + ".prefab");
if (type == "Elite" && mobOk && projOk) ok++;
sb.Append("| ").Append(kEliteSpawners[i]).Append(" | ").Append(F(row, "n_AppearMonsterLv"))
.Append(" | ").Append(F(row, "n_MonsterATK")).Append(" | ").Append(hp)
.Append(" | ").Append(mobId).Append(" | ").Append(type)
.Append(" | ").Append(hpRate.ToString("F0")).Append(" | ").Append(realHp.ToString("F0"))
.Append(" | ").Append((realHp / kDps).ToString("F1"))
.Append(" | ").Append(scale.ToString("F1")).Append(" | ").Append((scale * mul).ToString("F2"))
.Append(" | ").Append(F(row, "f_SpawnerDelay")).Append(" | ").Append(F(row, "n_MaxMonsterCount"))
.Append(" | ").Append(prefab).Append(mobOk ? " O" : " 🔴").Append(" | ").Append(proj).Append(projOk ? " O" : " 🔴")
.Append(" |\n");
}
sb.Append("새 행 정상 = ").Append(ok).Append(" / ").Append(kEliteSpawners.Length).Append("\n");
// 기존 존 행 무변경 확인(813u 와의 충돌 방지 규칙)
sb.Append("기존 존 행(813001~813005 · 813010) 값 = ");
for (int i = 0; i < kZoneSpawners.Length; i++)
{
string r = Row(appear, "n_SpawnerID", kZoneSpawners[i].ToString());
sb.Append(kZoneSpawners[i]).Append(":HP").Append(F(r, "n_MonsterHP")).Append("/몹").Append(F(r, "s_AppearMonster")).Append(" ");
}
sb.Append("\n");
// WL_Nature 스포너 전수
var map = UnityEditor.AssetDatabase.LoadAssetAtPath<UnityEngine.GameObject>(kMap);
if (map == null) sb.Append("🔴 ").Append(kMap).Append(" 로드 실패\n");
else
{
var mgrs = map.GetComponentsInChildren<MobControlMgr>(true);
int nullText = 0, eliteNodes = 0;
sb.Append("WL_Nature 스포너 ").Append(mgrs.Length).Append("개 — ");
for (int i = 0; i < mgrs.Length; i++)
{
if (mgrs[i].text_lv == null) nullText++;
for (int j = 0; j < kEliteSpawners.Length; j++) if (mgrs[i].SpawnerID == kEliteSpawners[j]) eliteNodes++;
}
sb.Append("엘리트 노드 ").Append(eliteNodes).Append("/4 · text_lv null ").Append(nullText).Append("\n");
for (int i = 0; i < mgrs.Length; i++)
{
bool isElite = false;
for (int j = 0; j < kEliteSpawners.Length; j++) if (mgrs[i].SpawnerID == kEliteSpawners[j]) isElite = true;
if (!isElite) continue;
var t = mgrs[i].transform;
var rend = mgrs[i].GetComponentInChildren<UnityEngine.MeshRenderer>(true);
sb.Append(" ").Append(Path(t)).Append(" id=").Append(mgrs[i].SpawnerID)
.Append(" pos=").Append(t.position.ToString("F1")).Append(" range=").Append(mgrs[i].MobGenRange.ToString("F0"))
.Append(" lv라벨렌더러=").Append(rend == null ? "없음" : (rend.enabled ? "켬" : "끔")).Append("\n");
}
// 같은 좌표의 존 스포너와 대조
for (int j = 0; j < kZoneSpawners.Length; j++)
{
UnityEngine.Vector3 zp = UnityEngine.Vector3.zero, ep = UnityEngine.Vector3.zero;
bool hz = false, he = false;
for (int i = 0; i < mgrs.Length; i++)
{
if (mgrs[i].SpawnerID == kZoneSpawners[j]) { zp = mgrs[i].transform.position; hz = true; }
if (mgrs[i].SpawnerID == kEliteSpawners[j]) { ep = mgrs[i].transform.position; he = true; }
}
if (hz && he)
sb.Append(" 존 ").Append(kZoneSpawners[j]).Append(" ↔ 엘리트 ").Append(kEliteSpawners[j])
.Append(" 거리 ").Append(UnityEngine.Vector3.Distance(zp, ep).ToString("F2")).Append(" m\n");
}
}
sb.Append("\n");
}
// ═══════════════════════════════════════════════ 준비
static void BuildFakes(System.Text.StringBuilder sb)
{
sb.Append("## 준비: 진짜 엘리트 프리팹으로 가짜 액터\n");
s_root = new UnityEngine.GameObject("[813s] root");
s_root.hideFlags = UnityEngine.HideFlags.HideAndDontSave;
s_root.SetActive(false);
string appear = System.IO.File.ReadAllText(kAppear);
string list = System.IO.File.ReadAllText(kList);
string mobId = F(Row(appear, "n_SpawnerID", "813021"), "s_AppearMonster");
string mrow = Row(list, "n_MonsterID", mobId);
string prefabName = mrow != null ? F(mrow, "s_MonsterPrefab") : "Elite/Aspethis_D";
float defScale = mrow != null ? Num(F(mrow, "f_DefaultScale")) : 1f;
var prefab = UnityEditor.AssetDatabase.LoadAssetAtPath<UnityEngine.GameObject>("Assets/Res_Addr/Mobs/" + prefabName + ".prefab");
if (prefab == null) { sb.Append("🔴 엘리트 프리팹 로드 실패 ").Append(prefabName).Append("\n\n"); return; }
s_eliteGo = UnityEngine.Object.Instantiate(prefab, s_root.transform);
s_eliteGo.hideFlags = UnityEngine.HideFlags.HideAndDontSave;
s_eliteGo.name = "[813s] elite " + prefabName;
s_elite = s_eliteGo.GetComponent<MobActor>();
if (s_elite == null) s_elite = s_eliteGo.AddComponent<MobActor>();
s_elite.m_Role = eRole.Mob;
s_elite.m_SubRole = eSubRol.Elite; // 원본 MobActor.Set(:82) 이 e_MonsterType 으로 넣는 값
s_elite.transform.localScale = defScale * UnityEngine.Vector3.one; // 원본 MobActor.Set(:87) 과 같은 대입
s_eliteBaseScale = defScale;
var normalPrefab = UnityEditor.AssetDatabase.LoadAssetAtPath<UnityEngine.GameObject>("Assets/Res_Addr/Mobs/Mob/Batty_A.prefab");
if (normalPrefab != null)
{
s_normalGo = UnityEngine.Object.Instantiate(normalPrefab, s_root.transform);
s_normalGo.hideFlags = UnityEngine.HideFlags.HideAndDontSave;
s_normalGo.name = "[813s] normal Batty_A";
s_normal = s_normalGo.GetComponent<MobActor>();
if (s_normal != null) { s_normal.m_Role = eRole.Mob; s_normal.m_SubRole = eSubRol.None; }
}
var rends = s_eliteGo.GetComponentsInChildren<UnityEngine.Renderer>(true);
sb.Append("엘리트 = ").Append(prefabName).Append(" · Renderer ").Append(rends.Length)
.Append(" · 기본 스케일 ").Append(defScale.ToString("F2")).Append(" · IsSubRole(Elite)=").Append(s_elite.IsSubRole(eSubRol.Elite)).Append("\n");
if (rends.Length > 0 && rends[0].sharedMaterial != null && rends[0].sharedMaterial.shader != null)
{
var sh = rends[0].sharedMaterial.shader;
sb.Append("셰이더[0] = ").Append(sh.name)
.Append(" · _AlbedoColor=").Append(sh.FindPropertyIndex("_AlbedoColor") >= 0)
.Append(" · _EmissionColor=").Append(sh.FindPropertyIndex("_EmissionColor") >= 0)
.Append(" · _RimColor=").Append(sh.FindPropertyIndex("_RimColor") >= 0)
.Append(" (🔴 _RimColor 는 _MK_RIM_* shader_feature 게이팅이라 MPB 로 못 켠다 → 쓰지 않는다)\n");
}
sb.Append("\n");
}
// ═══════════════════════════════════════════════ B. 마커
static void SectionB(System.Text.StringBuilder sb)
{
sb.Append("## B. 마커 — 크기 1.3× · 상시 색 · 멱등 · 처치 시간\n");
if (s_elite == null) { sb.Append("🔴 엘리트 액터 없음 — 건너뜀\n\n"); return; }
WL.Combat.Reaction.WLEliteSettings.RuntimeDisabled = false;
WL.Combat.Reaction.EliteMarker.ClearAll();
WL.Combat.Reaction.EliteMarker.ResetDiagnostics();
WL.Combat.Reaction.MobHitFlash.ResetDiagnostics();
WL.Combat.Reaction.ImpactRunner.ResetForceClock();
var el = WL.Combat.Reaction.WLEliteSettings.Instance;
float before = s_elite.transform.localScale.x;
WL.Combat.Reaction.EliteMarker.ProbeSpawned(s_elite);
float after1 = s_elite.transform.localScale.x;
bool tinted1 = WL.Combat.Reaction.MobHitFlash.HasEliteTint(s_elite);
bool block1 = WL.Combat.Reaction.MobHitFlash.HasAnyBlock(s_elite);
WL.Combat.Reaction.EliteMarker.ProbeSpawned(s_elite); // 멱등 확인(풀 재사용 재등장)
WL.Combat.Reaction.EliteMarker.ProbeSpawned(s_elite);
float after3 = s_elite.transform.localScale.x;
sb.Append("B-1 크기: 기본 ").Append(before.ToString("F3"))
.Append(" → 1회 ").Append(after1.ToString("F3"))
.Append(" (기대 ").Append((before * (el != null ? el.scaleMultiplier : 1.3f)).ToString("F3")).Append(")")
.Append(" → 3회 ").Append(after3.ToString("F3"))
.Append(" · 멱등 ").Append(UnityEngine.Mathf.Abs(after1 - after3) < 0.0005f ? "PASS" : "🔴 FAIL")
.Append(" · EliteMarker.BaseScaleOf=").Append(WL.Combat.Reaction.EliteMarker.BaseScaleOf(s_elite).ToString("F3")).Append("\n");
sb.Append("B-2 상시 색: HasEliteTint=").Append(tinted1)
.Append(" · MPB 부착=").Append(block1)
.Append(" · TintApplied=").Append(WL.Combat.Reaction.EliteMarker.TintApplied)
.Append(" TintSkipped=").Append(WL.Combat.Reaction.EliteMarker.TintSkipped)
.Append(" · MobHitFlash.StatesCreated=").Append(WL.Combat.Reaction.MobHitFlash.StatesCreated).Append("\n");
// 일반 몹은 마커 대상이 아니다
if (s_normal != null)
{
float nb = s_normal.transform.localScale.x;
WL.Combat.Reaction.EliteMarker.ProbeSpawned(s_normal);
sb.Append("B-3 일반 몹(Batty_A): 스케일 ").Append(nb.ToString("F3")).Append(" → ").Append(s_normal.transform.localScale.x.ToString("F3"))
.Append(" · 색=").Append(WL.Combat.Reaction.MobHitFlash.HasEliteTint(s_normal))
.Append(" · SkippedNotElite=").Append(WL.Combat.Reaction.EliteMarker.SkippedNotElite)
.Append(UnityEngine.Mathf.Abs(nb - s_normal.transform.localScale.x) < 0.0005f ? " → PASS" : " → 🔴 FAIL").Append("\n");
}
// 처치 시간 — 시뮬 시계를 10 s 민다(811gh ImpactRunner.ForceTick)
for (int i = 0; i < 20; i++) WL.Combat.Reaction.ImpactRunner.ForceTick(0.5f);
WL.Combat.Reaction.EliteMarker.ProbeKilled(s_elite, eSubRol.Elite);
sb.Append("B-4 처치: TTK=").Append(WL.Combat.Reaction.EliteMarker.LastKillTime.ToString("F2")).Append(" s (시뮬 10.00 s 주입)")
.Append(" · 색 뗌=").Append(!WL.Combat.Reaction.MobHitFlash.HasEliteTint(s_elite))
.Append(" · 크기 유지=").Append(s_elite.transform.localScale.x.ToString("F3"))
.Append(" · KillCount=").Append(WL.Combat.Reaction.EliteMarker.KillCount).Append("\n");
WL.Combat.Reaction.ImpactRunner.ResetForceClock();
WL.Combat.Reaction.EliteMarker.RestoreAll();
sb.Append("B-5 RestoreAll: 스케일 ").Append(s_elite.transform.localScale.x.ToString("F3"))
.Append(" (기대 ").Append(s_eliteBaseScale.ToString("F3")).Append(")")
.Append(" · MPB 잔류=").Append(WL.Combat.Reaction.MobHitFlash.HasAnyBlock(s_elite))
.Append(UnityEngine.Mathf.Abs(s_elite.transform.localScale.x - s_eliteBaseScale) < 0.0005f ? " → PASS" : " → 🔴 FAIL").Append("\n\n");
}
// ═══════════════════════════════════════════════ C. 연계(킬캠 · 드랍 · 티어)
static void SectionC(System.Text.StringBuilder sb)
{
sb.Append("## C. 연계 — 킬캠(811d) · 드랍(813f) · 임팩트 티어(811gh)\n");
// C-1 드랍 버스트 수 — 실제 함수 실행
var loot = WL.Combat.Loot.WLLootSettings.Instance;
if (loot == null) sb.Append("C-1 🔴 WLLootSettings 없음\n");
else
{
int min = int.MaxValue, max = int.MinValue, zero = 0;
for (int i = 0; i < 200; i++)
{
int c = WL.Combat.Loot.LootBurst.BurstCount(loot, eSubRol.Elite);
if (c < min) min = c; if (c > max) max = c; if (c <= 0) zero++;
}
int nrm = WL.Combat.Loot.LootBurst.BurstCount(loot, eSubRol.None);
int bos = WL.Combat.Loot.LootBurst.BurstCount(loot, eSubRol.Boss);
sb.Append("C-1 LootBurst.BurstCount(Elite) 200회 → ").Append(min).Append("~").Append(max)
.Append(" · 0건 ").Append(zero).Append("회 (0 이면 「드랍 100 %」 성립)")
.Append(" · 일반=").Append(nrm).Append(" 보스=").Append(bos).Append("\n");
sb.Append(" 버스트 1건은 백(").Append(loot.burstRollsBag ? "8130001 추첨" : "추첨 안 함")
.Append(") → 빈손이면 골드(비중 ").Append(loot.burstGoldShare.ToString("F2")).Append(")로 대체 = 무조건 무언가 떨어진다\n");
}
// C-2 임팩트 티어 — bossKillIncludesElite 실값(소스 ImpactTier.cs:130 의 유일한 분기)
var tier = WL.Combat.Reaction.WLImpactTierSettings.Instance;
sb.Append("C-2 ImpactTier.bossKillIncludesElite = ").Append(tier == null ? "?" : tier.bossKillIncludesElite.ToString())
.Append(" (이 값이 1 이면 Elite 처치가 BossKill 티어 = 히트스톱 ")
.Append(tier != null && tier.hitStopMs != null && tier.hitStopMs.Length > 3 ? tier.hitStopMs[3].ToString("F0") : "?")
.Append(" ms · 셰이크 배수 ")
.Append(tier != null && tier.shakeMultiplier != null && tier.shakeMultiplier.Length > 3 ? tier.shakeMultiplier[3].ToString("F2") : "?")
.Append(") · 이번 작업에서 값 변경 없음(이미 1)\n");
// C-3 킬캠 — 리플렉션으로 구독시키고 진짜 Killed 를 올린다
float ts0 = UnityEngine.Time.timeScale;
var camType = typeof(WL.CombatCamera.CombatCameraDirector);
var reg = camType.GetMethod("Register", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
if (reg == null || s_elite == null) sb.Append("C-3 🔴 Register/엘리트 없음 — 건너뜀\n");
else
{
WL.Combat.Core.CombatEvents.Killed.Clear();
WL.Combat.Core.CombatEvents.Spawned.Clear();
WL.Combat.Core.CombatEvents.ComboStage.Clear();
reg.Invoke(null, null);
WL.CombatCamera.CombatCameraDirector.ResetDiagnostics();
WL.CombatCamera.CombatCameraDirector.ResetRun();
WL.CombatCamera.CombatCameraDirector.ResetCooldown();
WL.Combat.Core.CombatEvents.RaiseKilled(s_elite);
int eliteCam = WL.CombatCamera.CombatCameraDirector.KillCamCount;
int eliteRole = WL.CombatCamera.CombatCameraDirector.KillCamSkippedRole;
string eliteInfo = WL.CombatCamera.CombatCameraDirector.LastInfo;
var slow = WL.CombatCamera.CombatCameraDirector.LastSlowMoResult;
WL.CombatCamera.CombatCameraDirector.ResetCooldown();
int camBeforeNormal = WL.CombatCamera.CombatCameraDirector.KillCamCount;
if (s_normal != null) WL.Combat.Core.CombatEvents.RaiseKilled(s_normal);
int normalCam = WL.CombatCamera.CombatCameraDirector.KillCamCount - camBeforeNormal;
int normalRole = WL.CombatCamera.CombatCameraDirector.KillCamSkippedRole - eliteRole;
sb.Append("C-3 킬캠: Killed(Elite) → KillCamCount ").Append(eliteCam)
.Append(" · 역할 스킵 ").Append(eliteRole)
.Append(" · 슬로모 ").Append(slow).Append(" · info=\"").Append(eliteInfo).Append("\"\n");
sb.Append(" 대조 Killed(일반 몹) → KillCamCount +").Append(normalCam)
.Append(" · 역할 스킵 +").Append(normalRole).Append(" (killCamOnNormal=0 이라 스킵이 정상)\n");
sb.Append(" 판정: 엘리트 킬캠 조건 ").Append(eliteCam >= 1 ? "열림 PASS" : "🔴 닫힘").Append("\n");
WL.CombatCamera.CombatCameraDirector.ReleaseAll();
WL.Combat.Core.TimeScaleArbiter.ReleaseAll();
WL.Combat.Core.CombatEvents.Killed.Clear();
WL.Combat.Core.CombatEvents.Spawned.Clear();
WL.Combat.Core.CombatEvents.ComboStage.Clear();
}
UnityEngine.Time.timeScale = ts0;
sb.Append(" Time.timeScale ").Append(ts0.ToString("F2")).Append(" → ").Append(UnityEngine.Time.timeScale.ToString("F2")).Append(" (복원)\n\n");
}
// ═══════════════════════════════════════════════ D. GC
static void SectionD(System.Text.StringBuilder sb)
{
sb.Append("## D. GC (0 B 목표)\n");
if (s_elite == null) { sb.Append("🔴 건너뜀\n\n"); return; }
WL.Combat.Reaction.WLEliteSettings.RuntimeDisabled = false;
// 상태·MPB 를 미리 만들어 두고(1회 할당은 풀 크기 상한) 반복분만 잰다
WL.Combat.Reaction.EliteMarker.ProbeSpawned(s_elite);
WL.Combat.Reaction.EliteMarker.ProbeKilled(s_elite, eSubRol.Elite);
long a0 = System.GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 100; i++) WL.Combat.Reaction.EliteMarker.ProbeSpawned(s_elite);
long a1 = System.GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 100; i++) { WL.Combat.Reaction.EliteMarker.ProbeSpawned(s_elite); WL.Combat.Reaction.EliteMarker.ProbeKilled(s_elite, eSubRol.Elite); }
long a2 = System.GC.GetAllocatedBytesForCurrentThread();
sb.Append("Spawned ×100 = ").Append(a1 - a0).Append(" B · Spawned+Killed ×100 = ").Append(a2 - a1).Append(" B\n");
sb.Append("(killTimeLogEnabled=").Append(WL.Combat.Reaction.WLEliteSettings.Instance != null && WL.Combat.Reaction.WLEliteSettings.Instance.killTimeLogEnabled)
.Append(" · 켜져 있으면 Debug.Log 문자열이 잡힌다 = 값이 0 이 아니면 그 로그분)\n\n");
}
// ═══════════════════════════════════════════════ E. C8
static void SectionE(System.Text.StringBuilder sb)
{
sb.Append("## E. C8 — WLEliteSettings 를 끄면 원본 동작 100 %\n");
if (s_elite == null) { sb.Append("🔴 건너뜀\n\n"); return; }
WL.Combat.Reaction.EliteMarker.ClearAll();
s_elite.transform.localScale = s_eliteBaseScale * UnityEngine.Vector3.one;
WL.Combat.Reaction.EliteMarker.ResetDiagnostics();
WL.Combat.Reaction.WLEliteSettings.RuntimeDisabled = true;
sb.Append("Enabled=").Append(WL.Combat.Reaction.WLEliteSettings.Enabled).Append(" (기대 False)\n");
WL.Combat.Reaction.EliteMarker.ProbeSpawned(s_elite);
bool sizeOk = UnityEngine.Mathf.Abs(s_elite.transform.localScale.x - s_eliteBaseScale) < 0.0005f;
bool tintOff = !WL.Combat.Reaction.MobHitFlash.HasEliteTint(s_elite);
bool blockOff = !WL.Combat.Reaction.MobHitFlash.HasAnyBlock(s_elite);
sb.Append("크기 ").Append(s_elite.transform.localScale.x.ToString("F3")).Append(" (원본 ").Append(s_eliteBaseScale.ToString("F3")).Append(")")
.Append(" · 색 ").Append(tintOff ? "0" : "🔴 남음").Append(" · MPB ").Append(blockOff ? "0" : "🔴 남음")
.Append(" · SkippedDisabled=").Append(WL.Combat.Reaction.EliteMarker.SkippedDisabled)
.Append(sizeOk && tintOff && blockOff ? " → PASS" : " → 🔴 FAIL").Append("\n");
WL.Combat.Reaction.WLEliteSettings.RuntimeDisabled = false;
sb.Append("\n");
}
// ═══════════════════════════════════════════════ F. 원본 · 배선
static void SectionF(System.Text.StringBuilder sb)
{
sb.Append("## F. 원본 무수정 · 배선\n");
string[] originals = new string[] {
"Assets/Script/Character/Actor.cs",
"Assets/Script/Character/Mob/MobActor.cs",
"Assets/Script/Mgr/MobControlMgr.cs",
"Assets/WL/Combat/Core/CombatEvents.cs",
"Assets/WL/Combat/Core/WLCombatCoreSettings.cs",
};
for (int i = 0; i < originals.Length; i++)
{
if (!System.IO.File.Exists(originals[i])) { sb.Append(" ").Append(originals[i]).Append(" 🔴 없음\n"); continue; }
string t = System.IO.File.ReadAllText(originals[i]);
sb.Append(" ").Append(originals[i]).Append(" — \"EliteMarker\" ").Append(t.Contains("EliteMarker") ? "🔴 있음" : "0")
.Append(" · \"813s\" ").Append(t.Contains("813s") ? "🔴 있음" : "0").Append("\n");
}
string flash = System.IO.File.Exists(kFlash) ? System.IO.File.ReadAllText(kFlash) : "";
sb.Append(" MobHitFlash 합성 배선 — SetEliteTint ").Append(flash.Contains("SetEliteTint") ? "O" : "🔴")
.Append(" · Apply 안 s.elite ").Append(flash.Contains("if (s.elite)") ? "O" : "🔴")
.Append(" · RestoreAll 포함 ").Append(flash.Contains("!s.tint && !s.elite") ? "O" : "🔴").Append("\n\n");
}
// ═══════════════════════════════════════════════ 정리 · 헬퍼
static void Cleanup(System.Text.StringBuilder sb)
{
WL.Combat.Reaction.WLEliteSettings.RuntimeDisabled = false;
try { WL.Combat.Reaction.EliteMarker.ClearAll(); } catch (System.Exception) { }
try { WL.Combat.Reaction.MobHitFlash.ClearAll(); } catch (System.Exception) { }
try { WL.Combat.Reaction.ImpactRunner.ResetForceClock(); } catch (System.Exception) { }
if (s_root != null) { UnityEngine.Object.DestroyImmediate(s_root); s_root = null; }
s_eliteGo = null; s_normalGo = null; s_elite = null; s_normal = null;
sb.Append("\n## 정리: 임시 오브젝트 파괴 · 상태 비움 완료\n");
}
static bool Exists(string path) { return UnityEditor.AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(path) != null; }
static string Path(UnityEngine.Transform t)
{
string p = t.name;
while (t.parent != null) { t = t.parent; p = t.name + "/" + p; }
return p;
}
/// <summary>단일 라인 JSON 에서 key == value 인 객체 하나를 문자열로 잘라 온다.</summary>
static string Row(string json, string key, string value)
{
if (json == null || value == null) return null;
string needle = "\"" + key + "\": \"" + value + "\"";
int i = json.IndexOf(needle, System.StringComparison.Ordinal);
if (i < 0) return null;
int s = json.LastIndexOf('{', i);
int e = json.IndexOf('}', i);
return s < 0 || e < 0 ? null : json.Substring(s, e - s + 1);
}
static string F(string row, string key)
{
if (row == null) return "?";
string needle = "\"" + key + "\": \"";
int i = row.IndexOf(needle, System.StringComparison.Ordinal);
if (i < 0) return "?";
i += needle.Length;
int e = row.IndexOf('"', i);
return e < 0 ? "?" : row.Substring(i, e - i);
}
static float Num(string s)
{
float v;
return float.TryParse(s, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out v) ? v : 0f;
}
}