// ───────────────────────────────────────────────────────────────────────────── // DeathDiag.cs — 사망 직전 피해 로그(링버퍼 16 · 사망마다 파일 1개 + 콘솔 1줄) // // PD 지시 #813 · 발주서 WL-813m3 §1-2 · 근거 = Q9 `qa/W2-9_재채점_v7.md` D-21 // (런당 사망 4/6/6 · 피해 배율 0.15→0.1 · 0.25→0.15 로 더 내려도 줄지 않았다 · 최대 피해 44~46 = MaxHP 310 의 15 %) // // ■ 무엇을 남기나 — PC 가 HP 를 잃을 때마다 1건: // 시각(게임 시간) · 프레임 · 경로(via) · 가해자 종류/ID · 원 피해 · 적용 배율 · 실제 깎인 HP · HP 후/최대 // 경로(via)는 "배율을 탔는가"를 그대로 말해 준다 — // Scaled = Survival.TakeDamage 가 배율을 곱하고 base 를 불렀다(정상) // ReviveInv = 부활 무적 창(피해 0) // SwitchOff = 잡몹/보스 스위치 off(원본 무적 그대로) // Passthru = SO off · 메인 PC 아님(원본 100 %) // 🔴 Unscaled = **훅을 거치지 않고 HP 가 깎였다**(DamageGuard 폴링이 잡은 차액 = 배율 미적용 경로) // 🔴 HitInv = WL-813i2 피격 후 무적 창이 흘린 타(피해 0) — Q10 D-21 보스 0.52 s 5연타 완화 // // ■ 기록 경로 GC 0 // · 사전 할당 struct 배열 16칸(링) · 문자열 0 · 박싱 0 · 가해자는 참조 1개만 담는다(파괴돼도 null 로 읽힌다). // · 문자열은 **사망 시에만** 만든다(런당 최대 사망 수 = 6회 · StringBuilder 1개 재사용). // // ■ 출력 // · 파일 = <프로젝트 루트>/Screenshots_WL/WL813m3/death__.txt (Assets 밖 · 미추적 · SO 로 경로 변경) // · 콘솔 = Debug.Log 1줄(`[WL813m3] death #n …`) — **verboseLog 와 무관**(발주서 §1-2 · Q10 이 읽는다). // 🔴 Debug.LogError/LogException 금지(813z — 그 자체가 파란 모달이 된다). // // 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것. // ───────────────────────────────────────────────────────────────────────────── using System; using System.IO; using System.Text; using UnityEngine; using WL.Combat.Core; namespace WL.Combat.Survival { /// 피해가 들어온 경로 — "배율을 탔는가" 를 그대로 말한다. public enum DamageVia : byte { Scaled = 0, // Survival.TakeDamage 가 배율을 곱했다 ReviveInv = 1, // 부활 무적 창 → 원본 무적 경로(피해 0) SwitchOff = 2, // takeMobDamage/takeBossDamage off → 원본 무적 그대로 Passthru = 3, // SO off · 메인 PC 아님 → 원본 100 % Unscaled = 4, // 🔴 훅을 거치지 않은 HP 감소(DoT · Cal_Damage 직접 호출 등) HitInv = 5, // 🔴 WL-813i2 피격 후 무적 창 → 원본 무적 경로(피해 0 · 연타에서 흘린 타) HoldInv = 6 // 🔴 WL-813w8 결과창 정지 무적 래치 → 원본 무적 경로(피해 0 · 런 밖 D-46) } /// 사망 직전 피해 로그. 정적 · 링버퍼 16 · 기록 경로 GC 0. public static class DeathDiag { /// 링버퍼 칸 수(발주서 WL-813m3 §1-2 고정값 = 구조 상수 · 튜닝 값 아님). public const int Capacity = 16; public struct Hit { public float time; // Time.time(게임 시간) public int frame; public DamageVia via; public Actor attacker; // 파괴되면 Unity == null 로 읽힌다 public int attackerId; public bool attackerBoss, attackerElite; public double raw; // 원 피해(Unscaled 는 폴링이 잡은 HP 감소량) public double mult; // 적용 배율(1 = 미적용) public double lost; // 실제로 깎인 HP(nested Cal_Damage 포함 · 폴링 실측) public double hpAfter, maxHp; } static readonly Hit[] s_ring = new Hit[Capacity]; static int s_next; // 다음에 쓸 칸 static int s_written; // 지금까지 기록한 총 건수 // ── 진단(프로브가 읽는다) public static int RecordCount { get { return s_written; } } public static int FileCount; public static string LastFilePath = ""; public static string LastSummary = ""; public static int UnscaledCount; // via = Unscaled 로 기록한 누계 public static double UnscaledHpTotal; // 그 경로로 잃은 HP 누계 static bool s_subscribed; static StringBuilder s_sb; // 사망 시에만 쓴다 static WLSurvivalSettings St { get { return WLSurvivalSettings.Instance; } } /// 러너가 매 프레임 부른다(첫 호출에 DeathFlow.Died 구독 · 이후 bool 1개). public static void EnsureSubscribed() { if (s_subscribed) return; s_subscribed = true; DeathFlow.Died.Add(OnDied); } /// 기록 1건(GC 0). Survival.TakeDamage · DamageGuard 가 부른다. public static void Record(DamageVia via, Actor attacker, double raw, double mult, double lost, double hpAfter, double maxHp) { var st = St; if (st == null || !st.deathDiagEnabled) return; int i = s_next; s_ring[i].time = Application.isPlaying ? Time.time : Survival.Now; s_ring[i].frame = Time.frameCount; s_ring[i].via = via; s_ring[i].attacker = attacker; s_ring[i].attackerId = attacker != null ? attacker.Get_ID() : -1; s_ring[i].attackerBoss = attacker != null && attacker.IsSubRole(eSubRol.Boss); s_ring[i].attackerElite = attacker != null && attacker.IsSubRole(eSubRol.Elite); s_ring[i].raw = raw; s_ring[i].mult = mult; s_ring[i].lost = lost; s_ring[i].hpAfter = hpAfter; s_ring[i].maxHp = maxHp; s_next = (i + 1) % Capacity; s_written++; if (via == DamageVia.Unscaled) { UnscaledCount++; UnscaledHpTotal += lost; } } // ───────────────────────────────────────── 사망 시 기록 static void OnDied(in DeathEvent e) { var st = St; if (st == null || !st.deathDiagEnabled) return; try { Dump(e); } catch (Exception ex) { LastSummary = "[WL813m3] death dump 실패: " + ex.Message; } // 🔴 LogError 금지 } static void Dump(in DeathEvent e) { if (s_sb == null) s_sb = new StringBuilder(4096); else s_sb.Length = 0; int n = Mathf.Min(s_written, Capacity); double sumScaled = 0d, sumUnscaled = 0d; int cntScaled = 0, cntUnscaled = 0; int cntHitInv = 0; // 813i2 — 무적 창이 흘린 타(최근 16건 중) s_sb.Append("# WL-813m3 사망 직전 피해 로그 (PD 지시 #813 · Q9 D-21)\n"); s_sb.Append("death #").Append(e.deathCount) .Append(" t=").Append(e.time.ToString("F2")) .Append(" frame=").Append(e.frame) .Append(" pos=").Append(e.position.ToString("F1")).Append('\n'); var pc = e.pc; if (pc != null) s_sb.Append("pc hp=").Append(pc.Get_HP().ToString("F0")).Append('/').Append(pc.Get_MaxHP().ToString("F0")).Append('\n'); var st = St; if (st != null) s_sb.Append("SO: mobMult=").Append(st.mobDamageMultiplier.ToString("F2")) .Append(" bossMult=").Append(st.bossDamageMultiplier.ToString("F2")) .Append(" takeMob=").Append(st.takeMobDamage ? 1 : 0) .Append(" takeBoss=").Append(st.takeBossDamage ? 1 : 0) .Append(" potion=").Append(PotionUse.Remaining).Append('/').Append(PotionUse.Max) .Append(" autoPotion=").Append(st.autoPotionEnabled ? 1 : 0) .Append('@').Append(st.autoPotionHpRatio.ToString("F2")) .Append(" guard=").Append(st.unscaledDamageGuardEnabled ? 1 : 0) // 813i2 — 피격 후 무적 창(Q10 D-21 보스 0.52 s 5연타) .Append(" hitInv=").Append(st.hitInvincibleEnabled ? 1 : 0) .Append('@').Append(st.bossHitInvincibleSeconds.ToString("F2")).Append("s(boss)") .Append(st.mobHitInvincible ? "/" + st.mobHitInvincibleSeconds.ToString("F2") + "s(mob)" : "/mob off") .Append('\n'); s_sb.Append("\n# t(s) frame via attacker(kind/id) raw mult lost hp_after/max\n"); // 링을 오래된 것 → 최신 순으로 int start = s_written <= Capacity ? 0 : s_next; for (int k = 0; k < n; k++) { int i = (start + k) % Capacity; ref Hit h = ref s_ring[i]; if (h.via == DamageVia.Unscaled) { cntUnscaled++; sumUnscaled += h.lost; } else if (h.via == DamageVia.Scaled) { cntScaled++; sumScaled += h.lost; } else if (h.via == DamageVia.HitInv) cntHitInv++; // 813i2 s_sb.Append(k < 9 ? " " : "").Append(k + 1).Append(" ") .Append(h.time.ToString("F2")).Append(" ") .Append(h.frame).Append(" ") .Append(ViaName(h.via)).Append(" ") .Append(AttackerKind(ref h)).Append('/').Append(h.attackerId).Append(" ") .Append(h.raw.ToString("F1")).Append(" ×") .Append(h.mult.ToString("F2")).Append(" ") .Append(h.lost.ToString("F1")).Append(" ") .Append(h.hpAfter.ToString("F0")).Append('/').Append(h.maxHp.ToString("F0")); if (h.attacker != null) s_sb.Append(" ").Append(h.attacker.name); s_sb.Append('\n'); } s_sb.Append("\n합계(최근 ").Append(n).Append("건): 배율적용 ").Append(cntScaled).Append("건 ").Append(sumScaled.ToString("F0")) .Append(" HP · 🔴배율미적용(Unscaled) ").Append(cntUnscaled).Append("건 ").Append(sumUnscaled.ToString("F0")).Append(" HP\n") .Append("누계: Unscaled ").Append(UnscaledCount).Append("건 ").Append(UnscaledHpTotal.ToString("F0")).Append(" HP · ") .Append("DamageGuard 환급 ").Append(DamageGuard.RefundCount).Append("건 ").Append(DamageGuard.RefundedHp.ToString("F0")).Append(" HP\n") // 813i2 — 무적 창이 실제로 몇 타를 흘렸는가(최근 16건 · 세션 누계) .Append("🔴813i2 무적창: 최근 ").Append(n).Append("건 중 흘린타 ").Append(cntHitInv) .Append("건 · 세션 누계 막은타 ").Append(Survival.BlockedHitInvincible) .Append("건 / 연 창 ").Append(Survival.HitInvincibleOpened) .Append("회(보스 ").Append(Survival.HitInvincibleOpenedByBoss).Append(")\n"); string path = Write(s_sb.ToString(), e.deathCount); // 콘솔 1줄(verboseLog 무관 · Q10 이 읽는다) LastSummary = "[WL813m3] death #" + e.deathCount + " t=" + e.time.ToString("F1") + " last" + n + " scaled=" + cntScaled + "(" + sumScaled.ToString("F0") + "hp)" + " unscaled=" + cntUnscaled + "(" + sumUnscaled.ToString("F0") + "hp)" + " hitInv=" + cntHitInv + "/" + Survival.BlockedHitInvincible // 813i2 + " potion=" + PotionUse.Remaining + "/" + PotionUse.Max + " file=" + (string.IsNullOrEmpty(path) ? "-" : path); Debug.Log(LastSummary); } static string ViaName(DamageVia v) { switch (v) { case DamageVia.Scaled: return "Scaled "; case DamageVia.ReviveInv: return "ReviveInv"; case DamageVia.SwitchOff: return "SwitchOff"; case DamageVia.Passthru: return "Passthru "; case DamageVia.HitInv: return "HitInv "; // 813i2 피격 후 무적 창(피해 0) case DamageVia.HoldInv: return "HoldInv "; // 813w8 결과창 정지 무적(피해 0) default: return "UNSCALED "; } } static string AttackerKind(ref Hit h) { if (h.attackerId < 0) return "none"; if (h.attackerBoss) return "boss"; if (h.attackerElite) return "elite"; return "mob"; } static string Write(string body, int deathCount) { try { var st = St; string sub = st != null && !string.IsNullOrEmpty(st.deathDiagDir) ? st.deathDiagDir : "Screenshots_WL/WL813m3"; string root = Application.isEditor ? Path.GetDirectoryName(Application.dataPath) : Application.persistentDataPath; string dir = string.IsNullOrEmpty(root) ? sub : Path.Combine(root, sub); Directory.CreateDirectory(dir); string file = Path.Combine(dir, "death_" + DateTime.Now.ToString("HHmmss") + "_" + deathCount + ".txt"); File.WriteAllText(file, body); LastFilePath = file; FileCount++; return file; } catch (Exception ex) { LastFilePath = ""; LastSummary = "[WL813m3] death 파일 기록 실패: " + ex.Message; // 🔴 LogError 금지 return ""; } } /// 프로브용: 링·카운터 초기화(구독은 유지). public static void ResetDiagnostics() { Array.Clear(s_ring, 0, s_ring.Length); s_next = 0; s_written = 0; FileCount = 0; LastFilePath = ""; LastSummary = ""; UnscaledCount = 0; UnscaledHpTotal = 0d; } /// 프로브용: 링 내용을 읽는다(범위 밖이면 기본값). public static Hit Get(int index) { if (index < 0 || index >= Capacity) return default(Hit); return s_ring[index]; } /// 프로브용: 사망을 강제로 덤프한다(Play 없이 파일 생성 검증). public static void DumpForProbe(int deathCount) { var e = new DeathEvent { pc = null, position = Vector3.zero, deathCount = deathCount, displaySeconds = 0f, time = Survival.Now, frame = Time.frameCount }; OnDied(in e); } } }