Project_WL/Assets/WL/Combat/Survival/DeathDiag.cs

260 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ─────────────────────────────────────────────────────────────────────────────
// 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 폴링이 잡은 차액 = 배율 미적용 경로)
//
// ■ 기록 경로 GC 0
// · 사전 할당 struct 배열 16칸(링) · 문자열 0 · 박싱 0 · 가해자는 참조 1개만 담는다(파괴돼도 null 로 읽힌다).
// · 문자열은 **사망 시에만** 만든다(런당 최대 사망 수 = 6회 · StringBuilder 1개 재사용).
//
// ■ 출력
// · 파일 = <프로젝트 루트>/Screenshots_WL/WL813m3/death_<HHmmss>_<n>.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
{
/// <summary>피해가 들어온 경로 — "배율을 탔는가" 를 그대로 말한다.</summary>
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 직접 호출 등)
}
/// <summary>사망 직전 피해 로그. 정적 · 링버퍼 16 · 기록 경로 GC 0.</summary>
public static class DeathDiag
{
/// <summary>링버퍼 칸 수(발주서 WL-813m3 §1-2 고정값 = 구조 상수 · 튜닝 값 아님).</summary>
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; } }
/// <summary>러너가 매 프레임 부른다(첫 호출에 DeathFlow.Died 구독 · 이후 bool 1개).</summary>
public static void EnsureSubscribed()
{
if (s_subscribed) return;
s_subscribed = true;
DeathFlow.Died.Add(OnDied);
}
/// <summary>기록 1건(GC 0). Survival.TakeDamage · DamageGuard 가 부른다.</summary>
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;
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).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; }
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");
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)"
+ " 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 ";
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 "";
}
}
/// <summary>프로브용: 링·카운터 초기화(구독은 유지).</summary>
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;
}
/// <summary>프로브용: 링 내용을 읽는다(범위 밖이면 기본값).</summary>
public static Hit Get(int index)
{
if (index < 0 || index >= Capacity) return default(Hit);
return s_ring[index];
}
/// <summary>프로브용: 사망을 강제로 덤프한다(Play 없이 파일 생성 검증).</summary>
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);
}
}
}