260 lines
13 KiB
C#
260 lines
13 KiB
C#
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 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);
|
||
}
|
||
}
|
||
}
|