// ───────────────────────────────────────────────────────────────────────────── // DamageGuard.cs — "배율을 타지 않는 PC 피해 경로" 에도 같은 배율을 먹인다(원본 훅 0 · 폴링 + 환급) // // PD 지시 #813 · 발주서 WL-813m3 §1-3 · 근거 = Q9 D-21(피해 배율을 0.15→0.1 · 0.25→0.15 로 내려도 사망이 안 줄었다) // // ■ 왜 필요한가 — 코드 실측(원본 읽기만 · Assets/Script 0줄 수정) // 813i 게이트는 `PCActor.Get_Damage` **한 곳**에만 있다(Survival.TakeDamage). 그런데 원본에는 // `Get_Damage` 를 **거치지 않고 곧장 `Actor.Cal_Damage(dinfo)` 로 HP 를 깎는** 경로가 있다: // · `Actor.Co_DotDamage :1536` — 독/화상/출혈 도트. 매 틱 `Beater.Get_DamageInfo()` 로 **새 피해를 다시 만들어** // `Cal_Damage` 를 직접 부른다 → 배율 0 · 방어 공식 0 · **원본 무적 플래그도 무시**된다. // · `Actor.Check_CC_byAttack :1116`(스턴 추가타) · `Check_Extra :1191·1197·1202·1222·1229`(빙결/둔화/스턴 추가 피해) // — 이쪽은 이미 배율이 곱해진 `_dinfo.Damage` 를 다시 쓰므로 **간접 적용**(2차 타격이라 총량은 2배가 된다). // · `Check_EnchantOption :1402(LUK 저주) · :1412(STR 스플래시 · allies[k].Cal_Damage) · :1453(LUK 크리)` // — 가해자 스탯에서 **피해를 새로 계산**하므로 배율 0. // `Cal_Damage` 안에는 `IsDead()` 와 `NoDmgTime`(몹 전용 · PC 는 0) 말고 어떤 가드도 없다 — PC 무적조차 없다. // // ■ 어떻게 훅 0 으로 막나 — HP 델타 정산(폴링) + 환급 // ① `Survival.TakeDamage` 가 base 를 부를 때마다 **호출 전후 HP 차이**를 `Survival.HookHpLoss` 에 누계한다 // (중첩 `Cal_Damage` 까지 포함한 "훅이 책임지는 감소량" 실측치). // ② 이 가드는 매 프레임(Update + **LateUpdate**) 메인 PC 의 HP 를 본다. // `실제 감소량 − 훅 누계 증가분` = **훅을 안 거친 감소량**(= 배율 미적용 경로). // ③ 그 차액에 같은 배율을 적용한 만큼을 되돌린다 — `Heal(차액 × (1 − 배율), showhud:false)`. // 결과적으로 그 경로의 순 피해도 `차액 × 배율` 이 된다. // · LateUpdate 를 함께 도는 이유: `WaitForSeconds` 코루틴(도트 틱)은 Update **뒤**에 재개된다 — // LateUpdate 폴링이면 **같은 프레임 안**에 정산된다(지연 ≈ 0). // · 🔴 한계(보고서 「미확인」): 한 틱이 곧바로 치명타여서 `Cal_Damage` 안에서 `Set_Die` 가 먼저 나면 // 환급이 늦다. 이 가드는 **누적 감소 속도**를 배율만큼 낮춰 그 지점에 도달하지 않게 하는 것이 목적이다. // // ■ 값은 전부 SO — 배율은 `WLSurvivalSettings.mobDamageMultiplier` **기존 필드 재사용**(발주서 §1-3). // 가해자를 알 수 없는 폴링이라 잡몹 배율을 쓴다(보스 배율보다 낮아 생존에 보수적) · 스위치 `unscaledDamageGuardEnabled`. // // ■ C8 롤백: `unscaledDamageGuardEnabled = 0` 또는 SO off → 폴링 자체를 하지 않는다(코드 무수정). // ■ GC 0: 문자열·할당 0(로그는 SO 스위치가 켜졌을 때 · 사건당 1줄). // 🔴 Debug.LogError/LogException 금지(813z). // 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것. // ───────────────────────────────────────────────────────────────────────────── using UnityEngine; namespace WL.Combat.Survival { /// 훅을 거치지 않은 PC HP 감소를 같은 배율로 되돌린다. 정적 · GC 0. public static class DamageGuard { /// 이 값보다 작은 감소는 무시한다(부동소수 잡음 · 구조 상수). const double Epsilon = 0.5d; // ── 진단(프로브가 읽는다) /// 훅을 안 거친 감소를 잡은 횟수. public static int DetectedCount; /// 그 경로로 깎인 HP 누계(환급 전). public static double DetectedHp; /// 실제로 환급(Heal)한 횟수 · HP 누계. public static int RefundCount; public static double RefundedHp; /// 죽어 있어서 환급하지 못한 횟수. public static int SkippedDead; /// 폴링 횟수 · 기준선 재설정 횟수. public static int PollCount, RebaseCount; public static double LastDetected, LastRefund; public static float LastDetectTime; static PCActor s_pc; static double s_lastHp; static double s_lastHookLoss; static bool s_armed; static WLSurvivalSettings St { get { return WLSurvivalSettings.Instance; } } /// 가드가 실제로 도는가 = 생존 축 on + 이 스위치 on. public static bool Active { get { var st = St; return WLSurvivalSettings.Enabled && st != null && st.unscaledDamageGuardEnabled; } } /// 폴링에 쓰는 배율(잡몹 배율 재사용 · 0~1 로 자른다). public static double Multiplier { get { var st = St; if (st == null) return 1d; double m = st.mobDamageMultiplier; if (m < 0d) m = 0d; if (m > 1d) m = 1d; return m; } } /// 기준선을 지금 HP 로 다시 잡는다(런 시작 · 부활 · PC 교체 · 회복). public static void Rebase() { s_armed = false; RebaseCount++; } /// 러너가 Update · LateUpdate 에서 부른다. 비활성이면 즉시 반환(C8). public static void Poll() { var st = St; if (st == null || !WLSurvivalSettings.Enabled || !st.unscaledDamageGuardEnabled) { s_armed = false; return; } var pc = MyValue.MyPC; if (pc == null) { s_armed = false; s_pc = null; return; } if (!ReferenceEquals(pc, s_pc)) { s_pc = pc; s_armed = false; } PollCount++; double hp = pc.Get_HP(); double hookLoss = Survival.HookHpLoss; if (!s_armed) { s_lastHp = hp; s_lastHookLoss = hookLoss; s_armed = true; return; } double drop = s_lastHp - hp; // > 0 = 잃었다 double accounted = hookLoss - s_lastHookLoss; // 훅이 책임지는 감소량 if (accounted < 0d) accounted = 0d; if (drop <= Epsilon) // 회복했거나 변화 없음 → 기준선만 갱신 { s_lastHp = hp; s_lastHookLoss = hookLoss; return; } double unscaled = drop - accounted; if (unscaled <= Epsilon) // 전부 훅을 거쳤다 = 정상 { s_lastHp = hp; s_lastHookLoss = hookLoss; return; } // ── 배율 미적용 경로를 잡았다 DetectedCount++; DetectedHp += unscaled; LastDetected = unscaled; LastDetectTime = Application.isPlaying ? Time.time : Survival.Now; // 🔴 WL-813w8 (Q11 D-46) — 결과창 정지 무적 중에는 **전액 환급**한다(배율 0). // 훅 경로는 Survival 이 피해 0 으로 만들지만, 이 폴링이 잡는 경로(도트 · Cal_Damage 직접 호출)는 // 원본 무적 플래그조차 거치지 않는다 — 정지 중 「피해 0」을 이 축에서도 지킨다. // 래치가 없으면(기본 · 런 중) 종전 그대로 잡몹 배율을 쓴다(C8). double mult = Survival.IsHoldInvincible(pc) ? 0d : Multiplier; double refund = unscaled * (1d - mult); double maxHp = pc.Get_MaxHP(); if (pc.IsOnlyDead() || hp <= 0d) { SkippedDead++; LastRefund = 0d; // 이미 죽었다 — Heal 은 IsDead 로 막힌다 } else if (refund >= 1d) // Actor.Heal 은 1 미만도 1 로 올린다 → 과환급 방지 { // Actor.Heal 은 마지막에 HUD(Get_HUDUI)를 건드린다 — 인게임 밖(프로브·전환 중)에서는 그 줄이 던질 수 있다. // HP 대입은 그보다 앞이므로 실제로 오른 만큼을 실측해 센다(예외가 나도 전투 흐름을 끊지 않는다). try { pc.Heal(refund, false); } catch (System.Exception) { } // 🔴 LogError 금지(813z) double healed = pc.Get_HP() - hp; if (healed > 0d) { RefundCount++; RefundedHp += healed; } LastRefund = healed > 0d ? healed : 0d; } else LastRefund = 0d; double hpAfter = pc.Get_HP(); DeathDiag.Record(DamageVia.Unscaled, null, unscaled, mult, unscaled, hpAfter, maxHp); if (st.unscaledDamageGuardLog) Debug.Log("[WL813m3] unscaled dmg " + unscaled.ToString("F1") + " ×" + mult.ToString("F2") + " → refund " + LastRefund.ToString("F1") + " hp=" + hpAfter.ToString("F0") + "/" + maxHp.ToString("F0")); // 🔴 Log 만(813z) s_lastHp = hpAfter; s_lastHookLoss = hookLoss; } /// 프로브용: 카운터 초기화 + 기준선 해제. public static void ResetDiagnostics() { DetectedCount = RefundCount = SkippedDead = PollCount = RebaseCount = 0; DetectedHp = RefundedHp = LastDetected = LastRefund = 0d; LastDetectTime = 0f; s_pc = null; s_armed = false; s_lastHp = 0d; s_lastHookLoss = 0d; } /// 프로브용: 기준선을 직접 주입한다(Play 없이 폴링 로직 검증). public static void ProbeArm(PCActor pc, double hp, double hookLoss) { s_pc = pc; s_lastHp = hp; s_lastHookLoss = hookLoss; s_armed = true; } } }