// ─────────────────────────────────────────────────────────────────────────────
// WLEquipStats.cs — 장비 능력치를 힘민지 ActorStatInfo 에 반영 (#821 → 2026-10-09 등급 7단계 개편 #823)
//
// PD 2026-10-09 원문: 등급별 「기본 수치 + 스텟 N % 비중」 · 배율 상한 없음.
// Lead 해석(PD 미지정 부분): 「스텟」 = 직업 주스탯 · 「기본 수치」 = FR 레벨표 값 → 장비 옵션 = 기본 수치 + 주스탯 × 등급 %
// 기본 수치 = FR 레벨표 값(AttackAbility / HealthAbility) × WLEquipSettings.levelStatScale
// 주스탯 = ActorStatInfo.Get_MainStatValue() — 직업 주스탯(힘·민첩·지능) · Reset 이 끝난 뒤의 최종값
// 등급 % = Resources/WL/WLGradeTable.json tiers[].statPercent (일반 10 … 신화 400)
// 부위 환산 = parts[].statFactor · 출력 = parts[].output (공격 부위 → ATK · 체력 부위 → MaxHP)
// 훅 1 = MyValue.cs ActorStatInfo.Reset() 안 `Apply(this, m_Role)` — 주스탯이 아직 최종값이 아니므로 재계산 번호만 올린다
// (구 FR 등급 % 옵션 abilityMap 은 applyLegacyFrOptions = 1 일 때만 여기서 더한다 · 기본 0).
// 훅 2 = Reset 뒤 `EnforceBaseAttack(info)` — WLInventory.Notify → RefreshPc · WLEquipLauncher 0.5 s 틱.
// ATK : pcBaseAttackOverride > 0 → 「기본값 + 장비 공격 합」으로 맞춘다(멱등) · 0 → 원본 공식에 장비 공격 합을 더한다
// MaxHP: 장비 생명 합을 더한다 — 재계산 1회(ResetSeq)마다 1번, 그 뒤 장착이 바뀌면 차이만 더한다
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections.Generic;
using UnityEngine;
namespace WL.Equip
{
public static class WLEquipStats
{
public static string LastReport { get; private set; } = "";
public static int LastAppliedItems { get; private set; }
/// 장착 장비 공격 합(기본 수치 + 주스탯 보너스) — ATK 목표값 = pcBaseAttackOverride + 이 값.
public static double LastEquipAttack { get; private set; }
public static double LastEquipHp { get; private set; }
public static double LastEquipDef { get; private set; }
public static double LastMainStat { get; private set; }
public static int BaseAttackEnforced;
public static string LastEnforceLog { get; private set; } = "";
/// 훅 1 이 돈 횟수 — 생명 합을 재계산 1회당 1번만 더하기 위한 번호.
public static int ResetSeq { get; private set; }
struct Applied { public int seq; public double hp; public double atk; public double def; }
static readonly Dictionary s_applied = new Dictionary();
static readonly List s_tmp = new List(8);
static readonly HashSet s_mapped = new HashSet();
static bool s_mappedBuilt;
public static bool IsMapped(eFRAbility a)
{
if (!s_mappedBuilt)
{
s_mappedBuilt = true;
var cfg = WLEquipSettings.Instance;
if (cfg != null && cfg.abilityMap != null)
for (int i = 0; i < cfg.abilityMap.Count; i++) s_mapped.Add(cfg.abilityMap[i].ability);
}
return s_mapped.Contains(a);
}
/// 훅 1 — ActorStatInfo.Reset() 안에서 부른다(PC 만). 재계산 번호를 올리고, 구 FR % 옵션은 설정이 켜진 경우만 더한다.
public static void Apply(ActorStatInfo info, eRole role)
{
if (info == null || role != eRole.PC) return;
var cfg = WLEquipSettings.Instance;
if (cfg == null || cfg.enabled_ == 0) return;
ResetSeq++;
if (cfg.applyLegacyFrOptions != 0) ApplyLegacyOptions(info, cfg);
WL.PartSkill.WLPartStats.ApplyHook1(info); // #879 파츠 패시브 · 목걸이 랜덤 스탯 = 배율(최종 공식 전) · 설정 off 면 무동작
}
static void ApplyLegacyOptions(ActorStatInfo info, WLEquipSettings cfg)
{
if (!WLInventory.EnsureLoaded()) return;
var data = WLInventory.Data;
if (data == null || cfg.abilityMap == null) return;
for (int i = 0; i < data.equipItems.Count; i++)
{
var item = WLInventory.FindItem(data.equipItems[i]);
if (item == null || !item.itemJson.IsEquipment) continue;
s_tmp.Clear();
WLEquipTables.CollectOpenedOptions(item.itemJson, s_tmp);
for (int o = 0; o < s_tmp.Count; o++)
{
var e = s_tmp[o];
for (int a = 0; a < e.Abilities.Count; a++)
{
var ab = e.Abilities[a];
for (int m = 0; m < cfg.abilityMap.Count; m++)
{
var map = cfg.abilityMap[m];
if (map.ability != ab.Ability) continue;
info.Set_Stat(true, map.stat, ab.AbilityValue * map.scale);
}
}
}
}
}
public struct Totals { public double atk; public double hp; public double def; public double mainStat; public int items; public string report; }
/// 장착 장비 합계 — 부위별 (기본 수치 + 주스탯 × 등급 % × 환산 계수). UI 와 같은 계산.
public static Totals Compute(ActorStatInfo info)
{
var t = new Totals { report = "" };
var cfg = WLEquipSettings.Instance;
if (cfg == null || cfg.enabled_ == 0 || info == null) return t;
if (!WLInventory.EnsureLoaded() || !WLEquipGrades.EnsureLoaded()) { t.report = WLEquipGrades.LastLog; return t; }
var data = WLInventory.Data;
if (data == null || data.equipItems.Count == 0) { t.report = "장착 0"; return t; }
double main = 0;
try { main = info.Get_MainStatValue(); } catch { }
t.mainStat = main;
var sb = new System.Text.StringBuilder();
for (int i = 0; i < data.equipItems.Count; i++)
{
var item = WLInventory.FindItem(data.equipItems[i]);
eStat outStat; double bv, bonus; string line;
if (!ItemValues(item, main, out outStat, out bv, out bonus, out line)) continue;
t.items++;
// #879 반지 = 지금 무기 계열(근접/원거리)과 맞을 때만 공격력에 더한다
string ringName;
if (item.itemJson.EquipmentType == eFREquipmentType.Belt && WLEquipGrades.IsRingOutput(WLEquipGrades.Part(item.itemJson.EquipmentType)) &&
!WL.PartSkill.WLPartStats.RingMatches(item, out ringName)) { sb.Append(item.itemJson.Item_name).Append(": 무기 계열 불일치(0) | "); continue; }
if (outStat == eStat.MaxHP) t.hp += bv + bonus; else if (outStat == eStat.DEF) t.def += bv + bonus; else t.atk += bv + bonus;
sb.Append(item.itemJson.Item_name).Append(": ").Append(line).Append(" | ");
}
t.report = sb.ToString();
return t;
}
/// 아이템 1개의 기본 수치·주스탯 보너스. line = 표시용 계산식(「공격 = 기본 7.0 + 힘 12 × 10% = 8.2」).
/// 0 보다 크면 그 강화 레벨로 계산(팝업 「다음 레벨 증가분」 표시용 · #881)
public static bool ItemValues(Item item, double mainStat, out eStat outStat, out double baseValue, out double bonus, out string line, int levelOverride = -1)
{
outStat = eStat.ATK; baseValue = 0; bonus = 0; line = "";
var cfg = WLEquipSettings.Instance;
if (cfg == null || item == null || item.itemJson == null || !item.itemJson.IsEquipment) return false;
if (!WLEquipGrades.EnsureLoaded()) return false;
var part = WLEquipGrades.Part(item.itemJson.EquipmentType);
WLGradeTier tier; int plus;
if (part == null || !WLEquipGrades.TryMap(item.itemJson, out tier, out plus)) return false;
if (WLEquipGrades.NoEffect(part, tier.tier)) { line = "효과 없음 — " + tier.name + " " + part.name + "(#895)"; return false; } // PD 「모든 무기의 일반 등급은 아무런 효과가 없어야」
if (WLEquipGrades.IsRateOutput(part)) return false; // #879 신발 이동 속도 · 목걸이 랜덤 = 배율(WL.PartSkill.WLPartStats)
outStat = WLEquipGrades.OutputStat(part);
var lt = WLEquipTables.FindItemLevelJson(item.itemJson.ItemLvAbilityID);
var lv = lt != null ? lt.FindLevelInfo(levelOverride > 0 ? levelOverride : item.rawItem.level) : null;
double raw = 0;
if (lv != null)
{
if (part.levelField == "Health") raw = lv.HealthAbility;
else if (part.levelField == "Defence") raw = lv.DefenceAbility;
else raw = lv.AttackAbility;
}
baseValue = raw * cfg.levelStatScale * part.levelFactor;
// #825 PD 2026-10-09 「단검 = 치명타 확률 · 검 = 치명타 피해량」 — 무기는 계열 축(WLWeaponAxis.json · 공격 표 main_stat)을 주스탯 자리에 넣는다.
double statValue = mainStat; string statName = MainStatName();
WL.Combat.Weapon.WLWeaponAxisDef axis;
if (item.itemJson.EquipmentType == eFREquipmentType.Weapon && WL.Combat.Weapon.WLWeaponAxis.TryFor(item.itemJson.GroupNo, out axis))
{
var live = LivePcInfo();
double v = 0; try { if (live != null) v = live.Get_Stat(axis.estat); } catch { v = 0; }
statValue = v * axis.scale; statName = axis.name;
}
bonus = statValue * (tier.statPercent / 100.0) * part.statFactor;
string outName = WLEquipGrades.OutputName(outStat);
if (WLEquipGrades.IsRingOutput(part)) { string rn; WL.PartSkill.WLPartStats.RingMatches(item, out rn); outName = rn; }
line = outName + " = 기본 " + baseValue.ToString("F1") + " + " + statName + " " + statValue.ToString("F0") +
" × " + tier.statPercent.ToString("F0") + "%" + (part.statFactor != 1.0 ? " × " + part.statFactor.ToString("F1") : "") +
" = " + (baseValue + bonus).ToString("F1");
return true;
}
/// UI 상세용 — 등급 줄 + 계산식 줄.
public static string FormulaText(Item item)
{
if (item == null || item.itemJson == null || !item.itemJson.IsEquipment) return "";
var sb = new System.Text.StringBuilder();
WLGradeTier tier; int plus;
if (WLEquipGrades.TryMap(item.itemJson, out tier, out plus))
sb.Append("등급 ").Append(WLEquipGrades.Label(item.itemJson, true))
.Append(WLEquipGrades.NoEffect(item.itemJson) ? "" : " · 주스탯 " + tier.statPercent.ToString("F0") + "% 반영").Append(tier.isS != 0 ? " · S급" : ""); // #895 일반 무기는 % 줄 생략
else
sb.Append("등급 대응 없음(FR ").Append(WLEquipTables.GradeName(item.itemJson.ItemGrade)).Append(')');
eStat o; double bv, bonus; string line;
if (ItemValues(item, CurrentMainStat(), out o, out bv, out bonus, out line)) sb.Append('\n').Append(line);
else if (WLEquipGrades.NoEffect(item.itemJson)) sb.Append('\n').Append(line); // #895 일반 무기 = 효과 없음
return sb.ToString();
}
/// 살아 있는 PC 의 스탯 객체. MyValue.Get_ActorStatInfoorNull 은 호출마다 Reset 을 돌려 값이 원본 공식으로 되돌아가므로 읽기용으로 쓰지 않는다(2026-10-09 스모크 실측).
public static ActorStatInfo LivePcInfo()
{
try { if (MyValue.bMyPC && MyValue.MyPC != null) return MyValue.MyPC.Get_StatInfo(); } catch { }
return null;
}
public static double CurrentMainStat()
{
try { var info = LivePcInfo(); return info != null ? info.Get_MainStatValue() : 0; }
catch { return 0; }
}
/// 직업 주스탯 이름(힘·민첩·지능) — 서버 데이터가 없으면 「주스탯」.
public static string MainStatName()
{
try
{
var sd = ServerInfo.Ins != null ? ServerInfo.Ins.m_ServerData : null;
if (sd == null) return "주스탯";
var cd = table_classconfig.Ins.Get_Data_orNull(sd.Get_EqiupmentEquip(eItem.PC));
if (cd == null) return "주스탯";
switch (cd.Get_MainStat())
{
case eMainStatType.STR: case eMainStatType.STR_DEX: case eMainStatType.STR_INT: return "힘";
case eMainStatType.DEX: case eMainStatType.DEX_STR: case eMainStatType.DEX_INT: return "민첩";
case eMainStatType.INT: case eMainStatType.INT_STR: case eMainStatType.INT_DEX: return "지능";
}
}
catch { }
return "주스탯";
}
/// UI 상단 한 줄 — PC 현재 공격력·최대 HP·주스탯·장비 합.
public static string StatusLine()
{
var info = LivePcInfo();
if (info == null) return "PC 스탯 없음(인게임에서 확인)";
try { EnforceBaseAttack(info); } catch { }
var sb = new System.Text.StringBuilder();
sb.Append("공격력 ").Append(info.Get_Stat(eStat.ATK).ToString("F1")).Append(" (최종 ").Append(info.Get_Stat(eStat.BaseDmg).ToString("F1"))
.Append(") 최대HP ").Append(info.Get_Stat(eStat.MaxHP).ToString("F0"))
.Append(" ").Append(MainStatName()).Append(' ').Append(LastMainStat.ToString("F0"))
.Append(" 장비 공격 +").Append(LastEquipAttack.ToString("F1")).Append(" · 장비 생명 +").Append(LastEquipHp.ToString("F1"))
.Append(" · 장비 방어 +").Append(LastEquipDef.ToString("F1"));
return sb.ToString();
}
/// PC 스탯 재반영(힘민지 EquipmentUI 와 같은 호출 시점). 게임 밖(타이틀)에서는 아무 일도 없다.
public static void RefreshPc()
{
try
{
if (ServerInfo.Ins == null || ServerInfo.Ins.m_ServerData == null) return;
var info = MyValue.Get_ActorStatInfoorNull(eRole.PC); // 재계산(Reset) 뒤 반영
EnforceBaseAttack(info);
var live = LivePcInfo();
if (live != null && live != info) EnforceBaseAttack(live);
}
catch (System.Exception ex) { Debug.LogWarning("[WL-821] PC 스탯 재반영 실패 — " + ex.Message); }
}
///
/// 훅 2 — Reset 뒤 장비 합을 반영한다. PD 2026-10-09 「플레이어 기본 공격력은 5」:
/// pcBaseAttackOverride > 0 이면 ATK = 기본값 + 장비 공격 합(멱등 · BaseDmg 는 같은 비율) · 0 이면 원본 공식에 장비 공격 합을 더한다.
/// MaxHP 에는 장비 생명 합을 더한다(재계산 1회당 1번 · 장착 변경 시 차이만).
///
public static bool EnforceBaseAttack(ActorStatInfo info)
{
var cfg = WLEquipSettings.Instance;
if (cfg == null || cfg.enabled_ == 0 || info == null) return false;
var t = Compute(info);
LastEquipAttack = t.atk; LastEquipHp = t.hp; LastMainStat = t.mainStat; LastAppliedItems = t.items; LastReport = t.report;
bool changed = false;
Applied ap;
if (!s_applied.TryGetValue(info, out ap) || ap.seq != ResetSeq) ap = new Applied { seq = ResetSeq, hp = 0, atk = 0, def = 0 };
double dHp = t.hp - ap.hp;
if (System.Math.Abs(dHp) >= 0.01) { info.Set_Stat(true, eStat.MaxHP, dHp); ap.hp = t.hp; changed = true; }
// #879 갑옷 = 방어력 — 최종 방어(FinalDef)에 차이만 더한다(생명과 같은 방식 · 재계산 1회당 1번)
double dDef = t.def - ap.def;
if (System.Math.Abs(dDef) >= 0.01) { info.Set_Stat(true, eStat.FinalDef, dDef); ap.def = t.def; changed = true; }
LastEquipDef = t.def;
if (cfg.pcBaseAttackOverride > 0)
{
double atk = info.Get_Stat(eStat.ATK);
double target = cfg.pcBaseAttackOverride + t.atk;
if (System.Math.Abs(atk - target) >= 0.01)
{
double bd = info.Get_Stat(eStat.BaseDmg);
double k = atk > 0.0001 ? bd / atk : 1.0;
info.Set_Stat(eStat.ATK, target);
info.Set_Stat(eStat.BaseDmg, target * k);
BaseAttackEnforced++;
LastEnforceLog = "ATK " + atk.ToString("F1") + " → " + target.ToString("F1") + " · BaseDmg " + bd.ToString("F1") + " → " + (target * k).ToString("F1") +
" (장비 공격 +" + t.atk.ToString("F1") + " · 생명 +" + t.hp.ToString("F1") + " · " + MainStatName() + " " + t.mainStat.ToString("F0") + ")";
if (cfg.verboseLog != 0 && BaseAttackEnforced <= 3) Debug.Log("[WL-821] 기본 공격력 보정 — " + LastEnforceLog);
changed = true;
}
}
else
{
double dAtk = t.atk - ap.atk;
if (System.Math.Abs(dAtk) >= 0.01)
{
double a0 = info.Get_Stat(eStat.ATK), b0 = info.Get_Stat(eStat.BaseDmg);
double k0 = a0 > 0.0001 ? b0 / a0 : 1.0;
info.Set_Stat(eStat.ATK, a0 + dAtk);
info.Set_Stat(eStat.BaseDmg, (a0 + dAtk) * k0);
ap.atk = t.atk;
changed = true;
}
}
s_applied[info] = ap;
return changed;
}
/// 주기 폴링용 — PC 가 있으면 현재 스탯 객체를 가져와(재계산 없이) 반영한다.
public static void EnforceBaseAttackTick()
{
try
{
if (!MyValue.bMyPC || MyValue.MyPC == null) return;
EnforceBaseAttack(MyValue.MyPC.Get_StatInfo());
}
catch { }
}
}
}