// ───────────────────────────────────────────────────────────────────────────── // WLInventory.cs — FR 기존 장비 시스템 로직 이식 (#821) // // 원본 = FR `NetPlayer.cs`(AddItem · EquipItem · ItemLevelUp · ComposeItem · DowngradeItem) + // `ComposePopup.cs`(IsMaterialItem · FindComposeItemInfo · FindResultItemJson) + `UIUtil`(강등 반환). // FR 은 서버/오프라인 4단 패턴이지만 WL 은 서버 없이 **로컬 세이브**(PD 결정 보류 → 가정 · persistentDataPath/WL_Equip.dat)로 돈다. // 골드 = 섬 코인(IslandManager.Coin · 던전 보상이 들어오는 재화) · 섬이 없으면 세이브 안 예비 골드. // // 규칙(PD 확인 2026-10-08): 합성 = 본 아이템 1 + 재료 RequiredItemCount(2) = 3개 소모 · 본 아이템이 상위 등급으로 바뀐다. // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using System.IO; using Newtonsoft.Json; using UnityEngine; namespace WL.Equip { public static class WLInventory { public static WLEquipSaveData Data { get; private set; } public static bool Loaded { get; private set; } public static string LastMessage { get; private set; } = ""; /// 최근 획득 로그(UI 표시용 · 최신이 앞). public static readonly List RecentGains = new List(); public static event System.Action Changed; static readonly Dictionary s_items = new Dictionary(); static bool s_resetDoneThisRun; // ── 로드 · 세이브 ────────────────────────────────────────────────── static string SavePath() { var cfg = WLEquipSettings.Instance; string name = cfg != null && !string.IsNullOrEmpty(cfg.saveName) ? cfg.saveName : "WL_Equip"; return Application.persistentDataPath + "/" + name + ".dat"; } public static bool EnsureLoaded() { if (Loaded) return true; if (!WLEquipTables.EnsureLoaded()) return false; var cfg = WLEquipSettings.Instance; string path = SavePath(); try { if (cfg != null && cfg.resetSaveEveryStart != 0 && !s_resetDoneThisRun) { s_resetDoneThisRun = true; if (File.Exists(path)) { File.Delete(path); Log("세이브 초기화(실행 1회당 1번) — " + path); } } Data = File.Exists(path) ? JsonConvert.DeserializeObject(File.ReadAllText(path)) : null; } catch (System.Exception ex) { Debug.LogWarning("[WL-821] 세이브 읽기 실패 — " + ex.Message); Data = null; } if (Data == null) Data = new WLEquipSaveData(); if (Data.items == null) Data.items = new List(); if (Data.equipItems == null) Data.equipItems = new List(); if (Data.nextId <= 0) Data.nextId = 1; s_items.Clear(); for (int i = Data.items.Count - 1; i >= 0; i--) { var raw = Data.items[i]; var json = WLEquipTables.FindItemJson(raw.index); if (json == null || raw.count <= 0 || s_items.ContainsKey(raw._id)) { Data.items.RemoveAt(i); continue; } s_items[raw._id] = new Item(raw, json); } for (int i = Data.equipItems.Count - 1; i >= 0; i--) if (!s_items.ContainsKey(Data.equipItems[i])) Data.equipItems.RemoveAt(i); Loaded = true; Log("로드 — 아이템 " + s_items.Count + " · 장착 " + Data.equipItems.Count + " · " + path); return true; } public static void Save() { if (!Loaded) return; try { File.WriteAllText(SavePath(), JsonConvert.SerializeObject(Data)); } catch (System.Exception ex) { Debug.LogWarning("[WL-821] 세이브 쓰기 실패 — " + ex.Message); } } /// 세이브를 지우고 빈 상태로(UI 「세이브 초기화」). public static void ResetAll() { try { var p = SavePath(); if (File.Exists(p)) File.Delete(p); } catch { } Data = new WLEquipSaveData(); s_items.Clear(); RecentGains.Clear(); Loaded = true; Save(); LastMessage = "세이브 초기화"; Notify(); } static void Notify() { Save(); try { if (Changed != null) Changed(); } catch (System.Exception ex) { Debug.LogWarning("[WL-821] Changed 콜백 예외 — " + ex.Message); } WLEquipStats.RefreshPc(); } static void Log(string s) { var cfg = WLEquipSettings.Instance; if (cfg != null && cfg.verboseLog != 0) Debug.Log("[WL-821] " + s); } // ── 재화 ────────────────────────────────────────────────────────── public static long GetGold() { int c; if (WL.Island.WLIslandCoin.TryGet(out c)) return c; return Data != null ? Data.gold : 0; } static bool SpendGold(long amount) { if (amount <= 0) return true; int c; if (WL.Island.WLIslandCoin.TryGet(out c)) { if (c < amount) return false; return WL.Island.WLIslandCoin.Add(-(int)amount); } if (Data.gold < amount) return false; Data.gold -= amount; return true; } public static void AddGold(long amount) { if (amount <= 0) return; if (!WL.Island.WLIslandCoin.Add((int)amount)) Data.gold += amount; } // ── 열쇠(상자 재화 · PD 2026-10-09 · 가격은 보류) ───────────────── public static int GetKeys() { return Data != null ? Data.keys : 0; } public static void AddKeys(int n) { if (n <= 0 || !EnsureLoaded()) return; Data.keys += n; PushGain("열쇠 +" + n); } public static bool TrySpendKeys(int n) { if (n <= 0) return true; if (Data == null || Data.keys < n) return false; Data.keys -= n; return true; } // ── 조회 ────────────────────────────────────────────────────────── public static Item FindItem(int id) { Item it; return s_items.TryGetValue(id, out it) ? it : null; } public static Item FindItemAnyByIndex(int index) { foreach (var it in s_items.Values) if (it.itemJson.Index == index) return it; return null; } public static long GetItemCountByIndex(int index) { var cfg = WLEquipSettings.Instance; if (cfg != null && index == cfg.goldItemIndex) return GetGold(); long n = 0; foreach (var it in s_items.Values) if (it.itemJson.Index == index) n += it.rawItem.count; return n; } public static bool IsEquipped(int id) { return Data != null && Data.equipItems.Contains(id); } public static Item EquippedInSlot(eFREquipmentType slot) { if (Data == null) return null; for (int i = 0; i < Data.equipItems.Count; i++) { var it = FindItem(Data.equipItems[i]); if (it != null && it.itemJson.EquipmentType == slot) return it; } return null; } /// 목록(장착 → 장비(등급 높은 순) → 소모품). public static List SortedItems() { var list = new List(s_items.Values); list.Sort((a, b) => { bool ea = IsEquipped(a.rawItem._id), eb = IsEquipped(b.rawItem._id); if (ea != eb) return ea ? -1 : 1; bool qa = a.itemJson.IsEquipment, qb = b.itemJson.IsEquipment; if (qa != qb) return qa ? -1 : 1; if (qa) { int t = a.itemJson.EquipmentType.CompareTo(b.itemJson.EquipmentType); if (t != 0) return t; int g = b.itemJson.ItemGrade.CompareTo(a.itemJson.ItemGrade); if (g != 0) return g; int gl = b.itemJson.GradeLv.CompareTo(a.itemJson.GradeLv); if (gl != 0) return gl; int lv = b.rawItem.level.CompareTo(a.rawItem.level); if (lv != 0) return lv; } int ix = a.itemJson.Index.CompareTo(b.itemJson.Index); if (ix != 0) return ix; return a.rawItem._id.CompareTo(b.rawItem._id); }); return list; } public static int ItemCount { get { return s_items.Count; } } // ── 획득 (FR AddItem / AddItemInternal) ────────────────────────── /// 아이템 지급. 골드 인덱스는 코인으로. 비스택 장비는 count 개를 각각 1개짜리로 만든다. public static void AddItem(int itemIndex, int level, int count, List gained = null) { if (count <= 0) return; if (!EnsureLoaded()) return; var cfg = WLEquipSettings.Instance; if (cfg != null && itemIndex == cfg.goldItemIndex) { AddGold(count); PushGain("골드 +" + count); return; } var json = WLEquipTables.FindItemJson(itemIndex); if (json == null) { Log("AddItem — 표에 없는 Index " + itemIndex); return; } if (level <= 0) level = 1; if (json.IsStack == 1) { var exist = FindItemAnyByIndex(itemIndex); if (exist != null) { exist.rawItem.count += count; if (gained != null) gained.Add(exist); } else { var it = NewItem(json, level, count); if (gained != null) gained.Add(it); } PushGain(json.Item_name + " ×" + count); } else { for (int i = 0; i < count; i++) { var it = NewItem(json, level, 1); if (gained != null) gained.Add(it); PushGain(json.Item_name + " [" + WLEquipGrades.Label(json, false) + "]"); } } } static Item NewItem(ItemJson json, int level, int count) { var raw = new RawItem(json.Index, level, count, Data.nextId++); Data.items.Add(raw); var it = new Item(raw, json); s_items[raw._id] = it; return it; } static void PushGain(string s) { RecentGains.Insert(0, s); if (RecentGains.Count > 30) RecentGains.RemoveAt(RecentGains.Count - 1); } /// 여러 번 AddItem 한 뒤 한 번만 저장·통지. public static void Commit(string message) { LastMessage = message; Notify(); } static void UpdateItemCount(RawItem raw) { if (raw.count <= 0) RemoveItem(raw._id); } public static bool RemoveItem(int id) { Item it; if (!s_items.TryGetValue(id, out it)) return false; Data.equipItems.Remove(id); Data.items.Remove(it.rawItem); s_items.Remove(id); return true; } // ── 장착 (FR EquipItem / UnequipItem) ──────────────────────────── public static bool Equip(int id) { var item = FindItem(id); if (item == null) { LastMessage = "아이템 없음"; return false; } if (!item.itemJson.IsEquipment) { LastMessage = "장비가 아닙니다"; return false; } for (int i = 0; i < Data.equipItems.Count; i++) { var e = FindItem(Data.equipItems[i]); if (e != null && e.itemJson.EquipmentType == item.itemJson.EquipmentType) { if (e == item) { LastMessage = "이미 장착 중"; return false; } Data.equipItems.RemoveAt(i); break; } } Data.equipItems.Add(id); LastMessage = item.itemJson.Item_name + " 장착"; Notify(); return true; } public static bool Unequip(int id) { if (!Data.equipItems.Remove(id)) { LastMessage = "장착 중이 아닙니다"; return false; } var item = FindItem(id); LastMessage = (item != null ? item.itemJson.Item_name : id.ToString()) + " 해제"; Notify(); return true; } // ── 강화(레벨업 · FR ItemLevelUp) ───────────────────────────────── /// 다음 레벨 비용(골드 · 강화석) — 없으면 null. public static ItemLevelInfo NextLevelCost(Item item) { if (item == null || !item.itemJson.IsEquipment) return null; if (item.rawItem.level >= item.itemJson.ItemMaxLv) return null; var lt = WLEquipTables.FindItemLevelJson(item.itemJson.ItemLvAbilityID); if (lt == null) return null; return lt.FindLevelInfo(item.rawItem.level); } public static bool ItemLevelUp(int id, bool all) { var item = FindItem(id); if (item == null) { LastMessage = "아이템 없음"; return false; } if (!item.itemJson.IsEquipment) { LastMessage = "장비가 아닙니다"; return false; } if (item.rawItem.level >= item.itemJson.ItemMaxLv) { LastMessage = "최대 레벨"; return false; } var lt = WLEquipTables.FindItemLevelJson(item.itemJson.ItemLvAbilityID); if (lt == null) { LastMessage = "레벨표 없음(" + item.itemJson.ItemLvAbilityID + ")"; return false; } int start = item.rawItem.level; string stop = ""; for (int lv = start; lv < item.itemJson.ItemMaxLv; lv++) { var info = lt.FindLevelInfo(lv); var next = lt.FindLevelInfo(lv + 1); if (info == null || next == null) { stop = "표 끝"; break; } if (GetGold() < info.MoneyCountRequired) { stop = "골드 부족(" + info.MoneyCountRequired + ")"; break; } Item stone = null; if (info.ItemKeyRequired > 0 && info.ItemCountRequired > 0) { stone = FindItemAnyByIndex(info.ItemKeyRequired); if (stone == null || stone.rawItem.count < info.ItemCountRequired) { var sj = WLEquipTables.FindItemJson(info.ItemKeyRequired); stop = (sj != null ? sj.Item_name : "강화석") + " 부족(" + info.ItemCountRequired + ")"; break; } } if (!SpendGold(info.MoneyCountRequired)) { stop = "골드 차감 실패"; break; } if (stone != null) { stone.rawItem.count -= info.ItemCountRequired; UpdateItemCount(stone.rawItem); } item.rawItem.level = next.Level; if (!all) break; } if (item.rawItem.level == start) { LastMessage = "강화 불가 — " + stop; return false; } LastMessage = item.itemJson.Item_name + " Lv " + start + " → " + item.rawItem.level + (stop != "" ? " (" + stop + ")" : ""); Notify(); return true; } /// FR ReturnItemLevel — 레벨 1 초과분에 쓴 강화석을 돌려준다(골드는 돌려주지 않는다 · FR 동일 「추정」). static void RefundLevelMaterials(Item item) { if (item.rawItem.level <= 1) return; var lt = WLEquipTables.FindItemLevelJson(item.itemJson.ItemLvAbilityID); if (lt == null) return; for (int lv = 1; lv < item.rawItem.level; lv++) { var info = lt.FindLevelInfo(lv); if (info == null) continue; if (info.ItemKeyRequired > 0 && info.ItemCountRequired > 0) AddItem(info.ItemKeyRequired, 1, info.ItemCountRequired); } } // ── 합성(등급 업 · FR ComposePopup + NetPlayer.ComposeItem) ───────── public class ComposePlan { public Item source; public ItemComposeJson compose; public ItemJson result; public readonly List materials = new List(); } /// FR `IsMaterialItem`(장비 분기 그대로). public static bool IsMaterialItem(Item check, Item source, ItemComposeJson c) { if (check.itemJson.EquipmentType != source.itemJson.EquipmentType) return false; if (c.RequiredGradeLevel != check.itemJson.GradeLv) return false; if (c.SameGroupNo == 0) { return check.itemJson.ItemGrade == c.ItemGrade; } if (check.itemJson.ItemGrade != c.ItemGrade) return false; if (check.itemJson.ItemType == eFRItemType.EnchantScroll) return false; return check.itemJson.GroupNo == source.itemJson.GroupNo; } /// 본 아이템 하나에 대한 합성 계획(재료는 장착 중이 아닌 것만 · FR 과 같이 재료끼리는 같은 Index). public static ComposePlan PlanCompose(Item source, HashSet excludeIds = null) { if (source == null || !source.itemJson.IsEquipment) return null; var c = WLEquipTables.FindItemComposeJson(source.itemJson.ItemGrade, source.itemJson.GradeLv); if (c == null) return null; var result = WLEquipTables.FindResultItemJson(source.itemJson, c); if (result == null) return null; // 재료 후보를 Index 별로 묶는다(FR: 비스택 재료는 첫 재료와 같은 Index 로 RequiredItemCount 개) var byIndex = new Dictionary>(); var order = new List(); foreach (var it in s_items.Values) { if (it == source) continue; if (IsEquipped(it.rawItem._id)) continue; if (excludeIds != null && excludeIds.Contains(it.rawItem._id)) continue; if (!IsMaterialItem(it, source, c)) continue; List l; if (!byIndex.TryGetValue(it.itemJson.Index, out l)) { l = new List(); byIndex[it.itemJson.Index] = l; order.Add(it.itemJson.Index); } l.Add(it); } // 같은 그룹(같은 이름) 재료를 우선, 그다음 레벨 낮은 것부터 order.Sort((a, b) => { bool ga = a == source.itemJson.Index, gb = b == source.itemJson.Index; if (ga != gb) return ga ? -1 : 1; return a.CompareTo(b); }); for (int o = 0; o < order.Count; o++) { var l = byIndex[order[o]]; l.Sort((x, y) => x.rawItem.level.CompareTo(y.rawItem.level)); int have = 0; var plan = new ComposePlan { source = source, compose = c, result = result }; for (int i = 0; i < l.Count && have < c.RequiredItemCount; i++) { int n = l[i].itemJson.IsStack == 1 ? l[i].rawItem.count : 1; plan.materials.Add(l[i]); have += n; } if (have >= c.RequiredItemCount) return plan; } return null; } public static bool Compose(ComposePlan p) { if (p == null || p.source == null) { LastMessage = "합성 재료 부족"; return false; } int need = p.compose.RequiredItemCount; string names = ""; for (int i = 0; i < p.materials.Count && need > 0; i++) { var m = p.materials[i]; if (m.itemJson.IsStack == 1) { int take = Mathf.Min(need, m.rawItem.count); m.rawItem.count -= take; need -= take; UpdateItemCount(m.rawItem); } else { RefundLevelMaterials(m); m.rawItem.count = 0; need -= 1; UpdateItemCount(m.rawItem); } names += (names == "" ? "" : ", ") + m.itemJson.Item_name; } string before = WLEquipGrades.Label(p.source.itemJson, false); p.source.rawItem.index = p.result.Index; p.source.itemJson = p.result; if (p.source.rawItem.level > p.result.ItemMaxLv) p.source.rawItem.level = p.result.ItemMaxLv; string after = WLEquipGrades.Label(p.result, false); LastMessage = p.result.Item_name + " " + before + " → " + after + " (재료 " + names + ")"; return true; } /// 합성 화면용 — 본 아이템의 합성 행·결과(둘 다 없으면 false). public static bool ComposeInfo(Item source, out ItemComposeJson compose, out ItemJson result) { compose = null; result = null; if (source == null || !source.itemJson.IsEquipment) return false; compose = WLEquipTables.FindItemComposeJson(source.itemJson.ItemGrade, source.itemJson.GradeLv); if (compose == null) return false; result = WLEquipTables.FindResultItemJson(source.itemJson, compose); return result != null; } /// 합성 화면용 — 본 아이템에 쓸 수 있는 재료 후보(장착 중 제외 · 본 아이템 제외). 같은 그룹 → 레벨 낮은 순. public static List MaterialCandidates(Item source) { var list = new List(); ItemComposeJson c; ItemJson r; if (!ComposeInfo(source, out c, out r)) return list; foreach (var it in s_items.Values) { if (it == source || IsEquipped(it.rawItem._id)) continue; if (IsMaterialItem(it, source, c)) list.Add(it); } list.Sort((a, b) => { bool ga = a.itemJson.Index == source.itemJson.Index, gb = b.itemJson.Index == source.itemJson.Index; if (ga != gb) return ga ? -1 : 1; int ix = a.itemJson.Index.CompareTo(b.itemJson.Index); if (ix != 0) return ix; int lv = a.rawItem.level.CompareTo(b.rawItem.level); if (lv != 0) return lv; return a.rawItem._id.CompareTo(b.rawItem._id); }); return list; } /// 재료 목록이 채우는 개수(스택은 count · 비스택은 1개씩). public static int MaterialCount(List mats) { int n = 0; if (mats == null) return 0; for (int i = 0; i < mats.Count; i++) n += mats[i].itemJson.IsStack == 1 ? mats[i].rawItem.count : 1; return n; } /// 합성 화면(FR ComposePopup 흐름) — 사용자가 고른 재료로 합성. FR 재료 규칙(같은 Index 끼리)은 PlanCompose 의 자동 선택에만 두고 여기서는 IsMaterialItem 만 본다. public static bool ComposeWith(int sourceId, List materialIds) { var src = FindItem(sourceId); ItemComposeJson c; ItemJson r; if (!ComposeInfo(src, out c, out r)) { LastMessage = "합성 불가 — 최고 등급이거나 합성표에 없음"; return false; } var plan = new ComposePlan { source = src, compose = c, result = r }; if (materialIds != null) for (int i = 0; i < materialIds.Count; i++) { var m = FindItem(materialIds[i]); if (m == null || m == src || IsEquipped(m.rawItem._id) || !IsMaterialItem(m, src, c)) { LastMessage = "재료가 아닙니다"; return false; } if (!plan.materials.Contains(m)) plan.materials.Add(m); } int have = MaterialCount(plan.materials); if (have < c.RequiredItemCount) { LastMessage = "재료 부족 — " + have + "/" + c.RequiredItemCount; return false; } bool ok = Compose(plan); if (ok) Notify(); return ok; } public static bool ComposeOne(int sourceId) { var src = FindItem(sourceId); var plan = PlanCompose(src); if (plan == null) { LastMessage = "합성 불가 — 같은 등급·부위 재료 " + (src != null ? ReqCountText(src) : "?") + "개 필요"; return false; } bool ok = Compose(plan); if (ok) Notify(); return ok; } static string ReqCountText(Item src) { var c = WLEquipTables.FindItemComposeJson(src.itemJson.ItemGrade, src.itemJson.GradeLv); return c != null ? c.RequiredItemCount.ToString() : "-"; } /// 전부 합성(FR 「모두 합성」) — 장착 중인 것부터 본 아이템으로, 더 안 될 때까지 반복. public static int ComposeAll() { int done = 0; for (int guard = 0; guard < 500; guard++) { ComposePlan plan = null; foreach (var it in SortedItems()) { if (!it.itemJson.IsEquipment) continue; plan = PlanCompose(it); if (plan != null) break; } if (plan == null) break; if (!Compose(plan)) break; done++; } LastMessage = done > 0 ? "전부 합성 — " + done + "회" : "합성할 조합 없음"; if (done > 0) Notify(); return done; } // ── 강등(FR DowngradeItem · 한 단계) ─────────────────────────────── public static bool Downgrade(int id) { var item = FindItem(id); if (item == null || !item.itemJson.IsEquipment) { LastMessage = "장비가 아닙니다"; return false; } var c = WLEquipTables.FindComposeByResult(item.itemJson.ItemGrade, item.itemJson.GradeLv); if (c == null) { LastMessage = "최하 등급 — 강등 불가"; return false; } var result = WLEquipTables.FindGroupItem(item.itemJson.GroupNo, c.ItemGrade, c.RequiredGradeLevel); if (result == null) { LastMessage = "강등 결과 아이템 없음"; return false; } string before = WLEquipGrades.Label(item.itemJson, false); item.rawItem.index = result.Index; item.itemJson = result; if (item.rawItem.level > result.ItemMaxLv) item.rawItem.level = result.ItemMaxLv; // 재료 반환 — 같은 그룹의 재료 등급 아이템 RequiredItemCount 개(FR UIUtil 「현재 등급까지 지급」의 한 단계 판) AddItem(result.Index, 1, c.RequiredItemCount); LastMessage = result.Item_name + " " + before + " → " + WLEquipGrades.Label(result, false) + " (재료 " + c.RequiredItemCount + "개 반환)"; Notify(); return true; } // ── 설명 ─────────────────────────────────────────────────────────── public static string Describe(Item it) { if (it == null) return ""; var j = it.itemJson; var sb = new System.Text.StringBuilder(); sb.Append(j.Item_name).Append(" [").Append(WLEquipGrades.Label(j, true)).Append("] "); if (j.IsEquipment) { sb.Append(WLEquipTables.SlotName(j.EquipmentType)).Append(" Lv ").Append(it.rawItem.level).Append('/').Append(j.ItemMaxLv); if (IsEquipped(it.rawItem._id)) sb.Append(" (장착 중)"); sb.Append('\n'); var cost = NextLevelCost(it); if (cost != null) { var sj = WLEquipTables.FindItemJson(cost.ItemKeyRequired); sb.Append("\n다음 강화: 골드 ").Append(cost.MoneyCountRequired); if (cost.ItemKeyRequired > 0) sb.Append(" + ").Append(sj != null ? sj.Item_name : "강화석").Append(' ').Append(cost.ItemCountRequired) .Append(" (보유 ").Append(GetItemCountByIndex(cost.ItemKeyRequired)).Append(')'); } else sb.Append("\n최대 레벨"); var comp = WLEquipTables.FindItemComposeJson(j.ItemGrade, j.GradeLv); if (comp != null) { var res = WLEquipTables.FindResultItemJson(j, comp); sb.Append("\n합성: 같은 ").Append(comp.SameGroupNo == 1 ? "아이템" : "등급·부위").Append(" ").Append(WLEquipGrades.LabelOf(comp.ItemGrade, comp.RequiredGradeLevel, true)) .Append(" 재료 ").Append(comp.RequiredItemCount).Append("개 → ") .Append(res != null ? WLEquipGrades.Label(res, true) : "?"); } else sb.Append("\n합성: 최고 등급"); // 등급 7단계 옵션(PD 2026-10-09) — 기본 수치 + 주스탯 × 등급 % · 상한 없음 string formula = WLEquipStats.FormulaText(it); if (!string.IsNullOrEmpty(formula)) sb.Append('\n').Append(formula); var cfgL = WLEquipSettings.Instance; if (cfgL != null && cfgL.applyLegacyFrOptions != 0) { sb.Append("\n구 FR 옵션(등급별 개방):"); var oj = WLEquipTables.FindItemOptionJson(j.GroupNo); if (oj == null) sb.Append(" 없음"); else { for (int i = 0; i < oj.ItemOptions.Count; i++) { var e = oj.ItemOptions[i]; bool open = e.ItemGrade <= j.ItemGrade; sb.Append("\n ").Append(open ? "● " : "○ ").Append(WLEquipTables.GradeName(e.ItemGrade)).Append(": "); for (int a = 0; a < e.Abilities.Count; a++) { var ab = e.Abilities[a]; sb.Append(WLEquipTables.AbilityName(ab.Ability)).Append(' ').Append(ab.AbilityValue); sb.Append(WLEquipStats.IsMapped(ab.Ability) ? "" : "(미적용)"); if (a < e.Abilities.Count - 1) sb.Append(", "); } if (e.ConditionalAbilities.Count > 0) sb.Append(e.Abilities.Count > 0 ? ", " : "").Append("조건부 ").Append(e.ConditionalAbilities.Count).Append("(미적용)"); } } } } else { sb.Append("×").Append(it.rawItem.count); if (!string.IsNullOrEmpty(j.Desc)) sb.Append('\n').Append(j.Desc); } return sb.ToString(); } public static string RowText(Item it) { var j = it.itemJson; string g = "[" + WLEquipGrades.Label(j, true) + "]"; if (j.IsEquipment) return (IsEquipped(it.rawItem._id) ? "E " : " ") + WLEquipTables.SlotName(j.EquipmentType) + " " + j.Item_name + " " + g + " Lv" + it.rawItem.level; return " " + j.Item_name + " ×" + it.rawItem.count; } } }