diff --git a/Client/KeyBindSettings.cs b/Client/KeyBindSettings.cs index b5a11e9ba..a57830245 100644 --- a/Client/KeyBindSettings.cs +++ b/Client/KeyBindSettings.cs @@ -100,7 +100,8 @@ public enum KeybindOptions : int HeroEquipment, HeroSkills, TargetSpellLockOn, - PetmodeFocusMasterTarget + PetmodeFocusMasterTarget, + Codex } public class KeyBind @@ -238,6 +239,8 @@ public void New(List list) list.Add(InputKey); InputKey = new KeyBind { Group = "Dialogs", Description = GameLanguage.ClientTextMap.GetLocalization(ClientTextKeys.CloseAllWindows), function = KeybindOptions.Closeall, RequireAlt = 2, RequireShift = 2, RequireTilde = 2, RequireCtrl = 2, Key = Keys.Escape }; list.Add(InputKey); + InputKey = new KeyBind { Group = "Dialogs", Description = GameLanguage.ClientTextMap.GetLocalization(ClientTextKeys.CodexOpenClose), function = KeybindOptions.Codex, RequireAlt = 1, RequireShift = 2, RequireTilde = 2, RequireCtrl = 2, Key = Keys.A }; + list.Add(InputKey); InputKey = new KeyBind { Group = "Skillbar", Description = GameLanguage.ClientTextMap.GetLocalization(ClientTextKeys.SkillbarSlot)+" 1", function = KeybindOptions.Bar1Skill1, RequireAlt = 2, RequireShift = 0, RequireTilde = 0, RequireCtrl = 0, Key = Keys.F1 }; list.Add(InputKey); diff --git a/Client/Localization/Chinese.json b/Client/Localization/Chinese.json index 15c1b7cfe..1606c6594 100644 --- a/Client/Localization/Chinese.json +++ b/Client/Localization/Chinese.json @@ -1203,6 +1203,68 @@ "HeroBehaviour_Attack": "攻击", "HeroBehaviour_CounterAttack": "反击", "HeroBehaviour_Follow": "跟随", - "HeroBehaviour_Custom": "自动" + "HeroBehaviour_Custom": "自动", + + "Codex_NothingToSubmit": "[图鉴] 暂无可提交或领取的内容。", + "Codex_RewardClaimed": "[图鉴] 奖励已领取。", + "Codex_CollectionCompleted": "[图鉴] 收集完成,奖励已生效。", + "Codex_NoEligibleItemOrCurrency": "[图鉴] 没有符合条件的物品或货币。", + "Codex_InventoryNotAvailable": "[图鉴] 背包不可用。", + "Codex_MissingItemStage": "[图鉴] 你没有 1× {0}。", + "Codex_SubmittingItem": "[图鉴] 正在提交 1× {0}...", + "Codex_LevelUp": "[图鉴] 收集等级提升!Lv.{0}", + "Codex_LevelUpShort": "[图鉴] 收集等级提升!", + "Codex_NoStone": "[图鉴] 你没有石头。", + "Codex_NoJade": "[图鉴] 你没有玉石。", + "Codex_CurrencyStoneHint": "石头\\n• 可替代 {0} 组。\\n• 使用背包中的石头计入图鉴。\\n• 获得时优先消耗石头。", + "Codex_CurrencyJadeHint": "玉石\\n• 可替代 {0} 组。\\n• 使用背包中的玉石计入图鉴。\\n• 获得时优先消耗玉石。", + "Codex_YouGainedStone": "你获得了 {0} 个石头。", + "Codex_YouGainedJade": "你获得了 {0} 个玉石。", + "Codex_RegisteredItem": "[图鉴] 已登记 {0}。", + "Codex_ShowAllButton": "显示全部", + "Codex_ShowAllHint": "显示所有收集", + "Codex_ShowRarityHint": "仅显示 {0}", + "Codex_UntitledCollection": "未命名收集", + "Codex_ExpLabel": "图鉴经验:{0}", + "Codex_ActiveUntil": "有效期至 {0}", + "Codex_StartsOn": "开始于 {0}", + "Codex_Expired": "已过期", + "Codex_ExpiredKeep": "已过期(保留属性)", + "Codex_NotStarted": "[图鉴] 该收集尚未开始。", + "Codex_ExpiredWindow": "[图鉴] 该收集已过期。", + "Codex_SetCompletedMessage": "收集完成:{0}", + "Codex_SetCompleteEffect": "收集完成!", + "Codex_SubmitConfirm": "提交 1× {0} 到此收集?\\n\\n该物品将被消耗。", + "Codex_LevelLabel": "收集等级:{0}", + "Codex_LevelHint": "收集等级 {0}", + "Codex_ClaimedSetBonuses": "已领取的套装加成:", + "Codex_StoneLabel": "石头", + "Codex_JadeLabel": "玉石", + + "Codex_Disabled": "[图鉴] 图鉴已关闭。", + "Codex_UnknownCollection": "[图鉴] 未知的收集。", + "Codex_DisabledCollection": "[图鉴] 此收集已禁用。", + "Codex_AlreadyClaimed": "[图鉴] 已领取。", + "Codex_NotComplete": "[图鉴] 未完成。", + "Codex_GainedXP": "[图鉴] +{0} 图鉴经验。", + "Codex_Claimed": "[图鉴] 已领取:{0},奖励已生效。", + "Codex_InvalidSubmission": "[图鉴] 提交无效。", + "Codex_ItemNotPart": "[图鉴] 物品不属于此收集。", + "Codex_ItemNotFound": "[图鉴] 背包中未找到该物品。", + "Codex_WrongItem": "[图鉴] 此收集需要其他物品。", + "Codex_UnableConsume": "[图鉴] 无法消耗物品。", + "Codex_Submitted": "[图鉴] 已提交:{0}。", + "Codex_InvalidCurrency": "[图鉴] 无效的货币。", + "Codex_InvalidSetId": "[图鉴] 无效的收集编号。", + "Codex_InvalidCurrencyForSet": "[图鉴] 此货币不能用于该收集。", + "Codex_NothingRequired": "[图鉴] 该收集无需物品。", + "Codex_SetComplete": "[图鉴] 收集已完成。", + "Codex_UsedCurrency": "[图鉴] 消耗 1× {0} 注册 {1}{2}。", + "Codex_Exported": "[图鉴] 已导出至 Envir/ItemCodex.json", + "Codex_LoadedAutoBuilt": "[图鉴] 读取自动生成的收集(文件缺失或无效)。", + "Codex_LoadedFromFile": "[图鉴] 已从 ItemCodex.json 读取收集。", + + "CodexKey": "图鉴 ({0})", + "CodexOpenClose": "图鉴 开/关" } } \ No newline at end of file diff --git a/Client/Localization/English.json b/Client/Localization/English.json index b86403d50..331ec2c98 100644 --- a/Client/Localization/English.json +++ b/Client/Localization/English.json @@ -1206,6 +1206,68 @@ "HeroBehaviour_Attack": "Attack", "HeroBehaviour_CounterAttack": "CounterAttack", "HeroBehaviour_Follow": "Follow", - "HeroBehaviour_Custom": "Custom" + "HeroBehaviour_Custom": "Custom", + + "Codex_NothingToSubmit": "[Codex] Nothing to submit or claim.", + "Codex_RewardClaimed": "[Codex] Reward claimed.", + "Codex_CollectionCompleted": "[Codex] Collection completed – reward applied.", + "Codex_NoEligibleItemOrCurrency": "[Codex] You don't have an eligible item or valid currency for this set.", + "Codex_InventoryNotAvailable": "[Codex] Inventory not available.", + "Codex_MissingItemStage": "[Codex] You do not have 1× {0}.", + "Codex_SubmittingItem": "[Codex] Submitting 1× {0}...", + "Codex_LevelUp": "[Codex] Collection Level Up! Lv.{0}", + "Codex_LevelUpShort": "[Codex] Collection Level Up!", + "Codex_NoStone": "[Codex] You do not have a Stone.", + "Codex_NoJade": "[Codex] You do not have a Jade.", + "Codex_CurrencyStoneHint": "Stone\\n• Substitutes for {0} sets.\\n• Using Stones from your bag adds to Codex counts.\\n• When obtaining, Stones are consumed first.", + "Codex_CurrencyJadeHint": "Jade\\n• Substitutes for {0} sets.\\n• Using Jade from your bag adds to Codex counts.\\n• When obtaining, Jade is consumed first.", + "Codex_YouGainedStone": "You gained {0} Stone.", + "Codex_YouGainedJade": "You gained {0} Jade.", + "Codex_RegisteredItem": "[Codex] Registered {0}.", + "Codex_ShowAllButton": "Show All", + "Codex_ShowAllHint": "Show all collections", + "Codex_ShowRarityHint": "Show only {0}", + "Codex_UntitledCollection": "Untitled Collection", + "Codex_ExpLabel": "Codex EXP : {0}", + "Codex_ActiveUntil": "Active until {0}", + "Codex_StartsOn": "Starts on {0}", + "Codex_Expired": "Expired", + "Codex_ExpiredKeep": "Expired (stats kept)", + "Codex_NotStarted": "[Codex] This collection has not started yet.", + "Codex_ExpiredWindow": "[Codex] This collection has expired.", + "Codex_SetCompletedMessage": "Collection set completed: {0}", + "Codex_SetCompleteEffect": "Collection set completed!", + "Codex_SubmitConfirm": "Submit 1× {0} to this collection?\\n\\nThis will consume the item.", + "Codex_LevelLabel": "Collection Level: {0}", + "Codex_LevelHint": "Collection Level {0}", + "Codex_ClaimedSetBonuses": "Claimed Set Bonuses:", + "Codex_StoneLabel": "Stone", + "Codex_JadeLabel": "Jade", + + "Codex_Disabled": "[Codex] The codex is currently disabled.", + "Codex_UnknownCollection": "[Codex] Unknown collection.", + "Codex_DisabledCollection": "[Codex] This collection is currently disabled.", + "Codex_AlreadyClaimed": "[Codex] Already claimed.", + "Codex_NotComplete": "[Codex] Not complete.", + "Codex_GainedXP": "[Codex] +{0} Codex EXP.", + "Codex_Claimed": "[Codex] Claimed: {0}. Reward applied.", + "Codex_InvalidSubmission": "[Codex] Invalid submission.", + "Codex_ItemNotPart": "[Codex] Item not part of this collection.", + "Codex_ItemNotFound": "[Codex] Item not found in your bag.", + "Codex_WrongItem": "[Codex] Wrong item for this collection.", + "Codex_UnableConsume": "[Codex] Unable to consume item.", + "Codex_Submitted": "[Codex] Submitted: {0}.", + "Codex_InvalidCurrency": "[Codex] Invalid currency.", + "Codex_InvalidSetId": "[Codex] Invalid set id.", + "Codex_InvalidCurrencyForSet": "[Codex] This currency can't be used on this set.", + "Codex_NothingRequired": "[Codex] Nothing required for this set.", + "Codex_SetComplete": "[Codex] Set already complete.", + "Codex_UsedCurrency": "[Codex] Used 1× {0} to register {1}{2}.", + "Codex_LoadedAutoBuilt": "[Codex] Loaded auto-built collections (no/invalid file).", + "Codex_Exported": "[Codex] Exported to Envir/ItemCodex.json", + "Codex_LoadedFromFile": "[Codex] Loaded collections from ItemCodex.json.", + + "CodexKey": "Codex ({0})", + "CodexOpenClose": "Codex Open/Close" } } \ No newline at end of file diff --git a/Client/MirGraphics/MLibrary.cs b/Client/MirGraphics/MLibrary.cs index 0ef71c288..62c160c38 100644 --- a/Client/MirGraphics/MLibrary.cs +++ b/Client/MirGraphics/MLibrary.cs @@ -30,7 +30,10 @@ public static readonly MLibrary Effect = new MLibrary(Settings.DataPath + "Effect"), MagicC = new MLibrary(Settings.DataPath + "MagicC"), GuildSkill = new MLibrary(Settings.DataPath + "GuildSkill"), - Weather = new MLibrary(Settings.DataPath + "Weather"); + Weather = new MLibrary(Settings.DataPath + "Weather"), + Title_32bit = new MLibrary(Settings.DataPath + "Title_32bit"), + UI_32bit = new MLibrary(Settings.DataPath + "UI_32bit"), + Effect_32bit = new MLibrary(Settings.DataPath + "Effect_32bit"); public static readonly MLibrary Background = new MLibrary(Settings.DataPath + "Background"); @@ -228,6 +231,12 @@ static void LoadLibraries() Title.Initialize(); Progress++; + + Title_32bit.Initialize(); + Progress++; + + UI_32bit.Initialize(); + Progress++; } private static void LoadGameLibraries() diff --git a/Client/MirObjects/UserObject.cs b/Client/MirObjects/UserObject.cs index dcddd932e..1571cbdac 100644 --- a/Client/MirObjects/UserObject.cs +++ b/Client/MirObjects/UserObject.cs @@ -147,6 +147,8 @@ public void RefreshStats() RefreshSkills(); RefreshBuffs(); RefreshGuildBuffs(); + RefreshItemCodexStats(); + RefreshItemCodexLevelStats(); SetLibraries(); SetEffects(); @@ -693,6 +695,44 @@ public void RefreshStatCaps() Stats[Stat.MinSC] = Math.Min(Stats[Stat.MinSC], Stats[Stat.MaxSC]); } + public void RefreshItemCodexStats() + { + var claimed = CodexDialog.ClaimedSetIds; // “completed set ids” per latest sync + var rewardBySet = CodexDialog.RewardBySet; + + if (claimed == null || claimed.Count == 0 || rewardBySet == null) return; + + foreach (var setId in claimed) + { + if (!rewardBySet.TryGetValue(setId, out var reward) || reward == null) continue; + + foreach (Stat s in Enum.GetValues(typeof(Stat))) + { + int v = 0; + try { v = reward[s]; } catch { v = 0; } + if (v == 0) continue; + + try { Stats[s] = Stats[s] + v; } catch { /* caps/write-protected */ } + } + } + } + + public void RefreshItemCodexLevelStats() + { + // Pull the current Collection Level bonus from CodexDialog + var levelBag = CodexDialog.GetCollectionLevelBonusStats(); + if (levelBag == null || levelBag.Values == null || levelBag.Values.Count == 0) return; + + foreach (Stat s in Enum.GetValues(typeof(Stat))) + { + int v = 0; + try { v = levelBag[s]; } catch { v = 0; } + if (v == 0) continue; + + try { Stats[s] = Stats[s] + v; } catch { /* cap / read-only */ } + } + } + public void BindAllItems() { for (int i = 0; i < Inventory.Length; i++) diff --git a/Client/MirScenes/Dialogs/CodexDialog.cs b/Client/MirScenes/Dialogs/CodexDialog.cs new file mode 100644 index 000000000..5f2ec2a4e --- /dev/null +++ b/Client/MirScenes/Dialogs/CodexDialog.cs @@ -0,0 +1,4054 @@ +using Client.MirControls; +using Client.MirGraphics; +using Client.MirNetwork; +using Client.MirSounds; +using System.Globalization; +using System.Text.RegularExpressions; +using Shared; +using C = ClientPackets; +using S = ServerPackets; + +namespace Client.MirScenes.Dialogs +{ + public class CodexDialog : MirImageControl + { + public static CodexDialog Instance; + + private static readonly Regex RewardTextTokenRx = new Regex( + @"^(?[\p{L}\p{N}\s]+?)\s*(?:[:=])?\s*(?[+\-]?)\s*(?\d+(?:\.\d+)?)(?%?)\s*(?:~\s*(?\d+(?:\.\d+)?)(?%?))?\s*$", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + public static Func IconResolver; + + private const int BG_INDEX = 310; + private const int ROW_BG_INDEX = 330; + private const int CELL_FRAME_IDX = 331; + private const int COLL_EMBLEM_IDX = 326; + + private const int RIGHT_ICON_BOOK = 310; + private const int RIGHT_ICON_CHARACTER = 311; + private const int RIGHT_ICON_LIMITED = 313; + private const int RIGHT_ICON_EVENT = 314; + + private const int BAR_BASE = 315; + private const int BAR_GREEN = 316; + private const int BAR_BLUE = 317; + + private const int XP_BAR_BACK = 318; + private const int XP_BAR_FILL = 319; + + private const int DD_PANEL_IDX = 323; + private const int DD_BAR_NORMAL_IDX = 324; + private const int DD_BAR_HOVER_IDX = 325; + private const int DD_VISIBLE_ROWS = 9; + + private static readonly Point DD_HDR_POS = new Point(102, 112); + private static readonly Point DD_PANEL_POS = new Point(100, 126); + private static readonly Size DD_PANEL_SIZE = new Size(180, 250); + private const int DD_ITEM_H = 16; + private const int DD_ITEM_X_PAD = 8; + private const int DD_ITEM_Y_PAD = 8; + + private static string L(string key, params object[] args) + { + if (!GameLanguage.ClientTextMap.Text.TryGetValue(key, out var value)) value = key; + return (args != null && args.Length > 0) ? string.Format(value, args) : value; + } + + private string _filterStatKey = null; + private readonly List _ddKeys = new List(); + + private const int DIALOG_W = 820; + private const int DIALOG_H = 520; + + private const int TOP_BAR_H = 75; + private const int LEFT_NAV_W = 120; + private const int LEFT_NAV_TOP_PAD = 75; + + private const int ROW_H = 64; + private const int ROW_GAP = 1; + private static int RowStep => ROW_H + ROW_GAP; + + private const int VISIBLE_ROWS = 5; + private const int LIST_X = 100; + private const int LIST_Y = 134; + private const int VIEWPORT_WIDTH = 420; + private const int VIEWPORT_HEIGHT = (ROW_H * VISIBLE_ROWS) + (ROW_GAP * (VISIBLE_ROWS - 1)) + 12; + + private const int EMBLEM_READY_IDX = 320; + private const int EMBLEM_DONE_IDX = 322; + + private static readonly Point SCROLL_UP_POS = new Point(551, 138); + private static readonly Size SCROLL_BTN_SIZE = new Size(18, 18); + private static readonly Point SCROLL_DOWN_POS = new Point(551, 532); + private static readonly Point SCROLL_THUMB_POS = new Point(551, 154); + private static readonly Size SCROLL_THUMB_SIZE = new Size(16, 30); + + private static class RightUI + { + public static Rectangle Panel = new Rectangle(567, TOP_BAR_H + 60, 155, DIALOG_H - (TOP_BAR_H + 30) - 4); + + public static Point Header = new Point(29, 30); + public static Point Prev = new Point(3, 30); + public static Point Next = new Point(131, 30); + + public static int RowStartY = 80; + public static int RowStepY = 62; + + public static Point Icon0 = new Point(3, 3 + RowStartY); + public static Point Bar0 = new Point(5, 41 + RowStartY); + public static Point Text0 = new Point(43, 25 + RowStartY); + + public static Rectangle RewardsRect = new Rectangle(0, 79, 135, 332); + public static Point RewardsUp = new Point(138, 81); + public static Point RewardsDown = new Point(138, 388); + public static Point RewardsThumb = new Point(138, 75); + } + + private const int ROW_TITLE_Y = 3; + private const int ROW_REWARD_Y = 18; + private const int ROW_LINE_STEP = 14; + + private const int XP_BAR_X = 104; + private const int XP_BAR_Y = 51; + private const int XP_BAR_H = 14; + + private MirControl _leftNav; + private MirControl _listViewport; + private MirControl _maskTop, _maskBottom; + + private MirButton _scrollUp, _scrollDown; + private MirImageControl _scrollThumb; + + private MirImageControl _claimedSetsEmblem; + + private MirTextBox _searchBox; + private MirButton _searchButton, _refreshSearchButton; + private MirControl _searchGate; + private bool _searchActivated; + + private bool _gatePressed; + private Point _gateDownPt; + private const int GateDragSqr = 9; + + private MirButton _ddHeader; + private MirImageControl _ddPanel; + private MirControl _ddViewport; + private MirButton _ddUp, _ddDown; + private MirImageControl _ddThumb; + //private readonly List _ddItems = new List(); + private readonly List _ddOptions = new List{ + "All","HP","Attack","Defense","Crit Rate","Crit Damage","Max HP","Max MP","HP Regen","MP Regen" + }; + private int _ddSelectedIndex = 0; + private int _ddScrollPx = 0; + private bool _ddOpen = false; + private MirControl _ddOverflowMaskBottom; + private MirControl _ddOverflowMaskTop; + + private MirButton _tabChar, _tabLim, _tabEvt; + private byte _sectionBucket = 0; + + private MirButton _btnAll; + private readonly Dictionary _btnByRarity = new Dictionary(); + private ItemGrade? _filterRarity = null; + + private const int NAV_BTN_W = 82; + private const int NAV_BTN_H = 24; + private const int NAV_BTN_X = 1; + private const int NAV_BTN_Y0 = 8; + private const int NAV_BTN_STEP = 24; + + private const int TAB_X0 = 7; + private const int TAB_Y = 79; + private const int TAB_STEP = 72; + + private MirControl _rightPanel; + + private readonly List _rightIcons = new List(); + private readonly List _rightBars = new List(); + private readonly List _rightLabels = new List(); + + private MirLabel _rightHeader; + private MirButton _rightPrev, _rightNext; + + private MirLabel _levelLabel; + private MirImageControl _xpBarFill; + private MirLabel _xpText; + + private readonly int[] _barFound = new int[4]; + private readonly int[] _barNeed = new int[4]; + + private enum RightPage { Progress = 0, Rewards = 1 } + private RightPage _rightPage = RightPage.Progress; + + private MirControl _statsViewport; + private MirButton _statsScrollUp, _statsScrollDown; + private MirImageControl _statsScrollThumb; + private int _statsScrollOffsetPx; + private const int StatsLineH = 18; + private readonly List _statsLines = new List(); + + private MirControl _rewardsViewport; + private MirButton _rewardsScrollUp, _rewardsScrollDown; + private MirImageControl _rewardsScrollThumb; + private int _rewardsScrollOffsetPx; + private readonly List _rewardsLines = new List(); + + private const int VisibleLineCount = 18; + private readonly List _rewardsData = new List(); + private int _firstVisibleLine = 0; + + private MirControl _rewardsMaskTop, _rewardsMaskBottom; + + private readonly List _rows = new List(); + private List _viewRows = new List(); + private string _searchQuery = string.Empty; + + private int _selectedIndex = -1; + private int _scrollOffsetPx = 0; + + public static readonly Dictionary RewardBySet = new Dictionary(); + public static readonly HashSet ClaimedSetIds = new HashSet(); + public static event Action CodexChanged; + + private static int _suppressTooltipUntilMs; + private static bool TooltipSuppressed => Environment.TickCount < _suppressTooltipUntilMs; + private static void SuppressTooltips(int ms = 200) => _suppressTooltipUntilMs = Environment.TickCount + ms; + + private static readonly Rectangle LEVEL_HINT_RECT = new Rectangle(35, 30, 55, 45); + private MirControl _levelHintHotspot; + + private int _lastSetCompleteFxAt = 0; + private MirControl _levelFxHost; + + private bool _setFxPlaying = false; + private bool _pendingLevelUpFx = false; + private int _lastLevelSeen = 0; + + private readonly List _ddItemRows = new List(); + + private MirImageControl _stoneIcon, _jadeIcon; + private MirLabel _stoneCountLbl, _jadeCountLbl; + private int _stoneCount, _jadeCount; + + private static readonly Point CURRENCY_STONE_POS = new Point(375, 102); + private static readonly Point CURRENCY_JADE_POS = new Point(450, 102); + + private const int ICON_STONE_IDX = 332; + private const int ICON_JADE_IDX = 333; + + private const ItemGrade STONE_SUBS_FOR = ItemGrade.Rare; + private const ItemGrade JADE_SUBS_FOR = ItemGrade.Legendary; + + private static bool _allowCrossGradeCurrency = true; + private static int _itemInfoStone = 0; + private static int _itemInfoJade = 0; + + private const int STONE_INFO_ID = 0; + private const int JADE_INFO_ID = 0; + + private long _lastInvSig = -1; + + public sealed class RowVM + { + public int SetId; + public string Title; + public short Found, Required; + public bool Claimed; + public bool Active; + public bool KeepStats; + public DateTime? StartTime; + public DateTime? EndTime; + + public readonly List ReqItemIndices = new List(); + public readonly List ReqStages = new List(); + public readonly List ReqItemIcons = new List(); + public readonly List ReqRegistered = new List(); + + public Stats Reward = new Stats(); + public string RewardText; + + public byte Bucket = 0; + + public int RewardXP; + public ItemGrade Rarity; + } + + private static Color RarityColor(ItemGrade r) + { + switch (r) + { + case ItemGrade.Common: return Color.WhiteSmoke; + case ItemGrade.Rare: return Color.FromArgb(90, 170, 255); + case ItemGrade.Legendary: return Color.FromArgb(255, 156, 0); + case ItemGrade.Mythical: return Color.FromArgb(200, 120, 255); + case ItemGrade.Heroic: return Color.FromArgb(255, 85, 120); + case ItemGrade.None: + default: return Color.White; + } + } + + private static string PrettyStatName(Stat s) + { + switch (s) + { + case Stat.MaxDC: return "Max DC"; + case Stat.MinDC: return "Min DC"; + case Stat.Accuracy: return "Accuracy"; + case Stat.HP: return "HP"; + case Stat.Luck: return "Luck"; + case Stat.Strength: return "Strength"; + case Stat.Intelligence: return "Intelligence"; + case Stat.AttackBonus: return "Power"; + case Stat.MinDamage: return "Damage Min"; + case Stat.MaxDamage: return "Damage Max"; + default: return s.ToString(); + } + } + + private IEnumerable BuildStatLines(RowVM data) + { + var vals = data?.Reward?.Values; + if (vals != null && vals.Count > 0) + { + var d = new Dictionary(vals); + + NormalizeFlatGroupStatsToMax(d); + + var lines = new List(); + + void EmitPair(Stat minStat, Stat maxStat, string label) + { + bool hasMin = d.TryGetValue(minStat, out int minVal); + bool hasMax = d.TryGetValue(maxStat, out int maxVal); + + if (hasMin && hasMax) + { + lines.Add($"{label} {minVal} ~ {maxVal}"); + d.Remove(minStat); + d.Remove(maxStat); + } + } + + EmitPair(Stat.MinAC, Stat.MaxAC, "AC"); + EmitPair(Stat.MinMAC, Stat.MaxMAC, "MAC"); + EmitPair(Stat.MinDC, Stat.MaxDC, "DC"); + EmitPair(Stat.MinMC, Stat.MaxMC, "MC"); + EmitPair(Stat.MinSC, Stat.MaxSC, "SC"); + EmitPair(Stat.MinDamage, Stat.MaxDamage, "Damage"); + + foreach (var kv in d) + { + string key = kv.Key.ToString(); + if (key.StartsWith("Min", StringComparison.OrdinalIgnoreCase) || + key.StartsWith("Max", StringComparison.OrdinalIgnoreCase)) + { + lines.Add($"{key} +{kv.Value}"); + } + else + { + lines.Add($"{PrettyStatName(kv.Key)} +{kv.Value}"); + } + } + + return lines; + } + + var list = new List(); + var t = data?.RewardText; + if (!string.IsNullOrWhiteSpace(t)) + { + foreach (var token in t.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries)) + { + var s = token.Trim(); + if (s.Length == 0) continue; + + if (Regex.IsMatch(s, @"^(Min|Max)\s*(AC|MAC|DC|MC|SC)\b", RegexOptions.IgnoreCase)) + { + s = Regex.Replace(s, @"^(?(?:Min|Max)\s*(?:AC|MAC|DC|MC|SC))\s*(?[+\-]?)\s*(?\d+)$", + m => $"{m.Groups["k"].Value} {(m.Groups["sign"].Value == "-" ? "-" : "+")}{m.Groups["num"].Value}", + RegexOptions.IgnoreCase); + list.Add(s); + continue; + } + + var m2 = RewardTextTokenRx.Match(s); + if (!m2.Success) + { + list.Add(s); + continue; + } + + var rawName = m2.Groups["name"].Value.Trim(); + var sign = m2.Groups["sign"].Value == "-" ? "-" : "+"; + string leftRaw = m2.Groups["left"].Value; + string leftSuffix = m2.Groups["leftsuffix"].Value; + decimal left = decimal.Parse(leftRaw, CultureInfo.InvariantCulture); + bool hasRight = m2.Groups["right"].Success; + string rightRaw = hasRight ? m2.Groups["right"].Value : string.Empty; + string rightSuffix = hasRight ? m2.Groups["rightsuffix"].Value : string.Empty; + decimal right = hasRight ? decimal.Parse(rightRaw, CultureInfo.InvariantCulture) : 0M; + + string leftDisplay = leftRaw + leftSuffix; + string rightDisplay = hasRight ? rightRaw + rightSuffix : string.Empty; + + var keyNoSpace = Regex.Replace(rawName, @"\s+", ""); + bool isRangeGroup = + keyNoSpace.Equals("AC", StringComparison.OrdinalIgnoreCase) || + keyNoSpace.Equals("MAC", StringComparison.OrdinalIgnoreCase) || + keyNoSpace.Equals("DC", StringComparison.OrdinalIgnoreCase) || + keyNoSpace.Equals("MC", StringComparison.OrdinalIgnoreCase) || + keyNoSpace.Equals("SC", StringComparison.OrdinalIgnoreCase); + + if (isRangeGroup) + { + var grp = keyNoSpace.ToUpperInvariant(); + + if (hasRight) + { + list.Add($"Min{grp} +{Math.Max(0M, left).ToString(CultureInfo.InvariantCulture)}"); + list.Add($"Max{grp} +{Math.Max(0M, right).ToString(CultureInfo.InvariantCulture)}"); + } + else + { + list.Add($"Max{grp} {sign}{left.ToString(CultureInfo.InvariantCulture)}"); + } + } + else + { + if (hasRight) + { + list.Add($"{rawName} {sign}{leftDisplay} ~ {rightDisplay}"); + } + else + { + list.Add($"{rawName} {sign}{leftDisplay}"); + } + } + } + } + return list; + } + + private static IEnumerable BuildStatLines(Stats bag) + { + var lines = new List(); + if (bag == null || bag.Values == null || bag.Values.Count == 0) return lines; + + var d = new Dictionary(bag.Values); + + NormalizeFlatGroupStatsToMax(d); + + void EmitPair(Stat minStat, Stat maxStat, string label) + { + if (d.TryGetValue(minStat, out int minVal) && d.TryGetValue(maxStat, out int maxVal)) + { + lines.Add($"{label} {minVal} ~ {maxVal}"); + d.Remove(minStat); + d.Remove(maxStat); + } + } + + EmitPair(Stat.MinAC, Stat.MaxAC, "AC"); + EmitPair(Stat.MinMAC, Stat.MaxMAC, "MAC"); + EmitPair(Stat.MinDC, Stat.MaxDC, "DC"); + EmitPair(Stat.MinMC, Stat.MaxMC, "MC"); + EmitPair(Stat.MinSC, Stat.MaxSC, "SC"); + EmitPair(Stat.MinDamage, Stat.MaxDamage, "Damage"); + + foreach (var kv in d) + if (kv.Value != 0) + { + string key = kv.Key.ToString(); + if (key.StartsWith("Min", StringComparison.OrdinalIgnoreCase) || + key.StartsWith("Max", StringComparison.OrdinalIgnoreCase)) + lines.Add($"{key} +{kv.Value}"); + else + lines.Add($"{PrettyStatName(kv.Key)} + {kv.Value}"); + } + + return lines; + } + + + private static void NormalizeFlatGroupStatsToMax(Dictionary bag) + { + if (bag == null || bag.Count == 0) return; + + foreach (var key in bag.Keys.ToList()) + { + var name = key.ToString(); + if (!name.Equals("AC", StringComparison.OrdinalIgnoreCase) && + !name.Equals("MAC", StringComparison.OrdinalIgnoreCase) && + !name.Equals("DC", StringComparison.OrdinalIgnoreCase) && + !name.Equals("MC", StringComparison.OrdinalIgnoreCase) && + !name.Equals("SC", StringComparison.OrdinalIgnoreCase)) + continue; + + int val = bag[key]; + bag.Remove(key); + + if (Enum.TryParse("Max" + name, true, out var maxKey)) + bag[maxKey] = (bag.TryGetValue(maxKey, out var cur) ? cur : 0) + val; + } + } + + public static CodexDialog GetOrCreate(GameScene scene) + { + if (Instance != null && !Instance.IsDisposed) return Instance; + + Instance = new CodexDialog + { + Parent = scene, + Visible = false + }; + return Instance; + } + + private void CloseDropdown() + { + _ddOpen = false; + + if (_ddPanel != null) + _ddPanel.Visible = false; + + if (_ddOverflowMaskTop != null) + _ddOverflowMaskTop.Visible = false; + + if (_ddOverflowMaskBottom != null) + _ddOverflowMaskBottom.Visible = false; + } + + public static void Toggle(GameScene scene) + { + var dlg = GetOrCreate(scene); + bool show = !dlg.Visible; + dlg.Visible = show; + if (show) dlg.BringToFront(); + else + { + dlg.DeactivateSearch(); + dlg.CloseDropdown(); + } + } + + public void ShowDialog() + { + Visible = true; + BringToFront(); + if (Location.X < 0 || Location.Y < 0 || Location.X > 2000 || Location.Y > 2000) + Location = new Point(160, 120); + } + + public CodexDialog() + { + Instance = this; + Sort = true; + Movable = true; + Visible = false; + + Library = Libraries.Title_32bit; + Index = BG_INDEX; + Size = new Size(DIALOG_W, DIALOG_H); + Location = new Point(160, 120); + + BuildUI(); + DoLayout(); + UpdateRightPage(); + } + + public override void Show() + { + if (!GameScene.AllowCodex) return; + base.Show(); + } + + public void ApplyPermissions() + { + if (!GameScene.AllowCodex && Visible) + Hide(); + } + + protected override void Dispose(bool disposing) + { + if (disposing && Instance == this) Instance = null; + DeactivateSearch(rebuildGate: false); + base.Dispose(disposing); + } + + private int GetXpBarWidth() + { + try + { + var s = Libraries.UI_32bit.GetSize(XP_BAR_BACK); + if (s.Width > 0) return s.Width; + } + catch { } + return 160; + } + private int GetXpBarInnerWidth() => Math.Max(0, GetXpBarWidth() - 8); + private int GetXpBarLeftInnerX() => XP_BAR_X + 4; + + private void BuildUI() + { + _claimedSetsEmblem = new MirImageControl + { + Parent = this, + Library = Libraries.UI_32bit, + Index = COLL_EMBLEM_IDX, + Location = new Point(656, 25), + Hint = "Claimed Set Bonuses", + NotControl = false + }; + + _levelLabel = new MirLabel + { + Parent = this, + Location = new Point(102, 31), + AutoSize = true, + ForeColour = Color.WhiteSmoke, + Font = new Font(Settings.FontName, 12f, FontStyle.Bold), + }; + + if (_levelHintHotspot == null) + { + _levelHintHotspot = new MirControl + { + Parent = this, + Location = new Point(LEVEL_HINT_RECT.X, LEVEL_HINT_RECT.Y), + Size = new Size(LEVEL_HINT_RECT.Width, LEVEL_HINT_RECT.Height), + Opacity = 0f, + NotControl = false + }; + } + + new MirImageControl + { + Parent = this, + Library = Libraries.UI_32bit, + Index = XP_BAR_BACK, + Location = new Point(XP_BAR_X, XP_BAR_Y), + }; + + _xpBarFill = new MirImageControl + { + Parent = this, + Library = Libraries.UI_32bit, + Index = XP_BAR_FILL, + Location = new Point(GetXpBarLeftInnerX(), XP_BAR_Y + 2), + DrawImage = false, + NotControl = true + }; + _xpBarFill.BeforeDraw += (s, e) => DrawXpFill(_xpBarFill); + + _xpText = new MirLabel + { + Parent = this, + AutoSize = true, + ForeColour = Color.White, + Font = new Font(Settings.FontName, 9.0f), + Text = "0 / 8" + }; + + BuildTopTabs(); + + _leftNav = new MirControl + { + Parent = this, + BackColour = Color.FromArgb(20, 20, 20), + Border = true + }; + + _listViewport = new MirControl + { + Parent = this, + BackColour = Color.FromArgb(12, 12, 12), + Border = true + }; + AttachWheel(_listViewport); + + _maskTop = new MirControl + { + Parent = this, + BackColour = BackColour == Color.Empty ? Color.Black : BackColour, + NotControl = true + }; + _maskBottom = new MirControl + { + Parent = this, + BackColour = BackColour == Color.Empty ? Color.Black : BackColour, + NotControl = true + }; + + _scrollUp = new MirButton + { + Parent = this, + Library = Libraries.Prguse2, + Index = 197, + HoverIndex = 198, + PressedIndex = 199, + Sound = SoundList.ButtonA, + Size = SCROLL_BTN_SIZE + }; + _scrollUp.Click += (s, e) => ScrollBy(-RowStep); + + _scrollDown = new MirButton + { + Parent = this, + Library = Libraries.Prguse2, + Index = 207, + HoverIndex = 208, + PressedIndex = 209, + Sound = SoundList.ButtonA, + Size = SCROLL_BTN_SIZE + }; + _scrollDown.Click += (s, e) => ScrollBy(+RowStep); + + _scrollThumb = new MirImageControl + { + Parent = this, + Library = Libraries.Prguse2, + Index = 205, + Size = SCROLL_THUMB_SIZE + }; + + _rightPanel = new MirControl + { + Parent = this, + BackColour = Color.FromArgb(20, 20, 20, 20), + }; + + _rightPrev = new MirButton + { + Parent = _rightPanel, + Library = Libraries.Prguse2, + Index = 240, + HoverIndex = 241, + PressedIndex = 242, + Sound = SoundList.ButtonA, + Size = new Size(18, 18) + }; + _rightPrev.Click += (o, e) => SwitchRightPage(-1); + + _rightHeader = new MirLabel + { + Parent = _rightPanel, + AutoSize = true, + ForeColour = Color.WhiteSmoke, + Font = new Font(Settings.FontName, 9.5f, FontStyle.Bold), + Text = "Progress" + }; + + _rightNext = new MirButton + { + Parent = _rightPanel, + Library = Libraries.Prguse2, + Index = 243, + HoverIndex = 244, + PressedIndex = 245, + Sound = SoundList.ButtonA, + Size = new Size(18, 18) + }; + _rightNext.Click += (o, e) => SwitchRightPage(+1); + + BuildProgressRow(0, "All"); + BuildProgressRow(1, "Character"); + BuildProgressRow(2, "Limited"); + BuildProgressRow(3, "Event"); + + _statsViewport = new MirControl + { + Parent = _rightPanel, + BackColour = Color.FromArgb(16, 16, 16), + Border = true, + Visible = false + }; + _statsViewport.MouseWheel += (o, e) => + { + int ticks = Math.Max(1, Math.Abs(e.Delta) / 120); + int dir = e.Delta > 0 ? -1 : +1; + ScrollStatsBy(dir * StatsLineH * ticks); + }; + + _statsScrollUp = new MirButton + { + Parent = _rightPanel, + Library = Libraries.Prguse2, + Index = 197, + HoverIndex = 198, + PressedIndex = 199, + Sound = SoundList.ButtonA, + Size = SCROLL_BTN_SIZE, + Visible = false + }; + _statsScrollUp.Click += (s, e) => ScrollStatsBy(-StatsLineH * 3); + + _statsScrollDown = new MirButton + { + Parent = _rightPanel, + Library = Libraries.Prguse2, + Index = 207, + HoverIndex = 208, + PressedIndex = 209, + Sound = SoundList.ButtonA, + Size = SCROLL_BTN_SIZE, + Visible = false + }; + _statsScrollDown.Click += (s, e) => ScrollStatsBy(StatsLineH * 3); + + _statsScrollThumb = new MirImageControl + { + Parent = _rightPanel, + Library = Libraries.Prguse2, + Index = 205, + Size = SCROLL_THUMB_SIZE, + Visible = false + }; + + _rewardsViewport = new MirControl + { + Parent = _rightPanel, + BackColour = Color.FromArgb(16, 16, 16), + Visible = false + }; + _rewardsViewport.MouseWheel += (o, e) => + { + int ticks = Math.Max(1, Math.Abs(e.Delta) / 120); + int dir = e.Delta > 0 ? -1 : +1; + ScrollRewardsBy(dir * StatsLineH * ticks); + }; + + _rewardsMaskTop = new MirControl + { + Parent = _rightPanel, + BackColour = _rightPanel.BackColour == Color.Empty ? Color.Black : _rightPanel.BackColour, + NotControl = true, + Visible = false + }; + _rewardsMaskBottom = new MirControl + { + Parent = _rightPanel, + BackColour = _rightPanel.BackColour == Color.Empty ? Color.Black : _rightPanel.BackColour, + NotControl = true, + Visible = false + }; + _rewardsScrollUp = new MirButton + { + Parent = _rightPanel, + Library = Libraries.Prguse2, + Index = 197, + HoverIndex = 198, + PressedIndex = 199, + Sound = SoundList.ButtonA, + Size = SCROLL_BTN_SIZE, + Visible = false + }; + _rewardsScrollUp.Click += (s, e) => ScrollRewardsBy(-StatsLineH * 3); + + _rewardsScrollDown = new MirButton + { + Parent = _rightPanel, + Library = Libraries.Prguse2, + Index = 207, + HoverIndex = 208, + PressedIndex = 209, + Sound = SoundList.ButtonA, + Size = SCROLL_BTN_SIZE, + Visible = false + }; + _rewardsScrollDown.Click += (s, e) => ScrollRewardsBy(StatsLineH * 3); + + _rewardsScrollThumb = new MirImageControl + { + Parent = _rightPanel, + Library = Libraries.Prguse2, + Index = 205, + Size = SCROLL_THUMB_SIZE, + Visible = false + }; + + _searchBox = new MirTextBox + { + Parent = this, + Location = new Point(535, 111), + Size = new Size(110, 17), + Font = new Font(Settings.FontName, 8F), + BackColour = Color.Black, + ForeColour = Color.White, + BorderColour = Color.Gray, + Border = true, + Text = string.Empty + }; + _searchBox.Enabled = false; + + _searchBox.KeyPress += (s, e) => + { + if (!_searchActivated) return; + if (e.KeyChar == (char)Keys.Return || e.KeyChar == (char)Keys.LineFeed) + { + if (_searchBox.ForeColour != Color.Gray) + ApplySearch(_searchBox.Text); + e.Handled = true; + } + }; + + _searchBox.Click += (s, e) => + { + if (!_searchActivated) ActivateSearch(); + }; + + _searchActivated = false; + _searchGate = new MirControl + { + Parent = this, + Location = _searchBox.Location, + Size = _searchBox.Size, + Opacity = 0f, + Border = false, + }; + _searchGate.MouseDown += (s, e) => + { + if (e.Button != MouseButtons.Left) return; + _gatePressed = true; + _gateDownPt = e.Location; + }; + _searchGate.MouseUp += (s, e) => + { + if (!_gatePressed || e.Button != MouseButtons.Left) return; + _gatePressed = false; + int dx = e.Location.X - _gateDownPt.X; + int dy = e.Location.Y - _gateDownPt.Y; + if (dx * dx + dy * dy <= GateDragSqr) ActivateSearch(); + }; + + _searchButton = new MirButton + { + Parent = this, + Library = Libraries.Title, + Index = 480, + HoverIndex = 481, + PressedIndex = 482, + Location = new Point(648, 107), + Size = new Size(60, 22), + Sound = SoundList.ButtonA, + }; + _searchButton.Click += (o, e) => + { + if (!_searchActivated) + { + ActivateSearch(); + return; + } + ApplySearch(_searchBox.Text); + }; + + _refreshSearchButton = new MirButton + { + Parent = this, + Library = Libraries.Prguse2, + Index = 267, + HoverIndex = 268, + PressedIndex = 269, + Location = new Point(697, 107), + Size = new Size(60, 22), + Sound = SoundList.ButtonA, + }; + _refreshSearchButton.Click += (o, e) => RefreshAllFilters(); + + BuildCurrencyUI(); + + var close = new MirButton + { + Parent = this, + Library = Libraries.Prguse2, + Index = 360, + HoverIndex = 361, + PressedIndex = 362, + Sound = SoundList.ButtonA, + Size = new Size(22, 18), + Location = new Point(Size.Width - 24, 6) + }; + close.Click += (s, e) => + { + DeactivateSearch(); + CloseDropdown(); + Hide(); + }; + + BuildFilterDropdownUI(); + _ddPanel?.BringToFront(); + _ddHeader?.BringToFront(); + + _levelHintHotspot = new MirControl + { + Parent = this, + Location = new Point(LEVEL_HINT_RECT.X, LEVEL_HINT_RECT.Y), + Size = new Size(LEVEL_HINT_RECT.Width, LEVEL_HINT_RECT.Height), + Opacity = 0f, + Border = true, + Visible = true + }; + } + + private MirButton MakeTopTab(Point p, int idxNormal, int idxHover, int idxPressed, string text, string hint, Action onClick) + { + var b = new MirButton + { + Parent = this, + Library = Libraries.Title_32bit, + Index = idxNormal, + HoverIndex = idxHover, + PressedIndex = idxPressed, + Location = p, + Size = new Size(80, 26), + Sound = SoundList.ButtonA, + }; + b.Click += (o, e) => onClick(); + return b; + } + + private void BuildTopTabs() + { + _tabChar = MakeTopTab(new Point(TAB_X0 + 0 * TAB_STEP, TAB_Y), 320, 321, 322, "캐릭터", "Tab: Character", () => + { + _sectionBucket = 0; + ApplySearch(_searchQuery, keepCursor: false); + UpdateTopTabVisuals(); + }); + + _tabLim = MakeTopTab(new Point(TAB_X0 + 1 * TAB_STEP, TAB_Y), 324, 325, 326, "한정판", "Tab: Limited", () => + { + _sectionBucket = 1; + ApplySearch(_searchQuery, keepCursor: false); + UpdateTopTabVisuals(); + }); + + _tabEvt = MakeTopTab(new Point(TAB_X0 + 2 * TAB_STEP, TAB_Y), 328, 329, 330, "이벤트", "Tab: Event", () => + { + _sectionBucket = 2; + ApplySearch(_searchQuery, keepCursor: false); + UpdateTopTabVisuals(); + }); + + UpdateTopTabVisuals(); + } + + private void UpdateTopTabVisuals() + { + if (_tabChar != null) _tabChar.Index = (_sectionBucket == 0) ? _tabChar.PressedIndex : 320; + if (_tabLim != null) _tabLim.Index = (_sectionBucket == 1) ? _tabLim.PressedIndex : 324; + if (_tabEvt != null) _tabEvt.Index = (_sectionBucket == 2) ? _tabEvt.PressedIndex : 328; + } + + private void BuildFilterDropdownUI() + { + BuildDropdownOptionsFromExistingFilters(); + + _ddHeader = new MirButton + { + Parent = this, + Library = Libraries.UI_32bit, + Index = DD_BAR_NORMAL_IDX, + HoverIndex = DD_BAR_HOVER_IDX, + PressedIndex = DD_BAR_HOVER_IDX, + Location = DD_HDR_POS, + Size = new Size(156, 22), + CenterText = true, + Text = (_ddOptions.Count > 0) ? _ddOptions[_ddSelectedIndex] : "Filter", + Sound = SoundList.ButtonA + }; + _ddHeader.Click += (s, e) => ToggleDropdown(); + + _ddPanel = new MirImageControl + { + Parent = this, + Library = Libraries.UI_32bit, + Index = DD_PANEL_IDX, + Location = DD_PANEL_POS, + Size = DD_PANEL_SIZE, + Visible = false + }; + + _ddViewport = new MirControl + { + Parent = _ddPanel, + Location = new Point(DD_ITEM_X_PAD, DD_ITEM_Y_PAD), + Size = new Size(DD_PANEL_SIZE.Width - DD_ITEM_X_PAD - 24, DD_VISIBLE_ROWS * DD_ITEM_H), + }; + AttachWheel(_ddViewport); + + UpdateDropdownOverflowMasks(); + + int upX = _ddPanel.Size.Width - SCROLL_BTN_SIZE.Width; + _ddUp = new MirButton + { + Parent = _ddPanel, + Library = Libraries.Prguse2, + Index = 197, + HoverIndex = 198, + PressedIndex = 199, + Location = new Point(upX, _ddViewport.Location.Y - SCROLL_BTN_SIZE.Height - -19), + Size = SCROLL_BTN_SIZE, + Sound = SoundList.ButtonA + }; + _ddUp.Click += (s, e) => ScrollDropdownBy(-DD_ITEM_H); + + _ddDown = new MirButton + { + Parent = _ddPanel, + Library = Libraries.Prguse2, + Index = 207, + HoverIndex = 208, + PressedIndex = 209, + Location = new Point(upX, _ddViewport.Location.Y + _ddViewport.Size.Height + -13), + Size = SCROLL_BTN_SIZE, + Sound = SoundList.ButtonA + }; + _ddDown.Click += (s, e) => ScrollDropdownBy(+DD_ITEM_H); + + bool ddDragging = false; + int ddGrabOffsetY = 0; + + _ddPanel.MouseMove += (s, e) => + { + if (!ddDragging) return; + + int contentH = _ddOptions.Count * DD_ITEM_H; + int viewportH = _ddViewport.Size.Height; + int inner = Math.Max(0, contentH - viewportH); + + int minY = _ddViewport.Location.Y; + int maxY = _ddViewport.Location.Y + _ddViewport.Size.Height - _ddThumb.Size.Height; + + int newTop = e.Y - ddGrabOffsetY; + newTop = Math.Max(minY, Math.Min(maxY, newTop)); + + float pct = (inner == 0) ? 0f : (float)(newTop - minY) / (float)(maxY - minY); + + int raw = (inner > 0) ? (int)Math.Round(inner * pct) : 0; + int desired = (raw / DD_ITEM_H) * DD_ITEM_H; + + int delta = desired - _ddScrollPx; + if (delta != 0) ScrollDropdownBy(delta); + }; + _ddPanel.MouseUp += (s, e) => + { + if (e.Button != MouseButtons.Left) return; + ddDragging = false; + }; + _ddPanel.MouseLeave += (s, e) => { ddDragging = false; }; + + RebuildDropdownItems(); + UpdateDropdownScrollUI(); + } + + private void ToggleDropdown() + { + _ddOpen = !_ddOpen; + + if (_ddPanel != null) + _ddPanel.Visible = _ddOpen; + + if (_ddOpen) + { + UpdateDropdownOverflowMasks(); + + if (_ddOverflowMaskTop != null) _ddOverflowMaskTop.Visible = true; + if (_ddOverflowMaskBottom != null) _ddOverflowMaskBottom.Visible = true; + + _ddPanel?.BringToFront(); + _ddOverflowMaskTop?.BringToFront(); + _ddOverflowMaskBottom?.BringToFront(); + _ddHeader?.BringToFront(); + + RebuildDropdownItems(); + UpdateDropdownScrollUI(); + } + else + { + if (_ddOverflowMaskTop != null) _ddOverflowMaskTop.Visible = false; + if (_ddOverflowMaskBottom != null) _ddOverflowMaskBottom.Visible = false; + } + } + + private void RebuildDropdownItems() + { + if (_ddViewport == null) return; + + foreach (var r in _ddItemRows) r.Dispose(); + _ddItemRows.Clear(); + + if (_ddOptions == null || _ddOptions.Count == 0) return; + + var barSize = Libraries.UI_32bit.GetSize(DD_BAR_NORMAL_IDX); + int barYOffset = Math.Max(0, (DD_ITEM_H - barSize.Height) / 2); + + for (int i = 0; i < _ddOptions.Count; i++) + { + int y = i * DD_ITEM_H; + + var row = new MirControl + { + Parent = _ddViewport, + Location = new Point(0, y), + Size = new Size(_ddViewport.Size.Width, DD_ITEM_H), + Border = false + }; + + var bar = new MirImageControl + { + Parent = row, + Library = Libraries.UI_32bit, + Index = DD_BAR_NORMAL_IDX, + Location = new Point(0, barYOffset), + NotControl = true + }; + + var label = new MirLabel + { + Parent = row, + Location = new Point(0, -1), + AutoSize = false, + Size = row.Size, + Text = _ddOptions[i], + ForeColour = Color.White, + DrawFormat = TextFormatFlags.HorizontalCenter | + TextFormatFlags.VerticalCenter | + TextFormatFlags.SingleLine + }; + + row.MouseEnter += (s, e) => { bar.Index = DD_BAR_HOVER_IDX; }; + row.MouseLeave += (s, e) => { bar.Index = DD_BAR_NORMAL_IDX; }; + label.MouseEnter += (s, e) => { bar.Index = DD_BAR_HOVER_IDX; }; + label.MouseLeave += (s, e) => { bar.Index = DD_BAR_NORMAL_IDX; }; + + int captured = i; + + row.Click += (s, e) => SelectDropdownIndex(captured); + label.Click += (s, e) => SelectDropdownIndex(captured); + + _ddItemRows.Add(row); + } + + RepositionDropdownItems(); + } + + + private void SelectDropdownIndex(int idx) + { + if (idx < 0 || idx >= _ddOptions.Count) return; + + _ddSelectedIndex = idx; + if (_ddHeader != null) _ddHeader.Text = _ddOptions[idx]; + + _filterStatKey = (idx == 0) ? null : _ddKeys[idx]; + + ApplySearch(_searchQuery, keepCursor: false); + + CloseDropdown(); + } + + private void ScrollDropdownBy(int deltaPx) + { + _ddScrollPx += deltaPx; + ClampDropdownScroll(); + RepositionDropdownItems(); + UpdateDropdownScrollUI(); + } + + private void ClampDropdownScroll() + { + int contentH = (_ddOptions != null ? _ddOptions.Count : 0) * DD_ITEM_H; + int viewportH = (_ddViewport != null) ? _ddViewport.Size.Height : 0; + int maxOff = Math.Max(0, contentH - viewportH); + + if (_ddScrollPx < 0) _ddScrollPx = 0; + if (_ddScrollPx > maxOff) _ddScrollPx = maxOff; + + if (DD_ITEM_H > 1) _ddScrollPx = (_ddScrollPx / DD_ITEM_H) * DD_ITEM_H; + } + + private void RepositionDropdownItems() + { + if (_ddViewport == null || _ddItemRows.Count == 0) return; + + int yOff = -_ddScrollPx; + + for (int i = 0; i < _ddItemRows.Count; i++) + { + var row = _ddItemRows[i]; + row.Location = new Point(0, i * DD_ITEM_H + yOff); + + bool vis = row.Location.Y + DD_ITEM_H > 0 && row.Location.Y < _ddViewport.Size.Height; + if (row.Visible != vis) row.Visible = vis; + } + } + + private void UpdateDropdownScrollUI() + { + if (_ddPanel == null || _ddViewport == null || _ddUp == null || _ddDown == null) return; + + int contentH = (_ddOptions != null ? _ddOptions.Count : 0) * DD_ITEM_H; + int viewportH = _ddViewport.Size.Height; + int inner = Math.Max(0, contentH - viewportH); + + bool canScroll = inner > 0; + + _ddUp.Enabled = canScroll; + _ddDown.Enabled = canScroll; + + if (_ddThumb == null) return; + + _ddThumb.Visible = _ddPanel.Visible && canScroll; + if (!canScroll) return; + + if (_ddScrollPx < 0) _ddScrollPx = 0; + if (_ddScrollPx > inner) _ddScrollPx = inner; + if (DD_ITEM_H > 1) _ddScrollPx = (_ddScrollPx / DD_ITEM_H) * DD_ITEM_H; + + const int DD_THUMB_Y_OFFSET = 0; + int minY = _ddViewport.Location.Y + DD_THUMB_Y_OFFSET; + int maxY = _ddViewport.Location.Y + _ddViewport.Size.Height - _ddThumb.Size.Height - DD_THUMB_Y_OFFSET; + + if (maxY <= minY) return; + + float pct = (inner == 0) ? 0f : (float)_ddScrollPx / inner; + int y = minY + (int)Math.Round(pct * (maxY - minY)); + int x = _ddUp.Location.X + 1; + + _ddThumb.Location = new Point(x, y); + } + + private void BuildDropdownOptionsFromExistingFilters() + { + if (_ddOptions == null || _rows == null) return; + + _ddOptions.Clear(); + _ddKeys.Clear(); + + _ddOptions.Add("All"); + _ddKeys.Add(null); + + var present = new HashSet(); + foreach (var r in _rows) foreach (var k in ExtractFilterKeysFromRow(r)) present.Add(k); + + foreach (var pair in _filterKeyOrder) + { + if (present.Contains(pair.key)) + { + _ddKeys.Add(pair.key); + _ddOptions.Add(pair.label); + } + } + + if (_ddSelectedIndex < 0) _ddSelectedIndex = 0; + if (_ddSelectedIndex >= _ddOptions.Count) _ddSelectedIndex = _ddOptions.Count - 1; + } + + private static string CanonicalStatKey(string raw) + { + if (string.IsNullOrWhiteSpace(raw)) return null; + string s = raw.Trim().ToLowerInvariant(); + + // English → keys + if (s.StartsWith("strength") || s == "str") return "strength"; + if (s.StartsWith("intelligence") || s == "int") return "intelligence"; + if (s.StartsWith("endurance") || s.StartsWith("stamina") || s == "sta") return "endurance"; + if (s.StartsWith("willpower") || s.StartsWith("will")) return "willpower"; + if (s.StartsWith("power") || s.StartsWith("might") || s.Contains("attack bonus")) return "power"; + if (s.StartsWith("damage")) return "damage"; + if (s.Contains("crit") && (s.Contains("rate") || s.Contains("chance"))) return "crit_rate"; + if (s.Contains("crit") && s.Contains("damage")) return "crit_damage"; + if (s == "hp" || s.StartsWith("max hp")) return "max_hp"; + if (s == "mp" || s.StartsWith("max mp")) return "max_mp"; + if (s.Contains("hp") && s.Contains("regen")) return "hp_regen"; + if (s.Contains("mp") && s.Contains("regen")) return "mp_regen"; + + // group + common stats + if (s == "ac") return "ac"; + if (s == "mac") return "mac"; + if (s == "dc") return "dc"; + if (s == "mc") return "mc"; + if (s == "sc") return "sc"; + if (s == "accuracy") return "accuracy"; + if (s == "luck") return "luck"; + + return null; + } + + private static string ItemTypeFilterKey(ItemType type) => $"type:{type}"; + private static string ItemTypeFilterLabel(ItemType type) => type.ToString(); + + private static readonly ItemType[] _itemTypeOrder = new[] + { + ItemType.Weapon, ItemType.Armour, ItemType.Helmet, ItemType.Necklace, ItemType.Bracelet, ItemType.Ring, + ItemType.Amulet, ItemType.Belt, ItemType.Boots, ItemType.Stone, ItemType.Torch, ItemType.Potion, + ItemType.Ore, ItemType.CraftingMaterial, ItemType.Scroll, ItemType.Gem, ItemType.Mount, ItemType.Book, + ItemType.Script, ItemType.Reins, ItemType.Bells, ItemType.Saddle, ItemType.Ribbon, ItemType.Mask, + ItemType.Food, ItemType.Hook, ItemType.Float, ItemType.Bait, ItemType.Finder, ItemType.Reel, + ItemType.Fish, ItemType.Quest, ItemType.Awakening, ItemType.Pets, ItemType.Transform, ItemType.Deco, + ItemType.Socket, ItemType.MonsterSpawn, ItemType.SealedHero + }; + + private static readonly (string key, string label)[] _filterKeyOrder = BuildFilterKeyOrder(); + + private static (string key, string label)[] BuildFilterKeyOrder() + { + var list = new List<(string key, string label)> + { + ("strength", "Strength"), + ("intelligence","Intelligence"), + ("power", "Power"), + ("damage", "Damage"), + ("endurance", "Endurance"), + ("willpower", "Willpower"), + ("crit_rate", "Critical Hit Chance"), + ("crit_damage", "Critical Damage"), + ("max_hp", "Max HP"), + ("max_mp", "Max MP"), + ("hp_regen", "HP Regeneration"), + ("mp_regen", "MP Regeneration"), + + ("ac", "AC"), ("mac","MAC"), ("dc","DC"), ("mc","MC"), ("sc","SC"), + ("accuracy","Accuracy"), ("luck","Luck"), + }; + + foreach (var t in _itemTypeOrder) + { + list.Add((ItemTypeFilterKey(t), ItemTypeFilterLabel(t))); + } + + return list.ToArray(); + } + + private HashSet ExtractFilterKeysFromRow(RowVM vm) + { + var set = new HashSet(); + if (vm == null) return set; + + var values = vm.Reward?.Values; + if (values != null && values.Count > 0) + { + foreach (var kv in values) + { + string name = kv.Key.ToString(); + if (name.StartsWith("Min")) name = name.Substring(3); + if (name.StartsWith("Max")) name = name.Substring(3); + var k = CanonicalStatKey(name) ?? CanonicalStatKey(PrettyStatName(kv.Key)); + if (k != null) set.Add(k); + } + } + + foreach (var line in BuildStatLines(vm)) + { + string token = line; + int cut = token.IndexOfAny(" +-0123456789~:".ToCharArray()); + if (cut > 0) token = token.Substring(0, cut).Trim(); + var k = CanonicalStatKey(token); + if (k != null) set.Add(k); + } + + if (vm.ReqItemIndices != null) + { + foreach (var ix in vm.ReqItemIndices) + { + var info = GameScene.Scene?.GetItemInfo(ix); + if (info == null || info.Type == ItemType.Nothing) continue; + set.Add(ItemTypeFilterKey(info.Type)); + } + } + + return set; + } + + private void UpdateDropdownOverflowMasks() + { + if (_ddPanel == null || _ddViewport == null) return; + + var native = Libraries.UI_32bit.GetSize(DD_PANEL_IDX); + + int topPad = _ddViewport.Location.Y; + int innerH = _ddViewport.Size.Height; + int bottomPad = Math.Max(0, native.Height - (topPad + innerH)); + + var bg = (BackColour == Color.Empty ? Color.Black : BackColour); + + if (_ddOverflowMaskTop == null) + _ddOverflowMaskTop = new MirControl { Parent = this, BackColour = bg, NotControl = true, Visible = false }; + + if (_ddOverflowMaskBottom == null) + _ddOverflowMaskBottom = new MirControl { Parent = this, BackColour = bg, NotControl = true, Visible = false }; + + Point shell = _ddPanel.Location; + + _ddOverflowMaskTop.Location = new Point(shell.X, shell.Y); + _ddOverflowMaskTop.Size = new Size(native.Width, Math.Max(0, topPad)); + + _ddOverflowMaskBottom.Location = new Point(shell.X, shell.Y + topPad + innerH); + _ddOverflowMaskBottom.Size = new Size(native.Width, bottomPad); + } + + private void BuildProgressRow(int idx, string label) + { + int iconIdx = idx switch + { + 1 => RIGHT_ICON_CHARACTER, + 2 => RIGHT_ICON_LIMITED, + 3 => RIGHT_ICON_EVENT, + _ => RIGHT_ICON_BOOK, + }; + + var icon = new MirImageControl + { + Parent = _rightPanel, + Library = Libraries.UI_32bit, + Index = iconIdx, + NotControl = true + }; + _rightIcons.Add(icon); + + var bar = new MirImageControl + { + Parent = _rightPanel, + Library = Libraries.UI_32bit, + Index = BAR_BLUE, + DrawImage = false, + NotControl = true, + Size = new Size(160, 14), + Visible = true + }; + bar.BeforeDraw += (s, e) => DrawRightBar(idx, bar); + _rightBars.Add(bar); + + var lbl = new MirLabel + { + Parent = _rightPanel, + AutoSize = true, + ForeColour = Color.Silver, + Text = $"{label} (0/0)" + }; + _rightLabels.Add(lbl); + } + + private void DrawRightBar(int idx, MirImageControl bar) + { + if (bar == null || bar.Library == null) return; + + var loc = bar.DisplayLocation; + var size = bar.Size; + + bar.Library.Draw(BAR_BASE, loc, size, Color.White); + + int need = _barNeed[idx]; + int found = _barFound[idx]; + if (need <= 0) return; + + double percent = found / (double)need; + percent = Math.Max(0, Math.Min(1, percent)); + + if (percent <= 0) return; + + if (percent >= 1.0) + { + bar.Library.Draw(BAR_GREEN, loc, size, Color.White); + return; + } + + int inner = Math.Max(0, size.Width - 3); + int fillW = (int)(inner * percent); + if (fillW <= 0) return; + + Rectangle section = new Rectangle(Point.Empty, new Size(fillW, size.Height)); + bar.Library.Draw(BAR_BLUE, section, loc, Color.White, false); + } + + private void DrawXpFill(MirImageControl fill) + { + if (fill == null || fill.Library == null) return; + + ComputeLevelProgress(out int level, out int xpWithin, out int needWithin); + + double pct = needWithin > 0 ? xpWithin / (double)needWithin : 0.0; + pct = Math.Max(0, Math.Min(1, pct)); + + int innerWidth = GetXpBarInnerWidth(); + int fillW = (int)Math.Round(innerWidth * pct); + if (fillW <= 0) return; + + var loc = fill.DisplayLocation; + Rectangle section = new Rectangle(Point.Empty, new Size(fillW, XP_BAR_H)); + fill.Library.Draw(XP_BAR_FILL, section, loc, Color.White, false); + } + + private void DoLayout() + { + _leftNav.Location = new Point(8, TOP_BAR_H + LEFT_NAV_TOP_PAD); + _leftNav.Size = new Size(LEFT_NAV_W - 10, Size.Height - TOP_BAR_H - LEFT_NAV_TOP_PAD - 8); + + _listViewport.Location = new Point(LIST_X, LIST_Y); + _listViewport.Size = new Size(VIEWPORT_WIDTH, VIEWPORT_HEIGHT); + + _maskTop.Location = new Point(_listViewport.Location.X, _listViewport.Location.Y - 2000); + _maskTop.Size = new Size(_listViewport.Size.Width, 2000); + _maskBottom.Location = new Point(_listViewport.Location.X, _listViewport.Location.Y + _listViewport.Size.Height); + _maskBottom.Size = new Size(_listViewport.Size.Width, 2000); + + _scrollUp.Location = SCROLL_UP_POS; + _scrollDown.Location = SCROLL_DOWN_POS; + _scrollThumb.Location = SCROLL_THUMB_POS; + + _rightPanel.Location = RightUI.Panel.Location; + _rightPanel.Size = RightUI.Panel.Size; + + if (_rightHeader != null) _rightHeader.Location = RightUI.Header; + if (_rightPrev != null) _rightPrev.Location = RightUI.Prev; + if (_rightNext != null) _rightNext.Location = RightUI.Next; + + for (int i = 0; i < _rightLabels.Count; i++) + { + int yAdd = i * RightUI.RowStepY; + + if (i < _rightIcons.Count && _rightIcons[i] != null) + _rightIcons[i].Location = new Point(RightUI.Icon0.X, RightUI.Icon0.Y + yAdd); + + if (i < _rightBars.Count && _rightBars[i] != null) + { + _rightBars[i].Location = new Point(RightUI.Bar0.X, RightUI.Bar0.Y + yAdd); + _rightBars[i].Size = new Size(160, 14); + _rightBars[i].Visible = true; + } + + if (_rightLabels[i] != null) + _rightLabels[i].Location = new Point(RightUI.Text0.X, RightUI.Text0.Y + yAdd); + } + + _rewardsViewport.Location = RightUI.RewardsRect.Location; + _rewardsViewport.Size = RightUI.RewardsRect.Size; + _rewardsScrollUp.Location = RightUI.RewardsUp; + _rewardsScrollDown.Location = RightUI.RewardsDown; + _rewardsScrollThumb.Location = RightUI.RewardsThumb; + + if (_rewardsViewport != null) + { + if (_rewardsMaskTop != null) + { + _rewardsMaskTop.Location = new Point(_rewardsViewport.Location.X, _rewardsViewport.Location.Y - 2000); + _rewardsMaskTop.Size = new Size(_rewardsViewport.Size.Width, 2000); + } + if (_rewardsMaskBottom != null) + { + _rewardsMaskBottom.Location = new Point(_rewardsViewport.Location.X, _rewardsViewport.Location.Y + _rewardsViewport.Size.Height); + _rewardsMaskBottom.Size = new Size(_rewardsViewport.Size.Width, 2000); + } + } + RebuildList(); + UpdateScrollButtons(); + UpdateScrollThumb(); + RefreshHeaderLevelXp(); + RefreshLevelHint(); + UpdateRightPage(); + RefreshClaimedSetsEmblem(); + ComputeLevelProgress(out int lvl0, out _, out _); + _lastLevelSeen = Math.Max(1, lvl0); + } + + public void ApplySync(S.ItemCodexSync p) + { + _rows.Clear(); + RewardBySet.Clear(); + ClaimedSetIds.Clear(); + + if (p?.Rows != null) + { + foreach (var r in p.Rows) + { + var vm = new RowVM + { + SetId = r.Id, + Title = r.Name, + Found = r.Found, + Required = r.Required, + Claimed = r.Claimed, + Reward = r.Reward ?? new Stats(), + RewardText = r.RewardPreview, + Bucket = r.Bucket, + RewardXP = r.RewardXP, + Rarity = (ItemGrade)r.Rarity, + Active = r.Active, + KeepStats = r.KeepStats, + StartTime = (r.StartTicks >= 0) ? new DateTime(r.StartTicks) : (DateTime?)null, + EndTime = (r.EndTicks >= 0) ? new DateTime(r.EndTicks) : (DateTime?)null + }; + + if (r.ReqItemIndices != null) vm.ReqItemIndices.AddRange(r.ReqItemIndices); + if (r.ReqStages != null) vm.ReqStages.AddRange(r.ReqStages); + if (r.ReqItemIcons != null) vm.ReqItemIcons.AddRange(r.ReqItemIcons); + if (r.ReqRegistered != null) vm.ReqRegistered.AddRange(r.ReqRegistered); + + _rows.Add(vm); + + RewardBySet[r.Id] = vm.Reward; + if (vm.Claimed) ClaimedSetIds.Add(r.Id); + } + } + + _viewRows = _rows.ToList(); + _selectedIndex = _viewRows.Count > 0 ? 0 : -1; + _scrollOffsetPx = 0; + + BuildLeftRarityButtons(); + BuildDropdownOptionsFromExistingFilters(); + + if (_filterStatKey != null && (_ddKeys == null || !_ddKeys.Contains(_filterStatKey))) + { + _filterStatKey = null; + _ddSelectedIndex = 0; + } + + if (_ddHeader != null && _ddOptions.Count > 0) + _ddHeader.Text = _ddOptions[_ddSelectedIndex]; + + RebuildDropdownItems(); + UpdateDropdownScrollUI(); + + ComputeLevelProgress(out int lvlNow, out _, out _); + _lastLevelSeen = Math.Max(1, lvlNow); + + bool filtersActive = _filterStatKey != null + || _filterRarity.HasValue + || !string.IsNullOrWhiteSpace(_searchQuery); + + if (filtersActive) + { + ApplySearch(_searchQuery, keepCursor: false); + } + else + { + RebuildList(); + UpdateScrollThumb(); + } + + RecalcRightCounters(); + RefreshHeaderLevelXp(); + RefreshLevelHint(); + + RebuildStatsList(); + RebuildRewardsList(); + UpdateRightPage(); + RefreshClaimedSetsEmblem(); + + CodexChanged?.Invoke(); + } + + public void ApplyUpdate(S.ItemCodexUpdate p) + { + if (p == null) return; + + var row = _rows.FirstOrDefault(x => x.SetId == p.Id); + if (row == null) return; + + row.Found = p.Found; + row.Required = p.Required; + row.Claimed = p.Claimed; + + if (row.Claimed) ClaimedSetIds.Add(p.Id); + else ClaimedSetIds.Remove(p.Id); + + ApplySearch(_searchQuery, keepCursor: true); + RecalcRightCounters(); + RefreshHeaderLevelXp(); + RefreshLevelHint(); + + RefreshRarityButtonStates(); + + RebuildStatsList(); + RebuildRewardsList(); + UpdateRightPage(); + RefreshClaimedSetsEmblem(); + + CodexChanged?.Invoke(); + } + + public void MarkRequirement(int setId, int itemInfoId, sbyte stage, bool registered) + { + var vm = _rows.FirstOrDefault(x => x.SetId == setId); + if (vm == null) return; + + int ix = FindRequirementSlot(vm, itemInfoId, stage); + if (ix >= 0) + { + while (vm.ReqRegistered.Count < vm.ReqItemIndices.Count) vm.ReqRegistered.Add(false); + vm.ReqRegistered[ix] = registered; + + int got = vm.ReqRegistered.Count(b => b); + int need = vm.Required > 0 ? vm.Required : vm.ReqItemIndices.Count; + vm.Found = (short)Math.Min(need, got); + } + + ApplySearch(_searchQuery, keepCursor: true); + RecalcRightCounters(); + RefreshHeaderLevelXp(); + RefreshLevelHint(); + + RebuildStatsList(); + RebuildRewardsList(); + UpdateRightPage(); + } + + private static int FindRequirementSlot(RowVM vm, int itemInfoId, sbyte stage) + { + if (vm == null) return -1; + for (int i = 0; i < vm.ReqItemIndices.Count; i++) + { + if (vm.ReqItemIndices[i] != itemInfoId) continue; + sbyte reqStage = (i < vm.ReqStages.Count) ? vm.ReqStages[i] : (sbyte)-1; + if (reqStage == -1 || stage == -1 || reqStage == stage) + return i; + } + return -1; + } + + private MirButton MakeNavButton(Point p, string text, string hint) + { + return new MirButton + { + Parent = _leftNav, + Library = Libraries.Prguse2, + Index = 918, + HoverIndex = 919, + PressedIndex = 919, + Location = p, + Size = new Size(NAV_BTN_W, NAV_BTN_H), + Sound = SoundList.ButtonA, + Text = text, + Hint = hint + }; + } + + private void BuildLeftRarityButtons() + { + if (_leftNav == null) return; + + foreach (var c in _leftNav.Controls.ToArray()) + if (c is MirButton b && (ReferenceEquals(b, _btnAll) || _btnByRarity.Values.Contains(b))) + b.Dispose(); + + _btnAll = null; + _btnByRarity.Clear(); + + int y = NAV_BTN_Y0; + + _btnAll = MakeNavButton(new Point(NAV_BTN_X, y), L("Codex_ShowAllButton"), L("Codex_ShowAllHint")); + _btnAll.Click += (s, e) => SetRarityFilter(null); + y += NAV_BTN_STEP; + + var present = _rows + .Where(r => r != null && r.Rarity != ItemGrade.None) + .Select(r => r.Rarity) + .Distinct() + .OrderBy(r => r); + + foreach (var r in present) + { + string label = r.ToString(); + string buttonText = $"{label}"; + var btn = MakeNavButton(new Point(NAV_BTN_X, y), buttonText, L("Codex_ShowRarityHint", label)); + var rr = r; + btn.Click += (s, e) => SetRarityFilter(rr); + _btnByRarity[rr] = btn; + y += NAV_BTN_STEP; + } + + RefreshRarityButtonStates(); + } + + private void SetRarityFilter(ItemGrade? rarity, bool rebuild = true) + { + _filterRarity = rarity; + RefreshRarityButtonStates(); + + if (rebuild) + { + ApplySearch(_searchQuery, keepCursor: false); + } + } + + private void RefreshRarityButtonStates() + { + if (_btnAll != null) + _btnAll.Index = (_filterRarity == null) ? 919 : 918; + + foreach (var kv in _btnByRarity) + { + bool active = _filterRarity.HasValue && kv.Key == _filterRarity.Value; + kv.Value.Index = active ? 919 : 918; + kv.Value.ForeColour = active ? Color.White : Color.Gainsboro; + } + } + + private void ActivateSearch() + { + if (_searchActivated) return; + _searchActivated = true; + + _searchGate?.Dispose(); + _searchGate = null; + + if (_searchBox != null) + _searchBox.Enabled = true; + } + + private void ApplySearch(string raw, bool keepCursor = false) + { + _searchQuery = raw ?? string.Empty; + string q = _searchQuery.Trim().ToLowerInvariant(); + + IEnumerable src = _rows; + + src = src.Where(r => r != null && r.Bucket == _sectionBucket); + + if (_filterRarity.HasValue) + src = src.Where(r => r.Rarity == _filterRarity.Value); + + if (!string.IsNullOrEmpty(_filterStatKey)) + src = src.Where(r => ExtractFilterKeysFromRow(r).Contains(_filterStatKey)); + + if (!string.IsNullOrEmpty(q)) + { + src = src.Where(r => + { + if ((r.Title ?? "").ToLowerInvariant().Contains(q)) return true; + + foreach (var ix in r.ReqItemIndices) + { + if (ix.ToString().Contains(q)) return true; + var info = GameScene.Scene?.GetItemInfo(ix); + if (info != null && (info.Name ?? "").ToLowerInvariant().Contains(q)) return true; + } + return false; + }); + } + + _viewRows = src.ToList(); + + if (!keepCursor) + { + _selectedIndex = _viewRows.Count > 0 ? 0 : -1; + _scrollOffsetPx = 0; + } + + RebuildList(); + UpdateScrollButtons(); + UpdateScrollThumb(); + } + + private void DeactivateSearch(bool rebuildGate = true) + { + _searchActivated = false; + + if (_searchBox != null) + _searchBox.Enabled = false; + + if (!rebuildGate || IsDisposed || _searchBox == null) return; + if (_searchGate == null) + { + _searchGate = new MirControl + { + Parent = this, + Location = _searchBox.Location, + Size = _searchBox.Size, + Opacity = 0f, + Border = false, + }; + _searchGate.MouseDown += (s, e) => + { + if (e.Button != MouseButtons.Left) return; + _gatePressed = true; + _gateDownPt = e.Location; + }; + _searchGate.MouseUp += (s, e) => + { + if (!_gatePressed || e.Button != MouseButtons.Left) return; + _gatePressed = false; + int dx = e.Location.X - _gateDownPt.X; + int dy = e.Location.Y - _gateDownPt.Y; + if (dx * dx + dy * dy <= GateDragSqr) ActivateSearch(); + }; + } + } + + private void ClearSearch() + { + _searchQuery = string.Empty; + + IEnumerable src = _rows.Where(r => r != null && r.Bucket == _sectionBucket); + if (_filterRarity.HasValue) src = src.Where(r => r.Rarity == _filterRarity.Value); + + _viewRows = src.ToList(); + _selectedIndex = _viewRows.Count > 0 ? 0 : -1; + _scrollOffsetPx = 0; + + if (_searchBox != null) + _searchBox.Text = string.Empty; + + RebuildList(); + UpdateScrollButtons(); + UpdateScrollThumb(); + } + + private void RefreshAllFilters() + { + _searchQuery = string.Empty; + if (_searchBox != null) _searchBox.Text = string.Empty; + + SetRarityFilter(null, rebuild: false); + + _sectionBucket = 0; + + BuildLeftRarityButtons(); + UpdateTopTabVisuals(); + + ApplySearch(_searchQuery, keepCursor: false); + UpdateScrollButtons(); + UpdateScrollThumb(); + RecalcRightCounters(); + RefreshHeaderLevelXp(); + RefreshLevelHint(); + UpdateRightPage(); + } + + private void RebuildList() + { + if (_listViewport == null) return; + + foreach (var c in _listViewport.Controls.ToArray()) + c.Dispose(); + + int viewH = _listViewport.Size.Height; + int firstIndex = Math.Max(0, _scrollOffsetPx / RowStep); + int intraOffset = _scrollOffsetPx % RowStep; + + int y = -intraOffset; + for (int i = firstIndex; i < _viewRows.Count; i++) + { + if (y >= viewH) break; + + var rc = MakeRow(_viewRows[i], i); + rc.Parent = _listViewport; + rc.Location = new Point(6, y + 6); + y += RowStep; + } + + _scrollUp?.BringToFront(); + _scrollDown?.BringToFront(); + _scrollThumb?.BringToFront(); + } + + private int MaxSubLinesForRow() + { + int usable = ROW_H - ROW_REWARD_Y - 4; + return Math.Max(1, usable / ROW_LINE_STEP); + } + + private MirControl MakeRow(RowVM data, int index) + { + var row = new MirControl + { + Size = new Size(_listViewport.Size.Width - 12, ROW_H), + Border = false + }; + + var hintLines = new List(); + if (data.StartTime.HasValue) hintLines.Add(L("Codex_StartsOn", data.StartTime.Value.ToString("yyyy-MM-dd HH:mm"))); + if (data.EndTime.HasValue) hintLines.Add(L("Codex_ActiveUntil", data.EndTime.Value.ToString("yyyy-MM-dd HH:mm"))); + if (!data.Active) hintLines.Add(data.KeepStats ? L("Codex_ExpiredKeep") : L("Codex_Expired")); + if (hintLines.Count > 0) row.Hint = string.Join("\n", hintLines); + + AttachWheel(row); + row.MouseDown += (s, e) => + { + if (e.Button == MouseButtons.Left) SelectIndex(index); + }; + + _ = new MirImageControl + { + Parent = row, + Library = Libraries.UI_32bit, + Index = ROW_BG_INDEX, + Location = new Point(0, 0), + NotControl = true + }; + + const int cell = 32; + const int pad = 4; + const int ACTION_RESERVE_W = 84; + const int GRID_OFFSET_X = 40; + const int GRID_OFFSET_Y = -9; + const int L5_SHIFT_X = 0; + const int L5_SHIFT_Y = 13; + + int totalReq = Math.Min(10, data.ReqItemIndices?.Count ?? 0); + + int labelRightLimit; + if (totalReq > 0) + { + Point[] layout5 = new[] + { + new Point(L5_SHIFT_X + 0 * (cell + pad), L5_SHIFT_Y + 0), + new Point(L5_SHIFT_X + 1 * (cell + pad), L5_SHIFT_Y + 0), + new Point(L5_SHIFT_X + 2 * (cell + pad), L5_SHIFT_Y + 0), + new Point(L5_SHIFT_X + 3 * (cell + pad), L5_SHIFT_Y + 0), + new Point(L5_SHIFT_X + 4 * (cell + pad), L5_SHIFT_Y + 0), + }; + Point[] layout10 = new[] + { + new Point(0 * (cell + pad), 0), + new Point(1 * (cell + pad), 0), + new Point(2 * (cell + pad), 0), + new Point(3 * (cell + pad), 0), + new Point(4 * (cell + pad), 0), + + new Point(0 * (cell + pad), cell + pad), + new Point(1 * (cell + pad), cell + pad), + new Point(2 * (cell + pad), cell + pad), + new Point(3 * (cell + pad), cell + pad), + new Point(4 * (cell + pad), cell + pad), + }; + var tplProbe = (totalReq <= 5) ? layout5 : layout10; + + int rightEdge = row.Size.Width - 10 - ACTION_RESERVE_W + GRID_OFFSET_X; + int minCx = int.MaxValue; + for (int i = 0; i < totalReq; i++) + { + Point off = tplProbe[i]; + int cx = rightEdge - cell - off.X; + if (cx < minCx) minCx = cx; + } + + labelRightLimit = (minCx == int.MaxValue) ? row.Size.Width - ACTION_RESERVE_W - 10 : minCx - 6; + } + else + { + labelRightLimit = row.Size.Width - ACTION_RESERVE_W - 10; + } + + int labelLeft = 3; + int maxLabelW = Math.Max(120, labelRightLimit - labelLeft); + + Color titleColor = data.Active ? RarityColor(data.Rarity) : Color.DimGray; + + _ = new MirLabel + { + Parent = row, + Location = new Point(labelLeft, ROW_TITLE_Y), + AutoSize = false, + Size = new Size(maxLabelW, 20), + ForeColour = titleColor, + Font = new Font(Settings.FontName, 10f, FontStyle.Bold), + Text = data.Title ?? L("Codex_UntitledCollection") + }; + + int lineY = ROW_REWARD_Y; + int shown = 0; + int maxLines = MaxSubLinesForRow(); + + Color codexExpColor = Color.FromArgb(118, 206, 255); + Color statRewardColor = Color.FromArgb(210, 234, 255); + + void AddSubLine(string text, Color color) + { + if (string.IsNullOrWhiteSpace(text) || shown >= maxLines) return; + _ = new MirLabel + { + Parent = row, + Location = new Point(labelLeft, lineY), + AutoSize = false, + Size = new Size(maxLabelW, 18), + ForeColour = color, + Font = new Font(Settings.FontName, 9f, FontStyle.Regular), + Text = text + }; + shown++; + lineY += ROW_LINE_STEP; + } + + // Time/status lines (Event/Limited) + string timeLine = null; + if (data.EndTime.HasValue && data.Active) + timeLine = L("Codex_ActiveUntil", data.EndTime.Value.ToString("yyyy-MM-dd HH:mm")); + else if (data.StartTime.HasValue && !data.Active) + timeLine = L("Codex_StartsOn", data.StartTime.Value.ToString("yyyy-MM-dd HH:mm")); + + if (!string.IsNullOrEmpty(timeLine)) + AddSubLine(timeLine, Color.Gainsboro); + + if (!data.Active) + { + string status = data.KeepStats ? L("Codex_ExpiredKeep") : L("Codex_Expired"); + AddSubLine(status, Color.LightGray); + } + + if (data.RewardXP > 0) + AddSubLine(L("Codex_ExpLabel", data.RewardXP), codexExpColor); + + var rawLines = BuildStatLines(data); + var preview = BuildRowPreviewLines(rawLines, 3, out var fullHint); + + foreach (var line in preview) + { + AddSubLine(line, statRewardColor); + if (shown >= maxLines) break; + } + + if (!string.IsNullOrEmpty(fullHint)) + { + int hintWidth = Math.Max(32, labelRightLimit); + var hintArea = new MirControl + { + Parent = row, + Location = new Point(0, 0), + Size = new Size(hintWidth, row.Size.Height), + Opacity = 0f, + Hint = fullHint, + Border = false + }; + AttachWheel(hintArea); + hintArea.MouseDown += (s, e) => { if (e.Button == MouseButtons.Left) SelectIndex(index); }; + } + + if (totalReq > 0) + { + int rightEdge = row.Size.Width - 10 - ACTION_RESERVE_W + GRID_OFFSET_X; + int baseTopY = 12 + GRID_OFFSET_Y; + + Point[] layout5 = new[] + { + new Point(L5_SHIFT_X + 0 * (cell + pad), L5_SHIFT_Y + 0), + new Point(L5_SHIFT_X + 1 * (cell + pad), L5_SHIFT_Y + 0), + new Point(L5_SHIFT_X + 2 * (cell + pad), L5_SHIFT_Y + 0), + new Point(L5_SHIFT_X + 3 * (cell + pad), L5_SHIFT_Y + 0), + new Point(L5_SHIFT_X + 4 * (cell + pad), L5_SHIFT_Y + 0), + }; + Point[] layout10 = new[] + { + new Point(0 * (cell + pad), 0), + new Point(1 * (cell + pad), 0), + new Point(2 * (cell + pad), 0), + new Point(3 * (cell + pad), 0), + new Point(4 * (cell + pad), 0), + + new Point(0 * (cell + pad), cell + pad), + new Point(1 * (cell + pad), cell + pad), + new Point(2 * (cell + pad), cell + pad), + new Point(3 * (cell + pad), cell + pad), + new Point(4 * (cell + pad), cell + pad), + }; + + var tpl = (totalReq <= 5) ? layout5 : layout10; + + while (data.ReqRegistered.Count < data.ReqItemIndices.Count) data.ReqRegistered.Add(false); + + for (int i = 0; i < totalReq; i++) + { + int itemInfoId = data.ReqItemIndices[i]; + sbyte reqStage = (i < data.ReqStages.Count) ? data.ReqStages[i] : (sbyte)-1; + Point off = tpl[i]; + + int cx = rightEdge - cell - off.X; + int cy = baseTopY + off.Y; + + int iconIndex = (i < data.ReqItemIcons.Count) ? data.ReqItemIcons[i] : 0; + if (iconIndex <= 0) + { + var info = GameScene.Scene?.GetItemInfo(itemInfoId); + if (info != null) iconIndex = info.Image; + if (IconResolver != null) iconIndex = IconResolver(itemInfoId); + } + + bool registered = data.ReqRegistered[i]; + bool hasInInv = HasItemInAnyInventory(itemInfoId, reqStage); + bool needsGrey = !(registered || hasInInv); + + _ = new MirImageControl + { + Parent = row, + Library = Libraries.UI_32bit, + Index = CELL_FRAME_IDX, + Location = new Point(cx, cy), + NotControl = true + }; + + if (iconIndex > 0) + { + // Center the item icon within the cell frame + Size iconSize = Libraries.Items.GetSize(iconIndex); + if (iconSize.IsEmpty) iconSize = new Size(cell - 4, cell - 4); + int ix = cx + (cell - iconSize.Width) / 2; + int iy = cy + (cell - iconSize.Height) / 2; + + var iconCtrl = new MirImageControl + { + Parent = row, + Library = Libraries.Items, + Index = iconIndex, + Location = new Point(ix, iy), + Size = iconSize, + DrawImage = true, + NotControl = true + }; + + if (needsGrey) + { + int drawIndex = iconIndex; + iconCtrl.DrawImage = false; + iconCtrl.BeforeDraw += (s, e) => + { + var lib = Libraries.Items; + var pos = iconCtrl.DisplayLocation; + lib.Draw(drawIndex, pos, Color.FromArgb(140, 140, 140)); + }; + } + } + + if (registered) + { + _ = new MirImageControl + { + Parent = row, + Library = Libraries.UI_32bit, + Index = EMBLEM_DONE_IDX, + Location = new Point(cx + cell - 15, cy + cell - 19), + NotControl = true + }; + } + else if (hasInInv) + { + _ = new MirImageControl + { + Parent = row, + Library = Libraries.UI_32bit, + Index = EMBLEM_READY_IDX, + Location = new Point(cx + 2, cy + 2), + NotControl = true + }; + } + + var hit = new MirControl + { + Parent = row, + Location = new Point(cx, cy), + Size = new Size(cell, cell), + Opacity = 0f + }; + + hit.MouseEnter += (s, e) => + { + if (TooltipSuppressed) return; + var h = (MirControl)s; + Point anchor = new Point(h.DisplayLocation.X, h.DisplayLocation.Y + h.Size.Height + 2); + ShowItemTooltip(itemInfoId, reqStage, anchor); + }; + + hit.MouseLeave += (s, e) => HideItemTooltip(); + + hit.MouseWheel += (o, e) => + { + HideItemTooltip(); + SuppressTooltips(200); + int ticks = Math.Max(1, Math.Abs(e.Delta) / 120); + int dir = e.Delta > 0 ? -1 : +1; + ScrollBy(dir * RowStep * ticks); + }; + } + } + + const int STATUS_RIGHT_MARGIN = -36; + const int STATUS_TOP_Y_CLAIMED = 25; + + const int ACTION_BTN_RIGHT_MARGIN = -28; + const int ACTION_BTN_TOP_Y = 22; + + bool SetIsComplete(RowVM r) => Math.Max(0, (int)r.Required) > 0 && Math.Max(0, (int)r.Found) >= Math.Max(0, (int)r.Required); + bool finished = SetIsComplete(data); + + if (finished && !data.Claimed) + { + var actionBtn = new MirButton + { + Parent = row, + Library = Libraries.Title_32bit, + Index = 307, + HoverIndex = 308, + PressedIndex = 309, + Sound = SoundList.ButtonA, + }; + actionBtn.Location = new Point( + row.Size.Width - ACTION_BTN_RIGHT_MARGIN - actionBtn.Size.Width, + ACTION_BTN_TOP_Y + ); + actionBtn.Click += (o, e) => HandleActionClick(actionBtn, data); + } + else if (!finished) + { + bool hasEligibleItem = TryFindFirstEligibleRequirement(data, out _, out _); + + bool canUseStone = (data.Rarity == ItemGrade.Rare) && (GameScene.Stone > 0); + bool canUseJade = (data.Rarity == ItemGrade.Legendary) && (GameScene.Jade > 0); + bool canUseCurrency = canUseStone || canUseJade; + + if (hasEligibleItem || canUseCurrency) + { + var submitBtn = new MirButton + { + Parent = row, + Library = Libraries.Title_32bit, + Index = 304, + HoverIndex = 305, + PressedIndex = 306, + Sound = SoundList.ButtonA, + }; + submitBtn.Location = new Point( + row.Size.Width - ACTION_BTN_RIGHT_MARGIN - submitBtn.Size.Width, + ACTION_BTN_TOP_Y + ); + submitBtn.Click += (o, e) => PromptSubmitChoice(data); + } + else + { + var incompleteLbl = new MirLabel + { + Parent = row, + AutoSize = true, + ForeColour = Color.Silver, + Font = new Font(Settings.FontName, 9f, FontStyle.Regular), + Text = "[ Incomplete ]", + NotControl = true + }; + incompleteLbl.Location = new Point( + row.Size.Width - STATUS_RIGHT_MARGIN - incompleteLbl.Size.Width, + STATUS_TOP_Y_CLAIMED + ); + } + } + else + { + var completedLine = new MirLabel + { + Parent = row, + AutoSize = true, + ForeColour = Color.LimeGreen, + Font = new Font(Settings.FontName, 9f, FontStyle.Regular), + Text = "[ Completed ]", + NotControl = true + }; + completedLine.Location = new Point( + row.Size.Width - STATUS_RIGHT_MARGIN - completedLine.Size.Width, + STATUS_TOP_Y_CLAIMED + ); + } + + return row; + } + + private void HandleActionClick(MirButton btn, RowVM data) + { + if (btn == null || data == null) return; + btn.Enabled = false; + + bool finished = data.Required > 0 && data.Found >= data.Required; + if (finished && !data.Claimed) + { + TryClaimSet(data.SetId); + return; + } + + if (TryFindFirstEligibleRequirement(data, out int eligibleInfoId, out sbyte eligibleStage)) + { + TrySubmitItem(data.SetId, eligibleInfoId, eligibleStage); + return; + } + + GameScene.Scene?.ChatDialog.ReceiveChat(L("Codex_NothingToSubmit"), ChatType.Hint); + } + + private static bool HasItemInAnyInventory(int itemInfoId, sbyte stage = -1) + { + bool Matches(UserItem it) + { + if (it?.Info == null || it.Info.Index != itemInfoId) return false; + return true; + } + + var user = GameScene.User; + if (user?.Inventory != null) + foreach (var it in user.Inventory) + if (Matches(it)) return true; + + var hero = GameScene.Hero; + if (hero?.Inventory != null) + foreach (var it in hero.Inventory) + if (Matches(it)) return true; + + return false; + } + + private bool TryFindFirstEligibleRequirement(RowVM vm, out int itemInfoId, out sbyte stage) + { + itemInfoId = -1; + stage = -1; + if (vm == null) return false; + + while (vm.ReqRegistered.Count < vm.ReqItemIndices.Count) + vm.ReqRegistered.Add(false); + + for (int i = 0; i < vm.ReqItemIndices.Count; i++) + { + if (vm.ReqRegistered[i]) continue; + int infoId = vm.ReqItemIndices[i]; + sbyte reqStage = (i < vm.ReqStages.Count) ? vm.ReqStages[i] : (sbyte)-1; + if (HasItemInAnyInventory(infoId, reqStage)) + { + itemInfoId = infoId; + stage = reqStage; + return true; + } + } + return false; + } + + private void TryClaimSet(int setId) + { + var vm = _rows.FirstOrDefault(x => x.SetId == setId); + if (vm == null) return; + + Network.Enqueue(new C.ClaimItemCodex { Id = setId }); + ApplyLocalClaim(vm); + + GameScene.Scene?.ChatDialog.ReceiveChat(L("Codex_RewardClaimed"), ChatType.Hint); + } + + private void ApplyLocalClaim(RowVM vm) + { + if (vm == null) return; + + if (!ClaimedSetIds.Contains(vm.SetId)) + ClaimedSetIds.Add(vm.SetId); + + vm.Claimed = true; + + ApplySearch(_searchQuery, keepCursor: true); + RecalcRightCounters(); + RefreshHeaderLevelXp(); + RefreshLevelHint(); + GameScene.User?.RefreshStats(); + + PlaySetCompleteEffect($"Collection set completed: {vm.Title}", 2.0f, -60, 120, 2000); + + RebuildStatsList(); + RebuildRewardsList(); + UpdateRightPage(); + RefreshClaimedSetsEmblem(); + } + + private void ApplyLocalRewardIfCompleted(RowVM vm) + { + if (vm == null) return; + + bool finished = vm.Required > 0 && vm.Found >= vm.Required; + if (!finished) return; + + if (!ClaimedSetIds.Contains(vm.SetId)) + { + ClaimedSetIds.Add(vm.SetId); + vm.Claimed = true; + + ApplySearch(_searchQuery, keepCursor: true); + RecalcRightCounters(); + RefreshHeaderLevelXp(); + RefreshLevelHint(); + GameScene.User?.RefreshStats(); + + PlaySetCompleteEffect(L("Codex_SetCompletedMessage", vm.Title), 2.0f, -60, 120, 2000); + PlaySetCompleteEffect(L("Codex_SetCompletedMessage", vm.Title), 2.0f, -60, 120, 2000); + + GameScene.Scene?.ChatDialog.ReceiveChat(L("Codex_CollectionCompleted"), ChatType.Hint); + + Network.Enqueue(new C.ClaimItemCodex { Id = vm.SetId }); + + RebuildStatsList(); + RebuildRewardsList(); + UpdateRightPage(); + RefreshClaimedSetsEmblem(); + } + } + + private void PromptSubmitChoice(RowVM data) + { + if (data == null) return; + var scene = GameScene.Scene; + + bool hasEligibleItem = TryFindFirstEligibleRequirement(data, out int eligibleInfoId, out sbyte eligibleStage); + + bool haveStone = GameScene.Stone > 0; + bool haveJade = GameScene.Jade > 0; + + bool stoneAllowed = haveStone && IsCurrencyAllowedForRarity(Currency.Stone, data.Rarity); + bool jadeAllowed = haveJade && IsCurrencyAllowedForRarity(Currency.Jade, data.Rarity); + bool canUseCurrency = stoneAllowed || jadeAllowed; + + Currency defaultCur = Currency.None; + if (data.Rarity == ItemGrade.Rare && stoneAllowed) defaultCur = Currency.Stone; + if (data.Rarity == ItemGrade.Legendary && jadeAllowed) defaultCur = Currency.Jade; + if (defaultCur == Currency.None) defaultCur = stoneAllowed ? Currency.Stone : + jadeAllowed ? Currency.Jade : Currency.None; + + string curLabel = defaultCur == Currency.Jade ? L("Codex_JadeLabel") : L("Codex_StoneLabel"); + int curCount = defaultCur == Currency.Jade ? (int)GameScene.Jade : (int)GameScene.Stone; + + string itemName = scene?.GetItemInfo(eligibleInfoId)?.Name ?? "item"; + string stageLabel = eligibleStage >= 0 ? $" (Stage {eligibleStage})" : string.Empty; + int itemCount = hasEligibleItem ? CountEligibleInBag(eligibleInfoId, eligibleStage) : 0; + + if (canUseCurrency && hasEligibleItem) + { + string msg = + $"Submit this entry?\n\n" + + $"• Use Currency ({curLabel}) — consumes 1 {curLabel}. You have {curCount}.\n" + + $"• Use Inventory Item — consumes 1 \"{itemName}{stageLabel}\". You own {itemCount}.\n\n" + + $"Yes = Currency ({curLabel}) • No = Inventory Item"; + + var box = new MirMessageBox(msg, MirMessageBoxButtons.YesNo); + + box.YesButton.Click += (o, e) => + { + var cBox = new MirMessageBox($"Use 1× {curLabel} to submit this entry?\nYou have {curCount}.", + MirMessageBoxButtons.OKCancel); + cBox.OKButton.Click += (o2, e2) => TryUseCurrency(data.SetId, defaultCur); + cBox.Show(); cBox.BringToFront(); + }; + + box.NoButton.Click += (o, e) => TrySubmitItem(data.SetId, eligibleInfoId, eligibleStage); + + box.Show(); box.BringToFront(); + return; + } + + if (canUseCurrency && !hasEligibleItem) + { + var cBox = new MirMessageBox($"Use 1× {curLabel} to submit this entry?\nYou have {curCount}.", + MirMessageBoxButtons.OKCancel); + cBox.OKButton.Click += (o, e) => TryUseCurrency(data.SetId, defaultCur); + cBox.Show(); cBox.BringToFront(); + return; + } + + if (hasEligibleItem) + { + TrySubmitItem(data.SetId, eligibleInfoId, eligibleStage); + return; + } + + scene?.ChatDialog.ReceiveChat(L("Codex_NoEligibleItemOrCurrency"), ChatType.Hint); + } + + private int CountEligibleInBag(int infoId, sbyte stage) + { + if (infoId <= 0 || GameScene.User == null) return 0; + return GameScene.User.Inventory.Count(u => + u?.Info != null && + u.Info.Index == infoId && + stage < 0); + } + + private void TrySubmitItem(int setId, int itemInfoIndex, sbyte stage) + { + var scene = GameScene.Scene; + var user = GameScene.User; + + if (user == null || user.Inventory == null) + { + scene?.ChatDialog.ReceiveChat(L("Codex_InventoryNotAvailable"), ChatType.Hint); + return; + } + + bool TryPickFrom(IEnumerable bag, out ulong selected) + { + selected = 0; + if (bag == null) return false; + foreach (var it in bag) + { + if (it?.Info == null || it.Info.Index != itemInfoIndex) continue; + selected = it.UniqueID; + return true; + } + return false; + } + + ulong uid = 0; + if (!TryPickFrom(user.Inventory, out uid)) + { + if (GameScene.Hero?.Inventory != null) + TryPickFrom(GameScene.Hero.Inventory, out uid); + } + + var info = scene?.GetItemInfo(itemInfoIndex); + string itemName = info?.Name ?? "item"; + string stageLabel = stage >= 0 ? $" (Stage {stage})" : string.Empty; + + if (uid == 0) + { + scene?.ChatDialog.ReceiveChat(L("Codex_MissingItemStage", $"{itemName}{stageLabel}"), ChatType.Hint); + return; + } + + var box = new MirMessageBox( + L("Codex_SubmitConfirm", $"{itemName}{stageLabel}"), + MirMessageBoxButtons.OKCancel); + + box.OKButton.Click += (o, e) => + { + Network.Enqueue(new C.SubmitItemToCodex + { + SetId = setId, + ItemInfoId = itemInfoIndex, + Stage = stage, + UniqueID = uid + }); + + scene?.ChatDialog.ReceiveChat(L("Codex_SubmittingItem", $"{itemName}{stageLabel}"), ChatType.Hint); + + var vm = _rows.FirstOrDefault(x => x.SetId == setId); + if (vm != null) + { + int reqIx = FindRequirementSlot(vm, itemInfoIndex, stage); + if (reqIx >= 0) + { + while (vm.ReqRegistered.Count < vm.ReqItemIndices.Count) vm.ReqRegistered.Add(false); + if (!vm.ReqRegistered[reqIx]) vm.ReqRegistered[reqIx] = true; + } + + int got = vm.ReqRegistered.Count(b => b); + int need = vm.Required > 0 ? vm.Required : vm.ReqItemIndices.Count; + vm.Found = (short)Math.Min(need, got); + + ApplySearch(_searchQuery, keepCursor: true); + RecalcRightCounters(); + RefreshHeaderLevelXp(); + RefreshLevelHint(); + + ApplyLocalRewardIfCompleted(vm); + } + }; + + box.Show(); + box.BringToFront(); + } + + private void RecalcRightCounters() + { + if (_rightLabels.Count == 0) return; + + (int done, int total) setAll = (0, 0), setCh = (0, 0), setLim = (0, 0), setEvt = (0, 0); + + foreach (var r in _rows) + { + if (r == null) continue; + + bool complete = r.Required > 0 && r.Found >= r.Required; + + switch (r.Bucket) + { + case 1: setLim.total++; if (complete) setLim.done++; break; + case 2: setEvt.total++; if (complete) setEvt.done++; break; + default: setCh.total++; if (complete) setCh.done++; break; + } + setAll.total++; if (complete) setAll.done++; + } + + SetRightProgress(0, "All", setAll.done, setAll.total); + SetRightProgress(1, "Character", setCh.done, setCh.total); + SetRightProgress(2, "Limited", setLim.done, setLim.total); + SetRightProgress(3, "Event", setEvt.done, setEvt.total); + + SetBarFill(0, setAll.done, setAll.total); + SetBarFill(1, setCh.done, setCh.total); + SetBarFill(2, setLim.done, setLim.total); + SetBarFill(3, setEvt.done, setEvt.total); + } + + private void SetRightProgress(int idx, string name, int done, int total) + { + if ((uint)idx >= (uint)_rightLabels.Count) return; + _rightLabels[idx].Text = $"{name} ({done}/{total})"; + } + + private void SetBarFill(int idx, int found, int need) + { + if (idx < 0 || idx >= _rightBars.Count) return; + + _barFound[idx] = Math.Max(0, found); + _barNeed[idx] = Math.Max(0, need); + } + + private void ScrollBy(int deltaPx) + { + HideItemTooltip(); + SuppressTooltips(200); + + _scrollOffsetPx += deltaPx; + ClampScroll(); + RebuildList(); + UpdateScrollButtons(); + UpdateScrollThumb(); + } + + private void ClampScroll() + { + int maxOff = GetMaxOffsetPx(); + if (_scrollOffsetPx < 0) _scrollOffsetPx = 0; + if (_scrollOffsetPx > maxOff) _scrollOffsetPx = maxOff; + } + + private int GetMaxOffsetPx() + { + if (_viewRows.Count <= 0) return 0; + int contentH = (_viewRows.Count * ROW_H) + ((_viewRows.Count - 1) * ROW_GAP) + 12; + int viewH = _listViewport.Size.Height; + return Math.Max(0, contentH - viewH); + } + + private void UpdateScrollButtons() + { + int maxOff = GetMaxOffsetPx(); + if (_scrollUp != null) _scrollUp.Enabled = _scrollOffsetPx > 0; + if (_scrollDown != null) _scrollDown.Enabled = _scrollOffsetPx < maxOff; + } + + private void UpdateScrollThumb() + { + if (_scrollThumb == null || _scrollUp == null || _scrollDown == null) return; + + int minY = _scrollUp.Location.Y + _scrollUp.Size.Height + 2; + int maxY = _scrollDown.Location.Y - _scrollThumb.Size.Height - 2; + + int maxOffPx = GetMaxOffsetPx(); + if (maxOffPx <= 0) + { + _scrollThumb.Location = new Point(_scrollThumb.Location.X, minY); + return; + } + + float percent = (float)_scrollOffsetPx / maxOffPx; + int y = minY + (int)((maxY - minY) * percent); + _scrollThumb.Location = new Point(_scrollThumb.Location.X, y); + } + + private void AttachWheel(MirControl c) + { + if (c == null) return; + c.MouseWheel += (o, e) => + { + HideItemTooltip(); + SuppressTooltips(200); + + int ticks = Math.Max(1, Math.Abs(e.Delta) / 120); + int dir = e.Delta > 0 ? -1 : +1; + ScrollBy(dir * RowStep * ticks); + }; + } + + private void ShowItemTooltip(int itemInfoIndex, sbyte stage, Point anchor) + { + if (TooltipSuppressed) return; + + var scene = GameScene.Scene; + var info = scene?.GetItemInfo(itemInfoIndex); + if (scene == null || info == null) return; + + var ui = new UserItem(info) + { + Count = 1, + CurrentDura = info.Durability, + MaxDura = info.Durability + }; + + scene.DisposeItemLabel(); + scene.CreateItemLabel(ui); + if (scene.ItemLabel != null) + { + scene.ItemLabel.Visible = false; + + int maxX = Math.Max(0, scene.Size.Width - scene.ItemLabel.Size.Width - 2); + int maxY = Math.Max(0, scene.Size.Height - scene.ItemLabel.Size.Height - 2); + int px = Math.Min(maxX, Math.Max(0, anchor.X)); + int py = Math.Min(maxY, Math.Max(0, anchor.Y)); + + scene.ItemLabel.Location = new Point(px, py); + scene.ItemLabel.BringToFront(); + scene.ItemLabel.Visible = true; + } + } + + private void HideItemTooltip() + { + GameScene.Scene?.DisposeItemLabel(); + } + + private void SelectIndex(int idx) + { + if (_viewRows.Count == 0) return; + + _selectedIndex = Math.Max(0, Math.Min(idx, _viewRows.Count - 1)); + + int selTop = _selectedIndex * RowStep; + int selBottom = selTop + ROW_H; + + int viewTop = _scrollOffsetPx; + int viewBot = _scrollOffsetPx + _listViewport.Size.Height; + + if (selTop < viewTop) _scrollOffsetPx = selTop; + else if (selBottom > viewBot) _scrollOffsetPx = selBottom - _listViewport.Size.Height; + + ClampScroll(); + RebuildList(); + } + + private void ComputeLevelProgress(out int level, out int xpWithin, out int needWithin) + { + int totalXp = 0; + bool anyXp = false; + + foreach (var r in _rows) + { + if (r == null || !r.Claimed) continue; + if (r.RewardXP > 0) { totalXp += r.RewardXP; anyXp = true; } + else totalXp += 1; + } + + if (!anyXp) + totalXp = _rows.Count(rr => rr != null && rr.Required > 0 && rr.Found >= rr.Required && rr.Claimed); + + var tiers = Enum.GetValues(typeof(CodexLevel)) + .Cast() + .Select(v => (int)v) + .Where(v => v > 0) + .OrderBy(v => v) + .ToArray(); + + if (tiers.Length == 0) + { + level = 1; + xpWithin = Math.Max(0, totalXp); + needWithin = 1; + UpdateLevelHint(level); + return; + } + + level = 1; + int remaining = Math.Max(0, totalXp); + int idx = 0; + + while (idx < tiers.Length && remaining >= tiers[idx]) + { + remaining -= tiers[idx]; + level++; + idx++; + } + + int need = tiers[Math.Min(idx, tiers.Length - 1)]; + xpWithin = remaining; + needWithin = need; + + UpdateLevelHint(level); + } + + private void RefreshHeaderLevelXp() + { + ComputeLevelProgress(out int level, out int xpWithin, out int needWithin); + + int previous = _lastLevelSeen; + _lastLevelSeen = Math.Max(1, level); + + if (previous > 0 && _lastLevelSeen > previous) + { + _pendingLevelUpFx = true; + + if (!_setFxPlaying) + { + var delay = new System.Windows.Forms.Timer { Interval = 350 }; + delay.Tick += (s, e) => + { + delay.Stop(); delay.Dispose(); + if (_pendingLevelUpFx && !_setFxPlaying) + { + _pendingLevelUpFx = false; + PlayLevelUpEffect(L("Codex_LevelUp", _lastLevelSeen)); + } + }; + delay.Start(); + } + } + + if (_levelLabel != null) + _levelLabel.Text = L("Codex_LevelLabel", Math.Max(1, level)); + + if (_xpText != null) + { + _xpText.Text = $"{Math.Max(0, xpWithin)} / {Math.Max(1, needWithin)}"; + int barW = GetXpBarWidth(); + Size sz = TextRenderer.MeasureText(_xpText.Text, _xpText.Font); + int cx = XP_BAR_X + (barW - sz.Width) / 2; + _xpText.Location = new Point(cx, XP_BAR_Y + 1); + } + } + + private void UpdateLevelHint(int level) + { + if (_levelHintHotspot == null) return; + + var lines = BuildCollectionLevelHint(level); + + _levelHintHotspot.Hint = + (lines == null || lines.Length == 0) + ? L("Codex_LevelHint", Math.Max(1, level)) + : L("Codex_LevelHint", Math.Max(1, level)) + "\n" + string.Join("\n", lines); + + _levelHintHotspot.Visible = true; + _levelHintHotspot.Location = new Point(LEVEL_HINT_RECT.X, LEVEL_HINT_RECT.Y); + _levelHintHotspot.Size = new Size(LEVEL_HINT_RECT.Width, LEVEL_HINT_RECT.Height); + } + + private void SwitchRightPage(int delta) + { + int x = ((int)_rightPage + delta) % 2; + if (x < 0) x += 2; + _rightPage = (RightPage)x; + UpdateRightPage(); + } + + private void UpdateRightPage() + { + if (_rightHeader != null) + { + _rightHeader.Text = _rightPage switch + { + RightPage.Progress => "Progress", + RightPage.Rewards => "Rewards", + _ => "Progress" + }; + _rightHeader.Location = RightUI.Header; + } + + bool showProgress = _rightPage == RightPage.Progress; + for (int i = 0; i < _rightIcons.Count; i++) if (_rightIcons[i] != null) _rightIcons[i].Visible = showProgress; + for (int i = 0; i < _rightBars.Count; i++) if (_rightBars[i] != null) _rightBars[i].Visible = showProgress && (_barNeed[i] > 0); + for (int i = 0; i < _rightLabels.Count; i++) if (_rightLabels[i] != null) _rightLabels[i].Visible = showProgress; + + bool showRewards = _rightPage == RightPage.Rewards; + UpdateRewardsVisible(showRewards); + if (showRewards) RebuildRewardsList(); + + } + + private Stats GetTotalClaimedStats(out int totalXp) + { + totalXp = 0; + var total = new Stats(); + + foreach (var vm in _rows) + { + if (vm == null || !vm.Claimed) continue; + + totalXp += (vm.RewardXP > 0) ? vm.RewardXP : 1; + + var s = vm.Reward; + if (s?.Values == null) continue; + + foreach (var kv in s.Values) + total[kv.Key] = total[kv.Key] + kv.Value; + } + return total; + } + + private void RebuildStatsList() + { + if (_statsViewport == null) return; + + foreach (var c in _statsViewport.Controls.ToArray()) + c.Dispose(); + _statsLines.Clear(); + _statsScrollOffsetPx = 0; + + int totalXp; + Stats total = GetTotalClaimedStats(out totalXp); + + int y = 4; + var prettyStats = FriendlyStatLines(BuildStatLines(total)); + foreach (var line in prettyStats) + { + var lbl = new MirLabel + { + Parent = _statsViewport, + AutoSize = true, + ForeColour = Color.White, + Font = new Font(Settings.FontName, 9f), + Location = new Point(6, y), + Text = line + }; + _statsLines.Add(lbl); + y += StatsLineH; + } + + UpdateStatsScrollUI(); + } + + private void ScrollStatsBy(int deltaPx) + { + int inner = Math.Max(0, _statsLines.Count * StatsLineH + 8 - _statsViewport.Size.Height); + _statsScrollOffsetPx = Math.Max(0, Math.Min(inner, _statsScrollOffsetPx + deltaPx)); + + int yBase = 4 - _statsScrollOffsetPx; + for (int i = 0; i < _statsLines.Count; i++) + _statsLines[i].Location = new Point(6, yBase + i * StatsLineH); + + UpdateStatsScrollUI(); + } + + private void UpdateStatsScrollUI() + { + if (_statsViewport == null) return; + + int inner = Math.Max(0, (_statsLines.Count * StatsLineH) + 8 - _statsViewport.Size.Height); + + bool canScroll = inner > 0; + if (_statsScrollUp != null) _statsScrollUp.Enabled = canScroll; + if (_statsScrollDown != null) _statsScrollDown.Enabled = canScroll; + + if (_statsScrollThumb != null) _statsScrollThumb.Visible = false; + } + + private static int RewardLineOrderKey(string s) + { + if (string.IsNullOrWhiteSpace(s)) return int.MaxValue; + + if (s.StartsWith("AC ", StringComparison.OrdinalIgnoreCase)) return 0; + if (s.StartsWith("MAC ", StringComparison.OrdinalIgnoreCase)) return 1; + if (s.StartsWith("DC ", StringComparison.OrdinalIgnoreCase)) return 2; + if (s.StartsWith("MC ", StringComparison.OrdinalIgnoreCase)) return 3; + if (s.StartsWith("SC ", StringComparison.OrdinalIgnoreCase)) return 4; + + if (s.StartsWith("Accuracy", StringComparison.OrdinalIgnoreCase)) return 5; + if (s.StartsWith("Agility", StringComparison.OrdinalIgnoreCase)) return 6; + + return 1000; + } + + private void RebuildRewardsList() + { + if (_rewardsViewport == null) return; + + foreach (var c in _rewardsViewport.Controls.ToArray()) c.Dispose(); + _rewardsLines.Clear(); + _rewardsData.Clear(); + _rewardsScrollOffsetPx = 0; + _firstVisibleLine = 0; + + int _; + var setTotals = GetTotalClaimedStats(out _); + + var levelTotals = GetCollectionLevelBonusStats(); + + var merged = new Stats(); + void AddInto(Stats s) + { + if (s?.Values == null) return; + foreach (var kv in s.Values) + merged[kv.Key] = merged[kv.Key] + kv.Value; + } + AddInto(setTotals); + AddInto(levelTotals); + + var pretty = FriendlyStatLines(BuildStatLines(merged)) + .OrderBy(RewardLineOrderKey) + .ThenBy(s => s, StringComparer.OrdinalIgnoreCase); + + foreach (var line in pretty) + _rewardsData.Add(line); + + EnsureRewardsPool(); + RepopulateRewardsSlice(); + ScrollRewardsBy(0); + } + + private void RepopulateRewardsSlice() + { + if (_rewardsViewport == null || _rewardsLines.Count == 0) return; + + for (int j = 0; j < _rewardsLines.Count; j++) + { + int i = _firstVisibleLine + j; + var lbl = _rewardsLines[j]; + + if (i < _rewardsData.Count) + { + lbl.Text = _rewardsData[i]; + lbl.Visible = true; + } + else + { + lbl.Text = string.Empty; + lbl.Visible = false; + } + } + } + + private void EnsureRewardsPool() + { + if (_rewardsViewport == null) return; + if (_rewardsLines.Count > 0) return; + + for (int i = 0; i < VisibleLineCount; i++) + { + var lbl = new MirLabel + { + Parent = _rewardsViewport, + AutoSize = true, + ForeColour = Color.White, + Font = new Font(Settings.FontName, 9f), + Location = new Point(6, 4 + i * StatsLineH), + Visible = false + }; + _rewardsLines.Add(lbl); + } + } + + private void ScrollRewardsBy(int deltaPx) + { + if (_rewardsViewport == null) return; + + int contentH = (_rewardsData.Count * StatsLineH) + 8; + int viewH = _rewardsViewport.Size.Height; + int maxOffset = Math.Max(0, contentH - viewH); + + _rewardsScrollOffsetPx = Math.Max(0, Math.Min(maxOffset, _rewardsScrollOffsetPx + deltaPx)); + + int newFirst = _rewardsScrollOffsetPx / StatsLineH; + if (newFirst != _firstVisibleLine) + { + _firstVisibleLine = newFirst; + RepopulateRewardsSlice(); + } + + int remainder = _rewardsScrollOffsetPx % StatsLineH; + for (int j = 0; j < _rewardsLines.Count; j++) + { + var lbl = _rewardsLines[j]; + int y = 4 + (j * StatsLineH) - remainder; + lbl.Location = new Point(6, y); + + int h = Math.Max(StatsLineH, lbl.Size.Height); + bool inside = lbl.Visible && (y + h) > 0 && y < viewH; + lbl.Visible = inside; + } + + UpdateRewardsScrollUI(); + } + + private void UpdateRewardsScrollUI() + { + if (_rewardsViewport == null) return; + + int inner = Math.Max(0, (_rewardsData.Count * StatsLineH) + 8 - _rewardsViewport.Size.Height); + + bool canScroll = inner > 0; + if (_rewardsScrollUp != null) _rewardsScrollUp.Enabled = canScroll; + if (_rewardsScrollDown != null) _rewardsScrollDown.Enabled = canScroll; + + if (_rewardsScrollThumb != null) _rewardsScrollThumb.Visible = false; + } + + private void UpdateRewardsVisible(bool showRewards) + { + if (_rewardsViewport != null) _rewardsViewport.Visible = showRewards; + if (_rewardsScrollUp != null) _rewardsScrollUp.Visible = showRewards; + if (_rewardsScrollDown != null) _rewardsScrollDown.Visible = showRewards; + + if (_rewardsScrollThumb != null) _rewardsScrollThumb.Visible = false; + + if (_rewardsMaskTop != null) _rewardsMaskTop.Visible = showRewards; + if (_rewardsMaskBottom != null) _rewardsMaskBottom.Visible = showRewards; + + if (!showRewards) return; + + _rewardsViewport.BringToFront(); + _rewardsMaskTop.BringToFront(); + _rewardsMaskBottom.BringToFront(); + _rewardsScrollUp.BringToFront(); + _rewardsScrollDown.BringToFront(); + + ScrollRewardsBy(0); + } + + private void AdjustRewardsMasksToViewport() + { + if (_rewardsViewport == null) return; + + if (_rewardsMaskTop != null) + { + _rewardsMaskTop.Location = new Point(_rewardsViewport.Location.X, _rewardsViewport.Location.Y - 2000); + _rewardsMaskTop.Size = new Size(_rewardsViewport.Size.Width, 2000); + } + if (_rewardsMaskBottom != null) + { + _rewardsMaskBottom.Location = new Point(_rewardsViewport.Location.X, _rewardsViewport.Location.Y + _rewardsViewport.Size.Height); + _rewardsMaskBottom.Size = new Size(_rewardsViewport.Size.Width, 2000); + } + } + + private static readonly Dictionary StatShortNames = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Accuracy"] = "Accuracy", + ["Agility"] = "Agility", + + // Attack speed + ["AttackSpeed"] = "A.Speed", + ["AttackSpeedRatePercent"] = "A.Speed %", + + // Weights + ["BagWeight"] = "B - Weight", + ["HandWeight"] = "H - Weight", + ["WearWeight"] = "W - Weight", + + // Poison / resists / recovery + ["PoisonAttack"] = "PSN Attack", + ["PoisonResist"] = "PSN Res", + ["PoisonRecovery"] = "PSN Regen", + + // Recovery + ["HealthRecovery"] = "HP Regen", + ["SpellRecovery"] = "MP Regen", + + // Crits + ["CriticalRate"] = "Crit %", + ["CriticalDamage"] = "Crit DMG", + ["MinDamage"] = "Damage Min", + ["MaxDamage"] = "Damage Max", + + // Other common resists / misc (optional shorthands) + ["MagicResist"] = "Magic Res", + ["Freezing"] = "Freeze", + ["Reflect"] = "Reflect", + ["Strong"] = "Strong", + ["Holy"] = "Holy", + + // Resource % + ["HPRatePercent"] = "HP %", + ["MPRatePercent"] = "MP %", + + // Leech / reductions / shields + ["HPDrainRatePercent"] = "Life Steal %", + ["DamageReductionPercent"] = "Damage Red. %", + ["EnergyShieldPercent"] = "Energy Shield %", + ["EnergyShieldHPGain"] = "Shield HP +", + + // Economy / meta progression + ["ExpRatePercent"] = "EXP %", + ["ItemDropRatePercent"] = "Drop %", + ["GoldDropRatePercent"] = "Gold %", + ["MineRatePercent"] = "Mine %", + ["GemRatePercent"] = "Gem %", + ["FishRatePercent"] = "Fish %", + ["CraftRatePercent"] = "Craft %", + ["SkillGainMultiplier"] = "Skill Gain ×", + + // Attack flat bonus + ["Strength"] = "Strength", + ["Intelligence"] = "Intelligence", + ["AttackBonus"] = "Power", + + // Social + ["LoverExpRatePercent"] = "Couple EXP %", + ["MentorDamageRatePercent"] = "Mentor Dmg %", + ["MentorExpRatePercent"] = "Mentor EXP %", + + // Penalties + ["ManaPenaltyPercent"] = "Mana Pen %", + ["TeleportManaPenaltyPercent"] = "TP Mana Pen %", + }; + + private static string ShortenStatLine(string line) + { + if (string.IsNullOrWhiteSpace(line)) return line; + + int colon = line.IndexOf(':'); + string name, rest; + + if (colon >= 0) + { + name = line.Substring(0, colon).Trim(); + rest = line.Substring(colon); + } + else + { + int cut = -1; + for (int i = 0; i < line.Length; i++) + { + char c = line[i]; + if (c == '+' || c == '-' || char.IsDigit(c)) + { + cut = (i > 0 && line[i - 1] == ' ') ? i - 1 : i; + break; + } + } + if (cut > 0) { name = line.Substring(0, cut).Trim(); rest = line.Substring(cut); } + else { name = line.Trim(); rest = string.Empty; } + } + + if (StatShortNames.TryGetValue(name, out var shortName)) + name = shortName; + + return string.IsNullOrEmpty(rest) ? name : name + rest; + } + + private static IEnumerable CoalesceMinMax(IEnumerable raw) + { + var rx = new Regex( + @"^(?Min|Max)(?AC|MAC|DC|MC|SC)\s*:?\s*(?[+\-]?)\s*(?\d+)", + RegexOptions.Compiled); + + var order = new List(); + var pairs = new Dictionary(StringComparer.OrdinalIgnoreCase); + var passthrough = new List(); + + foreach (var line in raw) + { + var m = rx.Match(line); + if (!m.Success) { passthrough.Add(line); continue; } + + string grp = m.Groups["grp"].Value; + bool isMin = m.Groups["kind"].Value.Equals("Min", StringComparison.OrdinalIgnoreCase); + int val = int.Parse((m.Groups["sign"].Value == "-" ? "-" : "") + m.Groups["num"].Value); + + if (!pairs.ContainsKey(grp)) { pairs[grp] = (null, null); order.Add(grp); } + var cur = pairs[grp]; + if (isMin) cur.min = val; else cur.max = val; + pairs[grp] = cur; + } + + foreach (var s in passthrough) + yield return s; + + foreach (var grp in order) + { + var (min, max) = pairs[grp]; + int left = min ?? 0; + int right = max ?? 0; + yield return $"{grp} {left}~{right}"; + } + } + + private static IEnumerable FriendlyStatLines(IEnumerable raw) + => CoalesceMinMax(raw).Select(ShortenStatLine); + + private static List LimitLinesWithHint(IEnumerable lines, int max, out string hint) + { + var all = lines.ToList(); + if (all.Count <= max) { hint = null; return all; } + + hint = string.Join("\n", all); + var preview = all.Take(max).ToList(); + preview.Add($"… +{all.Count - max} more"); + return preview; + } + + private static List BuildRowPreviewLines(IEnumerable raw, int max, out string hint) + { + var pretty = FriendlyStatLines(raw); + return LimitLinesWithHint(pretty, max, out hint); + } + + private const int MaxLevel = 20; + + private static readonly int[] XPPerLevel = + { + 0, 0, 8, 11, 14, 17, 20, 23, 26, 29, 32, 35, 38, 41, 44, 47, 51, 55, 59, 63, 67 + }; + + // Crit %, Crit DMG %, HP Regen %, MP Regen %, DC max, MC max + private static readonly (float crit, float critDmg, int hpRec, int mpRec, int dcMax, int mcMax)[] LV1 = + { + // 0-index dummy: + (0,0,0,0,0,0), + (0,0,0,0,0,0), // 1 + (0,0,10,10,1,1), // 2 + (0,0,15,15,1,1), // 3 + (0,0,15,15,1,1), // 4 + (0,0,20,20,1,1), // 5 + (0,0,25,25,1,1), // 6 + (0,0,25,25,1,1), // 7 + (0,0,30,30,1,1), // 8 + (0,0,30,30,1,1), // 9 + (0,0,30,30,2,2), // 10 + (1,5,30,30,2,2), // 11 + (1,5.5f,30,30,2,2), // 12 + (1,6,30,30,2,2), // 13 + (1,6.5f,30,30,2,2), // 14 + (1,6.5f,30,30,3,3), // 15 + (1.5f,7,30,30,3,3), // 16 + (2,7.5f,30,30,3,3), // 17 + (2,8,30,30,3,3), // 18 + (2,9,30,30,4,4), // 19 + (2,10,30,30,5,5), // 20 + }; + + // SC max, AC max, MAC max, A.Speed(?) max, Accuracy, Agility + private static readonly (int scMax, int acMax, int macMax, int speedMax, int precision, int quickness)[] LV2 = + { + (0,0,0,0,0,0), // 0 + (0,0,0,0,0,0), // 1 + (1,0,0,0,1,0), // 2 + (1,0,0,0,1,1), // 3 + (1,1,1,0,1,1), // 4 + (1,2,2,0,1,1), // 5 + (1,2,2,0,1,1), // 6 + (1,2,2,1,1,1), // 7 + (1,2,2,1,1,1), // 8 + (1,2,2,2,2,1), // 9 + (2,2,2,2,2,1), // 10 + (2,2,2,2,2,2), // 11 + (2,2,2,2,2,2), // 12 + (2,2,2,2,2,2), // 13 + (2,2,2,3,2,2), // 14 + (3,2,2,3,3,2), // 15 + (3,2,2,4,3,2), // 16 + (3,3,3,4,3,3), // 17 + (3,4,4,4,3,3), // 18 + (4,4,4,4,3,3), // 19 + (5,5,5,4,3,3), // 20 + }; + + // Addiction Resist, Magic Resist, HandWeight, WearWeight, BagWeight + private static readonly (int addRes, int magRes, int handW, int wearW, int bagW)[] LV3 = + { + (0,0,0,0,0), // 0 + (0,0,0,0,0), // 1 + (0,0,0,0,50), // 2 + (0,0,0,0,60), // 3 + (0,0,20,0,70), // 4 + (0,0,20,20,80), // 5 + (0,0,20,20,90), + (0,0,20,20,100), + (0,0,20,20,110), + (0,0,20,20,120), + (0,0,20,20,130), + (0,0,20,20,140), + (0,0,20,20,150), + (0,0,20,20,160), + (0,0,20,20,170), + (0,0,20,20,180), + (0,0,20,20,200), + (0,0,20,20,200), + (0,0,20,20,200), + (1,0,20,20,200), + (1,1,20,20,200), + }; + + private static string[] BuildCollectionLevelHint(int level) + { + level = Math.Max(1, Math.Min(MaxLevel, level)); + + var stats = BuildLevelStats(level); + + var pretty = FriendlyStatLines(BuildStatLines(stats)) + .OrderBy(LevelHintOrderKey) + .ThenBy(s => s, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return pretty; + } + + private static Stats BuildLevelStats(int level) + { + if (level < 1) level = 1; + if (level > 20) level = 20; + + // Arrays 1..20 (index 0 unused) + int[] critPct = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2 }; + int[] critDmg = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 6, 6, 7, 7, 7, 8, 8, 9, 10 }; + int[] hpRec = { 0, 0, 10, 15, 15, 20, 25, 25, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30 }; + int[] mpRec = { 0, 0, 10, 15, 15, 20, 25, 25, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30 }; + int[] dcMax = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; + int[] mcMax = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; + int[] scMax = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; + int[] acMax = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; + int[] macMax = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; + int[] precision = { 0, 0, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3 }; + int[] quickness = { 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3 }; + + int[] minAC = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; + int[] maxAC = { 0, 0, 0, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 4, 4, 5 }; + int[] minMAC = { 0, 0, 0, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 4, 4, 5 }; + int[] maxMAC = { 0, 0, 0, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 4, 4, 5 }; + int[] minDC = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; + int[] maxDC = { 0, 0, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 3, 3, 3, 3, 4, 5 }; + int[] minMC = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; + int[] maxMC = { 0, 0, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 3, 3, 3, 3, 4, 5 }; + int[] minSC = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; + int[] maxSC = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 3, 3, 3, 3, 4, 5 }; + + int[] addRes = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1 }; + int[] magRes = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 }; + int[] handW = { 0, 0, 0, 0, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20 }; + int[] wearW = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; + int[] bagW = { 0, 0, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160, 170, 180, 200, 200, 200, 200 }; + + var s = new Stats(); + if (critPct[level] != 0) s[Stat.CriticalRate] = s[Stat.CriticalRate] + critPct[level]; + if (critDmg[level] != 0) s[Stat.CriticalDamage] = s[Stat.CriticalDamage] + critDmg[level]; + if (hpRec[level] != 0) s[Stat.HealthRecovery] = s[Stat.HealthRecovery] + hpRec[level]; + if (mpRec[level] != 0) s[Stat.SpellRecovery] = s[Stat.SpellRecovery] + mpRec[level]; + + if (minAC[level] != 0) s[Stat.MinAC] = s[Stat.MinAC] + minAC[level]; + if (maxAC[level] != 0) s[Stat.MaxAC] = s[Stat.MaxAC] + maxAC[level]; + if (minMAC[level] != 0) s[Stat.MinMAC] = s[Stat.MinMAC] + minMAC[level]; + if (maxMAC[level] != 0) s[Stat.MaxMAC] = s[Stat.MaxMAC] + maxMAC[level]; + if (minDC[level] != 0) s[Stat.MinDC] = s[Stat.MinDC] + minDC[level]; + if (maxDC[level] != 0) s[Stat.MaxDC] = s[Stat.MaxDC] + maxDC[level]; + if (minMC[level] != 0) s[Stat.MinMC] = s[Stat.MinMC] + minMC[level]; + if (maxMC[level] != 0) s[Stat.MaxMC] = s[Stat.MaxMC] + maxMC[level]; + if (minSC[level] != 0) s[Stat.MinSC] = s[Stat.MinSC] + minSC[level]; + if (maxSC[level] != 0) s[Stat.MaxSC] = s[Stat.MaxSC] + maxSC[level]; + + if (precision[level] != 0) s[Stat.Accuracy] = s[Stat.Accuracy] + precision[level]; + if (quickness[level] != 0) s[Stat.Agility] = s[Stat.Agility] + quickness[level]; + + if (addRes[level] != 0) s[Stat.PoisonResist] = s[Stat.PoisonResist] + addRes[level]; + if (magRes[level] != 0) s[Stat.MagicResist] = s[Stat.MagicResist] + magRes[level]; + + if (handW[level] != 0) s[Stat.HandWeight] = s[Stat.HandWeight] + handW[level]; + if (wearW[level] != 0) s[Stat.WearWeight] = s[Stat.WearWeight] + wearW[level]; + if (bagW[level] != 0) s[Stat.BagWeight] = s[Stat.BagWeight] + bagW[level]; + + return s; + } + + private static int LevelHintOrderKey(string s) + { + if (string.IsNullOrWhiteSpace(s)) return int.MaxValue; + s = s.Trim(); + + // Range groups first (same as Rewards order) + if (s.StartsWith("AC ", StringComparison.OrdinalIgnoreCase)) return 0; + if (s.StartsWith("MAC ", StringComparison.OrdinalIgnoreCase)) return 1; + if (s.StartsWith("DC ", StringComparison.OrdinalIgnoreCase)) return 2; + if (s.StartsWith("MC ", StringComparison.OrdinalIgnoreCase)) return 3; + if (s.StartsWith("SC ", StringComparison.OrdinalIgnoreCase)) return 4; + + // Primaries + if (s.StartsWith("Accuracy", StringComparison.OrdinalIgnoreCase)) return 5; + if (s.StartsWith("Agility", StringComparison.OrdinalIgnoreCase)) return 6; + + // Tempo / % bonuses + if (s.StartsWith("A.Speed", StringComparison.OrdinalIgnoreCase) || + s.StartsWith("Attack Speed", StringComparison.OrdinalIgnoreCase)) return 7; + if (s.StartsWith("Crit %", StringComparison.OrdinalIgnoreCase)) return 8; + if (s.StartsWith("Crit DMG", StringComparison.OrdinalIgnoreCase)) return 9; + if (s.StartsWith("HP Regen", StringComparison.OrdinalIgnoreCase)) return 10; + if (s.StartsWith("MP Regen", StringComparison.OrdinalIgnoreCase)) return 11; + + // Resists + if (s.StartsWith("Magic Res", StringComparison.OrdinalIgnoreCase)) return 12; + if (s.StartsWith("Addiction Res", StringComparison.OrdinalIgnoreCase) || + s.StartsWith("PSN Res", StringComparison.OrdinalIgnoreCase)) return 13; + + // Weights + if (s.StartsWith("H - Weight", StringComparison.OrdinalIgnoreCase)) return 20; + if (s.StartsWith("W - Weight", StringComparison.OrdinalIgnoreCase)) return 21; + if (s.StartsWith("B - Weight", StringComparison.OrdinalIgnoreCase)) return 22; + + return 1000; + } + + private static Stats SumStats(Stats a, Stats b) + { + var r = new Stats(); + if (a != null) + foreach (var kv in a.Values?.ToArray() ?? Array.Empty>()) + if (kv.Value != 0) { try { r[kv.Key] = r[kv.Key] + kv.Value; } catch { try { r[kv.Key] = kv.Value; } catch { } } } + if (b != null) + foreach (var kv in b.Values?.ToArray() ?? Array.Empty>()) + if (kv.Value != 0) { try { r[kv.Key] = r[kv.Key] + kv.Value; } catch { try { r[kv.Key] = kv.Value; } catch { } } } + return r; + } + + public static Stats GetCollectionLevelBonusStats() + { + var dlg = Instance; + if (dlg == null) return new Stats(); + dlg.ComputeLevelProgress(out int level, out _, out _); + return BuildLevelStats(level); + } + + public static int GetCollectionLevel() + { + var dlg = Instance; + if (dlg == null) return 1; + dlg.ComputeLevelProgress(out int level, out _, out _); + return Math.Max(1, level); + } + + private void RefreshLevelHint() + { + if (_levelHintHotspot == null) return; + ComputeLevelProgress(out int level, out _, out _); + UpdateLevelHint(level); + } + + private void RefreshClaimedSetsEmblem() + { + if (_claimedSetsEmblem == null) return; + + int _xp; + var totals = GetTotalClaimedStats(out _xp); + + var lines = FriendlyStatLines(BuildStatLines(totals)) + .OrderBy(RewardLineOrderKey) + .ThenBy(s => s, StringComparer.OrdinalIgnoreCase) + .Where(l => !string.IsNullOrWhiteSpace(l)) + .ToList(); + + _claimedSetsEmblem.Hint = (lines.Count == 0) + ? L("Codex_ClaimedSetBonuses") + : L("Codex_ClaimedSetBonuses") + "\n" + string.Join("\n", lines); + } + + private void PlaySetCompleteEffect(string message = null, + float scale = 2.0f, + int yOffset = -60, + int frameMs = 120, + int lingerMs = 2000) + { + const int START = 890, END = 904; + + if (Environment.TickCount - _lastSetCompleteFxAt < 600) return; + _lastSetCompleteFxAt = Environment.TickCount; + + var scene = GameScene.Scene; + var lib = Libraries.Effect_32bit; + if (scene == null || lib == null) return; + message ??= L("Codex_SetCompleteEffect"); + + _setFxPlaying = true; + + var host = new MirControl + { + Parent = scene, + NotControl = true, + Visible = true, + Size = Size.Empty + }; + host.BringToFront(); + + MirLabel msgShadow = null, msg = null; + if (!string.IsNullOrWhiteSpace(message)) + { + var fnt = new Font(Settings.FontName, 13.0f, FontStyle.Bold); + msgShadow = new MirLabel { Parent = host, AutoSize = true, ForeColour = Color.FromArgb(32, 32, 32), Font = fnt, Text = message }; + msg = new MirLabel { Parent = host, AutoSize = true, ForeColour = Color.WhiteSmoke, Font = fnt, Text = message }; + } + + int frame = START; + int linger = 0; + + host.BeforeDraw += (s, e) => + { + var center = new Point(scene.Size.Width / 2, scene.Size.Height / 2 + yOffset); + DrawEffectFrameScaled(lib, frame, center, scale); + + if (msg != null) + { + int mx = center.X - (msg.Size.Width / 2); + int my = center.Y + 36; + if (msgShadow != null) msgShadow.Location = new Point(mx + 1, my + 1); + msg.Location = new Point(mx, my); + } + }; + + var timer = new System.Windows.Forms.Timer { Interval = frameMs }; + timer.Tick += (s, e) => + { + if (frame < END) { frame++; scene.Redraw(); return; } + if (linger < lingerMs) { linger += timer.Interval; return; } + + timer.Stop(); timer.Dispose(); + try { host.Dispose(); } catch { } + + _setFxPlaying = false; + + if (_pendingLevelUpFx) + { + _pendingLevelUpFx = false; + var after = new System.Windows.Forms.Timer { Interval = 350 }; + after.Tick += (s2, e2) => + { + after.Stop(); after.Dispose(); + if (!_setFxPlaying) + PlayLevelUpEffect($"Collection Level Up! Lv.{_lastLevelSeen}"); + }; + after.Start(); + } + + scene.Redraw(); + }; + + timer.Start(); + + if (!string.IsNullOrWhiteSpace(message)) + GameScene.Scene?.ChatDialog.ReceiveChat(message, ChatType.Hint); + } + + private static void DrawEffectFrameScaled(MLibrary lib, int index, Point center, float scale) + { + try + { + var mi = lib.GetType().GetMethod("Draw", + new[] { typeof(int), typeof(Point), typeof(Color), typeof(bool), typeof(float) }); + if (mi != null) + { + mi.Invoke(lib, new object[] { index, center, Color.White, true, scale }); + return; + } + } + catch { /* fall through to non-scaled draw */ } + + lib.Draw(index, center, Color.White, true); + } + + private void PlayLevelUpEffect(string message = null, + float scale = 2.0f, + int yOffset = -60, + int frameMs = 120, + int lingerMs = 2000) + { + message ??= L("Codex_LevelUpShort"); + var scene = GameScene.Scene; + var lib = Libraries.Effect_32bit; + if (scene == null || lib == null) return; + + _levelFxHost?.Dispose(); + _levelFxHost = new MirControl + { + Parent = scene, + NotControl = true, + Visible = true, + Size = Size.Empty + }; + _levelFxHost.BringToFront(); + + const int START = 920, END = 934; + int frame = START; + int linger = 0; + + MirLabel msgShadow = null, msg = null; + if (!string.IsNullOrWhiteSpace(message)) + { + var fnt = new Font(Settings.FontName, 13.0f, FontStyle.Bold); + msgShadow = new MirLabel { Parent = _levelFxHost, AutoSize = true, ForeColour = Color.FromArgb(32, 32, 32), Font = fnt, Text = message }; + msg = new MirLabel { Parent = _levelFxHost, AutoSize = true, ForeColour = Color.WhiteSmoke, Font = fnt, Text = message }; + } + + _levelFxHost.BeforeDraw += (s, e) => + { + var center = new Point(scene.Size.Width / 2, scene.Size.Height / 2 + yOffset); + DrawEffectFrameScaled(lib, frame, center, scale); + + if (msg != null) + { + int mx = center.X - (msg.Size.Width / 2); + int my = center.Y + 36; + if (msgShadow != null) msgShadow.Location = new Point(mx + 1, my + 1); + msg.Location = new Point(mx, my); + } + }; + + var timer = new System.Windows.Forms.Timer { Interval = frameMs }; + timer.Tick += (s, e) => + { + if (frame < END) + { + frame++; + scene.Redraw(); + return; + } + + if (linger < lingerMs) + { + linger += timer.Interval; + return; + } + + timer.Stop(); + timer.Dispose(); + try { _levelFxHost.Dispose(); } catch { } + _levelFxHost = null; + + scene.Redraw(); + }; + timer.Start(); + + if (!string.IsNullOrWhiteSpace(message)) + GameScene.Scene?.ChatDialog.ReceiveChat(message, ChatType.Hint); + } + + private void BuildCurrencyUI() + { + // --- Stone (Ongseok) --- + _stoneIcon = new MirImageControl + { + Parent = this, + Library = Libraries.UI_32bit, + Index = ICON_STONE_IDX, + Location = CURRENCY_STONE_POS, + Size = new Size(18, 18), + Hint = L("Codex_CurrencyStoneHint", STONE_SUBS_FOR), + }; + + _stoneCountLbl = new MirLabel + { + Parent = this, + Location = new Point(CURRENCY_STONE_POS.X + 30, CURRENCY_STONE_POS.Y + 8), + AutoSize = true, + ForeColour = Color.White, + Text = "x0", + Font = new Font(Settings.FontName, 10f, FontStyle.Bold), + }; + + // --- Jade --- + _jadeIcon = new MirImageControl + { + Parent = this, + Library = Libraries.UI_32bit, + Index = ICON_JADE_IDX, + Location = CURRENCY_JADE_POS, + Size = new Size(18, 18), + Hint = L("Codex_CurrencyJadeHint", JADE_SUBS_FOR), + }; + + _jadeCountLbl = new MirLabel + { + Parent = this, + Location = new Point(CURRENCY_JADE_POS.X + 25, CURRENCY_JADE_POS.Y + 8), + AutoSize = true, + ForeColour = Color.White, + Text = "x0", + Font = new Font(Settings.FontName, 10f, FontStyle.Bold), + }; + + UpdateCurrencyUI(); + } + + private void UpdateCurrencyUI() + { + if (_stoneCountLbl != null) _stoneCountLbl.Text = $"x{_stoneCount}"; + if (_jadeCountLbl != null) _jadeCountLbl.Text = $"x{_jadeCount}"; + } + public void SetCodexCurrencies(int stones, int jades) + { + _stoneCount = Math.Max(0, stones); + _jadeCount = Math.Max(0, jades); + UpdateCurrencyUI(); + } + + private static bool IsRareOrLegendary(ItemGrade g) => g == ItemGrade.Rare || g == ItemGrade.Legendary; + + private int FindFirstEligibleCurrencyInfoId(RowVM vm) + { + if (vm == null) return -1; + if (!_allowCrossGradeCurrency) return -1; + if (!IsRareOrLegendary(vm.Rarity)) return -1; + + int stoneId = _itemInfoStone; + int jadeId = _itemInfoJade; + + if (stoneId > 0 && HasItemInAnyInventory(stoneId)) return stoneId; + if (jadeId > 0 && HasItemInAnyInventory(jadeId)) return jadeId; + + return -1; + } + + private void RefreshCurrencyUI() + { + if (_stoneCountLbl != null) _stoneCountLbl.Text = $"x{_stoneCount}"; + if (_jadeCountLbl != null) _jadeCountLbl.Text = $"x{_jadeCount}"; + } + + private long ComputeInventorySignature() + { + var user = GameScene.User; + if (user?.Inventory == null) return 0; + + unchecked + { + long h = 1469598103934665603L; + foreach (var it in user.Inventory) + { + int infoIndex = it?.Info?.Index ?? -1; + int count = it?.Count ?? 0; + ulong uid = it?.UniqueID ?? 0UL; + + h ^= infoIndex; h *= 1099511628211L; + h ^= count; h *= 1099511628211L; + h ^= (long)uid; h *= 1099511628211L; + } + return h; + } + } + + public void InventoryChangedRefresh() + { + if (!Visible) return; + long sig = ComputeInventorySignature(); + if (sig == _lastInvSig) return; + _lastInvSig = sig; + + ApplySearch(_searchQuery, keepCursor: true); + RecalcRightCounters(); + UpdateScrollButtons(); + UpdateScrollThumb(); + RefreshHeaderLevelXp(); + RefreshLevelHint(); + + RefreshCurrencyUI(); + } + + private void TryUseCurrency(int setId, Currency cur) + { + var scene = GameScene.Scene; + + if (cur == Currency.Stone && GameScene.Stone <= 0) + { scene?.ChatDialog.ReceiveChat(L("Codex_NoStone"), ChatType.Hint); return; } + if (cur == Currency.Jade && GameScene.Jade <= 0) + { scene?.ChatDialog.ReceiveChat(L("Codex_NoJade"), ChatType.Hint); return; } + + Network.Enqueue(new C.CodexUseCurrency { RowId = setId, Currency = (byte)cur }); + } + + private static bool IsCurrencyAllowedForRarity(Currency cur, ItemGrade rarity) + { + return (rarity == ItemGrade.Rare && cur == Currency.Stone) || + (rarity == ItemGrade.Legendary && cur == Currency.Jade); + } + + private Currency PickDefaultCurrencyForSet(RowVM data) + { + if (data == null) return Currency.None; + + if (data.Rarity == ItemGrade.Legendary && GameScene.Jade > 0) return Currency.Jade; + if (data.Rarity == ItemGrade.Rare && GameScene.Stone > 0) return Currency.Stone; + return Currency.None; + } + } +} + diff --git a/Client/MirScenes/Dialogs/CurrencyListDialog.cs b/Client/MirScenes/Dialogs/CurrencyListDialog.cs new file mode 100644 index 000000000..493d8d463 --- /dev/null +++ b/Client/MirScenes/Dialogs/CurrencyListDialog.cs @@ -0,0 +1,289 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using Client.MirControls; +using Client.MirGraphics; +using Client.MirScenes; +using Client.MirSounds; +using Client.MirObjects; + +namespace Client.MirScenes.Dialogs +{ + public sealed class CurrencyListDialog : MirImageControl + { + public static CurrencyListDialog Instance; + + // If you know exact ItemInfo indices, set them at runtime (e.g., after login). + public static int StoneItemInfoIndex = 0; + public static int JadeItemInfoIndex = 0; + public static int PearlItemInfoIndex = 0; + public static int HwanyangItemInfoIndex = 0; + public static int MuhanItemInfoIndex = 0; + public static int BattleItemInfoIndex = 0; + public static int ChaosItemInfoIndex = 0; + + // Name fallbacks if indices above are 0. + public static string StoneNameContains = "Stone"; + public static string JadeNameContains = "Jade"; + public static string PearlNameContains = "Pearl"; + public static string HwanyangNameContains = "Hwanyang"; + public static string MuhanNameContains = "Muhan"; + public static string BattleNameContains = "Battle"; + public static string ChaosNameContains = "Chaos"; + + private const int RowHeight = 22; + private const int ArrowWidth = 16; + private const int ArrowGap = 4; + + private readonly MirControl _listHost; + private readonly MirButton _btnClose; + private readonly MirButton _btnUp; + private readonly MirButton _btnDown; + + private readonly List _rows = new List(); + private readonly List _model = new List(); + + private int _scrollIndex; + private int _visibleRows; + + public static bool ShowGuildRowsForEveryone = false; + + public CurrencyListDialog() + { + Library = Libraries.Prguse; + Index = 120; + Movable = true; + Sort = true; + + var listRect = new Rectangle(10, 54, Size.Width - 20, Size.Height - 110); + + _listHost = new MirControl + { + Parent = this, + Location = listRect.Location, + Size = listRect.Size, + NotControl = true + }; + + _btnClose = new MirButton + { + Parent = this, + Library = Libraries.Prguse2, + Index = 360, + HoverIndex = 361, + PressedIndex = 362, + Location = new Point(Size.Width - 26, 4), + Sound = SoundList.ButtonA + }; + _btnClose.Click += (o, e) => Hide(); + + _btnUp = new MirButton + { + Parent = this, + Library = Libraries.Prguse2, + Index = 197, + HoverIndex = 198, + PressedIndex = 199, + Size = new Size(ArrowWidth, 14), + Location = new Point(listRect.Right - ArrowWidth, listRect.Top + 1), + Sound = SoundList.ButtonA + }; + _btnUp.Click += (o, e) => Scroll(-1); + + _btnDown = new MirButton + { + Parent = this, + Library = Libraries.Prguse2, + Index = 207, + HoverIndex = 208, + PressedIndex = 209, + Size = new Size(ArrowWidth, 14), + Location = new Point(listRect.Right - ArrowWidth, listRect.Bottom - 12), + Sound = SoundList.ButtonA + }; + _btnDown.Click += (o, e) => Scroll(+1); + + RecalcVisibleRows(); + BuildRows(); + RefreshModel(); + RedrawRows(); + } + + public void Reposition(Point localPointWithinParent) => Location = localPointWithinParent; + + private void RecalcVisibleRows() + { + _visibleRows = Math.Max(1, _listHost.Size.Height / RowHeight); + } + + private void BuildRows() + { + _rows.Clear(); + + int reservedRight = ArrowWidth + ArrowGap; + int amountWidth = 75; + int amountX = _listHost.Size.Width - reservedRight - amountWidth; + if (amountX < 60) amountX = 60; + + for (int i = 0; i < _visibleRows; i++) + { + var host = new MirControl + { + Parent = _listHost, + Location = new Point(0, i * RowHeight), + Size = new Size(_listHost.Size.Width, RowHeight) + }; + + var name = new MirLabel + { + Parent = host, + Location = new Point(6, 3), + Size = new Size(amountX - 10, RowHeight - 6), + Font = new Font(Settings.FontName, 8F), + NotControl = true + }; + + var amount = new MirLabel + { + Parent = host, + Location = new Point(amountX, 3), + Size = new Size(amountWidth, RowHeight - 6), + DrawFormat = TextFormatFlags.Left | TextFormatFlags.VerticalCenter, + Font = new Font(Settings.FontName, 8F), + NotControl = true + }; + + _rows.Add(new Row { Host = host, Name = name, Amount = amount }); + } + } + + private void Scroll(int delta) + { + if (_model.Count <= _visibleRows) return; + _scrollIndex = Math.Max(0, Math.Min(_model.Count - _visibleRows, _scrollIndex + delta)); + RedrawRows(); + } + + public void RefreshModel() + { + _model.Clear(); + + // Core currencies + _model.Add(new Entry { Title = "Gold", GetAmount = () => GameScene.Gold }); + _model.Add(new Entry { Title = "Credits", GetAmount = () => GameScene.Credit }); + _model.Add(new Entry { Title = "Pearl", GetAmount = CountPearl }); + + // --- Guild (owner/leader only) --- + if (ShouldShowGuildRows()) + { + _model.Add(new Entry { Title = "Guild Gold", GetAmount = () => GameScene.Scene.GuildDialog.Gold }); + _model.Add(new Entry { Title = "Guild Points", GetAmount = () => GameScene.Scene.GuildDialog.SparePoints }); + } + + // Codex tokens + _model.Add(new Entry { Title = "Stone", GetAmount = () => GameScene.Stone }); + _model.Add(new Entry { Title = "Jade", GetAmount = () => GameScene.Jade }); + + _model.Sort((a, b) => string.Compare(a.Title, b.Title, StringComparison.Ordinal)); + } + + public void RedrawRows() + { + for (int i = 0; i < _rows.Count; i++) + { + int idx = _scrollIndex + i; + var row = _rows[i]; + + if (idx >= _model.Count) + { + row.Host.Visible = false; + continue; + } + + var e = _model[idx]; + row.Host.Visible = true; + + row.Name.Text = e.Title; + + long amount = 0; + try { amount = e.GetAmount?.Invoke() ?? 0; } catch { /* ignore */ } + row.Amount.Text = amount.ToString("###,###,##0"); + } + } + + public static void NotifyChanged() + { + if (Instance == null || Instance.IsDisposed) return; + Instance.RefreshModel(); + Instance.RedrawRows(); + } + + private static long CountPearl() + { + if (PearlItemInfoIndex > 0) return CountByIndex(PearlItemInfoIndex); + return CountByName(PearlNameContains); + } + + private static long CountByIndex(int infoIndex) + { + var grid = GameScene.Scene?.InventoryDialog?.Grid; + if (grid == null) return 0; + long total = 0; + foreach (var cell in grid) + { + var it = cell?.Item; + if (it?.Info == null) continue; + if (it.Info.Index == infoIndex) + total += Math.Max(1, (int)it.Count); + } + return total; + } + + private static long CountByName(string contains) + { + var grid = GameScene.Scene?.InventoryDialog?.Grid; + if (grid == null) return 0; + long total = 0; + foreach (var cell in grid) + { + var it = cell?.Item; + if (it?.Info == null || string.IsNullOrEmpty(it.Info.Name)) continue; + if (it.Info.Name.IndexOf(contains, StringComparison.OrdinalIgnoreCase) >= 0) + total += Math.Max(1, (int)it.Count); + } + return total; + } + private static bool ShouldShowGuildRows() + { + if (ShowGuildRowsForEveryone) return true; + + var user = MapObject.User; + var gd = GameScene.Scene?.GuildDialog; + if (user == null || gd == null) return false; + + // Must actually be in a guild + if (string.IsNullOrEmpty(user.GuildName)) return false; + + // Owner/leader is rank 0 in your codebase. + if (GuildDialog.MyRankId != 0) return false; + + // If this hides for real owners at login, remove this line. + if (string.IsNullOrEmpty(user.GuildRankName)) return false; + + return true; + } + + private struct Row + { + public MirControl Host; + public MirLabel Name; + public MirLabel Amount; + } + + private sealed class Entry + { + public string Title; + public Func GetAmount; + } + } +} diff --git a/Client/MirScenes/Dialogs/GuildDialog.cs b/Client/MirScenes/Dialogs/GuildDialog.cs index 2e0b0b6a0..455598099 100644 --- a/Client/MirScenes/Dialogs/GuildDialog.cs +++ b/Client/MirScenes/Dialogs/GuildDialog.cs @@ -1811,6 +1811,7 @@ public void MyRankChanged(GuildRank New) ResetButtonStats(); UpdateMembers(); + CurrencyListDialog.NotifyChanged(); } public void RankChangeRecieved(GuildRank New) { @@ -1837,6 +1838,7 @@ public void RankChangeRecieved(GuildRank New) } } UpdateRanks(); + CurrencyListDialog.NotifyChanged(); } public void UpdateRanks() { @@ -2148,6 +2150,7 @@ public void StatusChanged(GuildRankOptions status) if (GuildBuffInfos.Count == 0) BuffButton.Visible = false; else BuffButton.Visible = true; + CurrencyListDialog.NotifyChanged(); } #endregion diff --git a/Client/MirScenes/Dialogs/InventoryDialog.cs b/Client/MirScenes/Dialogs/InventoryDialog.cs index 7dffca052..86be339ea 100644 --- a/Client/MirScenes/Dialogs/InventoryDialog.cs +++ b/Client/MirScenes/Dialogs/InventoryDialog.cs @@ -17,6 +17,8 @@ public sealed class InventoryDialog : MirImageControl public MirButton CloseButton, ItemButton, ItemButton2, QuestButton, AddButton, DelItemButton; public MirLabel GoldLabel, WeightLabel; + public MirControl CurrencyHotspot; + private bool _deleteMode; private Size _binSize; private MirImageControl _deleteCursorIcon; @@ -144,6 +146,34 @@ public InventoryDialog() GameScene.PickedUpGold = !GameScene.PickedUpGold && GameScene.Gold > 0; }; + CurrencyHotspot = new MirControl + { + Parent = this, + Location = new Point(20, 210), + Size = new Size(20, 20), + Hint = "Currencies", + + }; + CurrencyHotspot.Click += (o, e) => + { + if (CurrencyListDialog.Instance == null || CurrencyListDialog.Instance.IsDisposed) + { + CurrencyListDialog.Instance = new CurrencyListDialog + { + Parent = GameScene.Scene + }; + } + + // toggle + CurrencyListDialog.Instance.Visible = !CurrencyListDialog.Instance.Visible; + if (CurrencyListDialog.Instance.Visible) + { + CurrencyListDialog.Instance.RefreshModel(); + CurrencyListDialog.Instance.RedrawRows(); + CurrencyListDialog.Instance.BringToFront(); + } + }; + Grid = new MirItemCell[8 * 10]; @@ -293,6 +323,7 @@ public void RefreshInventory() else grid.Visible = false; } + CodexDialog.Instance?.InventoryChangedRefresh(); } public void RefreshInventory2() @@ -318,6 +349,7 @@ public void RefreshInventory2() } AddButton.Visible = openLevel >= 10 ? false : true; + CodexDialog.Instance?.InventoryChangedRefresh(); } public void Process() @@ -329,6 +361,8 @@ public void Process() // Delete-mode cursor icon follows the mouse if (_deleteMode && _deleteCursorIcon != null && _deleteCursorIcon.Visible) UpdateDeleteCursorPos(); + + CodexDialog.Instance?.InventoryChangedRefresh(); } diff --git a/Client/MirScenes/Dialogs/MainDialogs.cs b/Client/MirScenes/Dialogs/MainDialogs.cs index cf86a466c..9343c8446 100644 --- a/Client/MirScenes/Dialogs/MainDialogs.cs +++ b/Client/MirScenes/Dialogs/MainDialogs.cs @@ -3002,11 +3002,13 @@ public sealed class MenuDialog : MirImageControl MentorButton, RelationshipButton, GroupButton, - GuildButton; + GuildButton, + CodexButton, + OptionsButton; public MenuDialog() { - Index = 567; + Index = 1415; Parent = GameScene.Scene; Library = Libraries.Title; Location = new Point(Settings.ScreenWidth - Size.Width, GameScene.Scene.MainDialog.Location.Y - this.Size.Height + 15); @@ -3236,7 +3238,7 @@ public MenuDialog() PressedIndex = 1996, Parent = this, Library = Libraries.Prguse, - Location = new Point(3, 259), + Location = new Point(3, 260), Hint = GameLanguage.ClientTextMap.GetLocalization((ClientTextKeys.GuildKey), CMain.InputKeys.GetKey(KeybindOptions.Guilds)) }; GuildButton.Click += (o, e) => @@ -3246,6 +3248,53 @@ public MenuDialog() else GameScene.Scene.GuildDialog.Show(); }; + CodexButton = new MirButton + { + Index = 1412, + HoverIndex = 1413, + PressedIndex = 1414, + Parent = this, + Library = Libraries.Title, + Location = new Point(3, 300), + Visible = GameScene.AllowCodex, + Enabled = GameScene.AllowCodex, + Hint = GameLanguage.ClientTextMap.GetLocalization(ClientTextKeys.CodexKey, CMain.InputKeys.GetKey(KeybindOptions.Codex)) + }; + CodexButton.Click += (o, e) => + { + if (!GameScene.AllowCodex) return; + + if (CodexDialog.Instance == null || CodexDialog.Instance.IsDisposed) + CodexDialog.Instance = new CodexDialog(); + + if (!CodexDialog.Instance.Visible) + { + CodexDialog.Instance.Show(); + Network.Enqueue(new C.RequestItemCodex()); + } + else + { + CodexDialog.Instance.Hide(); + } + }; + + OptionsButton = new MirButton + { + Index = 1416, + HoverIndex = 1417, + PressedIndex = 1418, + Parent = this, + Library = Libraries.Title, + Location = new Point(3, 321), + Hint = GameLanguage.ClientTextMap.GetLocalization(ClientTextKeys.OptionsKey, CMain.InputKeys.GetKey(KeybindOptions.Options)) + }; + OptionsButton.Click += (o, e) => + { + if (GameScene.Scene.OptionDialog.Visible) + GameScene.Scene.OptionDialog.Hide(); + else GameScene.Scene.OptionDialog.Show(); + }; + } diff --git a/Client/MirScenes/GameScene.cs b/Client/MirScenes/GameScene.cs index c67e3795d..ec5d0e621 100644 --- a/Client/MirScenes/GameScene.cs +++ b/Client/MirScenes/GameScene.cs @@ -6,6 +6,7 @@ using SlimDX; using SlimDX.Direct3D9; using Font = System.Drawing.Font; +using Shared; using S = ServerPackets; using C = ClientPackets; using Effect = Client.MirObjects.Effect; @@ -20,6 +21,7 @@ public sealed class GameScene : MirScene public static GameScene Scene; public static bool Observing; public static bool AllowObserve; + public static bool AllowCodex; public static UserObject User { @@ -32,6 +34,11 @@ public static UserHeroObject Hero get { return MapObject.Hero; } set { MapObject.Hero = value; } } + private static string CL(string key, params object[] args) + { + if (!GameLanguage.ClientTextMap.Text.TryGetValue(key, out var value)) value = key; + return (args != null && args.Length > 0) ? string.Format(value, args) : value; + } public static HeroObject HeroObject { get { return MapObject.HeroObject; } @@ -145,6 +152,8 @@ public bool HasHero public CompassDialog CompassControl; public RollDialog RollControl; + public CodexDialog CodexDialog; + public static List ItemInfoList = new List(); public static List UserIdList = new List(); @@ -168,7 +177,7 @@ public bool HasHero public static bool PickedUpGold; public MirControl ItemLabel, MailLabel, MemoLabel, GuildBuffLabel; public static long UseItemTime, PickUpTime, DropViewTime, TargetDeadTime; - public static uint Gold, Credit; + public static uint Gold, Credit, Stone, Jade; public static long InspectTime; public bool ShowReviveMessage; @@ -196,6 +205,8 @@ public bool HasHero public long OutputDelay; + public static bool CodexSubmitMode; + public GameScene() { MapControl.AutoRun = false; @@ -314,6 +325,8 @@ public GameScene() CompassControl = new CompassDialog { Parent = this, Visible = false }; RollControl = new RollDialog { Parent = this, Visible = false }; + CodexDialog = new CodexDialog { Parent = this, Visible = false }; + for (int i = 0; i < OutputLines.Length; i++) OutputLines[i] = new MirLabel { @@ -584,6 +597,22 @@ private void GameScene_KeyDown(object sender, KeyEventArgs e) QuitGame(); return; + case KeybindOptions.Codex: + if (!AllowCodex) return; + if (CodexDialog.Instance == null || CodexDialog.Instance.IsDisposed) + CodexDialog.Instance = new CodexDialog(); + + if (!CodexDialog.Instance.Visible) + { + CodexDialog.Instance.Show(); + Network.Enqueue(new C.RequestItemCodex()); + } + else + { + CodexDialog.Instance.Hide(); + } + break; + case KeybindOptions.Closeall: InventoryDialog.Hide(); CharacterDialog.Hide(); @@ -1632,6 +1661,15 @@ public override void ProcessPacket(Packet p) case (short)ServerPacketIds.AllowObserve: AllowObserve = ((S.AllowObserve)p).Allow; break; + case (short)ServerPacketIds.AllowCodex: + AllowCodex = ((S.AllowCodex)p).Allow; + CodexDialog.Instance?.ApplyPermissions(); + if (MenuDialog?.CodexButton != null) + { + MenuDialog.CodexButton.Visible = AllowCodex; + MenuDialog.CodexButton.Enabled = AllowCodex; + } + break; case (short)ServerPacketIds.ObjectRangeAttack: ObjectRangeAttack((S.ObjectRangeAttack)p); break; @@ -2024,6 +2062,27 @@ public override void ProcessPacket(Packet p) case (short)ServerPacketIds.GuildTerritoryPage: GuildTerritoryPage((S.GuildTerritoryPage)p); break; + case (short)ServerPacketIds.ItemCodexSync: + ItemCodexSync((S.ItemCodexSync)p); + break; + case (short)ServerPacketIds.ItemCodexUpdate: + ItemCodexUpdate((S.ItemCodexUpdate)p); + break; + case (short)ServerPacketIds.ItemCodexMark: + ItemCodexMark((S.ItemCodexMark)p); + break; + case (short)ServerPacketIds.GainedStone: + GainedStone((S.GainedStone)p); + break; + case (short)ServerPacketIds.LoseStone: + LoseStone((S.LoseStone)p); + break; + case (short)ServerPacketIds.GainedJade: + GainedJade((S.GainedJade)p); + break; + case (short)ServerPacketIds.LoseJade: + LoseJade((S.LoseJade)p); + break; default: base.ProcessPacket(p); break; @@ -2135,13 +2194,18 @@ private void UserInformation(S.UserInformation p) HeroBehaviourPanel.UpdateBehaviour(p.HeroBehaviour); Gold = p.Gold; Credit = p.Credit; + Stone = p.Stone; + Jade = p.Jade; CharacterDialog = new CharacterDialog(MirGridType.Equipment, User) { Parent = this, Visible = false }; InventoryDialog.RefreshInventory(); + CodexDialog.Instance?.InventoryChangedRefresh(); foreach (SkillBarDialog Bar in SkillBarDialogs) Bar.Update(); AllowObserve = p.AllowObserve; Observing = p.Observer; + + CodexDialog.Instance?.SetCodexCurrencies((int)Stone, (int)Jade); } private void UserSlotsRefresh(S.UserSlotsRefresh p) { @@ -2300,6 +2364,7 @@ private void MoveItem(S.MoveItem p) User.RefreshStats(); CharacterDuraPanel.GetCharacterDura(); + CurrencyListDialog.NotifyChanged(); } private void EquipItem(S.EquipItem p) { @@ -2511,6 +2576,7 @@ private void MergeItem(S.MergeItem p) } User.RefreshStats(); + CurrencyListDialog.NotifyChanged(); } private void RemoveItem(S.RemoveItem p) { @@ -2753,6 +2819,7 @@ private void SplitItem(S.SplitItem p) if (array[i] != null) continue; array[i] = p.Item; User.RefreshStats(); + CurrencyListDialog.NotifyChanged(); return; } } @@ -2763,6 +2830,7 @@ private void SplitItem(S.SplitItem p) if (array[i] != null) continue; array[i] = p.Item; User.RefreshStats(); + CurrencyListDialog.NotifyChanged(); return; } } @@ -2773,6 +2841,7 @@ private void SplitItem(S.SplitItem p) if (array[i] != null) continue; array[i] = p.Item; User.RefreshStats(); + CurrencyListDialog.NotifyChanged(); return; } @@ -2781,6 +2850,7 @@ private void SplitItem(S.SplitItem p) if (array[i] != null) continue; array[i] = p.Item; User.RefreshStats(); + CurrencyListDialog.NotifyChanged(); return; } } @@ -2836,6 +2906,7 @@ private void UseItem(S.UseItem p) Hero.RefreshStats(); else User.RefreshStats(); + CurrencyListDialog.NotifyChanged(); } private void DropItem(S.DropItem p) { @@ -2869,7 +2940,7 @@ private void DropItem(S.DropItem p) { User.RefreshStats(); } - + CurrencyListDialog.NotifyChanged(); } private void TakeBackHeroItem(S.TakeBackHeroItem p) @@ -3192,11 +3263,13 @@ private void GainedGold(S.GainedGold p) Gold += p.Gold; SoundManager.PlaySound(SoundList.Gold); OutputMessage(GameLanguage.ClientTextMap.GetLocalization((ClientTextKeys.YouGainedGold), p.Gold, GameLanguage.ClientTextMap.GetLocalization(ClientTextKeys.Gold))); + CurrencyListDialog.NotifyChanged(); } private void LoseGold(S.LoseGold p) { Gold -= p.Gold; SoundManager.PlaySound(SoundList.Gold); + CurrencyListDialog.NotifyChanged(); } private void GainedCredit(S.GainedCredit p) { @@ -3205,11 +3278,46 @@ private void GainedCredit(S.GainedCredit p) Credit += p.Credit; SoundManager.PlaySound(SoundList.Gold); OutputMessage(GameLanguage.ClientTextMap.GetLocalization((ClientTextKeys.YouGainedGold), p.Credit, GameLanguage.ClientTextMap.GetLocalization(ClientTextKeys.Credit))); + CurrencyListDialog.NotifyChanged(); } private void LoseCredit(S.LoseCredit p) { Credit -= p.Credit; SoundManager.PlaySound(SoundList.Gold); + CurrencyListDialog.NotifyChanged(); + } + private void GainedStone(S.GainedStone p) + { + if (p.Stone == 0) return; + Stone += p.Stone; + SoundManager.PlaySound(SoundList.Gold); + OutputMessage(CL("Codex_YouGainedStone", p.Stone)); + CurrencyListDialog.NotifyChanged(); + CodexDialog.Instance?.SetCodexCurrencies((int)Stone, (int)Jade); + } + private void LoseStone(S.LoseStone p) + { + Stone -= p.Stone; + SoundManager.PlaySound(SoundList.Gold); + CurrencyListDialog.NotifyChanged(); + CodexDialog.Instance?.SetCodexCurrencies((int)Stone, (int)Jade); + } + + private void GainedJade(S.GainedJade p) + { + if (p.Jade == 0) return; + Jade += p.Jade; + SoundManager.PlaySound(SoundList.Gold); + OutputMessage(CL("Codex_YouGainedJade", p.Jade)); + CurrencyListDialog.NotifyChanged(); + CodexDialog.Instance?.SetCodexCurrencies((int)Stone, (int)Jade); + } + private void LoseJade(S.LoseJade p) + { + Jade -= p.Jade; + SoundManager.PlaySound(SoundList.Gold); + CurrencyListDialog.NotifyChanged(); + CodexDialog.Instance?.SetCodexCurrencies((int)Stone, (int)Jade); } private void ObjectMonster(S.ObjectMonster p) { @@ -3670,6 +3778,7 @@ private void DeleteItem(S.DeleteItem p) } } actor?.RefreshStats(); + CurrencyListDialog.NotifyChanged(); } private void Death(S.Death p) { @@ -5815,6 +5924,7 @@ private void GuildStorageGoldChange(S.GuildStorageGoldChange p) GuildDialog.Gold += p.Amount; break; } + CurrencyListDialog.NotifyChanged(); } private void GuildStorageItemChange(S.GuildStorageItemChange p) @@ -6419,6 +6529,7 @@ private void ResizeInventory(S.ResizeInventory p) { Array.Resize(ref User.Inventory, p.Size); InventoryDialog.RefreshInventory2(); + CodexDialog.Instance?.InventoryChangedRefresh(); } private void ResizeStorage(S.ResizeStorage p) @@ -6595,6 +6706,7 @@ public void AddItem(UserItem item) if (item.Count + temp.Count <= temp.Info.StackSize) { temp.Count += item.Count; + CurrencyListDialog.NotifyChanged(); return; } item.Count -= (ushort)(temp.Info.StackSize - temp.Count); @@ -6608,6 +6720,7 @@ public void AddItem(UserItem item) { if (User.Inventory[i] != null) continue; User.Inventory[i] = item; + CurrencyListDialog.NotifyChanged(); return; } } @@ -6617,6 +6730,7 @@ public void AddItem(UserItem item) { if (User.Inventory[i] != null) continue; User.Inventory[i] = item; + CurrencyListDialog.NotifyChanged(); return; } } @@ -6626,6 +6740,7 @@ public void AddItem(UserItem item) { if (User.Inventory[i] != null) continue; User.Inventory[i] = item; + CurrencyListDialog.NotifyChanged(); return; } } @@ -6634,6 +6749,7 @@ public void AddItem(UserItem item) { if (User.Inventory[i] != null) continue; User.Inventory[i] = item; + CurrencyListDialog.NotifyChanged(); return; } } @@ -6717,6 +6833,52 @@ public void DisposeGuildBuffLabel() GuildBuffLabel = null; } + #region Codex + + private CodexDialog EnsureCodexDialog() + { + if (CodexDialog.Instance == null) + { + var dlg = new CodexDialog { Parent = this }; + dlg.Visible = true; + dlg.BringToFront(); + } + return CodexDialog.Instance; + } + + private void ItemCodexSync(S.ItemCodexSync p) + { + var dlg = EnsureCodexDialog(); + dlg.ApplySync(p); + //User?.RefreshStats(); + if (User != null) User.RefreshStats(); + } + + private void ItemCodexUpdate(S.ItemCodexUpdate p) + { + var dlg = EnsureCodexDialog(); + dlg.ApplyUpdate(p); + + // If we don't know this set’s reward yet (no full sync this session), fetch it once. + if (!Client.MirScenes.Dialogs.CodexDialog.RewardBySet.ContainsKey(p.Id)) + Network.Enqueue(new C.RequestItemCodex()); + + if (User != null) User.RefreshStats(); + } + + private void ItemCodexMark(S.ItemCodexMark p) + { + EnsureCodexDialog(); + CodexDialog.Instance.MarkRequirement(p.SetId, p.ItemInfoId, p.Stage, p.Registered); + if (p.Registered) + { + var name = GetItemInfo(p.ItemInfoId)?.Name ?? "item"; + ChatDialog.ReceiveChat(CL("Codex_RegisteredItem", name), ChatType.Hint); + } + } + #endregion + + public MirControl NameInfoLabel(UserItem item, bool inspect = false, bool hideDura = false) { ushort level = inspect ? InspectDialog.Level : MapObject.User.Level; diff --git a/Server.MirForms/ConfigForm.Designer.cs b/Server.MirForms/ConfigForm.Designer.cs index 69b67af32..b124f5d99 100644 --- a/Server.MirForms/ConfigForm.Designer.cs +++ b/Server.MirForms/ConfigForm.Designer.cs @@ -71,6 +71,7 @@ private void InitializeComponent() SafeZoneHealingCheckBox = new CheckBox(); SafeZoneBorderCheckBox = new CheckBox(); ObserveCheckBox = new CheckBox(); + AllowCodexCheckBox = new CheckBox(); gbCharacterScreen = new GroupBox(); StartGameCheckBox = new CheckBox(); NCharacterCheckBox = new CheckBox(); @@ -559,12 +560,13 @@ private void InitializeComponent() // // gbGameWorld // + gbGameWorld.Controls.Add(AllowCodexCheckBox); gbGameWorld.Controls.Add(SafeZoneHealingCheckBox); gbGameWorld.Controls.Add(SafeZoneBorderCheckBox); gbGameWorld.Controls.Add(ObserveCheckBox); gbGameWorld.Location = new Point(190, 20); gbGameWorld.Name = "gbGameWorld"; - gbGameWorld.Size = new Size(272, 296); + gbGameWorld.Size = new Size(272, 320); gbGameWorld.TabIndex = 2; gbGameWorld.TabStop = false; gbGameWorld.Text = "Game World"; @@ -600,10 +602,21 @@ private void InitializeComponent() ObserveCheckBox.Margin = new Padding(3, 4, 3, 4); ObserveCheckBox.Name = "ObserveCheckBox"; ObserveCheckBox.Size = new Size(103, 19); - ObserveCheckBox.TabIndex = 30; + ObserveCheckBox.TabIndex = 2; ObserveCheckBox.Text = "Observe Mode"; ObserveCheckBox.UseVisualStyleBackColor = true; // + // AllowCodexCheckBox + // + AllowCodexCheckBox.AutoSize = true; + AllowCodexCheckBox.Location = new Point(6, 104); + AllowCodexCheckBox.Margin = new Padding(3, 4, 3, 4); + AllowCodexCheckBox.Name = "AllowCodexCheckBox"; + AllowCodexCheckBox.Size = new Size(119, 19); + AllowCodexCheckBox.TabIndex = 3; + AllowCodexCheckBox.Text = "Allow Item Codex"; + AllowCodexCheckBox.UseVisualStyleBackColor = true; + // // gbCharacterScreen // gbCharacterScreen.Controls.Add(StartGameCheckBox); @@ -1143,6 +1156,8 @@ private void InitializeComponent() private System.Windows.Forms.TabPage tabPage5; private System.Windows.Forms.CheckBox SafeZoneBorderCheckBox; private System.Windows.Forms.CheckBox SafeZoneHealingCheckBox; + private System.Windows.Forms.CheckBox ObserveCheckBox; + private System.Windows.Forms.CheckBox AllowCodexCheckBox; private System.Windows.Forms.CheckBox AllowArcherCheckBox; private System.Windows.Forms.CheckBox AllowAssassinCheckBox; private System.Windows.Forms.Label label9; @@ -1192,7 +1207,6 @@ private void InitializeComponent() private Button ReaddSinDrops; private Button RemoveArcDrops; private Button RemoveSinDrops; - private CheckBox ObserveCheckBox; private GroupBox gbHTTPService; private GroupBox gbConnectionSettings; private GroupBox gbServerConnection; diff --git a/Server.MirForms/ConfigForm.cs b/Server.MirForms/ConfigForm.cs index 7a7268e67..5d8d20b6d 100644 --- a/Server.MirForms/ConfigForm.cs +++ b/Server.MirForms/ConfigForm.cs @@ -33,6 +33,7 @@ public ConfigForm() AllowArcherCheckBox.Checked = Settings.AllowCreateArcher; Resolution_textbox.Text = Settings.AllowedResolution.ToString(); ObserveCheckBox.Checked = Settings.AllowObserve; + AllowCodexCheckBox.Checked = Settings.AllowCodex; SafeZoneBorderCheckBox.Checked = Settings.SafeZoneBorder; SafeZoneHealingCheckBox.Checked = Settings.SafeZoneHealing; @@ -107,6 +108,7 @@ public void Save() Settings.AllowCreateAssassin = AllowAssassinCheckBox.Checked; Settings.AllowCreateArcher = AllowArcherCheckBox.Checked; Settings.AllowObserve = ObserveCheckBox.Checked; + Settings.AllowCodex = AllowCodexCheckBox.Checked; if (int.TryParse(Resolution_textbox.Text, out tempint)) Settings.AllowedResolution = tempint; diff --git a/Server.MirForms/Database/ItemCodexEditorForm.Designer.cs b/Server.MirForms/Database/ItemCodexEditorForm.Designer.cs new file mode 100644 index 000000000..d67899b3f --- /dev/null +++ b/Server.MirForms/Database/ItemCodexEditorForm.Designer.cs @@ -0,0 +1,492 @@ +// Server/MirForms/ItemCodexEditorForm.Designer.cs +using System; +using System.Drawing; +using System.Windows.Forms; +using Shared; // ItemGrade, CodexBucket, Stat + +namespace Server.MirForms +{ + partial class ItemCodexEditorForm + { + private System.ComponentModel.IContainer components = null; + + // Top strip + private ToolStrip tsMain; + private ToolStripButton tsbLoadTxt, tsbSaveTxt, tsbApply, tsbRebuild, tsbSave; + + // Main split: Left (Collections) | Right (Selected + Tabs) + private SplitContainer splitMain; + + // LEFT: Collections + private TableLayoutPanel leftLayout; + private TextBox txtSearchCollections; + private ComboBox cbBucketFilter; + private FlowLayoutPanel leftButtons; + private Button btnAddCollection, btnRemoveCollection, btnDuplicateCollection; + private DataGridView dgvCollections; + private DataGridViewTextBoxColumn colId, colName, colCount, colXP; + private DataGridViewComboBoxColumn colBucket, colRarity; + private DataGridViewCheckBoxColumn colEnabled; + + // RIGHT split: Top (Selected Items) | Bottom (Tabs) + private SplitContainer splitRight; + + // Selected items area + private TableLayoutPanel selectedLayout; + private FlowLayoutPanel selectedHeader; + private Label lblAddIndex; + private TextBox txtAddIndex; + private Button btnAddItem, btnRemoveItem, btnSortItems; + private Label lblStageQuick; + private ComboBox cbStageQuick; + private Button btnApplyStage; + private DataGridView dgvItems; + private DataGridViewTextBoxColumn colItemIndex, colItemName, colItemType; + private DataGridViewComboBoxColumn colItemStage; + + // Tabs + private TabControl tabs; + private TabPage tabOverview, tabRewards, tabAvailable; + + // Overview tab + private TableLayoutPanel overviewLayout; + private Label lblId, lblName, lblBucket, lblRarity, lblXP, lblStartUtc, lblEndUtc; + private TextBox txtName; + private ComboBox cbBucketDetail, cbRarityDetail; + private NumericUpDown nudXP; + private DateTimePicker dtpStartUtc, dtpEndUtc; + private CheckBox chkEnabled, chkKeepStats; + + // Rewards tab + private TableLayoutPanel rewardsLayout; + private DataGridView dgvRewards; + private DataGridViewComboBoxColumn colRewardStat; + private DataGridViewTextBoxColumn colRewardValue; + private FlowLayoutPanel rewardsButtons; + private Button btnAddReward, btnRemoveReward; + + // Available tab + private TableLayoutPanel availLayout; + private FlowLayoutPanel availHeader; + private Label lblTypeFilter, lblSearchAvail; + private ComboBox cbTypeFilter; + private TextBox txtSearchAvail; + private Button btnAddSelectedAvail; + private DataGridView dgvAvailable; + private DataGridViewTextBoxColumn colAvailIndex, colAvailName, colAvailType; + + // Status + private StatusStrip statusBar; + private ToolStripStatusLabel statusText; + + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + components.Dispose(); + base.Dispose(disposing); + } + + private void InitializeComponent() + { + components = new System.ComponentModel.Container(); + + SuspendLayout(); + Text = "Item Codex Editor"; + StartPosition = FormStartPosition.CenterScreen; + MinimumSize = new Size(1200, 800); + ClientSize = new Size(1400, 900); + AutoScaleMode = AutoScaleMode.Font; + KeyPreview = true; + + // ───────── ToolStrip ───────── + tsMain = new ToolStrip { GripStyle = ToolStripGripStyle.Hidden, Dock = DockStyle.Top, ImageScalingSize = new Size(20, 20) }; + tsbLoadTxt = new ToolStripButton("Import") { Visible = false }; + tsbSave = new ToolStripButton("Save"); + tsbSaveTxt = new ToolStripButton("Export") { Visible = false }; + tsbApply = new ToolStripButton("Apply to Server"); + tsbRebuild = new ToolStripButton("Rebuild (Auto)"); + tsbLoadTxt.Click += btnLoadTxt_Click; + tsbSave.Click += btnSave_Click; + tsbSaveTxt.Click += btnSaveTxt_Click; + tsbApply.Click += btnApply_Click; + tsbRebuild.Click += btnRebuild_Click; + tsMain.Items.Add(tsbSave); + tsMain.Items.Add(new ToolStripSeparator()); + tsMain.Items.Add(tsbApply); + tsMain.Items.Add(tsbRebuild); + + // ───────── Status ───────── + statusBar = new StatusStrip(); + statusText = new ToolStripStatusLabel { Text = "Ready" }; + statusBar.Items.Add(statusText); + statusBar.Dock = DockStyle.Bottom; + + // ───────── Main Split (Left | Right) ───────── + splitMain = new SplitContainer + { + Dock = DockStyle.Fill, + Orientation = Orientation.Vertical, + SplitterWidth = 6 + }; + // NOTE: No Panel1MinSize/Panel2MinSize/ SplitterDistance here (set later at runtime). + + // LEFT layout (search + buttons + grid) + leftLayout = new TableLayoutPanel + { + Dock = DockStyle.Fill, + ColumnCount = 1, + RowCount = 4 + }; + leftLayout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); // search + leftLayout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); // bucket filter + leftLayout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); // buttons + leftLayout.RowStyles.Add(new RowStyle(SizeType.Percent, 100f)); // grid + + txtSearchCollections = new TextBox + { + Dock = DockStyle.Top, + PlaceholderText = "Search collections (id/name)…", + Margin = new Padding(6) + }; + txtSearchCollections.TextChanged += txtSearchCollections_TextChanged; + + cbBucketFilter = new ComboBox + { + Dock = DockStyle.Top, + DropDownStyle = ComboBoxStyle.DropDownList, + Margin = new Padding(6) + }; + + leftButtons = new FlowLayoutPanel + { + Dock = DockStyle.Top, + AutoSize = true, + FlowDirection = FlowDirection.LeftToRight, + WrapContents = false, + Margin = new Padding(6) + }; + btnAddCollection = new Button { Text = "Add", AutoSize = true }; + btnRemoveCollection = new Button { Text = "Delete", AutoSize = true }; + btnDuplicateCollection = new Button { Text = "Duplicate", AutoSize = true }; + btnAddCollection.Click += btnAddCollection_Click; + btnRemoveCollection.Click += btnRemoveCollection_Click; + btnDuplicateCollection.Click += btnDuplicateCollection_Click; + leftButtons.Controls.AddRange(new System.Windows.Forms.Control[] { btnAddCollection, btnRemoveCollection, btnDuplicateCollection }); + + dgvCollections = new DataGridView + { + Dock = DockStyle.Fill, + AllowUserToAddRows = false, + AllowUserToDeleteRows = false, + ReadOnly = false, + MultiSelect = true, + SelectionMode = DataGridViewSelectionMode.FullRowSelect, + RowHeadersVisible = false, + AutoGenerateColumns = false, + AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.None, // fixed widths + ScrollBars = ScrollBars.Both // horizontal scroll + }; + colId = new DataGridViewTextBoxColumn { HeaderText = "ID", DataPropertyName = "Id", Width = 50 }; + colName = new DataGridViewTextBoxColumn { HeaderText = "Name", DataPropertyName = "Name", Width = 160 }; + colBucket = new DataGridViewComboBoxColumn + { + HeaderText = "Category", + DataPropertyName = "Bucket", + DataSource = Enum.GetValues(typeof(CodexBucket)), + DisplayStyle = DataGridViewComboBoxDisplayStyle.DropDownButton, + FlatStyle = FlatStyle.Standard, + Width = 100 + }; + colRarity = new DataGridViewComboBoxColumn + { + HeaderText = "Rarity", + DataPropertyName = "Rarity", + DataSource = Enum.GetValues(typeof(ItemGrade)), + DisplayStyle = DataGridViewComboBoxDisplayStyle.DropDownButton, + FlatStyle = FlatStyle.Standard, + Width = 90 + }; + colEnabled = new DataGridViewCheckBoxColumn { HeaderText = "Enabled", DataPropertyName = "Enabled", Width = 70 }; + colCount = new DataGridViewTextBoxColumn { HeaderText = "Items", DataPropertyName = "RequiredCount", ReadOnly = true, Width = 60 }; + colXP = new DataGridViewTextBoxColumn { HeaderText = "XP", DataPropertyName = "RewardXP", Width = 70 }; + dgvCollections.Columns.AddRange(new DataGridViewColumn[] { colId, colName, colBucket, colRarity, colEnabled, colCount, colXP }); + dgvCollections.SelectionChanged += dgvCollections_SelectionChanged; + + leftLayout.Controls.Add(txtSearchCollections, 0, 0); + leftLayout.Controls.Add(cbBucketFilter, 0, 1); + leftLayout.Controls.Add(leftButtons, 0, 2); + leftLayout.Controls.Add(dgvCollections, 0, 3); + + splitMain.Panel1.Controls.Add(leftLayout); + + // ───────── RIGHT split (Selected | Tabs) ───────── + splitRight = new SplitContainer + { + Dock = DockStyle.Fill, + Orientation = Orientation.Horizontal, + SplitterWidth = 6 + }; + // NOTE: No min sizes or distances here either. + + // Selected items layout + selectedLayout = new TableLayoutPanel + { + Dock = DockStyle.Fill, + ColumnCount = 1, + RowCount = 2 + }; + selectedLayout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + selectedLayout.RowStyles.Add(new RowStyle(SizeType.Percent, 100f)); + + selectedHeader = new FlowLayoutPanel + { + Dock = DockStyle.Top, + AutoSize = true, + FlowDirection = FlowDirection.LeftToRight, + WrapContents = false, + Padding = new Padding(6) + }; + lblAddIndex = new Label { Text = "Index:", AutoSize = true, Margin = new Padding(6, 10, 6, 6) }; + txtAddIndex = new TextBox { Width = 120 }; + btnAddItem = new Button { Text = "Add by Index", AutoSize = true }; + btnRemoveItem = new Button { Text = "Remove Selected", AutoSize = true }; + btnSortItems = new Button { Text = "Sort by Index", AutoSize = true }; + lblStageQuick = new Label { Text = "Stage:", AutoSize = true, Margin = new Padding(12, 10, 6, 6) }; + cbStageQuick = new ComboBox { DropDownStyle = ComboBoxStyle.DropDownList, Width = 110, Margin = new Padding(0, 6, 0, 6) }; + btnApplyStage = new Button { Text = "Apply Stage", AutoSize = true }; + btnAddItem.Click += btnAddItem_Click; + btnRemoveItem.Click += btnRemoveItem_Click; + btnSortItems.Click += btnSortItems_Click; + btnApplyStage.Click += btnApplyStage_Click; + selectedHeader.Controls.AddRange(new System.Windows.Forms.Control[] { lblAddIndex, txtAddIndex, btnAddItem, btnRemoveItem, btnSortItems, lblStageQuick, cbStageQuick, btnApplyStage }); + + dgvItems = new DataGridView + { + Dock = DockStyle.Fill, + AllowUserToAddRows = false, + AllowUserToDeleteRows = false, + ReadOnly = false, + MultiSelect = false, + SelectionMode = DataGridViewSelectionMode.FullRowSelect, + RowHeadersVisible = false, + AutoGenerateColumns = false, + AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill + }; + colItemIndex = new DataGridViewTextBoxColumn { HeaderText = "ItemIndex", FillWeight = 18 }; + colItemName = new DataGridViewTextBoxColumn { HeaderText = "Item Name", FillWeight = 52 }; + colItemType = new DataGridViewTextBoxColumn { HeaderText = "Type", FillWeight = 15 }; + colItemStage = new DataGridViewComboBoxColumn { HeaderText = "Stage", FillWeight = 15, FlatStyle = FlatStyle.Standard, DisplayStyle = DataGridViewComboBoxDisplayStyle.DropDownButton }; + colItemIndex.DataPropertyName = "ItemIndex"; + colItemName.DataPropertyName = "ItemName"; + colItemType.DataPropertyName = "ItemType"; + colItemStage.DataPropertyName = "Stage"; + dgvItems.Columns.AddRange(new DataGridViewColumn[] { colItemIndex, colItemName, colItemType, colItemStage }); + dgvItems.CellValueChanged += dgvItems_CellValueChanged; + dgvItems.CurrentCellDirtyStateChanged += dgvItems_CurrentCellDirtyStateChanged; + dgvItems.DataError += dgvItems_DataError; + + selectedLayout.Controls.Add(selectedHeader, 0, 0); + selectedLayout.Controls.Add(dgvItems, 0, 1); + + splitRight.Panel1.Controls.Add(selectedLayout); + + // Tabs + tabs = new TabControl { Dock = DockStyle.Fill }; + tabOverview = new TabPage("Overview"); + tabRewards = new TabPage("Rewards"); + tabAvailable = new TabPage("Available"); + + // Overview layout + overviewLayout = new TableLayoutPanel + { + Dock = DockStyle.Top, + ColumnCount = 4, + AutoSize = true, + Padding = new Padding(10) + }; + overviewLayout.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + overviewLayout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50f)); + overviewLayout.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + overviewLayout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50f)); + + lblId = new Label { Text = "ID: -", AutoSize = true, Margin = new Padding(0, 4, 16, 8) }; + lblName = new Label { Text = "Name:", AutoSize = true }; + txtName = new TextBox { Dock = DockStyle.Fill, Margin = new Padding(6) }; + + lblBucket = new Label { Text = "Category:", AutoSize = true }; + cbBucketDetail = new ComboBox { DropDownStyle = ComboBoxStyle.DropDownList, Dock = DockStyle.Fill, Margin = new Padding(6) }; + + lblRarity = new Label { Text = "Rarity:", AutoSize = true }; + cbRarityDetail = new ComboBox { DropDownStyle = ComboBoxStyle.DropDownList, Dock = DockStyle.Fill, Margin = new Padding(6) }; + + lblXP = new Label { Text = "Codex XP:", AutoSize = true }; + nudXP = new NumericUpDown { Maximum = 1000000, Minimum = 0, Increment = 10, Dock = DockStyle.Left, Width = 120, Margin = new Padding(6) }; + + lblStartUtc = new Label { Text = "Start (UTC):", AutoSize = true }; + lblEndUtc = new Label { Text = "End (UTC):", AutoSize = true }; + dtpStartUtc = new DateTimePicker + { + Format = DateTimePickerFormat.Custom, + CustomFormat = "yyyy-MM-dd HH:mm", + ShowCheckBox = true, + Checked = false, + Dock = DockStyle.Left, + Width = 170, + Margin = new Padding(6) + }; + dtpEndUtc = new DateTimePicker + { + Format = DateTimePickerFormat.Custom, + CustomFormat = "yyyy-MM-dd HH:mm", + ShowCheckBox = true, + Checked = false, + Dock = DockStyle.Left, + Width = 170, + Margin = new Padding(6) + }; + + chkEnabled = new CheckBox { Text = "Enabled", AutoSize = true, Margin = new Padding(6, 8, 6, 8) }; + chkKeepStats = new CheckBox { Text = "Keep stats after expiry", AutoSize = true, Margin = new Padding(6, 8, 6, 8) }; + + var topRow = new FlowLayoutPanel { Dock = DockStyle.Top, AutoSize = true, Padding = new Padding(10, 8, 10, 0) }; + topRow.Controls.Add(lblId); + + overviewLayout.Controls.Add(lblName, 0, 0); + overviewLayout.Controls.Add(txtName, 1, 0); + overviewLayout.Controls.Add(lblBucket, 2, 0); + overviewLayout.Controls.Add(cbBucketDetail, 3, 0); + + overviewLayout.Controls.Add(lblRarity, 0, 1); + overviewLayout.Controls.Add(cbRarityDetail, 1, 1); + overviewLayout.Controls.Add(lblXP, 2, 1); + overviewLayout.Controls.Add(nudXP, 3, 1); + + overviewLayout.Controls.Add(lblStartUtc, 0, 2); + overviewLayout.Controls.Add(dtpStartUtc, 1, 2); + overviewLayout.Controls.Add(lblEndUtc, 2, 2); + overviewLayout.Controls.Add(dtpEndUtc, 3, 2); + + overviewLayout.Controls.Add(chkEnabled, 0, 3); + overviewLayout.SetColumnSpan(chkEnabled, 2); + overviewLayout.Controls.Add(chkKeepStats, 2, 3); + overviewLayout.SetColumnSpan(chkKeepStats, 2); + + var panelOverview = new Panel { Dock = DockStyle.Fill, AutoScroll = true }; + panelOverview.Controls.Add(overviewLayout); + panelOverview.Controls.Add(topRow); + tabOverview.Controls.Add(panelOverview); + + // Rewards layout + rewardsLayout = new TableLayoutPanel + { + Dock = DockStyle.Fill, + ColumnCount = 1, + RowCount = 2 + }; + rewardsLayout.RowStyles.Add(new RowStyle(SizeType.Percent, 100f)); + rewardsLayout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + + dgvRewards = new DataGridView + { + Dock = DockStyle.Fill, + AllowUserToAddRows = true, + AllowUserToDeleteRows = true, + ReadOnly = false, + MultiSelect = false, + SelectionMode = DataGridViewSelectionMode.FullRowSelect, + RowHeadersVisible = false, + AutoGenerateColumns = false, + AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill + }; + colRewardStat = new DataGridViewComboBoxColumn { HeaderText = "Stat", DataPropertyName = "Stat", DataSource = Enum.GetValues(typeof(Stat)), FillWeight = 70 }; + colRewardValue = new DataGridViewTextBoxColumn { HeaderText = "Value", DataPropertyName = "Value", FillWeight = 30 }; + dgvRewards.Columns.AddRange(new DataGridViewColumn[] { colRewardStat, colRewardValue }); + + rewardsButtons = new FlowLayoutPanel + { + Dock = DockStyle.Bottom, + AutoSize = true, + FlowDirection = FlowDirection.LeftToRight, + WrapContents = false, + Padding = new Padding(10) + }; + btnAddReward = new Button { Text = "Add Stat", AutoSize = true }; + btnRemoveReward = new Button { Text = "Remove", AutoSize = true }; + btnAddReward.Click += btnAddReward_Click; + btnRemoveReward.Click += btnRemoveReward_Click; + rewardsButtons.Controls.AddRange(new System.Windows.Forms.Control[] { btnAddReward, btnRemoveReward }); + + rewardsLayout.Controls.Add(dgvRewards, 0, 0); + rewardsLayout.Controls.Add(rewardsButtons, 0, 1); + tabRewards.Controls.Add(rewardsLayout); + + // Available layout + availLayout = new TableLayoutPanel + { + Dock = DockStyle.Fill, + ColumnCount = 1, + RowCount = 2 + }; + availLayout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + availLayout.RowStyles.Add(new RowStyle(SizeType.Percent, 100f)); + + availHeader = new FlowLayoutPanel + { + Dock = DockStyle.Top, + AutoSize = true, + FlowDirection = FlowDirection.LeftToRight, + WrapContents = false, + Padding = new Padding(10, 8, 10, 4) + }; + lblTypeFilter = new Label { Text = "Type:", AutoSize = true, Margin = new Padding(0, 6, 6, 0) }; + cbTypeFilter = new ComboBox { DropDownStyle = ComboBoxStyle.DropDownList, Width = 160 }; + cbTypeFilter.SelectedIndexChanged += cbTypeFilter_SelectedIndexChanged; + + lblSearchAvail = new Label { Text = "Search:", AutoSize = true, Margin = new Padding(12, 6, 6, 0) }; + txtSearchAvail = new TextBox { Width = 300 }; + txtSearchAvail.TextChanged += txtSearchAvail_TextChanged; + + btnAddSelectedAvail = new Button { Text = "Add Selected →", AutoSize = true, Margin = new Padding(12, 3, 0, 3) }; + btnAddSelectedAvail.Click += btnAddSelectedAvail_Click; + + availHeader.Controls.AddRange(new System.Windows.Forms.Control[] { lblTypeFilter, cbTypeFilter, lblSearchAvail, txtSearchAvail, btnAddSelectedAvail }); + + dgvAvailable = new DataGridView + { + Dock = DockStyle.Fill, + AllowUserToAddRows = false, + AllowUserToDeleteRows = false, + ReadOnly = true, + MultiSelect = true, + SelectionMode = DataGridViewSelectionMode.FullRowSelect, + RowHeadersVisible = false, + AutoGenerateColumns = false, + AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill + }; + colAvailIndex = new DataGridViewTextBoxColumn { HeaderText = "Index", DataPropertyName = "Index", FillWeight = 15 }; + colAvailName = new DataGridViewTextBoxColumn { HeaderText = "Name", DataPropertyName = "Name", FillWeight = 65 }; + colAvailType = new DataGridViewTextBoxColumn { HeaderText = "Type", DataPropertyName = "Type", FillWeight = 20 }; + dgvAvailable.Columns.AddRange(new DataGridViewColumn[] { colAvailIndex, colAvailName, colAvailType }); + dgvAvailable.CellDoubleClick += dgvAvailable_CellDoubleClick; + + availLayout.Controls.Add(availHeader, 0, 0); + availLayout.Controls.Add(dgvAvailable, 0, 1); + tabAvailable.Controls.Add(availLayout); + + tabs.TabPages.AddRange(new TabPage[] { tabOverview, tabRewards, tabAvailable }); + + splitRight.Panel2.Controls.Add(tabs); + splitMain.Panel2.Controls.Add(splitRight); + + Controls.Add(splitMain); + Controls.Add(tsMain); + Controls.Add(statusBar); + + ResumeLayout(false); + PerformLayout(); + } + + // wrapper for lambda-free event hook + private void dgvCollections_SelectionChanged(object sender, EventArgs e) => OnCollectionSelectionChanged(); + } +} diff --git a/Server.MirForms/Database/ItemCodexEditorForm.cs b/Server.MirForms/Database/ItemCodexEditorForm.cs new file mode 100644 index 000000000..49855a55e --- /dev/null +++ b/Server.MirForms/Database/ItemCodexEditorForm.cs @@ -0,0 +1,1750 @@ +// Server/MirForms/ItemCodexEditorForm.cs +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Windows.Forms; +using System.Text.Json; +using System.Text.Json.Serialization; +using Server.MirDatabase; +using Server.MirEnvir; +using Shared; +using Shared.Data; + +namespace Server.MirForms +{ + public partial class ItemCodexEditorForm : Form + { + private static ItemCodexEditorForm _instance; + + private readonly Envir _envir; + + // Collections data (master + view) + private readonly BindingList _collectionsMaster = new BindingList(); + private readonly BindingList _collectionsView = new BindingList(); + private CollectionRow _current; + + // Rewards + private readonly BindingList _rewards = new BindingList(); + + // Available items + private readonly BindingList _availItems = new BindingList(); + private ItemType? _filterType = null; + private string _lastAvailSearch = string.Empty; + + // Stage picker options + private class StageOption + { + public int Value { get; set; } + public string Text { get; set; } = string.Empty; + public override string ToString() => Text; + } + + private readonly BindingList _stageOptions = new BindingList(); + + // ItemIndex -> ItemInfo + private readonly Dictionary _itemIndexMap = new Dictionary(); + + // Auto-build helpers + private readonly Regex _reBrackets = new Regex(@"\[[^]]*\]", RegexOptions.Compiled); + private readonly Regex _reParens = new Regex(@"\([^)]*\)", RegexOptions.Compiled); + private readonly Regex _reTrailing = new Regex(@"\s+\d+$", RegexOptions.Compiled); + + private bool _suspendRewardSync = false; + private bool _loadingSelection = false; + private bool _suspendAutoSave = false; + + // ---------- models ---------- + private class ItemRow + { + public int ItemIndex { get; set; } + public string ItemName { get; set; } + public string ItemType { get; set; } + public int Stage { get; set; } = CodexRequirement.AnyStage; // -1 = Any + } + + private class RewardRow + { + public Stat Stat { get; set; } + public int Value { get; set; } + } + + private class ItemDto + { + public int Index { get; set; } + public sbyte Stage { get; set; } + } + + private class CollectionDto + { + public int Id { get; set; } + public string Name { get; set; } + public List Items { get; set; } + public Dictionary Reward { get; set; } + public int XP { get; set; } + public string Rarity { get; set; } + public string Bucket { get; set; } + public bool Enabled { get; set; } + public string Start { get; set; } + public string End { get; set; } + public bool KeepStats { get; set; } + } + + private class CollectionRow + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string RewardText { get; set; } = string.Empty; + public BindingList Items { get; } = new BindingList(); + public int RequiredCount => Items?.Count ?? 0; + + public ItemGrade Rarity { get; set; } = ItemGrade.None; + public int RewardXP { get; set; } = 0; + public CodexBucket Bucket { get; set; } = CodexBucket.Character; // 0=Character,1=Limited,2=Event + public bool Enabled { get; set; } = true; + public DateTime? StartTimeUtc { get; set; } + public DateTime? EndTimeUtc { get; set; } + public bool KeepStatsAfterExpiry { get; set; } + } + + private IEnumerable EnumerateSelectedCollections() + { + if (dgvCollections == null) + { + if (_current != null) yield return _current; + yield break; + } + + if (dgvCollections.SelectedRows.Count == 0) + { + if (_current != null) yield return _current; + yield break; + } + + foreach (DataGridViewRow row in dgvCollections.SelectedRows) + { + if (row?.DataBoundItem is CollectionRow col) + yield return col; + } + } + + private void ApplyToSelectedCollections(Action updater) + { + if (updater == null) return; + + bool applied = false; + + foreach (var col in EnumerateSelectedCollections()) + { + updater(col); + applied = true; + } + + if (!applied && _current != null) + updater(_current); + } + + private void InitializeStageOptions() + { + _stageOptions.Clear(); + _stageOptions.Add(new StageOption { Value = CodexRequirement.AnyStage, Text = "Any Stage" }); + //for (int stage = 0; stage <= TranscendenceInfo.MaxStage; stage++) + // _stageOptions.Add(new StageOption { Value = stage, Text = $"Stage {stage}" }); + } + + private void EnsureStageOptionExists(int stage) + { + if (_stageOptions.Any(opt => opt.Value == stage)) return; + + string label = stage == CodexRequirement.AnyStage ? "Any Stage" : $"Stage {stage}"; + var option = new StageOption { Value = stage, Text = label }; + + int insertIndex = 0; + while (insertIndex < _stageOptions.Count && _stageOptions[insertIndex].Value < stage) + insertIndex++; + + _stageOptions.Insert(insertIndex, option); + } + + private void SyncStageOptionsWithCurrentItems() + { + if (_current == null) return; + foreach (var row in _current.Items) + EnsureStageOptionExists(row.Stage); + } + + // ---------- static launchers ---------- + public static void ShowForMain() + { + if (Envir.Main == null) + { + MessageBox.Show("Envir.Main is not initialized.", "Item Codex Editor", + MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + ShowForEnvir(Envir.Main); + } + public static void ShowForEnvir(Envir envir) + { + if (_instance == null || _instance.IsDisposed) + _instance = new ItemCodexEditorForm(envir); + + _instance.Show(); + _instance.BringToFront(); + } + + // ---------- ctor ---------- + public ItemCodexEditorForm() : this(Envir.Main) { } + public ItemCodexEditorForm(Envir envir) + { + _envir = envir ?? Envir.Main; + InitializeComponent(); + + // Bind collections grid to view list + dgvCollections.AutoGenerateColumns = false; + dgvCollections.DataSource = _collectionsView; + + // Selected items grid + dgvItems.AutoGenerateColumns = false; + InitializeStageOptions(); + colItemIndex.DataPropertyName = nameof(ItemRow.ItemIndex); + colItemName.DataPropertyName = nameof(ItemRow.ItemName); + colItemType.DataPropertyName = nameof(ItemRow.ItemType); + colItemStage.DataPropertyName = nameof(ItemRow.Stage); + colItemStage.DisplayMember = nameof(StageOption.Text); + colItemStage.ValueMember = nameof(StageOption.Value); + colItemStage.DataSource = _stageOptions; + colItemStage.DisplayStyle = DataGridViewComboBoxDisplayStyle.DropDownButton; + colItemStage.FlatStyle = FlatStyle.Standard; + cbStageQuick.DisplayMember = nameof(StageOption.Text); + cbStageQuick.ValueMember = nameof(StageOption.Value); + cbStageQuick.DataSource = _stageOptions; + if (_stageOptions.Count > 0) + cbStageQuick.SelectedValue = CodexRequirement.AnyStage; + + // Rewards grid + dgvRewards.AutoGenerateColumns = false; + dgvRewards.DataSource = _rewards; + dgvRewards.CellParsing += (s, e) => + { + if (e.ColumnIndex == 1) + { + var txt = (e.Value?.ToString() ?? "").Trim(); + if (txt.Length == 0) { e.Value = 0; e.ParsingApplied = true; return; } + txt = txt.Replace(",", ""); + if (int.TryParse(txt, NumberStyles.Integer, CultureInfo.InvariantCulture, out int v)) + { + e.Value = v; + e.ParsingApplied = true; + } + } + }; + dgvRewards.CurrentCellDirtyStateChanged += (s, e) => + { + var cell = dgvRewards.CurrentCell; + if (cell is DataGridViewComboBoxCell && dgvRewards.IsCurrentCellDirty) + dgvRewards.CommitEdit(DataGridViewDataErrorContexts.Commit); + }; + dgvRewards.DataError += (s, e) => { e.ThrowException = false; }; + dgvRewards.CellValueChanged += (s, e) => SyncRewardsToCurrent(); + _rewards.ListChanged += (s, e) => SyncRewardsToCurrent(); + + // Available items grid + dgvAvailable.AutoGenerateColumns = false; + dgvAvailable.DataSource = _availItems; + + // Details dropdowns + cbBucketDetail.Items.Clear(); + foreach (var val in Enum.GetValues(typeof(CodexBucket))) cbBucketDetail.Items.Add(val); + cbRarityDetail.Items.Clear(); + foreach (var val in Enum.GetValues(typeof(ItemGrade))) cbRarityDetail.Items.Add(val); + + // Bucket filter (left pane) + if (cbBucketFilter != null) + { + cbBucketFilter.Items.Clear(); + cbBucketFilter.Items.Add("All"); + foreach (var val in Enum.GetValues(typeof(CodexBucket))) cbBucketFilter.Items.Add(val); + cbBucketFilter.SelectedIndex = 0; + cbBucketFilter.SelectedIndexChanged += (_, __) => ApplyCollectionFilters(); + } + + // Detail change events + txtName.TextChanged += (_, __) => + { + if (_current == null || _loadingSelection) return; + var name = txtName.Text?.Trim() ?? string.Empty; + ApplyToSelectedCollections(col => col.Name = name); + dgvCollections.Refresh(); + AutoSaveIfPossible(); + }; + cbBucketDetail.SelectedIndexChanged += (_, __) => + { + if (_current == null || cbBucketDetail.SelectedItem == null || _loadingSelection) return; + var prevBucket = _current.Bucket; + var bucket = (CodexBucket)cbBucketDetail.SelectedItem; + ApplyToSelectedCollections(col => + { + col.Bucket = bucket; + if (bucket == CodexBucket.Character) + { + if ((col.StartTimeUtc.HasValue || col.EndTimeUtc.HasValue || col.KeepStatsAfterExpiry) && + MessageBox.Show("Changing to Character will clear time window and keep-stats. Continue?", "Change Category", MessageBoxButtons.YesNo, MessageBoxIcon.Question) != DialogResult.Yes) + { + _loadingSelection = true; + cbBucketDetail.SelectedItem = prevBucket; + _loadingSelection = false; + return; + } + col.StartTimeUtc = null; + col.EndTimeUtc = null; + col.KeepStatsAfterExpiry = false; + } + }); + UpdateTimeFieldAvailability(); + dgvCollections.Refresh(); + AutoSaveIfPossible(); + }; + cbRarityDetail.SelectedIndexChanged += (_, __) => + { + if (_current == null || cbRarityDetail.SelectedItem == null || _loadingSelection) return; + var rarity = (ItemGrade)cbRarityDetail.SelectedItem; + ApplyToSelectedCollections(col => col.Rarity = rarity); + dgvCollections.Refresh(); + AutoSaveIfPossible(); + }; + nudXP.ValueChanged += (_, __) => + { + if (_current == null || _loadingSelection) return; + int xp = (int)nudXP.Value; + ApplyToSelectedCollections(col => col.RewardXP = xp); + dgvCollections.Refresh(); + AutoSaveIfPossible(); + }; + chkEnabled.CheckedChanged += (_, __) => + { + if (_current == null || _loadingSelection) return; + bool enabled = chkEnabled.Checked; + ApplyToSelectedCollections(col => col.Enabled = enabled); + dgvCollections.Refresh(); + AutoSaveIfPossible(); + }; + dtpStartUtc.ValueChanged += (_, __) => UpdateTimeFieldsFromUI(); + dtpEndUtc.ValueChanged += (_, __) => UpdateTimeFieldsFromUI(); + chkKeepStats.CheckedChanged += (_, __) => + { + if (_current == null || _loadingSelection) return; + bool keep = chkKeepStats.Checked; + ApplyToSelectedCollections(col => col.KeepStatsAfterExpiry = keep); + dgvCollections.Refresh(); + AutoSaveIfPossible(); + }; + + chkEnabled.Checked = true; + + // Build item map, filters, and load data + RebuildItemIndexMap(); + PopulateTypeFilter(); + RefreshAvailableItems(string.Empty); + LoadFromServer(); + + // Keyboard shortcuts + KeyDown += ItemCodexEditorForm_KeyDown; + + // SAFELY set splitter constraints & distances *after* layout + Shown += (_, __) => InitSplittersSafely(); + splitMain.SizeChanged += (_, __) => InitSplittersSafely(); + splitRight.SizeChanged += (_, __) => InitSplittersSafely(); + } + + // Set distances WITHOUT throwing, even during small sizes + private void InitSplittersSafely() + { + // Left/Right + SafeConfigureSplit(splitMain, desired: 320, min1: 220, min2: 300); + // Top/Bottom on right (horizontal orientation) + SafeConfigureSplit(splitRight, desired: 360, min1: 200, min2: 220); + } + + private static void SafeConfigureSplit(SplitContainer sc, int desired, int min1, int min2) + { + try + { + int avail = (sc.Orientation == Orientation.Vertical) ? sc.ClientSize.Width : sc.ClientSize.Height; + int splitter = sc.SplitterWidth; + int space = avail - splitter; + if (space <= 0) return; // will run again on SizeChanged + + // Clamp mins so they never exceed available space + int p1 = Math.Max(0, Math.Min(min1, space)); + int p2 = Math.Max(0, Math.Min(min2, Math.Max(0, space - p1))); + + // Compute a distance within the final legal range + int maxLeft = space - p2; + int dist = Math.Max(p1, Math.Min(desired, maxLeft)); + + // 1) Set a safe SplitterDistance first (within 0..space) to avoid ApplyPanel*MinSize throwing. + int distSafe = Math.Max(0, Math.Min(dist, space)); + sc.SplitterDistance = distSafe; + + // 2) Now apply min sizes (current distance is already within the upcoming legal range). + sc.Panel1MinSize = p1; + sc.Panel2MinSize = p2; + } + catch + { + // swallow early layout quirks; next SizeChanged/Shown will fix it + } + } + + // ---------- map/helpers ---------- + private void RebuildItemIndexMap() + { + _itemIndexMap.Clear(); + if (_envir?.ItemInfoList == null) return; + foreach (var ii in _envir.ItemInfoList) + { + if (ii == null) continue; + _itemIndexMap[ii.Index] = ii; + } + } + + private ItemInfo ItemByIndex(int itemIndex) + { + if (_itemIndexMap.TryGetValue(itemIndex, out var info)) + return info; + + var found = _envir?.ItemInfoList?.FirstOrDefault(x => x != null && x.Index == itemIndex); + if (found != null) _itemIndexMap[itemIndex] = found; + return found; + } + + private bool ItemExists(int itemIndex) => + _itemIndexMap.ContainsKey(itemIndex) || + (_envir?.ItemInfoList?.Any(x => x != null && x.Index == itemIndex) ?? false); + + private static string StatsToText(Stats s) + { + if (s == null || s.Values == null || s.Values.Count == 0) return string.Empty; + return string.Join(",", s.Values.Select(kv => $"{kv.Key}={kv.Value}")); + } + + private static Stats ParseStats(string txt) + { + var s = new Stats(); + if (string.IsNullOrWhiteSpace(txt)) return s; + + var parts = txt.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries); + foreach (var p in parts) + { + var kv = p.Split('='); + if (kv.Length != 2) continue; + if (!Enum.TryParse(kv[0].Trim(), true, out var stat)) continue; + if (!int.TryParse(kv[1].Trim(), out int val)) continue; + s[stat] += val; + } + return s; + } + + // ---------- UI events ---------- + private void txtSearchCollections_TextChanged(object sender, EventArgs e) => ApplyCollectionFilters(); + + private void btnAddCollection_Click(object sender, EventArgs e) + { + int nextId = (_collectionsMaster.Count == 0) ? 1 : _collectionsMaster.Max(c => c.Id) + 1; + var row = new CollectionRow { Id = nextId, Name = $"Collection {nextId}", Enabled = true }; + _collectionsMaster.Add(row); + ApplyCollectionFilters(); + AutoSaveIfPossible(); + + for (int i = 0; i < _collectionsView.Count; i++) + { + if (_collectionsView[i].Id == nextId) + { + dgvCollections.ClearSelection(); + dgvCollections.Rows[i].Selected = true; + dgvCollections.FirstDisplayedScrollingRowIndex = i; + break; + } + } + } + + private void btnRemoveCollection_Click(object sender, EventArgs e) + { + if (_current == null) return; + if (MessageBox.Show($"Delete collection '{_current.Name}'?", "Confirm", + MessageBoxButtons.YesNo, MessageBoxIcon.Warning) != DialogResult.Yes) return; + + int oldId = _current.Id; + _collectionsMaster.Remove(_current); + _current = null; + ApplyCollectionFilters(); + AutoSaveIfPossible(); + + if (_collectionsView.Count > 0) + { + int idx = _collectionsView.TakeWhile(r => r.Id < oldId).Count(); + idx = Math.Min(idx, _collectionsView.Count - 1); + dgvCollections.Rows[idx].Selected = true; + } + } + + private void btnDuplicateCollection_Click(object sender, EventArgs e) + { + if (_current == null) return; + int nextId = (_collectionsMaster.Count == 0) ? 1 : _collectionsMaster.Max(c => c.Id) + 1; + + var copy = new CollectionRow + { + Id = nextId, + Name = _current.Name + " (Copy)", + RewardText = _current.RewardText, + Rarity = _current.Rarity, + RewardXP = _current.RewardXP, + Bucket = _current.Bucket, + Enabled = _current.Enabled, + StartTimeUtc = _current.StartTimeUtc, + EndTimeUtc = _current.EndTimeUtc, + KeepStatsAfterExpiry = _current.KeepStatsAfterExpiry + }; + foreach (var it in _current.Items) + copy.Items.Add(new ItemRow { ItemIndex = it.ItemIndex, ItemName = it.ItemName, ItemType = it.ItemType }); + + _collectionsMaster.Add(copy); + ApplyCollectionFilters(); + AutoSaveIfPossible(); + + for (int i = 0; i < _collectionsView.Count; i++) + if (_collectionsView[i].Id == nextId) { dgvCollections.ClearSelection(); dgvCollections.Rows[i].Selected = true; break; } + } + + private void OnCollectionSelectionChanged() + { + if (_current != null) + { + try { dgvRewards.EndEdit(); } catch { } + _suspendRewardSync = true; + _current.RewardText = RewardsGridToText(); + _suspendRewardSync = false; + } + + _current = null; + if (dgvCollections.CurrentRow?.DataBoundItem is CollectionRow r) _current = r; + + _loadingSelection = true; + if (_current == null) + { + dgvItems.DataSource = null; + lblId.Text = "ID: -"; + txtName.Text = string.Empty; + cbBucketDetail.SelectedItem = null; + cbRarityDetail.SelectedItem = null; + nudXP.Value = 0; + chkEnabled.Checked = true; + _suspendRewardSync = true; _rewards.Clear(); _suspendRewardSync = false; + _loadingSelection = false; + UpdateTimeFieldAvailability(); + UpdateButtonsEnabled(); + return; + } + + dgvItems.DataSource = _current.Items; + SortCurrentItemsByIndex(); + SyncStageOptionsWithCurrentItems(); + + lblId.Text = $"ID: {_current.Id}"; + txtName.Text = _current.Name ?? string.Empty; + cbBucketDetail.SelectedItem = _current.Bucket; + cbRarityDetail.SelectedItem = _current.Rarity; + nudXP.Value = Math.Max(nudXP.Minimum, Math.Min(nudXP.Maximum, _current.RewardXP)); + chkEnabled.Checked = _current.Enabled; + + _suspendRewardSync = true; + _rewards.Clear(); + var stats = ParseStats(_current.RewardText ?? string.Empty); + if (stats?.Values != null) + foreach (var kv in stats.Values) + _rewards.Add(new RewardRow { Stat = kv.Key, Value = kv.Value }); + _suspendRewardSync = false; + + _loadingSelection = false; + UpdateTimeFieldAvailability(); + UpdateButtonsEnabled(); + } + + private void UpdateTimeFieldAvailability() + { + bool limited = _current != null && (_current.Bucket == CodexBucket.Limited || _current.Bucket == CodexBucket.Event); + dtpStartUtc.Enabled = limited; + dtpEndUtc.Enabled = limited; + chkKeepStats.Enabled = limited; + + if (_current == null) + { + dtpStartUtc.Checked = false; + dtpEndUtc.Checked = false; + chkKeepStats.Checked = false; + return; + } + + _loadingSelection = true; + + if (_current.StartTimeUtc.HasValue) + { + var start = _current.StartTimeUtc.Value; + dtpStartUtc.Value = start < dtpStartUtc.MinDate ? dtpStartUtc.MinDate : start; + dtpStartUtc.Checked = true; + } + else + { + dtpStartUtc.Checked = false; + } + + if (_current.EndTimeUtc.HasValue) + { + var end = _current.EndTimeUtc.Value; + dtpEndUtc.Value = end < dtpEndUtc.MinDate ? dtpEndUtc.MinDate : end; + dtpEndUtc.Checked = true; + } + else + { + dtpEndUtc.Checked = false; + } + + chkKeepStats.Checked = _current.KeepStatsAfterExpiry; + + _loadingSelection = false; + } + + private void UpdateTimeFieldsFromUI() + { + if (_current == null || _loadingSelection) return; + + bool limited = _current.Bucket == CodexBucket.Limited || _current.Bucket == CodexBucket.Event; + if (!limited) + { + dtpStartUtc.Checked = false; + dtpEndUtc.Checked = false; + chkKeepStats.Checked = false; + return; + } + + DateTime SnapMinutes(DateTime dt, int step) + { + if (step <= 0) return dt; + int minutes = (int)Math.Round(dt.Minute / (double)step) * step; + if (minutes >= 60) { dt = dt.AddHours(1); minutes = 0; } + return new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour, minutes, 0); + } + + DateTime? start = dtpStartUtc.Checked ? (DateTime?)SnapMinutes(dtpStartUtc.Value, 30) : null; + DateTime? end = dtpEndUtc.Checked ? (DateTime?)SnapMinutes(dtpEndUtc.Value, 30) : null; + + if (start.HasValue && end.HasValue && start.Value >= end.Value) + { + MessageBox.Show("Start time must be before End time.", "Time Window", MessageBoxButtons.OK, MessageBoxIcon.Warning); + dtpEndUtc.Checked = false; + end = null; + } + + ApplyToSelectedCollections(col => + { + col.StartTimeUtc = start; + col.EndTimeUtc = end; + col.KeepStatsAfterExpiry = chkKeepStats.Checked; + }); + + dgvCollections.Refresh(); + AutoSaveIfPossible(); + } + + private void UpdateButtonsEnabled() + { + bool hasSel = _current != null; + btnAddItem.Enabled = hasSel; + btnRemoveItem.Enabled = hasSel && dgvItems.CurrentRow != null; + btnSortItems.Enabled = hasSel; + btnAddSelectedAvail.Enabled = hasSel; + + tsbSaveTxt.Enabled = false; + tsbLoadTxt.Enabled = false; + tsbSaveTxt.Visible = false; + tsbLoadTxt.Visible = false; + tsbApply.Enabled = true; + tsbRebuild.Enabled = true; + } + + private void AutoSaveIfPossible() + { + if (_suspendAutoSave) return; + try + { + // keep current reward row in sync before persisting + SyncRewardsToCurrent(); + + // push changes into the live server environment + ApplyToServer(); + + // keep a text export up to date without prompting + SaveToTxt(silent: true); + + if (statusText != null) + statusText.Text = "Auto-applied to server."; + } + catch + { + // ignore autosave errors (status bar in Envir will log if needed) + } + } + + // Selected items buttons + private void btnAddItem_Click(object sender, EventArgs e) + { + if (_current == null) return; + if (!int.TryParse(txtAddIndex.Text.Trim(), out int idx) || idx <= 0) + { + MessageBox.Show("Enter a valid ItemIndex.", "Add Item", + MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + var info = ItemByIndex(idx); + if (info == null) + { + MessageBox.Show($"No item with Index={idx} exists.", "Add Item", + MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + _current.Items.Add(new ItemRow { ItemIndex = idx, ItemName = info.Name, ItemType = info.Type.ToString(), Stage = CodexRequirement.AnyStage }); + SortCurrentItemsByIndex(); + AutoSaveIfPossible(); + } + private void btnRemoveItem_Click(object sender, EventArgs e) + { + if (_current == null || dgvItems.CurrentRow == null) return; + if (dgvItems.CurrentRow.DataBoundItem is ItemRow ir) + { + _current.Items.Remove(ir); + AutoSaveIfPossible(); + } + } + private void btnSortItems_Click(object sender, EventArgs e) { SortCurrentItemsByIndex(); AutoSaveIfPossible(); } + + private void SortCurrentItemsByIndex() + { + if (_current == null) return; + var sorted = _current.Items.OrderBy(i => i.ItemIndex).ToList(); + _current.Items.RaiseListChangedEvents = false; + _current.Items.Clear(); + foreach (var it in sorted) _current.Items.Add(it); + _current.Items.RaiseListChangedEvents = true; + _current.Items.ResetBindings(); + SyncStageOptionsWithCurrentItems(); + } + + private void dgvItems_CellValueChanged(object sender, DataGridViewCellEventArgs e) + { + if (_current == null) return; + if (e.RowIndex < 0 || e.ColumnIndex < 0) return; + + var column = dgvItems.Columns[e.ColumnIndex]; + if (column == colItemStage) + { + if (dgvItems.Rows[e.RowIndex].DataBoundItem is ItemRow row) + { + EnsureStageOptionExists(row.Stage); + _current.Items.ResetBindings(); + SyncStageOptionsWithCurrentItems(); + AutoSaveIfPossible(); + if (statusText != null) + statusText.Text = row.Stage == CodexRequirement.AnyStage + ? "Stage updated: Any Stage." + : $"Stage updated: Stage {row.Stage}."; + } + } + else + { + AutoSaveIfPossible(); + } + } + + private void dgvItems_CurrentCellDirtyStateChanged(object sender, EventArgs e) + { + if (dgvItems.IsCurrentCellDirty) + dgvItems.CommitEdit(DataGridViewDataErrorContexts.Commit); + } + + private void dgvItems_DataError(object sender, DataGridViewDataErrorEventArgs e) + { + if (e.Exception is ArgumentException && e.RowIndex >= 0 && e.ColumnIndex >= 0 && + dgvItems.Columns[e.ColumnIndex] == colItemStage) + { + if (dgvItems.Rows[e.RowIndex].DataBoundItem is ItemRow row) + { + EnsureStageOptionExists(row.Stage); + _current?.Items.ResetBindings(); + SyncStageOptionsWithCurrentItems(); + e.ThrowException = false; + return; + } + } + + e.ThrowException = false; + } + + private void btnApplyStage_Click(object sender, EventArgs e) + { + if (_current == null) return; + if (cbStageQuick.SelectedItem is not StageOption option) return; + + var rows = dgvItems.SelectedRows.Cast().ToList(); + if (rows.Count == 0 && dgvItems.CurrentRow != null) + rows.Add(dgvItems.CurrentRow); + + if (rows.Count == 0) return; + + foreach (var gridRow in rows) + { + if (gridRow?.DataBoundItem is ItemRow item) + { + item.Stage = option.Value; + EnsureStageOptionExists(item.Stage); + } + } + + _current.Items.ResetBindings(); + SyncStageOptionsWithCurrentItems(); + AutoSaveIfPossible(); + if (statusText != null) + statusText.Text = option.Value == CodexRequirement.AnyStage + ? "Applied Any Stage to selected item(s)." + : $"Applied Stage {option.Value} to selected item(s)."; + } + + // Rewards + private void btnAddReward_Click(object sender, EventArgs e) + { + _suspendRewardSync = true; + _rewards.Add(new RewardRow { Stat = Stat.MaxDC, Value = 1 }); + _suspendRewardSync = false; + SyncRewardsToCurrent(); + AutoSaveIfPossible(); + } + private void btnRemoveReward_Click(object sender, EventArgs e) + { + _suspendRewardSync = true; + if (dgvRewards.CurrentRow?.DataBoundItem is RewardRow rr) + _rewards.Remove(rr); + _suspendRewardSync = false; + SyncRewardsToCurrent(); + AutoSaveIfPossible(); + } + private void SyncRewardsToCurrent() + { + if (_suspendRewardSync) return; + if (_current == null) return; + try { dgvRewards.EndEdit(); } catch { } + _current.RewardText = RewardsGridToText(); + // don't autosave on every keystroke from grid; handled by callers + } + private string RewardsGridToText() + { + if (_rewards.Count == 0) return string.Empty; + return string.Join(",", _rewards.Select(r => $"{r.Stat}={r.Value}")); + } + + // Available + private void PopulateTypeFilter() + { + var items = new List { new { Text = "All", Value = (ItemType?)null } }; + foreach (var t in Enum.GetValues(typeof(ItemType)).Cast()) + items.Add(new { Text = t.ToString(), Value = (ItemType?)t }); + + cbTypeFilter.DisplayMember = "Text"; + cbTypeFilter.ValueMember = "Value"; + cbTypeFilter.DataSource = items; + cbTypeFilter.SelectedIndex = 0; + } + private void cbTypeFilter_SelectedIndexChanged(object sender, EventArgs e) + { + var sel = cbTypeFilter.SelectedItem; + if (sel == null) { _filterType = null; return; } + var pi = sel.GetType().GetProperty("Value"); + _filterType = (ItemType?)pi?.GetValue(sel); + RefreshAvailableItems(_lastAvailSearch); + } + private void txtSearchAvail_TextChanged(object sender, EventArgs e) => RefreshAvailableItems(txtSearchAvail.Text); + private void btnAddSelectedAvail_Click(object sender, EventArgs e) + { + if (_current == null || dgvAvailable.SelectedRows.Count == 0) return; + + var toAdd = new List(); + foreach (DataGridViewRow r in dgvAvailable.SelectedRows) + if (r?.DataBoundItem != null) toAdd.Add(r.DataBoundItem); + + foreach (var x in toAdd) + { + int ix = (int)x.Index; + var info = ItemByIndex(ix); + if (info == null) continue; + + _current.Items.Add(new ItemRow { ItemIndex = ix, ItemName = info.Name, ItemType = info.Type.ToString(), Stage = CodexRequirement.AnyStage }); + } + SortCurrentItemsByIndex(); + AutoSaveIfPossible(); + } + private void dgvAvailable_CellDoubleClick(object sender, DataGridViewCellEventArgs e) => + btnAddSelectedAvail_Click(sender, e); + + private void RefreshAvailableItems(string q) + { + if (_envir?.ItemInfoList == null) return; + q = (q ?? string.Empty).Trim(); + _lastAvailSearch = q; + + var query = _envir.ItemInfoList.Where(ii => ii != null); + if (_filterType.HasValue) query = query.Where(ii => ii.Type == _filterType.Value); + if (q.Length > 0) query = query.Where(ii => (ii.Name ?? "").IndexOf(q, StringComparison.OrdinalIgnoreCase) >= 0); + + var list = query.Select(ii => new { Index = ii.Index, Name = ii.Name ?? $"#{ii.Index}", Type = ii.Type.ToString() }) + .OrderBy(x => x.Index).ToList(); + + _availItems.Clear(); + foreach (var x in list) _availItems.Add(x); + } + + // Top strip actions + private void btnApply_Click(object sender, EventArgs e) + { + SyncRewardsToCurrent(); + ApplyToServer(); + statusText.Text = "Applied to server (in memory)."; + MessageBox.Show("Applied to server (in memory).", "Item Codex", + MessageBoxButtons.OK, MessageBoxIcon.Information); + } + private void btnSaveTxt_Click(object sender, EventArgs e) { } + private void btnLoadTxt_Click(object sender, EventArgs e) { } + private void btnRebuild_Click(object sender, EventArgs e) + { + RebuildFromItems(); + statusText.Text = "Rebuilt from items."; + } + + // Server I/O + private void ApplyToServer() + { + var list = new List(); + var byId = new Dictionary(); + + foreach (var r in _collectionsMaster) + { + if (r.Id <= 0) continue; + var encoded = r.Items + .Where(i => i.ItemIndex > 0) + .Select(i => + { + int stage = i.Stage; + if (stage < sbyte.MinValue) stage = sbyte.MinValue; + if (stage > sbyte.MaxValue) stage = sbyte.MaxValue; + return CodexRequirement.Encode(i.ItemIndex, (sbyte)stage); + }) + .Distinct() + .OrderBy(req => CodexRequirement.DecodeItemIndex(req)) + .ThenBy(req => CodexRequirement.DecodeStage(req)) + .ToList(); + if (encoded.Count == 0) continue; + + var col = new Envir.ItemCodexCollection + { + Id = r.Id, + Name = r.Name ?? $"Collection {r.Id}", + ItemIndices = encoded, + Reward = ParseStats(r.RewardText ?? string.Empty), + RewardXP = r.RewardXP, + Rarity = r.Rarity, + Bucket = (byte)r.Bucket, + Enabled = r.Enabled, + StartTimeUtc = r.StartTimeUtc, + EndTimeUtc = r.EndTimeUtc, + KeepStatsAfterExpiry = r.KeepStatsAfterExpiry + }; + list.Add(col); + byId[col.Id] = col; + } + + _envir.ItemCodexCollections = list; + _envir.ItemCodexById = byId; + + // Auto-save to file as well + try + { + var path = Path.Combine(Settings.ConfigPath, "ItemCodex.json"); + _envir.SaveItemCodexToTxt(path); + } + catch { } + } + + private void LoadFromServer() + { + _suspendAutoSave = true; + try + { + var path = EditorTxtPath(); + _collectionsMaster.Clear(); + _collectionsView.Clear(); + + if (File.Exists(path)) + { + var raw = File.ReadAllText(path); + if (TryParseJson(raw, out var jsonRows) && jsonRows.Count > 0) + { + foreach (var row in jsonRows.OrderBy(r => r.Id)) + _collectionsMaster.Add(row); + + ApplyCollectionFilters(); + if (_collectionsView.Count > 0) + dgvCollections.Rows[0].Selected = true; + + UpdateButtonsEnabled(); + + // push parsed data into the server environment + ApplyToServer(); + + return; + } + } + + // Try loading from server's in-memory data first + if (_envir?.ItemCodexCollections != null && _envir.ItemCodexCollections.Count > 0) + { + foreach (var c in _envir.ItemCodexCollections.OrderBy(c => c.Id)) + { + var row = new CollectionRow + { + Id = c.Id, + Name = c.Name ?? string.Empty, + RewardText = StatsToText(c.Reward), + Rarity = c.Rarity, + RewardXP = c.RewardXP, + Bucket = (CodexBucket)c.Bucket, + Enabled = c.Enabled, + StartTimeUtc = c.StartTimeUtc, + EndTimeUtc = c.EndTimeUtc, + KeepStatsAfterExpiry = c.KeepStatsAfterExpiry + }; + + foreach (var req in c.ItemIndices.Distinct()) + { + int ix = CodexRequirement.DecodeItemIndex(req); + int stage = CodexRequirement.DecodeStage(req); + EnsureStageOptionExists(stage); + var info = ItemByIndex(ix); + row.Items.Add(new ItemRow + { + ItemIndex = ix, + ItemName = info?.Name ?? $"#{ix}", + ItemType = info?.Type.ToString() ?? "?", + Stage = stage + }); + } + _collectionsMaster.Add(row); + } + } + ApplyCollectionFilters(); + if (_collectionsView.Count > 0) + dgvCollections.Rows[0].Selected = true; + + UpdateButtonsEnabled(); + } + finally + { + _suspendAutoSave = false; + AutoSaveIfPossible(); + } + } + + private void ApplyCollectionFilters() + { + var search = (txtSearchCollections.Text ?? string.Empty).Trim(); + CodexBucket? bucketFilter = null; + if (cbBucketFilter != null && cbBucketFilter.SelectedItem != null && cbBucketFilter.SelectedIndex > 0) + { + if (cbBucketFilter.SelectedItem is CodexBucket b) bucketFilter = b; + } + + var filtered = _collectionsMaster.Where(r => + (search.Length == 0 || + (r.Name?.IndexOf(search, StringComparison.OrdinalIgnoreCase) >= 0) || + r.Id.ToString().Contains(search)) && + (!bucketFilter.HasValue || r.Bucket == bucketFilter.Value)) + .OrderBy(r => r.Id) + .ToList(); + + var selectedId = _current?.Id ?? -1; + + _collectionsView.RaiseListChangedEvents = false; + _collectionsView.Clear(); + foreach (var r in filtered) _collectionsView.Add(r); + _collectionsView.RaiseListChangedEvents = true; + _collectionsView.ResetBindings(); + + if (selectedId > 0) + { + for (int i = 0; i < _collectionsView.Count; i++) + { + if (_collectionsView[i].Id == selectedId) + { + dgvCollections.ClearSelection(); + dgvCollections.Rows[i].Selected = true; + break; + } + } + } + UpdateButtonsEnabled(); + } + + private string _cachedCodexPath; + + private string EditorTxtPath() + { + if (!string.IsNullOrEmpty(_cachedCodexPath)) + return _cachedCodexPath; + + // Primary candidate: Configs relative to server settings (JSON) + try + { + var candidate = Path.GetFullPath(Path.Combine(Settings.ConfigPath, "ItemCodex.json")); + if (File.Exists(candidate)) + { + _cachedCodexPath = candidate; + return _cachedCodexPath; + } + + // If directory exists but file missing, still remember this path for saving later. + if (!File.Exists(candidate) && Directory.Exists(Path.GetDirectoryName(candidate))) + { + _cachedCodexPath = candidate; + return _cachedCodexPath; + } + } + catch + { + // ignore and fall through to search + } + + // Search upwards from the executable directory for a Configs\ItemCodex.json + try + { + var baseDir = new DirectoryInfo(AppDomain.CurrentDomain.BaseDirectory); + DirectoryInfo current = baseDir; + int depth = 0; + while (current != null && depth < 6) + { + var probeJson = Path.Combine(current.FullName, "Configs", "ItemCodex.json"); + if (File.Exists(probeJson)) + { + _cachedCodexPath = probeJson; + return _cachedCodexPath; + } + current = current.Parent; + depth++; + } + } + catch + { + // ignore + } + + // As a last resort, drop alongside the executable + var fallback = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Configs", "ItemCodex.json"); + Directory.CreateDirectory(Path.GetDirectoryName(fallback)); + _cachedCodexPath = fallback; + return _cachedCodexPath; + } + + private static bool ParseEnabledField(string raw) + { + if (string.IsNullOrWhiteSpace(raw)) return true; + + raw = raw.Trim(); + if (bool.TryParse(raw, out var flag)) return flag; + if (int.TryParse(raw, out var num)) return num != 0; + + raw = raw.ToLowerInvariant(); + return raw switch + { + "yes" or "y" => true, + "no" or "n" => false, + _ => true + }; + } + + private static DateTime? ParseUtc(Dictionary section, string key) + { + if (section == null || !section.TryGetValue(key, out var raw) || string.IsNullOrWhiteSpace(raw)) return null; + if (DateTime.TryParse(raw.Trim(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var dt)) + return dt; + return null; + } + + private void SaveToTxt(bool silent = false) + { + var path = EditorTxtPath(); + Directory.CreateDirectory(Path.GetDirectoryName(path)); + + var payload = _collectionsMaster + .Where(c => c.Items != null && c.Items.Count > 0) + .OrderBy(c => c.Id) + .Select(r => new CollectionDto + { + Id = r.Id, + Name = r.Name, + Items = r.Items.Select(i => new ItemDto { Index = i.ItemIndex, Stage = (sbyte)i.Stage }).ToList(), + Reward = ParseStats(r.RewardText ?? string.Empty)?.Values?.ToDictionary(kv => kv.Key.ToString(), kv => kv.Value) ?? new Dictionary(), + XP = r.RewardXP, + Rarity = r.Rarity.ToString(), + Bucket = r.Bucket.ToString(), + Enabled = r.Enabled, + Start = r.StartTimeUtc.HasValue ? r.StartTimeUtc.Value.ToString("yyyy-MM-dd HH:mm") : null, + End = r.EndTimeUtc.HasValue ? r.EndTimeUtc.Value.ToString("yyyy-MM-dd HH:mm") : null, + KeepStats = r.KeepStatsAfterExpiry + }) + .ToList(); + + if (payload.Count == 0) return; + + var options = new JsonSerializerOptions { WriteIndented = true }; + var json = JsonSerializer.Serialize(payload, options); + File.WriteAllText(path, json, System.Text.Encoding.UTF8); + + if (!silent) + MessageBox.Show($"Saved to:\n{path}", "Save Codex", MessageBoxButtons.OK, MessageBoxIcon.Information); + } + + private void LoadFromTxt() + { + var path = EditorTxtPath(); + if (!File.Exists(path)) + { + MessageBox.Show($"File not found:\n{path}", "Import Codex", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + _suspendAutoSave = true; + try + { + var raw = File.ReadAllText(path); + var temp = new List(); + + if (TryParseJson(raw, out var jsonRows)) + { + temp.AddRange(jsonRows); + } + else + { + // Invalid json; skip without blocking + return; + } + + if (temp.Count == 0) return; + + _collectionsMaster.Clear(); + foreach (var r in temp.OrderBy(r => r.Id)) _collectionsMaster.Add(r); + + ApplyCollectionFilters(); + if (_collectionsView.Count > 0) + dgvCollections.Rows[0].Selected = true; + + UpdateButtonsEnabled(); + } + finally + { + _suspendAutoSave = false; + AutoSaveIfPossible(); + } + } + + private static DateTime? ParseLocalDate(string raw) + { + if (string.IsNullOrWhiteSpace(raw)) return null; + if (DateTime.TryParse(raw.Trim(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var dt)) + return dt; + return null; + } + + private static string RewardDictToText(Dictionary reward) + { + if (reward == null || reward.Count == 0) return string.Empty; + return string.Join(",", reward.Select(kv => $"{kv.Key}={kv.Value}")); + } + + private static TEnum ParseEnumString(string raw, TEnum fallback) where TEnum : struct + { + if (!string.IsNullOrWhiteSpace(raw) && Enum.TryParse(raw.Trim(), true, out var val)) + return val; + return fallback; + } + + private bool TryParseJson(string raw, out List rows) + { + rows = null; + if (string.IsNullOrWhiteSpace(raw)) return false; + try + { + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var dtoList = JsonSerializer.Deserialize>(raw, options); + if (dtoList == null || dtoList.Count == 0) return false; + + var result = new List(); + foreach (var dto in dtoList) + { + if (dto == null || dto.Id <= 0) continue; + var row = new CollectionRow + { + Id = dto.Id, + Name = dto.Name ?? string.Empty, + RewardText = RewardDictToText(dto.Reward), + RewardXP = dto.XP, + Rarity = ParseEnumString(dto.Rarity, ItemGrade.None), + Bucket = ParseEnumString(dto.Bucket, CodexBucket.Character), + Enabled = dto.Enabled, + StartTimeUtc = ParseLocalDate(dto.Start), + EndTimeUtc = ParseLocalDate(dto.End), + KeepStatsAfterExpiry = dto.KeepStats + }; + + if (dto.Items != null) + { + foreach (var it in dto.Items) + { + if (it == null || it.Index <= 0) continue; + if (!ItemExists(it.Index)) continue; + int stage = it.Stage; + if (stage < sbyte.MinValue) stage = sbyte.MinValue; + if (stage > sbyte.MaxValue) stage = sbyte.MaxValue; + var info = ItemByIndex(it.Index); + row.Items.Add(new ItemRow + { + ItemIndex = it.Index, + ItemName = info?.Name ?? $"#{it.Index}", + ItemType = info?.Type.ToString() ?? "?", + Stage = stage + }); + } + } + + if (row.Items.Count == 0) continue; + result.Add(row); + } + + if (result.Count == 0) return false; + rows = result; + return true; + } + catch + { + rows = null; + return false; + } + } + + private bool TryParseIni(string[] lines, out List rows) + { + rows = null; + if (lines == null || lines.Length == 0) return false; + + var result = new List(); + Dictionary section = null; + bool sawCollection = false; + + void FinalizeSection() + { + if (section == null) return; + if (!section.TryGetValue("Id", out var idText) || !int.TryParse(idText.Trim(), out int id) || id <= 0) + { + section = null; + return; + } + + var row = new CollectionRow + { + Id = id, + Name = section.TryGetValue("Name", out var nameText) ? (nameText ?? string.Empty) : string.Empty, + RewardText = section.TryGetValue("Reward", out var rewardText) ? (rewardText ?? string.Empty) : string.Empty, + RewardXP = ParseInt(section, "XP", 0), + Rarity = ParseEnum(section, "Rarity", ItemGrade.None), + Bucket = ParseEnum(section, "Bucket", CodexBucket.Character), + Enabled = section.TryGetValue("Enabled", out var enabledText) ? ParseEnabledField(enabledText) : true, + StartTimeUtc = ParseUtc(section, "StartUtc"), + EndTimeUtc = ParseUtc(section, "EndUtc"), + KeepStatsAfterExpiry = section.TryGetValue("KeepStats", out var keepText) ? ParseEnabledField(keepText) : false + }; + + var items = new List<(int index, int stage)>(); + if (section.TryGetValue("Items", out var itemsText) && !string.IsNullOrWhiteSpace(itemsText)) + { + foreach (var token in itemsText.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries)) + { + var t = token.Trim(); + if (t.Length == 0) continue; + int ix, stage = CodexRequirement.AnyStage; + int at = t.IndexOf('@'); + if (at >= 0) + { + var left = t.Substring(0, at).Trim(); + var right = t.Substring(at + 1).Trim(); + if (!int.TryParse(left, NumberStyles.Integer, CultureInfo.InvariantCulture, out ix)) continue; + if (!int.TryParse(right, NumberStyles.Integer, CultureInfo.InvariantCulture, out stage)) continue; + } + else + { + if (!int.TryParse(t, NumberStyles.Integer, CultureInfo.InvariantCulture, out ix)) continue; + } + if (ix > 0) items.Add((ix, stage)); + } + } + + var filtered = items + .Where(p => ItemExists(p.index)) + .Distinct() + .OrderBy(p => p.index) + .ThenBy(p => p.stage) + .ToList(); + + if (filtered.Count == 0) + { + section = null; + return; + } + + foreach (var pair in filtered) + { + EnsureStageOptionExists(pair.stage); + var info = ItemByIndex(pair.index); + row.Items.Add(new ItemRow + { + ItemIndex = pair.index, + ItemName = info?.Name ?? $"#{pair.index}", + ItemType = info?.Type.ToString() ?? "?", + Stage = pair.stage + }); + } + + result.Add(row); + section = null; + } + + foreach (var raw in lines) + { + var line = raw.Trim(); + if (line.Length == 0 || line.StartsWith("#") || line.StartsWith(";")) continue; + + if (line.StartsWith("[") && line.EndsWith("]")) + { + FinalizeSection(); + var header = line.Substring(1, line.Length - 2).Trim(); + if (header.StartsWith("Collection", StringComparison.OrdinalIgnoreCase)) + { + section = new Dictionary(StringComparer.OrdinalIgnoreCase); + sawCollection = true; + } + else + { + section = null; + } + continue; + } + + if (section == null) continue; + + int eq = line.IndexOf('='); + if (eq < 0) continue; + + var key = line.Substring(0, eq).Trim(); + var value = line.Substring(eq + 1).Trim(); + section[key] = value; + } + + FinalizeSection(); + + if (!sawCollection) return false; + + rows = result; + return true; + } + + private static int ParseInt(Dictionary section, string key, int defaultValue) + { + if (section.TryGetValue(key, out var text) && !string.IsNullOrWhiteSpace(text)) + { + if (int.TryParse(text.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)) + return Math.Max(0, value); + } + return defaultValue; + } + + private static T ParseEnum(Dictionary section, string key, T defaultValue) where T : struct + { + if (section.TryGetValue(key, out var text) && !string.IsNullOrWhiteSpace(text)) + { + if (Enum.TryParse(text.Trim(), true, out T parsed)) + return parsed; + + if (byte.TryParse(text.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var numeric)) + return (T)Enum.ToObject(typeof(T), numeric); + } + return defaultValue; + } + + private CollectionRow ParseLegacyLine(string raw) + { + var line = raw.Trim(); + if (line.Length == 0 || line.StartsWith("#")) return null; + + var parts = line.Split('|'); + if (parts.Length < 3) return null; + + if (!int.TryParse(parts[0].Trim(), out int id) || id <= 0) return null; + + string name = parts[1].Trim(); + + var items = new List<(int index, int stage)>(); + foreach (var tok in parts[2].Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries)) + { + var t = tok.Trim(); + if (t.Length == 0) continue; + int ix, stage = CodexRequirement.AnyStage; + int at = t.IndexOf('@'); + if (at >= 0) + { + var left = t.Substring(0, at).Trim(); + var right = t.Substring(at + 1).Trim(); + if (!int.TryParse(left, NumberStyles.Integer, CultureInfo.InvariantCulture, out ix)) continue; + if (!int.TryParse(right, NumberStyles.Integer, CultureInfo.InvariantCulture, out stage)) continue; + } + else + { + if (!int.TryParse(t, NumberStyles.Integer, CultureInfo.InvariantCulture, out ix)) continue; + } + if (ix > 0) items.Add((ix, stage)); + } + + var filtered = items + .Where(p => ItemExists(p.index)) + .Distinct() + .OrderBy(p => p.index) + .ThenBy(p => p.stage) + .ToList(); + + if (filtered.Count == 0) return null; + + string rewardText = parts.Length >= 4 ? parts[3].Trim() : string.Empty; + + int xp = 0; + if (parts.Length >= 5 && !int.TryParse(parts[4].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out xp)) + xp = 0; + if (xp < 0) xp = 0; + + ItemGrade rarity = ItemGrade.None; + if (parts.Length >= 6) + { + var rtxt = parts[5].Trim(); + if (!string.IsNullOrEmpty(rtxt)) + { + if (!Enum.TryParse(rtxt, true, out rarity)) + if (byte.TryParse(rtxt, NumberStyles.Integer, CultureInfo.InvariantCulture, out var b)) rarity = (ItemGrade)b; + } + } + + CodexBucket bucket = CodexBucket.Character; + if (parts.Length >= 7) + { + var btxt = parts[6].Trim(); + if (!string.IsNullOrEmpty(btxt)) + { + if (!Enum.TryParse(btxt, true, out bucket)) + if (byte.TryParse(btxt, NumberStyles.Integer, CultureInfo.InvariantCulture, out var bval)) bucket = (CodexBucket)bval; + } + } + + bool enabled = true; + if (parts.Length >= 8) + enabled = ParseEnabledField(parts[7]); + + var row = new CollectionRow + { + Id = id, + Name = name, + RewardText = rewardText, + RewardXP = Math.Max(0, xp), + Rarity = rarity, + Bucket = bucket, + Enabled = enabled + }; + + foreach (var pair in filtered) + { + EnsureStageOptionExists(pair.stage); + var info = ItemByIndex(pair.index); + row.Items.Add(new ItemRow + { + ItemIndex = pair.index, + ItemName = info?.Name ?? $"#{pair.index}", + ItemType = info?.Type.ToString() ?? "?", + Stage = pair.stage + }); + } + + return row; + } + + private static List ConvertIniToLegacyLines(IEnumerable lines) + { + if (lines == null) return null; + + var result = new List(); + Dictionary current = null; + string currentSection = null; + bool sawCollection = false; + + void FinalizeSection() + { + if (current == null) return; + if (!current.TryGetValue("Id", out var idText) || !int.TryParse(idText.Trim(), out _)) { current = null; return; } + if (!current.TryGetValue("Items", out var itemsText) || string.IsNullOrWhiteSpace(itemsText)) { current = null; return; } + + current.TryGetValue("Name", out var nameText); + current.TryGetValue("Reward", out var rewardText); + current.TryGetValue("XP", out var xpText); + current.TryGetValue("Rarity", out var rarityText); + current.TryGetValue("Bucket", out var bucketText); + current.TryGetValue("Enabled", out var enabledText); + + string legacyLine = string.Join("|", new[] + { + idText.Trim(), + (nameText ?? string.Empty).Trim(), + itemsText.Trim(), + (rewardText ?? string.Empty).Trim(), + string.IsNullOrWhiteSpace(xpText) ? "0" : xpText.Trim(), + string.IsNullOrWhiteSpace(rarityText) ? ItemGrade.None.ToString() : rarityText.Trim(), + string.IsNullOrWhiteSpace(bucketText) ? CodexBucket.Character.ToString() : bucketText.Trim(), + string.IsNullOrWhiteSpace(enabledText) ? "True" : enabledText.Trim() + }); + + result.Add(legacyLine); + current = null; + } + + foreach (var raw in lines) + { + var line = raw.Trim(); + if (line.Length == 0 || line.StartsWith("#") || line.StartsWith(";")) continue; + + if (line.StartsWith("[") && line.EndsWith("]")) + { + FinalizeSection(); + currentSection = line.Substring(1, line.Length - 2).Trim(); + if (currentSection.StartsWith("Collection", StringComparison.OrdinalIgnoreCase)) + { + current = new Dictionary(StringComparer.OrdinalIgnoreCase); + sawCollection = true; + } + else + { + current = null; + } + continue; + } + + if (current == null) continue; + + int eq = line.IndexOf('='); + if (eq < 0) continue; + + string key = line.Substring(0, eq).Trim(); + string value = line.Substring(eq + 1).Trim(); + + current[key] = value; + } + + FinalizeSection(); + + return sawCollection ? result : null; + } + + private void RebuildFromItems() + { + if (_envir == null) return; + + var candidates = _envir.ItemInfoList.Where(i => + i != null && (i.Type == ItemType.Necklace || i.Type == ItemType.Bracelet || i.Type == ItemType.Ring)); + + string Key(string raw) + { + if (string.IsNullOrWhiteSpace(raw)) return string.Empty; + string t = _reBrackets.Replace(raw, ""); + t = _reParens.Replace(t, ""); + t = _reTrailing.Replace(t, ""); + return t.Trim(); + } + + var groups = candidates + .GroupBy(i => new { Base = Key(i.Name), i.Type }) + .Where(g => g.Count() >= 2) + .OrderBy(g => g.Key.Base); + + int nextId = (_collectionsMaster.Count == 0) ? 1 : _collectionsMaster.Max(c => c.Id) + 1; + var temp = new List(); + foreach (var g in groups) + { + var items = g.Select(ii => ii.Index).Distinct().OrderBy(ix => ix).ToList(); + if (items.Count < 2) continue; + + string name = $"{g.Key.Type} Collection: {g.Key.Base}"; + var row = new CollectionRow + { + Id = nextId++, + Name = name, + RewardText = $"MaxDC={Math.Min(3, items.Count)}", + Bucket = CodexBucket.Character, + Enabled = true + }; + foreach (var ix in items) + { + var info = ItemByIndex(ix); + row.Items.Add(new ItemRow { ItemIndex = ix, ItemName = info?.Name ?? $"#{ix}", ItemType = info?.Type.ToString() ?? "?" }); + } + temp.Add(row); + } + + _collectionsMaster.Clear(); + foreach (var r in temp) _collectionsMaster.Add(r); + + ApplyCollectionFilters(); + if (_collectionsView.Count > 0) + dgvCollections.Rows[0].Selected = true; + + UpdateButtonsEnabled(); + } + + private void ItemCodexEditorForm_KeyDown(object sender, KeyEventArgs e) + { + // Import/Export hotkeys removed (JSON only) + if (e.Control && e.KeyCode == Keys.R) { btnRebuild_Click(sender, e); e.Handled = true; } + if (e.Control && e.KeyCode == Keys.A) { btnApply_Click(sender, e); e.Handled = true; } + } + + // explicit save from toolbar + private void btnSave_Click(object sender, EventArgs e) + { + SyncRewardsToCurrent(); + SaveToTxt(silent: false); + statusText.Text = "Saved ItemCodex.json"; + } + } +} diff --git a/Server.MirForms/Database/ItemCodexEditorForm.resx b/Server.MirForms/Database/ItemCodexEditorForm.resx new file mode 100644 index 000000000..ebc756227 --- /dev/null +++ b/Server.MirForms/Database/ItemCodexEditorForm.resx @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + 17, 17 + + + 107, 17 + + \ No newline at end of file diff --git a/Server.MirForms/SMain.Designer.cs b/Server.MirForms/SMain.Designer.cs index fa77c29fd..dbbb78efd 100644 --- a/Server.MirForms/SMain.Designer.cs +++ b/Server.MirForms/SMain.Designer.cs @@ -52,6 +52,7 @@ private void InitializeComponent() levelHeader = new ColumnHeader(); classHeader = new ColumnHeader(); genderHeader = new ColumnHeader(); + mapHeader = new ColumnHeader(); tabPage5 = new TabPage(); GuildListView = new CustomFormControl.ListViewNF(); columnHeader1 = new ColumnHeader(); @@ -117,7 +118,7 @@ private void InitializeComponent() CharacterToolStripMenuItem = new ToolStripMenuItem(); UpTimeLabel = new ToolStripTextBox(); InterfaceTimer = new Timer(components); - mapHeader = new ColumnHeader(); + itemCodexToolStripMenuItem = new ToolStripMenuItem(); MainTabs.SuspendLayout(); tabPage1.SuspendLayout(); tabPage2.SuspendLayout(); @@ -304,6 +305,11 @@ private void InitializeComponent() // genderHeader.Text = "Gender"; // + // mapHeader + // + mapHeader.Text = "Current Map"; + mapHeader.Width = 220; + // // tabPage5 // tabPage5.Controls.Add(GuildListView); @@ -620,126 +626,126 @@ private void InitializeComponent() // serverToolStripMenuItem // serverToolStripMenuItem.Name = "serverToolStripMenuItem"; - serverToolStripMenuItem.Size = new Size(152, 22); + serverToolStripMenuItem.Size = new Size(180, 22); serverToolStripMenuItem.Text = "Server"; serverToolStripMenuItem.Click += serverToolStripMenuItem_Click; // // balanceToolStripMenuItem // balanceToolStripMenuItem.Name = "balanceToolStripMenuItem"; - balanceToolStripMenuItem.Size = new Size(152, 22); + balanceToolStripMenuItem.Size = new Size(180, 22); balanceToolStripMenuItem.Text = "Balance"; balanceToolStripMenuItem.Click += balanceToolStripMenuItem_Click; // // systemToolStripMenuItem // - systemToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { dragonSystemToolStripMenuItem, miningToolStripMenuItem, guildsToolStripMenuItem, fishingToolStripMenuItem, mailToolStripMenuItem, goodsToolStripMenuItem, refiningToolStripMenuItem, relationshipToolStripMenuItem, mentorToolStripMenuItem, gemToolStripMenuItem, conquestToolStripMenuItem, respawnsToolStripMenuItem, heroesToolStripMenuItem }); + systemToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { dragonSystemToolStripMenuItem, miningToolStripMenuItem, guildsToolStripMenuItem, fishingToolStripMenuItem, mailToolStripMenuItem, goodsToolStripMenuItem, refiningToolStripMenuItem, relationshipToolStripMenuItem, mentorToolStripMenuItem, gemToolStripMenuItem, conquestToolStripMenuItem, respawnsToolStripMenuItem, heroesToolStripMenuItem, itemCodexToolStripMenuItem }); systemToolStripMenuItem.Name = "systemToolStripMenuItem"; - systemToolStripMenuItem.Size = new Size(152, 22); + systemToolStripMenuItem.Size = new Size(180, 22); systemToolStripMenuItem.Text = "System"; // // dragonSystemToolStripMenuItem // dragonSystemToolStripMenuItem.Name = "dragonSystemToolStripMenuItem"; - dragonSystemToolStripMenuItem.Size = new Size(139, 22); + dragonSystemToolStripMenuItem.Size = new Size(180, 22); dragonSystemToolStripMenuItem.Text = "Dragon"; dragonSystemToolStripMenuItem.Click += dragonSystemToolStripMenuItem_Click; // // miningToolStripMenuItem // miningToolStripMenuItem.Name = "miningToolStripMenuItem"; - miningToolStripMenuItem.Size = new Size(139, 22); + miningToolStripMenuItem.Size = new Size(180, 22); miningToolStripMenuItem.Text = "Mining"; miningToolStripMenuItem.Click += miningToolStripMenuItem_Click; // // guildsToolStripMenuItem // guildsToolStripMenuItem.Name = "guildsToolStripMenuItem"; - guildsToolStripMenuItem.Size = new Size(139, 22); + guildsToolStripMenuItem.Size = new Size(180, 22); guildsToolStripMenuItem.Text = "Guilds"; guildsToolStripMenuItem.Click += guildsToolStripMenuItem_Click; // // fishingToolStripMenuItem // fishingToolStripMenuItem.Name = "fishingToolStripMenuItem"; - fishingToolStripMenuItem.Size = new Size(139, 22); + fishingToolStripMenuItem.Size = new Size(180, 22); fishingToolStripMenuItem.Text = "Fishing"; fishingToolStripMenuItem.Click += fishingToolStripMenuItem_Click; // // mailToolStripMenuItem // mailToolStripMenuItem.Name = "mailToolStripMenuItem"; - mailToolStripMenuItem.Size = new Size(139, 22); + mailToolStripMenuItem.Size = new Size(180, 22); mailToolStripMenuItem.Text = "Mail"; mailToolStripMenuItem.Click += mailToolStripMenuItem_Click; // // goodsToolStripMenuItem // goodsToolStripMenuItem.Name = "goodsToolStripMenuItem"; - goodsToolStripMenuItem.Size = new Size(139, 22); + goodsToolStripMenuItem.Size = new Size(180, 22); goodsToolStripMenuItem.Text = "Goods"; goodsToolStripMenuItem.Click += goodsToolStripMenuItem_Click; // // refiningToolStripMenuItem // refiningToolStripMenuItem.Name = "refiningToolStripMenuItem"; - refiningToolStripMenuItem.Size = new Size(139, 22); + refiningToolStripMenuItem.Size = new Size(180, 22); refiningToolStripMenuItem.Text = "Refining"; refiningToolStripMenuItem.Click += refiningToolStripMenuItem_Click; // // relationshipToolStripMenuItem // relationshipToolStripMenuItem.Name = "relationshipToolStripMenuItem"; - relationshipToolStripMenuItem.Size = new Size(139, 22); + relationshipToolStripMenuItem.Size = new Size(180, 22); relationshipToolStripMenuItem.Text = "Relationship"; relationshipToolStripMenuItem.Click += relationshipToolStripMenuItem_Click; // // mentorToolStripMenuItem // mentorToolStripMenuItem.Name = "mentorToolStripMenuItem"; - mentorToolStripMenuItem.Size = new Size(139, 22); + mentorToolStripMenuItem.Size = new Size(180, 22); mentorToolStripMenuItem.Text = "Mentor"; mentorToolStripMenuItem.Click += mentorToolStripMenuItem_Click; // // gemToolStripMenuItem // gemToolStripMenuItem.Name = "gemToolStripMenuItem"; - gemToolStripMenuItem.Size = new Size(139, 22); + gemToolStripMenuItem.Size = new Size(180, 22); gemToolStripMenuItem.Text = "Gem"; gemToolStripMenuItem.Click += gemToolStripMenuItem_Click; // // conquestToolStripMenuItem // conquestToolStripMenuItem.Name = "conquestToolStripMenuItem"; - conquestToolStripMenuItem.Size = new Size(139, 22); + conquestToolStripMenuItem.Size = new Size(180, 22); conquestToolStripMenuItem.Text = "Conquest"; conquestToolStripMenuItem.Click += conquestToolStripMenuItem_Click; // // respawnsToolStripMenuItem // respawnsToolStripMenuItem.Name = "respawnsToolStripMenuItem"; - respawnsToolStripMenuItem.Size = new Size(139, 22); + respawnsToolStripMenuItem.Size = new Size(180, 22); respawnsToolStripMenuItem.Text = "SpawnTick"; respawnsToolStripMenuItem.Click += respawnsToolStripMenuItem_Click; // // heroesToolStripMenuItem // heroesToolStripMenuItem.Name = "heroesToolStripMenuItem"; - heroesToolStripMenuItem.Size = new Size(139, 22); + heroesToolStripMenuItem.Size = new Size(180, 22); heroesToolStripMenuItem.Text = "Heroes"; heroesToolStripMenuItem.Click += heroesToolStripMenuItem_Click; // // monsterTunerToolStripMenuItem // monsterTunerToolStripMenuItem.Name = "monsterTunerToolStripMenuItem"; - monsterTunerToolStripMenuItem.Size = new Size(152, 22); + monsterTunerToolStripMenuItem.Size = new Size(180, 22); monsterTunerToolStripMenuItem.Text = "Monster Tuner"; monsterTunerToolStripMenuItem.Click += monsterTunerToolStripMenuItem_Click; // // dropBuilderToolStripMenuItem // dropBuilderToolStripMenuItem.Name = "dropBuilderToolStripMenuItem"; - dropBuilderToolStripMenuItem.Size = new Size(152, 22); + dropBuilderToolStripMenuItem.Size = new Size(180, 22); dropBuilderToolStripMenuItem.Text = "Drop Builder"; dropBuilderToolStripMenuItem.Click += dropBuilderToolStripMenuItem_Click; // @@ -764,10 +770,12 @@ private void InitializeComponent() InterfaceTimer.Enabled = true; InterfaceTimer.Tick += InterfaceTimer_Tick; // - // mapHeader + // itemCodexToolStripMenuItem // - mapHeader.Text = "Current Map"; - mapHeader.Width = 220; + itemCodexToolStripMenuItem.Name = "itemCodexToolStripMenuItem"; + itemCodexToolStripMenuItem.Size = new Size(180, 22); + itemCodexToolStripMenuItem.Text = "Item Codex"; + itemCodexToolStripMenuItem.Click += itemCodexToolStripMenuItem_Click; // // SMain // @@ -889,6 +897,7 @@ private void InitializeComponent() internal TextBox ChatLogTextBox; private ColumnHeader columnHeader7; private ColumnHeader mapHeader; + private ToolStripMenuItem itemCodexToolStripMenuItem; } } diff --git a/Server.MirForms/SMain.cs b/Server.MirForms/SMain.cs index b835dcb37..5befc9cd5 100644 --- a/Server.MirForms/SMain.cs +++ b/Server.MirForms/SMain.cs @@ -3,6 +3,7 @@ using Server.Database; using Server.MirDatabase; using Server.MirEnvir; +using Server.MirForms; using Server.MirForms.Systems; using Server.MirObjects; using Server.Systems; @@ -666,5 +667,29 @@ private void PlayersOnlineListView_ColumnClick(object sender, ColumnClickEventAr PlayersOnlineListView.Sort(); } + + private void itemCodexToolStripMenuItem_Click(object sender, EventArgs e) + { + if (Envir.Main == null) + { + MessageBox.Show("Envir.Main is not initialized.", "Item Codex Editor", + MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + var form = Application.OpenForms + .OfType() + .FirstOrDefault(); + + if (form == null || form.IsDisposed) + form = new ItemCodexEditorForm(Envir.Main); + + if (!form.Visible) form.Show(); + if (form.WindowState == FormWindowState.Minimized) + form.WindowState = FormWindowState.Normal; + + form.BringToFront(); + form.Focus(); + } } } \ No newline at end of file diff --git a/Server.MirForms/Server.csproj.user b/Server.MirForms/Server.csproj.user index 19610cc89..42b225a63 100644 --- a/Server.MirForms/Server.csproj.user +++ b/Server.MirForms/Server.csproj.user @@ -25,6 +25,9 @@ Form + + Form + Form diff --git a/Server/Localization/Chinese.json b/Server/Localization/Chinese.json new file mode 100644 index 000000000..8c1d52594 --- /dev/null +++ b/Server/Localization/Chinese.json @@ -0,0 +1,34 @@ +{ + "Text": { + "Codex_Disabled": "[图鉴] 图鉴已关闭。", + "Codex_UnknownCollection": "[图鉴] 未知的收集。", + "Codex_DisabledCollection": "[图鉴] 此收集已禁用。", + "Codex_AlreadyClaimed": "[图鉴] 已领取。", + "Codex_NotComplete": "[图鉴] 未完成。", + "Codex_GainedXP": "[图鉴] +{0} 图鉴经验。", + "Codex_Claimed": "[图鉴] 已领取:{0},奖励已生效。", + "Codex_InvalidSubmission": "[图鉴] 提交无效。", + "Codex_ItemNotPart": "[图鉴] 物品不属于此收集。", + "Codex_ItemNotFound": "[图鉴] 背包中未找到该物品。", + "Codex_WrongItem": "[图鉴] 此收集需要其他物品。", + "Codex_UnableConsume": "[图鉴] 无法消耗物品。", + "Codex_Submitted": "[图鉴] 已提交:{0}。", + "Codex_InvalidCurrency": "[图鉴] 无效的货币。", + "Codex_InvalidSetId": "[图鉴] 无效的收集编号。", + "Codex_InvalidCurrencyForSet": "[图鉴] 此货币不能用于该收集。", + "Codex_NothingRequired": "[图鉴] 该收集无需物品。", + "Codex_SetComplete": "[图鉴] 收集已完成。", + "Codex_NoStone": "[图鉴] 你没有石头。", + "Codex_NoJade": "[图鉴] 你没有玉石。", + "Codex_UsedCurrency": "[图鉴] 消耗 1× {0} 注册 {1}{2}。", + "Codex_Exported": "[图鉴] 已导出至 Envir/ItemCodex.json", + "Codex_LoadedAutoBuilt": "[图鉴] 读取自动生成的收集(文件缺失或无效)。", + "Codex_LoadedFromFile": "[图鉴] 已从 ItemCodex.json 读取收集。", + "Codex_CompletedAll": "[图鉴] 所有收集已完成并领取。", + "Codex_Cleared": "[图鉴] 图鉴进度已清空。", + "Codex_CompletedSingle": "[图鉴] 已完成收集 {0}:{1}。", + "Codex_UnknownId": "[图鉴] 未知的收集编号 {0}。" + }, + "Enum": {} +} + diff --git a/Server/Localization/English.json b/Server/Localization/English.json new file mode 100644 index 000000000..8002c42d5 --- /dev/null +++ b/Server/Localization/English.json @@ -0,0 +1,34 @@ +{ + "Text": { + "Codex_Disabled": "[Codex] The codex is currently disabled.", + "Codex_UnknownCollection": "[Codex] Unknown collection.", + "Codex_DisabledCollection": "[Codex] This collection is currently disabled.", + "Codex_AlreadyClaimed": "[Codex] Already claimed.", + "Codex_NotComplete": "[Codex] Not complete.", + "Codex_GainedXP": "[Codex] +{0} Codex EXP.", + "Codex_Claimed": "[Codex] Claimed: {0}. Reward applied.", + "Codex_InvalidSubmission": "[Codex] Invalid submission.", + "Codex_ItemNotPart": "[Codex] Item not part of this collection.", + "Codex_ItemNotFound": "[Codex] Item not found in your bag.", + "Codex_WrongItem": "[Codex] Wrong item for this collection.", + "Codex_UnableConsume": "[Codex] Unable to consume item.", + "Codex_Submitted": "[Codex] Submitted: {0}.", + "Codex_InvalidCurrency": "[Codex] Invalid currency.", + "Codex_InvalidSetId": "[Codex] Invalid set id.", + "Codex_InvalidCurrencyForSet": "[Codex] This currency can't be used on this set.", + "Codex_NothingRequired": "[Codex] Nothing required for this set.", + "Codex_SetComplete": "[Codex] Set already complete.", + "Codex_NoStone": "[Codex] You don't have a Stone.", + "Codex_NoJade": "[Codex] You don't have a Jade.", + "Codex_UsedCurrency": "[Codex] Used 1× {0} to register {1}{2}.", + "Codex_Exported": "[Codex] Exported to Envir/ItemCodex.json", + "Codex_LoadedAutoBuilt": "[Codex] Loaded auto-built collections (no/invalid file).", + "Codex_LoadedFromFile": "[Codex] Loaded collections from ItemCodex.json.", + "Codex_CompletedAll": "[Codex] All collections completed and claimed.", + "Codex_Cleared": "[Codex] All codex progress cleared.", + "Codex_CompletedSingle": "[Codex] Completed collection {0}: {1}.", + "Codex_UnknownId": "[Codex] Unknown collection id {0}." + }, + "Enum": {} +} + diff --git a/Server/MirDatabase/AccountInfo.cs b/Server/MirDatabase/AccountInfo.cs index 1f366282c..658f6fe24 100644 --- a/Server/MirDatabase/AccountInfo.cs +++ b/Server/MirDatabase/AccountInfo.cs @@ -56,6 +56,8 @@ public string Password public DateTime ExpandedStorageExpiryDate; public uint Gold; public uint Credit; + public uint Stone; + public uint Jade; public MirConnection Connection; @@ -174,6 +176,11 @@ public AccountInfo(BinaryReader reader) Envir.CheckRankUpdate(Characters[i]); } } + if (Envir.LoadVersion >= 116) + { + Stone = reader.ReadUInt32(); + Jade = reader.ReadUInt32(); + } } public void Save(BinaryWriter writer) @@ -220,6 +227,8 @@ public void Save(BinaryWriter writer) Storage[i].Save(writer); } writer.Write(AdminAccount); + writer.Write(Stone); + writer.Write(Jade); } public List GetSelectInfo() diff --git a/Server/MirDatabase/CharacterInfo.cs b/Server/MirDatabase/CharacterInfo.cs index f7a32d468..406b0419a 100644 --- a/Server/MirDatabase/CharacterInfo.cs +++ b/Server/MirDatabase/CharacterInfo.cs @@ -106,6 +106,11 @@ protected static Envir Envir public bool HeroSpawned; public HeroBehaviour HeroBehaviour; + public Dictionary> ItemCodexProgress = new Dictionary>(); + public HashSet ItemCodexClaimed = new HashSet(); + public HashSet ItemCodexDiscovered = new HashSet(); + public int CodexXP; + public CharacterInfo() { } public CharacterInfo(ClientPackets.NewCharacter p, MirConnection c) @@ -386,6 +391,47 @@ public virtual void Load(BinaryReader reader, int version, int customVersion) if (version > 100) HeroBehaviour = (HeroBehaviour)reader.ReadByte(); + + if (version >= 116) + { + // --- Codex Progress (collectionId -> set of submitted item indices) --- + ItemCodexProgress = new Dictionary>(); + int colCount = reader.ReadInt32(); + for (int i = 0; i < colCount; i++) + { + int colId = reader.ReadInt32(); + int cnt = reader.ReadInt32(); + var set = new HashSet(); + for (int k = 0; k < cnt; k++) + set.Add(reader.ReadInt32()); + ItemCodexProgress[colId] = set; + } + + // --- Codex Discovered (flat item indices) --- + ItemCodexDiscovered = new HashSet(); + int discCount = reader.ReadInt32(); + for (int i = 0; i < discCount; i++) + ItemCodexDiscovered.Add(reader.ReadInt32()); + + // --- Codex Claimed (collection ids) --- + ItemCodexClaimed = new HashSet(); + int claimCount = reader.ReadInt32(); + for (int i = 0; i < claimCount; i++) + ItemCodexClaimed.Add(reader.ReadInt32()); + + // Optional safety: ensure Discovered includes everything in Progress + if (ItemCodexProgress.Count > 0) + ItemCodexDiscovered.UnionWith(ItemCodexProgress.Values.SelectMany(v => v)); + + CodexXP = reader.ReadInt32(); + } + else + { + ItemCodexProgress = new Dictionary>(); + ItemCodexDiscovered = new HashSet(); + ItemCodexClaimed = new HashSet(); + CodexXP = 0; + } } public virtual void Save(BinaryWriter writer) @@ -567,6 +613,39 @@ public virtual void Save(BinaryWriter writer) writer.Write(CurrentHeroIndex); writer.Write(HeroSpawned); writer.Write((byte)HeroBehaviour); + + if (Envir.Version >= 116) + { + writer.Write(ItemCodexProgress?.Count ?? 0); + if (ItemCodexProgress != null) + { + foreach (var kv in ItemCodexProgress) + { + writer.Write(kv.Key); // collection Id + writer.Write(kv.Value?.Count ?? 0); // submitted item count + if (kv.Value != null) + foreach (var idx in kv.Value) + writer.Write(idx); + } + } + + // --- Codex Discovered (flat) --- + writer.Write(ItemCodexDiscovered?.Count ?? 0); + if (ItemCodexDiscovered != null) + { + foreach (var idx in ItemCodexDiscovered) + writer.Write(idx); + } + + // --- Codex Claimed (collection ids) --- + writer.Write(ItemCodexClaimed?.Count ?? 0); + if (ItemCodexClaimed != null) + { + foreach (var colId in ItemCodexClaimed) + writer.Write(colId); + } + writer.Write(CodexXP); + } } public SelectInfo ToSelectInfo() diff --git a/Server/MirEnvir/Envir.cs b/Server/MirEnvir/Envir.cs index 8655fa04a..8a45c7136 100644 --- a/Server/MirEnvir/Envir.cs +++ b/Server/MirEnvir/Envir.cs @@ -7,10 +7,15 @@ using Server.MirObjects.Monsters; using System.Collections.Concurrent; using System.Diagnostics; +using System.Globalization; using System.Net; using System.Net.Sockets; using System.Numerics; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; using System.Text.RegularExpressions; +using Shared.Data; using S = ServerPackets; namespace Server.MirEnvir @@ -53,7 +58,7 @@ public class Envir public static object LoadLock = new object(); public const int MinVersion = 60; - public const int Version = 115; + public const int Version = 116; public const int CustomVersion = 0; public static readonly string DatabasePath = Path.Combine(".", "Server.MirDB"); public static readonly string AccountPath = Path.Combine(".", "Server.MirADB"); @@ -61,6 +66,7 @@ public class Envir public static readonly string AccountsBackUpPath = Path.Combine(".", "Back Up", "Accounts"); public static readonly string ArchivePath = Path.Combine(".", "Archive"); public bool ResetGS = false; + private bool _codexLoadedSuccessfully = false; public bool GuildRefreshNeeded; private static readonly Regex AccountIDReg, PasswordReg, EMailReg, CharacterReg; @@ -2898,6 +2904,10 @@ public bool LoadDB() RespawnTick = new RespawnTimer(reader); } Settings.LinkGuildCreationItems(ItemInfoList); + + // --- Item Codex collections --- + var codexPathJson = Path.Combine(Settings.ConfigPath, "ItemCodex.json"); + _codexLoadedSuccessfully = LoadItemCodexFromTxt(codexPathJson); } return true; @@ -5434,5 +5444,861 @@ public void DeleteGuild(GuildObject guild) GuildRefreshNeeded = true; MessageQueue.Enqueue(GameLanguage.ServerTextMap.GetLocalization((ServerTextKeys.GuildWillBeDeletedFromServer), guild.Info.Name)); } + + #region Codex + // ===== Item Codex (Item Collections) ===== + public sealed class ItemCodexCollection + { + public int Id; + public string Name = string.Empty; + public List ItemIndices = new List(); + public Stats Reward = new Stats(); + + public int RewardXP = 0; + public ItemGrade Rarity = ItemGrade.None; + + // 0=Character, 1=Limited, 2=Event + public byte Bucket = 0; + + public bool Enabled = true; + + // Time window for Limited/Event + public DateTime? StartTimeUtc; + public DateTime? EndTimeUtc; + public bool KeepStatsAfterExpiry; + + public CodexBucket BucketEnum + { + get => (CodexBucket)Bucket; + set => Bucket = (byte)value; + } + + public bool IsActive(DateTime nowUtc) + { + if (!Enabled) return false; + if (BucketEnum == CodexBucket.Character) return true; + + if (StartTimeUtc.HasValue && nowUtc < StartTimeUtc.Value) return false; + if (EndTimeUtc.HasValue && nowUtc > EndTimeUtc.Value) return false; + return true; + } + } + + private class CodexJsonItem + { + public int Index { get; set; } + public sbyte Stage { get; set; } + } + + private class CodexJsonCollection + { + public int Id { get; set; } + public string Name { get; set; } + public List Items { get; set; } + public Dictionary Reward { get; set; } + public int XP { get; set; } + public string Rarity { get; set; } + public string Bucket { get; set; } + public bool Enabled { get; set; } + public string Start { get; set; } + public string End { get; set; } + public bool KeepStats { get; set; } + } + + private class CodexJsonMeta + { + public int Version { get; set; } = Envir.Version; + public string TimeBasis { get; set; } = "local"; + } + + private class CodexJsonRoot + { + public CodexJsonMeta _meta { get; set; } = new CodexJsonMeta(); + public List Collections { get; set; } = new List(); + } + + public List ItemCodexCollections = new List(); + public Dictionary ItemCodexById = new Dictionary(); + public static bool CodexAutoDiscover = false; // keep OFF: submit-only flow + + private static Stats ReadStatsSafe(BinaryReader reader, int version, int customVersion) + { + if (reader == null) return new Stats(); + + long start = reader.BaseStream.Position; + try + { + return new Stats(reader, version, customVersion); + } + catch (EndOfStreamException) + { + reader.BaseStream.Position = reader.BaseStream.Length; + return new Stats(); + } + } + + private static bool TryReadInt32(BinaryReader reader, out int value) + { + if (reader != null && reader.BaseStream.Length - reader.BaseStream.Position >= sizeof(int)) + { + value = reader.ReadInt32(); + return true; + } + + value = 0; + if (reader != null) reader.BaseStream.Position = reader.BaseStream.Length; + return false; + } + + private static bool TryReadInt16(BinaryReader reader, out short value) + { + if (reader != null && reader.BaseStream.Length - reader.BaseStream.Position >= sizeof(short)) + { + value = reader.ReadInt16(); + return true; + } + + value = 0; + if (reader != null) reader.BaseStream.Position = reader.BaseStream.Length; + return false; + } + + private static bool TryReadByte(BinaryReader reader, out byte value) + { + if (reader != null && reader.BaseStream.Length - reader.BaseStream.Position >= 1) + { + value = reader.ReadByte(); + return true; + } + + value = 0; + if (reader != null) reader.BaseStream.Position = reader.BaseStream.Length; + return false; + } + + private static bool TryReadBoolean(BinaryReader reader, out bool value) + { + if (reader != null && reader.BaseStream.Length - reader.BaseStream.Position >= 1) + { + value = reader.ReadBoolean(); + return true; + } + + value = false; + if (reader != null) reader.BaseStream.Position = reader.BaseStream.Length; + return false; + } + + private static bool TryReadDecimal(BinaryReader reader, out decimal value) + { + const int decimalSize = 16; + if (reader != null && reader.BaseStream.Length - reader.BaseStream.Position >= decimalSize) + { + value = reader.ReadDecimal(); + return true; + } + + value = 0M; + if (reader != null) reader.BaseStream.Position = reader.BaseStream.Length; + return false; + } + + private static bool TryParseCodexStat(string key, out Stat stat) + { + if (key.Equals("DamageMinBonus", StringComparison.OrdinalIgnoreCase)) + { + stat = Stat.MinDamage; + return true; + } + if (key.Equals("DamageMaxBonus", StringComparison.OrdinalIgnoreCase)) + { + stat = Stat.MaxDamage; + return true; + } + + return Enum.TryParse(key, true, out stat); + } + + private static string ReadStringSafe(BinaryReader reader) + { + if (reader == null) return string.Empty; + + long start = reader.BaseStream.Position; + try + { + return reader.ReadString(); + } + catch (EndOfStreamException) + { + reader.BaseStream.Position = reader.BaseStream.Length; + return string.Empty; + } + } + + public bool LoadItemCodexFromTxt(string path) + { + if (!File.Exists(path)) return false; + + var rawText = File.ReadAllText(path); + var ext = Path.GetExtension(path)?.ToLowerInvariant() ?? string.Empty; + + if (ext == ".json" || rawText.TrimStart().StartsWith("[")) + { + if (TryParseCodexJson(rawText, out var jsonCollections, out var jsonById)) + { + ItemCodexCollections = jsonCollections; + ItemCodexById = jsonById; + return true; + } + } + + var rawLines = rawText.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None); + + if (TryParseCodexIni(rawLines, out var iniCollections, out var iniById)) + { + if (iniCollections.Count > 0) + { + ItemCodexCollections = iniCollections; + ItemCodexById = iniById; + return true; + } + } + + var iniConverted = ConvertCodexIniToLegacyLines(rawLines); + IEnumerable lines = (iniConverted != null && iniConverted.Count > 0) + ? iniConverted + : rawLines; + var cols = new List(); + var byId = new Dictionary(); + + foreach (var raw in lines) + { + var line = raw.Trim(); + if (line.Length == 0 || line.StartsWith("#")) continue; + + // Format Id|Name|itemIndex,itemIndex,...|Stat=Value,Stat=Value|XP|Rarity|Bucket|Enabled + var parts = line.Split('|'); + if (parts.Length < 3) continue; + + if (!int.TryParse(parts[0].Trim(), out int id)) continue; + if (id <= 0) continue; + if (byId.ContainsKey(id)) continue; + + var name = parts[1].Trim(); + + // --- items --- + var requirements = new List(); + foreach (var token in parts[2].Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries)) + { + var trimmed = token.Trim(); + if (trimmed.Length == 0) continue; + + sbyte stage = CodexRequirement.AnyStage; + string indexPart = trimmed; + + int at = trimmed.IndexOf('@'); + if (at >= 0) + { + indexPart = trimmed.Substring(0, at); + string stagePart = trimmed.Substring(at + 1); + if (!int.TryParse(stagePart, NumberStyles.Integer, CultureInfo.InvariantCulture, out int stageValue)) + continue; + if (stageValue < sbyte.MinValue || stageValue > sbyte.MaxValue) + continue; + stage = (sbyte)stageValue; + } + + if (!int.TryParse(indexPart, NumberStyles.Integer, CultureInfo.InvariantCulture, out int itemIndex)) + continue; + + if (itemIndex <= 0) continue; + + requirements.Add(CodexRequirement.Encode(itemIndex, stage)); + } + + requirements = requirements.Distinct().ToList(); + + if (ItemInfoList != null && ItemInfoList.Count > 0) + { + requirements = requirements + .Where(req => + { + int idx = CodexRequirement.DecodeItemIndex(req); + return idx >= 0 && idx < ItemInfoList.Count && ItemInfoList[idx] != null; + }) + .ToList(); + } + + if (requirements.Count == 0) continue; + + // --- rewards (Stats) --- + var reward = new Stats(); + if (parts.Length >= 4 && !string.IsNullOrWhiteSpace(parts[3])) + { + foreach (var kv in parts[3].Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries)) + { + var p = kv.Split('='); + if (p.Length != 2) continue; + var key = p[0].Trim(); + var val = p[1].Trim(); + if (TryParseCodexStat(key, out var stat) && int.TryParse(val, out int amount)) + reward[stat] += amount; + } + } + + // --- XP / Rarity --- + int xp = 0; + ItemGrade rarity = ItemGrade.None; + DateTime? startUtc = null, endUtc = null; + bool keepStats = false; + + if (parts.Length >= 5) int.TryParse(parts[4].Trim(), out xp); + if (xp < 0) xp = 0; + + int rarityIndex = 5; + int bucketIndex = 6; + int enabledIndex = 7; + int startIndex = 8; + int endIndex = 9; + int keepIndex = 10; + + if (parts.Length > rarityIndex) + { + var rtxt = parts[rarityIndex].Trim(); + if (!string.IsNullOrEmpty(rtxt)) + { + // allow enum name or numeric value + if (!Enum.TryParse(rtxt, true, out rarity)) + { + if (byte.TryParse(rtxt, out var rv)) rarity = (ItemGrade)rv; + } + } + } + + CodexBucket bucketEnum = CodexBucket.Character; // default for 7-field lines + if (bucketIndex >= 0 && parts.Length > bucketIndex) + { + var btxt = parts[bucketIndex].Trim(); + if (!string.IsNullOrEmpty(btxt)) + { + if (!Enum.TryParse(btxt, true, out bucketEnum)) + { + if (byte.TryParse(btxt, out var bval)) bucketEnum = (CodexBucket)bval; + } + } + } + + var col = new ItemCodexCollection + { + Id = id, + Name = name, + ItemIndices = requirements, + Reward = reward, + RewardXP = xp, + Rarity = rarity, + Bucket = (byte)bucketEnum, + Enabled = (enabledIndex >= 0 && parts.Length > enabledIndex) + ? ParseCodexEnabled(parts[enabledIndex]) + : parts.Length >= 8 // legacy format: enabled field at index 8 + ? ParseCodexEnabled(parts[8]) + : true + }; + + cols.Add(col); + byId[id] = col; + } + + if (cols.Count == 0) return false; + + // Thread-safe swap + lock (LoadLock) + { + ItemCodexCollections = cols; + ItemCodexById = byId; + } + return true; + } + + private bool TryParseCodexIni(string[] lines, out List collectionsResult, out Dictionary byIdResult) + { + collectionsResult = null; + byIdResult = null; + if (lines == null || lines.Length == 0) return false; + + var collections = new List(); + var byId = new Dictionary(); + + Dictionary section = null; + bool sawCollection = false; + + void FinalizeSection() + { + if (section == null) return; + if (!section.TryGetValue("Id", out var idText) || !int.TryParse(idText.Trim(), out int id) || id <= 0) + { + section = null; + return; + } + + if (byId.ContainsKey(id)) + { + section = null; + return; + } + + section.TryGetValue("Name", out var nameText); + section.TryGetValue("Items", out var itemsText); + section.TryGetValue("Reward", out var rewardText); + section.TryGetValue("XP", out var xpText); + section.TryGetValue("Rarity", out var rarityText); + section.TryGetValue("Bucket", out var bucketText); + section.TryGetValue("Enabled", out var enabledText); + section.TryGetValue("StartUtc", out var startText); + section.TryGetValue("EndUtc", out var endText); + section.TryGetValue("KeepStats", out var keepText); + + var requirements = new List(); + if (!string.IsNullOrWhiteSpace(itemsText)) + { + foreach (var token in itemsText.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries)) + { + var trimmed = token.Trim(); + if (trimmed.Length == 0) continue; + + sbyte stage = CodexRequirement.AnyStage; + string indexPart = trimmed; + + int at = trimmed.IndexOf('@'); + if (at >= 0) + { + indexPart = trimmed.Substring(0, at); + string stagePart = trimmed.Substring(at + 1); + if (!int.TryParse(stagePart, NumberStyles.Integer, CultureInfo.InvariantCulture, out int stageValue)) + continue; + if (stageValue < sbyte.MinValue || stageValue > sbyte.MaxValue) + continue; + stage = (sbyte)stageValue; + } + + if (!int.TryParse(indexPart, NumberStyles.Integer, CultureInfo.InvariantCulture, out int itemIndex)) + continue; + + if (itemIndex <= 0) continue; + + requirements.Add(CodexRequirement.Encode(itemIndex, stage)); + } + } + + requirements = requirements.Distinct().ToList(); + + if (ItemInfoList != null && ItemInfoList.Count > 0) + { + requirements = requirements + .Where(req => + { + int idx = CodexRequirement.DecodeItemIndex(req); + return idx >= 0 && idx < ItemInfoList.Count && ItemInfoList[idx] != null; + }) + .ToList(); + } + + if (requirements.Count == 0) + { + section = null; + return; + } + + var reward = new Stats(); + if (!string.IsNullOrWhiteSpace(rewardText)) + { + foreach (var kv in rewardText.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries)) + { + var p = kv.Split('='); + if (p.Length != 2) continue; + var key = p[0].Trim(); + var val = p[1].Trim(); + if (TryParseCodexStat(key, out var stat) && int.TryParse(val, out int amount)) + reward[stat] += amount; + } + } + + int xp = 0; + if (!string.IsNullOrWhiteSpace(xpText) && !int.TryParse(xpText.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out xp)) + xp = 0; + if (xp < 0) xp = 0; + + ItemGrade rarity = ItemGrade.None; + if (!string.IsNullOrWhiteSpace(rarityText)) + { + if (!Enum.TryParse(rarityText.Trim(), true, out rarity)) + { + if (byte.TryParse(rarityText.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var rv)) rarity = (ItemGrade)rv; + } + } + + CodexBucket bucketEnum = CodexBucket.Character; + if (!string.IsNullOrWhiteSpace(bucketText)) + { + if (!Enum.TryParse(bucketText.Trim(), true, out bucketEnum)) + { + if (byte.TryParse(bucketText.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var bval)) bucketEnum = (CodexBucket)bval; + } + } + + bool enabled = true; + if (!string.IsNullOrWhiteSpace(enabledText)) + enabled = ParseCodexEnabled(enabledText); + + DateTime? startUtc = null, endUtc = null; + bool keepStats = false; + if (!string.IsNullOrWhiteSpace(startText) && DateTime.TryParse(startText.Trim(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var dtStart)) + startUtc = dtStart; + if (!string.IsNullOrWhiteSpace(endText) && DateTime.TryParse(endText.Trim(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var dtEnd)) + endUtc = dtEnd; + if (!string.IsNullOrWhiteSpace(keepText)) + keepStats = ParseCodexEnabled(keepText); + + if (startUtc.HasValue && endUtc.HasValue && startUtc.Value >= endUtc.Value) + { + section = null; + return; + } + + var col = new ItemCodexCollection + { + Id = id, + Name = nameText ?? string.Empty, + ItemIndices = requirements + .OrderBy(req => CodexRequirement.DecodeItemIndex(req)) + .ThenBy(req => CodexRequirement.DecodeStage(req)) + .ToList(), + Reward = reward, + RewardXP = xp, + Rarity = rarity, + Bucket = (byte)bucketEnum, + Enabled = enabled, + StartTimeUtc = startUtc, + EndTimeUtc = endUtc, + KeepStatsAfterExpiry = keepStats + }; + + // maintain dictionary + collections.Add(col); + byId[id] = col; + section = null; + } + + foreach (var raw in lines) + { + var line = raw.Trim(); + if (line.Length == 0 || line.StartsWith("#") || line.StartsWith(";")) continue; + + if (line.StartsWith("[") && line.EndsWith("]")) + { + FinalizeSection(); + var header = line.Substring(1, line.Length - 2).Trim(); + if (header.StartsWith("Collection", StringComparison.OrdinalIgnoreCase)) + { + section = new Dictionary(StringComparer.OrdinalIgnoreCase); + sawCollection = true; + } + else + { + section = null; + } + continue; + } + + if (section == null) continue; + + int eq = line.IndexOf('='); + if (eq < 0) continue; + + var key = line.Substring(0, eq).Trim(); + var value = line.Substring(eq + 1).Trim(); + section[key] = value; + } + + FinalizeSection(); + + if (!sawCollection) + return false; + + collectionsResult = collections ?? new List(); + byIdResult = byId ?? new Dictionary(); + return true; + } + + private static DateTime? ParseCodexLocalDate(string raw) + { + if (string.IsNullOrWhiteSpace(raw)) return null; + if (DateTime.TryParse(raw.Trim(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var dt)) + return dt; + return null; + } + + private static TEnum ParseCodexEnum(string raw, TEnum fallback) where TEnum : struct + { + if (!string.IsNullOrWhiteSpace(raw) && Enum.TryParse(raw.Trim(), true, out var val)) + return val; + return fallback; + } + + private bool TryParseCodexJson(string raw, out List collectionsResult, out Dictionary byIdResult) + { + collectionsResult = null; + byIdResult = null; + if (string.IsNullOrWhiteSpace(raw)) return false; + + try + { + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + + List dtoList = null; + var root = JsonSerializer.Deserialize(raw, options); + if (root != null && root.Collections != null && root.Collections.Count > 0) + { + dtoList = root.Collections; + } + else + { + dtoList = JsonSerializer.Deserialize>(raw, options); + } + + if (dtoList == null || dtoList.Count == 0) return false; + + var cols = new List(); + var byId = new Dictionary(); + + foreach (var dto in dtoList) + { + if (dto == null || dto.Id <= 0) continue; + if (byId.ContainsKey(dto.Id)) continue; + + var reqs = new List(); + if (dto.Items != null) + { + foreach (var it in dto.Items) + { + if (it == null || it.Index <= 0) continue; + int stage = it.Stage; + if (stage < sbyte.MinValue) stage = sbyte.MinValue; + if (stage > sbyte.MaxValue) stage = sbyte.MaxValue; + reqs.Add(CodexRequirement.Encode(it.Index, (sbyte)stage)); + } + } + + reqs = reqs.Distinct().ToList(); + if (ItemInfoList != null && ItemInfoList.Count > 0) + { + reqs = reqs + .Where(req => + { + int idx = CodexRequirement.DecodeItemIndex(req); + return idx >= 0 && idx < ItemInfoList.Count && ItemInfoList[idx] != null; + }) + .ToList(); + } + + if (reqs.Count == 0) continue; + + var reward = new Stats(); + if (dto.Reward != null) + { + foreach (var kv in dto.Reward) + { + if (TryParseCodexStat(kv.Key, out var stat)) + reward[stat] += kv.Value; + } + } + + var col = new ItemCodexCollection + { + Id = dto.Id, + Name = dto.Name ?? string.Empty, + ItemIndices = reqs + .OrderBy(req => CodexRequirement.DecodeItemIndex(req)) + .ThenBy(req => CodexRequirement.DecodeStage(req)) + .ToList(), + Reward = reward, + RewardXP = dto.XP, + Rarity = ParseCodexEnum(dto.Rarity, ItemGrade.None), + Bucket = (byte)ParseCodexEnum(dto.Bucket, CodexBucket.Character), + Enabled = dto.Enabled, + StartTimeUtc = ParseCodexLocalDate(dto.Start), + EndTimeUtc = ParseCodexLocalDate(dto.End), + KeepStatsAfterExpiry = dto.KeepStats + }; + + if (col.StartTimeUtc.HasValue && col.EndTimeUtc.HasValue && col.StartTimeUtc.Value >= col.EndTimeUtc.Value) + continue; + + cols.Add(col); + byId[col.Id] = col; + } + + if (cols.Count == 0) return false; + collectionsResult = cols; + byIdResult = byId; + return true; + } + catch + { + return false; + } + } + + private static List ConvertCodexIniToLegacyLines(IEnumerable lines) + { + if (lines == null) return null; + + var result = new List(); + Dictionary current = null; + string currentSection = null; + bool sawCollection = false; + + void FinalizeSection() + { + if (current == null) return; + if (!current.TryGetValue("Id", out var idText) || !int.TryParse(idText.Trim(), out _)) { current = null; return; } + if (!current.TryGetValue("Items", out var itemsText) || string.IsNullOrWhiteSpace(itemsText)) { current = null; return; } + + current.TryGetValue("Name", out var nameText); + current.TryGetValue("Reward", out var rewardText); + current.TryGetValue("XP", out var xpText); + current.TryGetValue("Rarity", out var rarityText); + current.TryGetValue("Bucket", out var bucketText); + current.TryGetValue("Enabled", out var enabledText); + + string legacyLine = string.Join("|", new[] + { + idText.Trim(), + (nameText ?? string.Empty).Trim(), + itemsText.Trim(), + (rewardText ?? string.Empty).Trim(), + string.IsNullOrWhiteSpace(xpText) ? "0" : xpText.Trim(), + string.IsNullOrWhiteSpace(rarityText) ? ItemGrade.None.ToString() : rarityText.Trim(), + string.IsNullOrWhiteSpace(bucketText) ? CodexBucket.Character.ToString() : bucketText.Trim(), + string.IsNullOrWhiteSpace(enabledText) ? "True" : enabledText.Trim() + }); + + result.Add(legacyLine); + current = null; + } + + foreach (var raw in lines) + { + var line = raw.Trim(); + if (line.Length == 0 || line.StartsWith("#") || line.StartsWith(";")) continue; + + if (line.StartsWith("[") && line.EndsWith("]")) + { + FinalizeSection(); + currentSection = line.Substring(1, line.Length - 2).Trim(); + if (currentSection.StartsWith("Collection", StringComparison.OrdinalIgnoreCase)) + { + current = new Dictionary(StringComparer.OrdinalIgnoreCase); + sawCollection = true; + } + else + { + current = null; + } + continue; + } + + if (current == null) continue; + + int eq = line.IndexOf('='); + if (eq < 0) continue; + + string key = line.Substring(0, eq).Trim(); + string value = line.Substring(eq + 1).Trim(); + + current[key] = value; + } + + FinalizeSection(); + + return sawCollection ? result : null; + } + + private static bool ParseCodexEnabled(string rawEnabled) + { + if (string.IsNullOrWhiteSpace(rawEnabled)) return true; + + rawEnabled = rawEnabled.Trim(); + if (bool.TryParse(rawEnabled, out var flag)) return flag; + if (int.TryParse(rawEnabled, out var num)) return num != 0; + + rawEnabled = rawEnabled.ToLowerInvariant(); + if (rawEnabled == "yes" || rawEnabled == "y") return true; + if (rawEnabled == "no" || rawEnabled == "n") return false; + + return true; + } + + public void SaveItemCodexToTxt(string path) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)); + var dtoList = ItemCodexCollections + .OrderBy(c => c.Id) + .Select(c => new CodexJsonCollection + { + Id = c.Id, + Name = c.Name, + Items = c.ItemIndices.Select(req => new CodexJsonItem + { + Index = CodexRequirement.DecodeItemIndex(req), + Stage = CodexRequirement.DecodeStage(req) + }).ToList(), + Reward = ((IEnumerable>)(c.Reward?.Values ?? Enumerable.Empty>())) + .ToDictionary(kv => kv.Key.ToString(), kv => kv.Value), + XP = c.RewardXP, + Rarity = c.Rarity.ToString(), + Bucket = ((CodexBucket)c.Bucket).ToString(), + Enabled = c.Enabled, + Start = c.StartTimeUtc.HasValue ? c.StartTimeUtc.Value.ToString("yyyy-MM-dd HH:mm") : null, + End = c.EndTimeUtc.HasValue ? c.EndTimeUtc.Value.ToString("yyyy-MM-dd HH:mm") : null, + KeepStats = c.KeepStatsAfterExpiry + }) + .ToList(); + + var root = new CodexJsonRoot + { + _meta = new CodexJsonMeta { Version = Version, TimeBasis = "local" }, + Collections = dtoList + }; + + var options = new JsonSerializerOptions { WriteIndented = true }; + var json = JsonSerializer.Serialize(root, options); + File.WriteAllText(path, json, Encoding.UTF8); + } + + private void AutoPersistExternalData() + { + AutoSaveCodex(); + } + + private void AutoSaveCodex() + { + if (!Settings.AllowCodex) return; + try + { + var path = Path.Combine(Settings.ConfigPath, "ItemCodex.json"); + if (!_codexLoadedSuccessfully && File.Exists(path)) + return; + if ((ItemCodexCollections == null || ItemCodexCollections.Count == 0) && File.Exists(path)) + return; // avoid overwriting existing data with an empty collection during early startup + SaveItemCodexToTxt(path); + } + catch (Exception ex) + { + MessageQueue.EnqueueDebugging($"[Codex] Auto-save failed: {ex}"); + } + } + + + #endregion } } \ No newline at end of file diff --git a/Server/MirNetwork/MirConnection.cs b/Server/MirNetwork/MirConnection.cs index 6c2426bc3..04119795e 100644 --- a/Server/MirNetwork/MirConnection.cs +++ b/Server/MirNetwork/MirConnection.cs @@ -731,6 +731,18 @@ private void ProcessPacket(Packet p) case (short)ClientPacketIds.DeleteItem: DeleteItem((C.DeleteItem)p); break; + case (short)ClientPacketIds.RequestItemCodex: + RequestItemCodex((C.RequestItemCodex)p); + break; + case (short)ClientPacketIds.ClaimItemCodex: + ClaimItemCodex((C.ClaimItemCodex)p); + break; + case (short)ClientPacketIds.SubmitItemToCodex: + SubmitItemToCodex((C.SubmitItemToCodex)p); + break; + case (short)ClientPacketIds.CodexUseCurrency: + HandleCodexUseCurrency((C.CodexUseCurrency)p); + break; default: MessageQueue.Enqueue(GameLanguage.ServerTextMap.GetLocalization((ServerTextKeys.InvalidPacketReceived), p.Index)); break; @@ -2149,6 +2161,28 @@ private void DeleteItem(C.DeleteItem p) Player.DeleteItem(p.UniqueID, p.Count); } + + private void RequestItemCodex(C.RequestItemCodex p) + { + if (Player == null) return; + Player.SendItemCodexSync(); + } + + private void ClaimItemCodex(C.ClaimItemCodex p) + { + if (Player == null) return; + Player.HandleCodexClaim(p.Id); + } + + private void SubmitItemToCodex(C.SubmitItemToCodex p) + { + Player?.HandleCodexSubmit(p); // pass the full packet (SetId, ItemInfoId, UniqueID) + } + private void HandleCodexUseCurrency(C.CodexUseCurrency p) + { + if (p == null || Player == null) return; + Player.HandleCodexUseCurrency(p); + } } public class MirConnectionLog { diff --git a/Server/MirObjects/HumanObject.cs b/Server/MirObjects/HumanObject.cs index d20adf969..50746648b 100644 --- a/Server/MirObjects/HumanObject.cs +++ b/Server/MirObjects/HumanObject.cs @@ -1745,6 +1745,7 @@ public void RefreshStats() RefreshSkills(); RefreshBuffs(); RefreshGuildBuffs(); + ApplyItemCodexBonuses(); //Add any rate percent changes @@ -1766,7 +1767,51 @@ public void RefreshStats() AttackSpeed = 1400 - ((Stats[Stat.AttackSpeed] * 60) + Math.Min(370, (Level * 14))); if (AttackSpeed < 550) AttackSpeed = 550; + + } + + // Rewards are derived from COMPLETION (progress) or legacy Claimed + private void ApplyItemCodexBonuses() + { + if (Envir.ItemCodexCollections == null || Envir.ItemCodexCollections.Count == 0) return; + + Info.ItemCodexProgress ??= new Dictionary>(); + Info.ItemCodexClaimed ??= new HashSet(); + + var nowUtc = Envir.Now; + + foreach (var col in Envir.ItemCodexCollections) + { + if (col == null) continue; + + int required = col.ItemIndices?.Count ?? 0; + if (required <= 0) continue; + + bool doneByProgress = + Info.ItemCodexProgress.TryGetValue(col.Id, out var set) && + set != null && set.Count >= required; + + bool doneByLegacyClaim = Info.ItemCodexClaimed.Contains(col.Id); + + if (!(doneByProgress || doneByLegacyClaim)) continue; + + bool active = col.IsActive(nowUtc); + if (!active && !col.KeepStatsAfterExpiry) continue; + + var reward = col.Reward; + if (reward == null) continue; + + foreach (Stat s in Enum.GetValues(typeof(Stat))) + { + int v = 0; + try { v = reward[s]; } catch { v = 0; } + if (v == 0) continue; + + try { Stats[s] += v; } catch { /* ignore write-protected */ } + } + } } + public virtual void RefreshGuildBuffs() { } public virtual void RefreshMaxExperience() { } @@ -2253,6 +2298,49 @@ public void RefreshStatCaps() } #endregion + private void RefreshItemCodexStats() + { + if (Info.ItemCodexClaimed == null || Info.ItemCodexClaimed.Count == 0) return; + + foreach (var colId in Info.ItemCodexClaimed) + { + Envir.ItemCodexCollection col = null; + + if (Envir.ItemCodexById != null && Envir.ItemCodexById.TryGetValue(colId, out var byId)) + { + col = byId; + } + else if (Envir.ItemCodexCollections != null) + { + col = Envir.ItemCodexCollections.FirstOrDefault(c => c.Id == colId); + } + + var reward = col?.Reward; + if (reward == null) continue; + + foreach (Stat s in Enum.GetValues(typeof(Stat))) + { + int v = 0; + try { v = reward[s]; } catch { v = 0; } + if (v == 0) continue; + try { Stats[s] += v; } catch { /* ignore write-protected stats */ } + } + } + } + private bool TryGetCodexReward(int setId, out Stats reward) + { + reward = null; + + Envir.ItemCodexCollection col = null; + if (Envir.ItemCodexById != null && Envir.ItemCodexById.TryGetValue(setId, out var byId)) + col = byId; + else if (Envir.ItemCodexCollections != null) + col = Envir.ItemCodexCollections.FirstOrDefault(c => c.Id == setId); + + reward = col?.Reward; + return reward != null; + } + private void AddTempSkills(IEnumerable skillsToAdd) { foreach (var skill in skillsToAdd) diff --git a/Server/MirObjects/PlayerObject.cs b/Server/MirObjects/PlayerObject.cs index a7d8f6e1c..a8fc9a5c5 100644 --- a/Server/MirObjects/PlayerObject.cs +++ b/Server/MirObjects/PlayerObject.cs @@ -6,6 +6,7 @@ using S = ServerPackets; using System.Text.RegularExpressions; using Timer = Server.MirEnvir.Timer; +using Shared.Data; namespace Server.MirObjects { @@ -18,6 +19,12 @@ public class PlayerObject : HumanObject public bool GMLogin, EnableGroupRecall, EnableGuildInvite, AllowMarriage, AllowLoverRecall, AllowMentor, HasMapShout, HasServerShout; //TODO - Remove public long LastRecallTime, LastTeleportTime, LastProbeTime; + + private static string SL(string key, params object[] args) + { + if (!GameLanguage.ServerTextMap.Text.TryGetValue(key, out var value)) value = key; + return (args != null && args.Length > 0) ? string.Format(value, args) : value; + } public long NextMailTime; public long MenteeEXP; @@ -1182,6 +1189,19 @@ private void StartGameSuccess() GetItemInfo(Connection); GetMapInfo(Connection); GetUserInfo(Connection); + Connection.Enqueue(new S.AllowCodex { Allow = Settings.AllowCodex }); + + + Info.ItemCodexProgress ??= new Dictionary>(); + Info.ItemCodexClaimed ??= new HashSet(); // claimed sets only (manual) + Info.ItemCodexDiscovered ??= new HashSet(); + + if (Info.ItemCodexProgress.Count > 0) + Info.ItemCodexDiscovered = new HashSet(Info.ItemCodexProgress.Values.SelectMany(v => v)); + + SendCodexItemInfosToClient(); // ensures client has icons for sets/items + SendItemCodexSync(); // full, honest progress + claimed flag (no inflation) + Spawned(); @@ -1214,7 +1234,14 @@ private void StartGameSuccess() SendUpdateQuest(quest, QuestState.Add); } - SendBaseStats(); + //SendBaseStats(); REMOVED? + // -------------------------------------------------------------------- + // 3) Finalize authoritative gameplay stats for the session. + // This computes all stats (including Codex *claimed* bonuses) and pushes once. + // (Keep your one-time SendBaseStats() guard inside RefreshStats()). + // -------------------------------------------------------------------- + RefreshStats(); + GetObjectsPassive(); Enqueue(new S.TimeOfDay { Lights = Envir.Lights }); Enqueue(new S.ChangeAMode { Mode = AMode }); @@ -1682,6 +1709,8 @@ public void GetUserInfo(MirConnection c)//was private luke QuestInventory = new UserItem[Info.QuestInventory.Length], Gold = Account.Gold, Credit = Account.Credit, + Stone = Account.Stone, + Jade = Account.Jade, HasExpandedStorage = Account.ExpandedStorageExpiryDate > Envir.Now ? true : false, ExpandedStorageExpiryTime = Account.ExpandedStorageExpiryDate, AllowObserve = AllowObserve, @@ -4143,6 +4172,110 @@ public void Chat(string message, List linkedItems = null) Enqueue(GetUpdateInfo()); Broadcast(GetUpdateInfo()); break; + + case "COMPLETECODEX": + { + if ((!IsGM && !Settings.TestServer) || parts.Length < 2) { ReceiveChat("Usage: @completecodex ", ChatType.System); return; } + if (!int.TryParse(parts[1], out int cid) || cid <= 0) { ReceiveChat("Usage: @completecodex ", ChatType.System); return; } + + var col = Envir.ItemCodexCollections?.FirstOrDefault(x => x != null && x.Id == cid); + if (col == null || !col.Enabled) + { + ReceiveChat(SL("Codex_UnknownId", cid), ChatType.Hint); + return; + } + + Info.ItemCodexProgress ??= new Dictionary>(); + Info.ItemCodexDiscovered ??= new HashSet(); + Info.ItemCodexClaimed ??= new HashSet(); + + var set = Info.ItemCodexProgress.TryGetValue(col.Id, out var existing) && existing != null + ? existing + : (Info.ItemCodexProgress[col.Id] = new HashSet()); + + if (col.ItemIndices != null) + { + foreach (var req in col.ItemIndices) + { + set.Add(req); + Info.ItemCodexDiscovered.Add(req); + } + } + + Info.ItemCodexClaimed.Add(col.Id); + + SendItemCodexSync(); + RefreshStats(); + SendBaseStats(); + Envir.Main.BeginSaveAccounts(); + ReceiveChat(SL("Codex_CompletedSingle", col.Id, col.Name ?? string.Empty), ChatType.Hint); + return; + } + case "GIVESTONE": + if ((!IsGM && !Settings.TestServer) || parts.Length < 2) return; + + player = this; + + if (parts.Length > 2) + { + if (!IsGM) return; + + if (!uint.TryParse(parts[2], out count)) return; + player = Envir.GetPlayer(parts[1]); + + if (player == null) + { + ReceiveChat(string.Format("Player {0} was not found.", parts[1]), ChatType.System); + return; + } + } + else if (!uint.TryParse(parts[1], out count)) return; + + if (count + player.Account.Stone >= uint.MaxValue) + count = uint.MaxValue - player.Account.Stone; + + player.Account.Stone += count; + player.Enqueue(new S.GainedStone { Stone = count }); + + { + string msg = $"Player {player.Name} has been given {count} stone by GM: {Name}"; + MessageQueue.Enqueue(msg); + Helpers.ChatSystem.SystemMessage(chatMessage: msg); + } + break; + + case "GIVEJADE": + if ((!IsGM && !Settings.TestServer) || parts.Length < 2) return; + + player = this; + + if (parts.Length > 2) + { + if (!IsGM) return; + + if (!uint.TryParse(parts[2], out count)) return; + player = Envir.GetPlayer(parts[1]); + + if (player == null) + { + ReceiveChat(string.Format("Player {0} was not found.", parts[1]), ChatType.System); + return; + } + } + else if (!uint.TryParse(parts[1], out count)) return; + + if (count + player.Account.Jade >= uint.MaxValue) + count = uint.MaxValue - player.Account.Jade; + + player.Account.Jade += count; + player.Enqueue(new S.GainedJade { Jade = count }); + + { + string msg = $"Player {player.Name} has been given {count} jade by GM: {Name}"; + MessageQueue.Enqueue(msg); + Helpers.ChatSystem.SystemMessage(chatMessage: msg); + } + break; default: break; } @@ -7606,6 +7739,39 @@ public void GainCredit(uint credit) Enqueue(new S.GainedCredit { Credit = credit }); } + public void GainStone(uint amt) + { + if (amt == 0) return; + if ((ulong)Account.Stone + amt > uint.MaxValue) amt = uint.MaxValue - Account.Stone; + Account.Stone += amt; + Enqueue(new S.GainedStone { Stone = amt }); + } + + bool TryLoseStone(uint amt) + { + if (amt == 0) return true; + if (Account.Stone < amt) return false; + Account.Stone -= amt; + Enqueue(new S.LoseStone { Stone = amt }); + return true; + } + + void GainJade(uint amt) + { + if (amt == 0) return; + if ((ulong)Account.Jade + amt > uint.MaxValue) amt = uint.MaxValue - Account.Jade; + Account.Jade += amt; + Enqueue(new S.GainedJade { Jade = amt }); + } + + bool TryLoseJade(uint amt) + { + if (amt == 0) return true; + if (Account.Jade < amt) return false; + Account.Jade -= amt; + Enqueue(new S.LoseJade { Jade = amt }); + return true; + } public void GainItemMail(UserItem item, int reason) { Envir.MailCharacter(Info, item: item, reason: reason); @@ -14542,5 +14708,573 @@ public void SendNPCGoods(List goods, float rate, PanelType panelType, { Enqueue(new S.NPCGoods { List = goods, Rate = rate, Type = panelType, HideAddedStats = hideAddedStats }); } + + #region Codex + + private void SendCodexItemInfosToClient() + { + var uniq = new HashSet(); + var collections = Envir.ItemCodexCollections ?? new List(); + foreach (var col in collections) + { + if (col?.ItemIndices == null || !col.Enabled) continue; + foreach (var req in col.ItemIndices) + { + int ix = CodexRequirement.DecodeItemIndex(req); + if (!uniq.Add(ix)) continue; + var info = Envir.GetItemInfo(ix); + if (info != null) CheckItemInfo(info); + } + } + } + + private HashSet GetSubmittedForCollection(int colId) + { + if (Info.ItemCodexProgress != null && + Info.ItemCodexProgress.TryGetValue(colId, out var set) && + set != null) + return set; + + return new HashSet(); + } + + private enum CodexWindowState + { + BeforeStart, + Active, + AfterEnd + } + + private static CodexWindowState EvaluateCodexWindow(Envir.ItemCodexCollection col, DateTime now) + { + if (col.StartTimeUtc.HasValue && now < col.StartTimeUtc.Value) + return CodexWindowState.BeforeStart; + if (col.EndTimeUtc.HasValue && now > col.EndTimeUtc.Value) + return CodexWindowState.AfterEnd; + return CodexWindowState.Active; + } + + public void SendItemCodexSync() + { + if (!Settings.AllowCodex) return; + var pkt = new S.ItemCodexSync(); + var collections = Envir.ItemCodexCollections ?? Enumerable.Empty(); + + Info.ItemCodexProgress ??= new Dictionary>(); + Info.ItemCodexClaimed ??= new HashSet(); + Info.ItemCodexDiscovered ??= new HashSet(); + + var now = Envir.Now; + + foreach (var col in collections) + { + if (col == null || !col.Enabled) continue; + + var submitted = GetSubmittedForCollection(col.Id) ?? new HashSet(); + + byte bucket = col.Bucket; + if (bucket > 2) bucket = 0; + + var rewardCopy = CopyStatsSafe(col.Reward); + string rewardText = BuildRewardPreviewServerSafe(col.Reward); + + bool claimed = Info.ItemCodexClaimed.Contains(col.Id); + short required = (short)(col.ItemIndices?.Count ?? 0); + short found = (short)submitted.Count; + + bool completed = required > 0 && found >= required; + bool active = col.IsActive(now); + + if (!active && !(col.KeepStatsAfterExpiry && completed)) + continue; + + var row = new S.ItemCodexSync.Row + { + Id = col.Id, + Name = col.Name, + Found = found, + Required = required, + Claimed = claimed, + Bucket = bucket, + ReqItemIndices = new List(col.ItemIndices?.Count ?? 0), + ReqStages = new List(col.ItemIndices?.Count ?? 0), + ReqItemIcons = new List(col.ItemIndices?.Count ?? 0), + ReqRegistered = new List(col.ItemIndices?.Count ?? 0), + Reward = rewardCopy, + RewardPreview = rewardText, + + RewardXP = col.RewardXP, + Rarity = (byte)col.Rarity, + + Active = active, + KeepStats = col.KeepStatsAfterExpiry, + StartTicks = col.StartTimeUtc.HasValue ? col.StartTimeUtc.Value.Ticks : -1, + EndTicks = col.EndTimeUtc.HasValue ? col.EndTimeUtc.Value.Ticks : -1 + }; + + if (col.ItemIndices != null) + { + foreach (var requirement in col.ItemIndices) + { + int itemIndex = CodexRequirement.DecodeItemIndex(requirement); + sbyte reqStage = CodexRequirement.DecodeStage(requirement); + + row.ReqItemIndices.Add(itemIndex); + row.ReqStages.Add(reqStage); + + var info = Envir.GetItemInfo(itemIndex); + row.ReqItemIcons.Add(info != null ? info.Image : 0); + row.ReqRegistered.Add(submitted.Contains(requirement)); + } + } + + pkt.Rows.Add(row); + } + + Enqueue(pkt); + } + + public void HandleCodexClaim(int id) + { + if (!Settings.AllowCodex) + { + ReceiveChat(SL("Codex_Disabled"), ChatType.System); + return; + } + if (!Envir.ItemCodexById.TryGetValue(id, out var col) || col == null) + { ReceiveChat(SL("Codex_UnknownCollection"), ChatType.Hint); return; } + + if (!col.Enabled) + { ReceiveChat(SL("Codex_DisabledCollection"), ChatType.Hint); return; } + + var now = Envir.Now; + var window = EvaluateCodexWindow(col, now); + if (window == CodexWindowState.BeforeStart) + { ReceiveChat(SL("Codex_NotStarted"), ChatType.Hint); return; } + if (window == CodexWindowState.AfterEnd && !col.KeepStatsAfterExpiry) + { ReceiveChat(SL("Codex_ExpiredWindow"), ChatType.Hint); return; } + + Info.ItemCodexProgress ??= new Dictionary>(); + Info.ItemCodexClaimed ??= new HashSet(); + Info.ItemCodexDiscovered ??= new HashSet(); + + if (Info.ItemCodexClaimed.Contains(id)) + { ReceiveChat(SL("Codex_AlreadyClaimed"), ChatType.Hint); return; } + + var submitted = GetSubmittedForCollection(id); + int found = submitted.Count; + int required = col.ItemIndices?.Count ?? 0; + + if (required <= 0 || found < required) + { ReceiveChat(SL("Codex_NotComplete"), ChatType.Hint); return; } + + Info.ItemCodexClaimed.Add(id); + + Enqueue(new S.ItemCodexUpdate + { + Id = id, + Found = (short)found, + Required = (short)required, + Claimed = true + }); + + if (col.RewardXP > 0) + { + Info.CodexXP += col.RewardXP; + ReceiveChat(SL("Codex_GainedXP", col.RewardXP), ChatType.Hint); + } + + RefreshStats(); + SendBaseStats(); + + Envir.Main.BeginSaveAccounts(); + + ReceiveChat(SL("Codex_Claimed", col.Name), ChatType.Hint); + } + + public void HandleCodexSubmit(C.SubmitItemToCodex p) + { + if (!Settings.AllowCodex) + { + ReceiveChat(SL("Codex_Disabled"), ChatType.System); + return; + } + if (p == null || p.SetId <= 0 || p.ItemInfoId <= 0) + { ReceiveChat(SL("Codex_InvalidSubmission"), ChatType.Hint); return; } + + var col = Envir.ItemCodexCollections?.FirstOrDefault(x => x.Id == p.SetId); + if (col == null) + { ReceiveChat(SL("Codex_UnknownCollection"), ChatType.Hint); return; } + + if (!col.Enabled) + { ReceiveChat(SL("Codex_DisabledCollection"), ChatType.Hint); return; } + + var now = Envir.Now; + var window = EvaluateCodexWindow(col, now); + if (window == CodexWindowState.BeforeStart) + { ReceiveChat(SL("Codex_NotStarted"), ChatType.Hint); return; } + if (window == CodexWindowState.AfterEnd) + { ReceiveChat(SL("Codex_ExpiredWindow"), ChatType.Hint); return; } + + if (col.ItemIndices == null || col.ItemIndices.Count == 0) + { ReceiveChat(SL("Codex_ItemNotPart"), ChatType.Hint); return; } + + Info.ItemCodexProgress ??= new Dictionary>(); + Info.ItemCodexDiscovered ??= new HashSet(); + Info.ItemCodexClaimed ??= new HashSet(); + + if (!Info.ItemCodexProgress.TryGetValue(p.SetId, out var submitted) || submitted == null) + { + submitted = new HashSet(); + Info.ItemCodexProgress[p.SetId] = submitted; + } + + if (!TryGetInventoryItemByUID(p.UniqueID, out var ui, out var slot) || ui?.Info == null) + { + ReceiveChat(SL("Codex_ItemNotFound"), ChatType.Hint); + return; + } + + if (ui.Info.Index != p.ItemInfoId) + { + ReceiveChat(SL("Codex_WrongItem"), ChatType.Hint); + return; + } + + int matchingRequirement = -1; + sbyte requirementStage = CodexRequirement.AnyStage; + + foreach (var requirement in col.ItemIndices) + { + if (submitted.Contains(requirement)) continue; + + int baseIndex = CodexRequirement.DecodeItemIndex(requirement); + if (baseIndex != ui.Info.Index) continue; + + matchingRequirement = requirement; + requirementStage = CodexRequirement.DecodeStage(requirement); + break; + } + + if (matchingRequirement == -1) + { + bool hasAnyRequirementForItem = col.ItemIndices.Any(req => CodexRequirement.DecodeItemIndex(req) == ui.Info.Index); + if (!hasAnyRequirementForItem) + { + ReceiveChat(SL("Codex_ItemNotPart"), ChatType.Hint); + } + return; + } + + if (!ConsumeOneAtInventorySlot(slot)) + { + ReceiveChat(SL("Codex_UnableConsume"), ChatType.Hint); + return; + } + + submitted.Add(matchingRequirement); + Info.ItemCodexDiscovered.Add(matchingRequirement); + + ReceiveChat(SL("Codex_Submitted", ui.Info.Name), ChatType.Hint); + + Enqueue(new S.ItemCodexMark + { + SetId = p.SetId, + ItemInfoId = ui.Info.Index, + Stage = requirementStage, + Registered = true + }); + + short required = (short)(col.ItemIndices?.Count ?? 0); + short found = (short)submitted.Count; + + Enqueue(new S.ItemCodexUpdate + { + Id = p.SetId, + Found = found, + Required = required, + Claimed = Info.ItemCodexClaimed.Contains(p.SetId) + }); + + Envir.Main.BeginSaveAccounts(); + } + + private static Stats CopyStatsSafe(Stats src) + { + var dst = new Stats(); + if (src == null) return dst; + + foreach (Stat s in Enum.GetValues(typeof(Stat))) + { + int v = 0; + try { v = src[s]; } catch { v = 0; } + if (v != 0) { try { dst[s] = v; } catch { } } + } + return dst; + } + + private static string BuildRewardPreviewServerSafe(Stats reward) + { + if (reward == null) return string.Empty; + + var parts = new List(3); + + int dmgMin = 0, dmgMax = 0; + try { dmgMin = reward[Stat.MinDamage]; } catch { dmgMin = 0; } + try { dmgMax = reward[Stat.MaxDamage]; } catch { dmgMax = 0; } + if (dmgMin != 0 || dmgMax != 0) + { + if (dmgMin == dmgMax) + parts.Add($"Damage {(dmgMin > 0 ? "+" : "")}{dmgMin}"); + else + parts.Add($"Damage {(dmgMin > 0 ? "+" : "")}{dmgMin}~{(dmgMax > 0 ? "+" : "")}{dmgMax}"); + if (parts.Count == 3) return string.Join(", ", parts); + } + + foreach (Stat s in Enum.GetValues(typeof(Stat))) + { + int v = 0; + try { v = reward[s]; } catch { v = 0; } + if (v == 0) continue; + + if (s == Stat.MinDamage || s == Stat.MaxDamage) continue; + + string name = s.ToString(); + if (name.Equals("HP", StringComparison.OrdinalIgnoreCase)) name = "HP"; + else if (name.Equals("MP", StringComparison.OrdinalIgnoreCase)) name = "MP"; + else if (name.Equals("MaxHp", StringComparison.OrdinalIgnoreCase) || name.Equals("MaxHP", StringComparison.OrdinalIgnoreCase)) name = "Max HP"; + else if (name.Equals("MaxMp", StringComparison.OrdinalIgnoreCase) || name.Equals("MaxMP", StringComparison.OrdinalIgnoreCase)) name = "Max MP"; + + bool isPercent = s.ToString().EndsWith("Rate", StringComparison.OrdinalIgnoreCase); + parts.Add($"{name} {(v > 0 ? "+" : "")}{v}{(isPercent ? "%" : "")}"); + if (parts.Count == 3) break; + } + return string.Join(", ", parts); + } + + private UserItem TryGetInventoryItemByUID(ulong uid, out int slot) + { + slot = -1; + var inv = Info?.Inventory; + if (inv == null) return null; + + for (int i = 0; i < inv.Length; i++) + { + var it = inv[i]; + if (it != null && it.UniqueID == uid) + { + slot = i; + return it; + } + } + return null; + } + + private bool TryGetInventoryItemByUID(ulong uid, out UserItem item, out int slot) + { + item = TryGetInventoryItemByUID(uid, out slot); + return item != null; + } + + private bool HasCurrency(Currency currency, uint amount) + { + if (amount == 0) return true; + + return currency switch + { + Currency.Gold => Account.Gold >= amount, + Currency.Stone => Account.Stone >= amount, + Currency.Jade => Account.Jade >= amount, + _ => false + }; + } + + private bool TryLoseCurrency(Currency currency, uint amount) + { + if (amount == 0) return true; + + return currency switch + { + Currency.Stone => TryLoseStone(amount), + Currency.Jade => TryLoseJade(amount), + _ => false + }; + } + + private int FindMaterialSlot(int shape) + { + var inv = Info?.Inventory; + if (inv == null) return -1; + + for (int i = 0; i < inv.Length; i++) + { + var it = inv[i]; + if (it == null) continue; + if (it.Info == null) continue; + if (it.Info.Type != ItemType.CraftingMaterial) continue; + if (it.Info.Shape != shape) continue; + if (it.Count <= 0) continue; + return i; + } + + return -1; + } + + private static bool IsJewelry(UserItem item) + { + var info = item?.Info; + if (info == null) return false; + + return info.Type == ItemType.Ring || + info.Type == ItemType.Bracelet || + info.Type == ItemType.Necklace; + } + + private bool ConsumeOneAtInventorySlot(int slot) + { + var inv = Info?.Inventory; + if (inv == null || slot < 0 || slot >= inv.Length) return false; + + var it = inv[slot]; + if (it == null) return false; + + if (it.Count > 1) + { + it.Count -= 1; + Enqueue(new S.RefreshItem { Item = it }); + } + else + { + var uid = it.UniqueID; + inv[slot] = null; + Enqueue(new S.DeleteItem { UniqueID = uid, Count = 1 }); + + } + return true; + } + + public void HandleCodexUseCurrency(C.CodexUseCurrency p) + { + if (!Settings.AllowCodex) + { + ReceiveChat(SL("Codex_Disabled"), ChatType.System); + return; + } + if (p == null) return; + + var cur = (Currency)p.Currency; + if (cur != Currency.Stone && cur != Currency.Jade) + { + ReceiveChat(SL("Codex_InvalidCurrency"), ChatType.Hint); + return; + } + if (p.SetId <= 0) + { + ReceiveChat(SL("Codex_InvalidSetId"), ChatType.Hint); + return; + } + + var col = Envir.ItemCodexById != null && Envir.ItemCodexById.TryGetValue(p.SetId, out var c) + ? c + : Envir.ItemCodexCollections?.FirstOrDefault(x => x.Id == p.SetId); + + if (col == null) + { + ReceiveChat(SL("Codex_UnknownCollection"), ChatType.Hint); + return; + } + + if (!col.Enabled) + { + ReceiveChat(SL("Codex_DisabledCollection"), ChatType.Hint); + return; + } + + bool currencyAllowed = + (col.Rarity == ItemGrade.Rare && cur == Currency.Stone) || + (col.Rarity == ItemGrade.Legendary && cur == Currency.Jade) || + (col.Rarity != ItemGrade.Rare && col.Rarity != ItemGrade.Legendary); + + if (!currencyAllowed) + { + ReceiveChat(SL("Codex_InvalidCurrencyForSet"), ChatType.Hint); + return; + } + + Info.ItemCodexProgress ??= new Dictionary>(); + Info.ItemCodexDiscovered ??= new HashSet(); + Info.ItemCodexClaimed ??= new HashSet(); + + if (!Info.ItemCodexProgress.TryGetValue(p.SetId, out var submitted) || submitted == null) + { + submitted = new HashSet(); + Info.ItemCodexProgress[p.SetId] = submitted; + } + + int required = col.ItemIndices?.Count ?? 0; + if (required <= 0) + { + ReceiveChat(SL("Codex_NothingRequired"), ChatType.Hint); + return; + } + if (submitted.Count >= required) + { + ReceiveChat(SL("Codex_SetComplete"), ChatType.Hint); + return; + } + + int missingRequirement = 0; + foreach (var requirement in col.ItemIndices) + { + if (!submitted.Contains(requirement)) + { + missingRequirement = requirement; + break; + } + } + if (missingRequirement == 0) + { + ReceiveChat(SL("Codex_SetComplete"), ChatType.Hint); + return; + } + + bool ok = (cur == Currency.Stone) ? TryLoseStone(1) : TryLoseJade(1); + if (!ok) + { + ReceiveChat(cur == Currency.Stone ? SL("Codex_NoStone") : SL("Codex_NoJade"), ChatType.Hint); + return; + } + + submitted.Add(missingRequirement); + Info.ItemCodexDiscovered.Add(missingRequirement); + + Enqueue(new S.ItemCodexMark + { + SetId = p.SetId, + ItemInfoId = CodexRequirement.DecodeItemIndex(missingRequirement), + Stage = CodexRequirement.DecodeStage(missingRequirement), + Registered = true + }); + + short found = (short)submitted.Count; + + Enqueue(new S.ItemCodexUpdate + { + Id = p.SetId, + Found = found, + Required = (short)required, + Claimed = Info.ItemCodexClaimed.Contains(p.SetId) + }); + + Envir.Main.BeginSaveAccounts(); + + int itemIndex = CodexRequirement.DecodeItemIndex(missingRequirement); + var itemName = Envir.GetItemInfo(itemIndex)?.Name ?? "item"; + sbyte stage = CodexRequirement.DecodeStage(missingRequirement); + string stageText = stage == CodexRequirement.AnyStage ? string.Empty : $" (Stage {stage})"; + + ReceiveChat(SL("Codex_UsedCurrency", cur == Currency.Stone ? "Stone" : "Jade", itemName, stageText), ChatType.System); + } + #endregion } -} +} \ No newline at end of file diff --git a/Server/Settings.cs b/Server/Settings.cs index 71f3ee265..7e95a7e28 100644 --- a/Server/Settings.cs +++ b/Server/Settings.cs @@ -95,6 +95,7 @@ private static MessageQueue MessageQueue GameMasterEffect = false, GatherOrbsPerLevel = true, ExpMobLevelDifference = true; + public static bool AllowCodex = true; public static int LineMessageTimer = 10; //Database diff --git a/Shared/BaseStats.cs b/Shared/BaseStats.cs index 31a6ab97e..dc2a91a85 100644 --- a/Shared/BaseStats.cs +++ b/Shared/BaseStats.cs @@ -94,8 +94,8 @@ public BaseStats(MirClass job) Caps[Stat.CriticalDamage] = 10; Caps[Stat.Freezing] = 6; Caps[Stat.PoisonAttack] = 6; - Caps[Stat.HealthRecovery] = 8; - Caps[Stat.SpellRecovery] = 8; + Caps[Stat.HealthRecovery] = 30; + Caps[Stat.SpellRecovery] = 30; Caps[Stat.PoisonRecovery] = 6; } diff --git a/Shared/ClientPackets.cs b/Shared/ClientPackets.cs index 170730fab..7a9707f25 100644 --- a/Shared/ClientPackets.cs +++ b/Shared/ClientPackets.cs @@ -2597,4 +2597,113 @@ protected override void WritePacket(BinaryWriter writer) writer.Write(HeroInventory); } } + + public sealed class RequestItemCodex : Packet + { + public override short Index => (short)ClientPacketIds.RequestItemCodex; + protected override void ReadPacket(BinaryReader reader) { } + protected override void WritePacket(BinaryWriter writer) { } + } + + public sealed class ClaimItemCodex : Packet + { + public override short Index => (short)ClientPacketIds.ClaimItemCodex; + public int Id; + + protected override void ReadPacket(BinaryReader reader) + { + Id = reader.ReadInt32(); + } + + protected override void WritePacket(BinaryWriter writer) + { + writer.Write(Id); + } + } + + public sealed class SubmitItemToCodex : Packet + { + public override short Index => (short)ClientPacketIds.SubmitItemToCodex; + + public int SetId; // WHICH collection you’re submitting to + public int ItemInfoId; // WHICH required item in that collection + public sbyte Stage; // Required transcendence stage (-1 for Any) + public ulong UniqueID; // WHICH concrete inventory item (server validates/removes) + + protected override void ReadPacket(BinaryReader r) + { + SetId = r.ReadInt32(); + ItemInfoId = r.ReadInt32(); + Stage = r.ReadSByte(); + UniqueID = r.ReadUInt64(); + } + + protected override void WritePacket(BinaryWriter w) + { + w.Write(SetId); + w.Write(ItemInfoId); + w.Write(Stage); + w.Write(UniqueID); + } + } + + public sealed class CodexClaimSet : Packet + { + public override short Index => (short)ClientPacketIds.CodexClaimSet; + + public int SetId; + + protected override void ReadPacket(BinaryReader reader) + { + SetId = reader.ReadInt32(); + } + + protected override void WritePacket(BinaryWriter writer) + { + writer.Write(SetId); + } + } + + public sealed class CodexRegisterItem : Packet + { + public override short Index => (short)ClientPacketIds.CodexRegisterItem; + + /// + /// ItemInfo.Index of the item you’re submitting to the codex. + /// + public int ItemInfoId; + + protected override void ReadPacket(BinaryReader reader) + { + ItemInfoId = reader.ReadInt32(); + } + + protected override void WritePacket(BinaryWriter writer) + { + writer.Write(ItemInfoId); + } + } + + public sealed class CodexUseCurrency : Packet + { + public override short Index => (short)ClientPacketIds.CodexUseCurrency; + + public int SetId; // primary field used by the server + public byte Currency; // 1 = Stone, 2 = Jade + + // --- Compatibility alias so old call sites using RowId still compile --- + public int RowId { get => SetId; set => SetId = value; } + + protected override void ReadPacket(BinaryReader reader) + { + SetId = reader.ReadInt32(); + Currency = reader.ReadByte(); + } + + protected override void WritePacket(BinaryWriter writer) + { + writer.Write(SetId); + writer.Write(Currency); + } + } } diff --git a/Shared/Data/CodexRequirement.cs b/Shared/Data/CodexRequirement.cs new file mode 100644 index 000000000..b2c74c7df --- /dev/null +++ b/Shared/Data/CodexRequirement.cs @@ -0,0 +1,55 @@ +using System; + +namespace Shared.Data +{ + /// + /// Utility helpers to encode Codex requirements that may carry an additional transcendence stage. + /// + public static class CodexRequirement + { + // Reserve low 24 bits for the item index (supports up to ~16.7M items) and high 8 bits for the stage. + public const int StageShift = 24; + public const int StageMask = 0xFF; + public const int IndexMask = (1 << StageShift) - 1; + public const sbyte AnyStage = -1; + private const sbyte EncodedAnyStage = unchecked((sbyte)StageMask); // 255 => Any + + /// + /// Encodes an item index and optional stage into a single integer for storage. + /// Stage -1 (Any) is used when no stage constraint exists. + /// + public static int Encode(int itemIndex, sbyte stage) + { + if (itemIndex < 0) throw new ArgumentOutOfRangeException(nameof(itemIndex)); + + int safeIndex = itemIndex & IndexMask; + int stageByte = stage == AnyStage ? EncodedAnyStage : stage & StageMask; + return (stageByte << StageShift) | safeIndex; + } + + /// + /// Extracts the raw item index from an encoded requirement. + /// + public static int DecodeItemIndex(int encoded) => encoded & IndexMask; + + /// + /// Extracts the stage requirement from an encoded requirement. + /// Returns -1 when the requirement accepts any stage. + /// + public static sbyte DecodeStage(int encoded) + { + int raw = (encoded >> StageShift) & StageMask; + return raw == StageMask ? AnyStage : (sbyte)raw; + } + + /// + /// Returns true if the provided stage value satisfies the encoded requirement. + /// + public static bool StageMatches(int encodedRequirement, sbyte actualStage) + { + sbyte requiredStage = DecodeStage(encodedRequirement); + return requiredStage == AnyStage || actualStage == requiredStage; + } + } +} + diff --git a/Shared/Data/Stat.cs b/Shared/Data/Stat.cs index e004f398f..9aca57fe8 100644 --- a/Shared/Data/Stat.cs +++ b/Shared/Data/Stat.cs @@ -148,5 +148,13 @@ public enum Stat : byte TeleportManaPenaltyPercent = 128, Hero = 129, + // Custom codex-related stats + MinDamage = 200, + MaxDamage = 201, + Strength = 202, + Intelligence = 203, + Endurance = 204, + WillPower = 205, + Unknown = 255 } \ No newline at end of file diff --git a/Shared/Enums.cs b/Shared/Enums.cs index deb8730d3..c42a0db08 100644 --- a/Shared/Enums.cs +++ b/Shared/Enums.cs @@ -1567,6 +1567,7 @@ public enum ServerPacketIds : short ObjectMana, MapEffect, AllowObserve, + AllowCodex, ObjectRangeAttack, AddBuff, RemoveBuff, @@ -1701,6 +1702,16 @@ public enum ServerPacketIds : short GroupMembersMap, SendMemberLocation, GuildTerritoryPage, + + ItemCodexSync, + ItemCodexUpdate, + ItemCodexMark, + + GainedStone, + LoseStone, + GainedJade, + LoseJade, + CodexCurrencyUpdate, } public enum ClientPacketIds : short @@ -1859,6 +1870,13 @@ public enum ClientPacketIds : short GuildTerritoryPage, PurchaseGuildTerritory, DeleteItem, + + RequestItemCodex, + ClaimItemCodex, + SubmitItemToCodex, + CodexClaimSet, + CodexRegisterItem, + CodexUseCurrency, } public enum ConquestType : byte @@ -1932,4 +1950,46 @@ public enum MarketCollectionMode : byte Any = 0, Sold = 1, Expired = 2 -} \ No newline at end of file +} + +public enum CodexLevel : byte +{ + Level1 = 8, + Level2 = 11, + Level3 = 14, + Level4 = 17, + Level5 = 20, + Level6 = 23, + Level7 = 26, + Level8 = 29, + Level9 = 32, + Level10 = 35, + Level11 = 38, + Level12 = 41, + Level13 = 44, + Level14 = 47, + Level15 = 51, + Level16 = 55, + Level17 = 59, + Level18 = 63, + Level19 = 67, + Level20, +} + +public enum CodexBucket : byte +{ + Character = 0, + Limited = 1, + Event = 2 +} + +public enum Currency : byte +{ + None = 0, + Credits = 1, // GameShop + Gold = 2, + Pearl = 3, // PickupPet? + + Jade = 4, // Codex + Stone = 5, // Codex +} diff --git a/Shared/Language.cs b/Shared/Language.cs index 6b3423547..aa87fe322 100644 --- a/Shared/Language.cs +++ b/Shared/Language.cs @@ -28,6 +28,7 @@ public enum ClientTextKeys SkillsKey, QuestsKey, OptionsKey, + CodexKey, Menu, GameShopKey, BigMapKey, @@ -1014,6 +1015,7 @@ public enum ClientTextKeys QuestDiaryOpenClose, OptionsOpenClose, OptionsOpenCloseAlt, + CodexOpenClose, GroupOpenClose, BeltOpenClose, MinimapOpenClose, @@ -1887,6 +1889,35 @@ public static class GameLanguage { nameof(ServerTextKeys.BeenPoisoned), "You have been poisoned" }, { nameof(ServerTextKeys.AllowingMentorRequests), "You're now allowing mentor requests." }, { nameof(ServerTextKeys.BlockingMentorRequests), "You're now blocking mentor requests." }, + // Codex (string-keyed) + { "Codex_Disabled", "[Codex] The codex is currently disabled." }, + { "Codex_UnknownCollection", "[Codex] Unknown collection." }, + { "Codex_DisabledCollection", "[Codex] This collection is currently disabled." }, + { "Codex_AlreadyClaimed", "[Codex] Already claimed." }, + { "Codex_NotComplete", "[Codex] Not complete." }, + { "Codex_GainedXP", "[Codex] +{0} Codex EXP." }, + { "Codex_Claimed", "[Codex] Claimed: {0}. Reward applied." }, + { "Codex_InvalidSubmission", "[Codex] Invalid submission." }, + { "Codex_ItemNotPart", "[Codex] Item not part of this collection." }, + { "Codex_ItemNotFound", "[Codex] Item not found in your bag." }, + { "Codex_WrongItem", "[Codex] Wrong item for this collection." }, + { "Codex_UnableConsume", "[Codex] Unable to consume item." }, + { "Codex_Submitted", "[Codex] Submitted: {0}." }, + { "Codex_InvalidCurrency", "[Codex] Invalid currency." }, + { "Codex_InvalidSetId", "[Codex] Invalid set id." }, + { "Codex_InvalidCurrencyForSet", "[Codex] This currency can't be used on this set." }, + { "Codex_NothingRequired", "[Codex] Nothing required for this set." }, + { "Codex_SetComplete", "[Codex] Set already complete." }, + { "Codex_NoStone", "[Codex] You don't have a Stone." }, + { "Codex_NoJade", "[Codex] You don't have a Jade." }, + { "Codex_UsedCurrency", "[Codex] Used 1× {0} to register {1}{2}." }, + { "Codex_Exported", "[Codex] Exported to Envir/ItemCodex.json" }, + { "Codex_LoadedAutoBuilt", "[Codex] Loaded auto-built collections (no/invalid file)." }, + { "Codex_LoadedFromFile", "[Codex] Loaded collections from ItemCodex.json." }, + { "Codex_CompletedAll", "[Codex] All collections completed and claimed." }, + { "Codex_Cleared", "[Codex] All codex progress cleared." }, + { "Codex_CompletedSingle", "[Codex] Completed collection {0}: {1}." }, + { "Codex_UnknownId", "[Codex] Unknown collection id {0}." }, { nameof(ServerTextKeys.RemoveGuild), "You have been removed from the guild." }, { nameof(ServerTextKeys.HasConnected), "{0} has connected." }, { nameof(ServerTextKeys.MailOverflowing), "Your mailbox is overflowing." }, @@ -2669,6 +2700,7 @@ public static class GameLanguage { nameof(ClientTextKeys.SkillsKey), "Skills ({0})" }, { nameof(ClientTextKeys.QuestsKey), "Quests ({0})" }, { nameof(ClientTextKeys.OptionsKey), "Options ({0})" }, + { nameof(ClientTextKeys.CodexKey), "Codex ({0})" }, { nameof(ClientTextKeys.Menu), "Menu" }, { nameof(ClientTextKeys.GameShopKey), "Game Shop ({0})" }, { nameof(ClientTextKeys.BigMapKey), "BigMap ({0})" }, @@ -3897,6 +3929,42 @@ public static class GameLanguage { nameof(ClientTextKeys.MailHeaderNew), "[New]" }, { nameof(ClientTextKeys.UserNameUnknown), "Unknown" }, { nameof(ClientTextKeys.ItemRentalCancelledFaceOtherParty), "Item rental cancelled.\r\nTo complete item rental please face the other party throughout the transaction." }, + // Codex (string-keyed) + { "Codex_NothingToSubmit", "[Codex] Nothing to submit or claim." }, + { "Codex_RewardClaimed", "[Codex] Reward claimed." }, + { "Codex_CollectionCompleted", "[Codex] Collection completed – reward applied." }, + { "Codex_NoEligibleItemOrCurrency", "[Codex] You don't have an eligible item or valid currency for this set." }, + { "Codex_InventoryNotAvailable", "[Codex] Inventory not available." }, + { "Codex_MissingItemStage", "[Codex] You do not have 1× {0}." }, + { "Codex_SubmittingItem", "[Codex] Submitting 1× {0}..." }, + { "Codex_LevelUp", "[Codex] Collection Level Up! Lv.{0}" }, + { "Codex_LevelUpShort", "[Codex] Collection Level Up!" }, + { "Codex_NoStone", "[Codex] You do not have a Stone." }, + { "Codex_NoJade", "[Codex] You do not have a Jade." }, + { "Codex_CurrencyStoneHint", "Stone\n• Substitutes for {0} sets.\n• Using Stones from your bag adds to Codex counts.\n• When obtaining, Stones are consumed first." }, + { "Codex_CurrencyJadeHint", "Jade\n• Substitutes for {0} sets.\n• Using Jade from your bag adds to Codex counts.\n• When obtaining, Jade is consumed first." }, + { "Codex_YouGainedStone", "You gained {0} Stone." }, + { "Codex_YouGainedJade", "You gained {0} Jade." }, + { "Codex_RegisteredItem", "[Codex] Registered {0}." }, + { "Codex_ShowAllButton", "Show All" }, + { "Codex_ShowAllHint", "Show all collections" }, + { "Codex_ShowRarityHint", "Show only {0}" }, + { "Codex_UntitledCollection", "Untitled Collection" }, + { "Codex_ExpLabel", "Codex EXP : {0}" }, + { "Codex_ActiveUntil", "Active until {0}" }, + { "Codex_StartsOn", "Starts on {0}" }, + { "Codex_Expired", "Expired" }, + { "Codex_ExpiredKeep", "Expired (stats kept)" }, + { "Codex_NotStarted", "[Codex] This collection has not started yet." }, + { "Codex_ExpiredWindow", "[Codex] This collection has expired." }, + { "Codex_SetCompletedMessage", "Collection set completed: {0}" }, + { "Codex_SetCompleteEffect", "Collection set completed!" }, + { "Codex_SubmitConfirm", "Submit 1× {0} to this collection?\n\nThis will consume the item." }, + { "Codex_LevelLabel", "Collection Level: {0}" }, + { "Codex_LevelHint", "Collection Level {0}" }, + { "Codex_ClaimedSetBonuses", "Claimed Set Bonuses:" }, + { "Codex_StoneLabel", "Stone" }, + { "Codex_JadeLabel", "Jade" }, { nameof(ClientTextKeys.AutoRunOn), "[AutoRun: On]" }, { nameof(ClientTextKeys.AutoRunOff), "[AutoRun: Off]" }, { nameof(ClientTextKeys.YouCannotDrop), "You cannot drop {0}" }, @@ -3958,6 +4026,7 @@ public static class GameLanguage { nameof(ClientTextKeys.QuestDiaryOpenClose), "Quest Diary Open/Close" }, { nameof(ClientTextKeys.OptionsOpenClose), "Options Open/Close" }, { nameof(ClientTextKeys.OptionsOpenCloseAlt), "Options Open/Close Alt" }, + { nameof(ClientTextKeys.CodexOpenClose), "Codex Open/Close" }, { nameof(ClientTextKeys.GroupOpenClose), "Group Open/Close" }, { nameof(ClientTextKeys.BeltOpenClose), "Belt Open/Close" }, { nameof(ClientTextKeys.MinimapOpenClose), "Minimap Open/Close" }, diff --git a/Shared/Packet.cs b/Shared/Packet.cs index e99178437..d366e9ef9 100644 --- a/Shared/Packet.cs +++ b/Shared/Packet.cs @@ -387,6 +387,14 @@ private static Packet GetClientPacket(short index) return new C.GuildTerritoryPage(); case (short)ClientPacketIds.DeleteItem: return new C.DeleteItem(); + case (short)ClientPacketIds.RequestItemCodex: + return new C.RequestItemCodex(); + case (short)ClientPacketIds.ClaimItemCodex: + return new C.ClaimItemCodex(); + case (short)ClientPacketIds.SubmitItemToCodex: + return new C.SubmitItemToCodex(); + case (short)ClientPacketIds.CodexUseCurrency: + return new C.CodexUseCurrency(); default: return null; } @@ -678,6 +686,8 @@ public static Packet GetServerPacket(short index) return new S.MapEffect(); case (short)ServerPacketIds.AllowObserve: return new S.AllowObserve(); + case (short)ServerPacketIds.AllowCodex: + return new S.AllowCodex(); case (short)ServerPacketIds.ObjectRangeAttack: return new S.ObjectRangeAttack(); case (short)ServerPacketIds.AddBuff: @@ -944,6 +954,20 @@ public static Packet GetServerPacket(short index) return new S.SetCompass(); case (short)ServerPacketIds.GuildTerritoryPage: return new S.GuildTerritoryPage(); + case (short)ServerPacketIds.ItemCodexSync: + return new S.ItemCodexSync(); + case (short)ServerPacketIds.ItemCodexUpdate: + return new S.ItemCodexUpdate(); + case (short)ServerPacketIds.ItemCodexMark: + return new S.ItemCodexMark(); + case (short)ServerPacketIds.GainedStone: + return new S.GainedStone(); + case (short)ServerPacketIds.LoseStone: + return new S.LoseStone(); + case (short)ServerPacketIds.GainedJade: + return new S.GainedJade(); + case (short)ServerPacketIds.LoseJade: + return new S.LoseJade(); default: return null; } diff --git a/Shared/ServerPackets.cs b/Shared/ServerPackets.cs index 1cd780d97..3956f0ae7 100644 --- a/Shared/ServerPackets.cs +++ b/Shared/ServerPackets.cs @@ -547,6 +547,7 @@ public override short Index public HeroBehaviour HeroBehaviour; public UserItem[] Inventory, Equipment, QuestInventory; public uint Gold, Credit; + public uint Stone, Jade; public bool HasExpandedStorage; public DateTime ExpandedStorageExpiryTime; @@ -637,6 +638,9 @@ protected override void ReadPacket(BinaryReader reader) CreatureSummoned = reader.ReadBoolean(); AllowObserve = reader.ReadBoolean(); Observer = reader.ReadBoolean(); + + Stone = reader.ReadUInt32(); + Jade = reader.ReadUInt32(); } protected override void WritePacket(BinaryWriter writer) @@ -726,6 +730,9 @@ protected override void WritePacket(BinaryWriter writer) writer.Write(CreatureSummoned); writer.Write(AllowObserve); writer.Write(Observer); + + writer.Write(Stone); + writer.Write(Jade); } } @@ -3858,6 +3865,26 @@ protected override void WritePacket(BinaryWriter writer) writer.Write(Allow); } } + + public sealed class AllowCodex : Packet + { + public override short Index + { + get { return (short)ServerPacketIds.AllowCodex; } + } + + public bool Allow; + + protected override void ReadPacket(BinaryReader reader) + { + Allow = reader.ReadBoolean(); + } + + protected override void WritePacket(BinaryWriter writer) + { + writer.Write(Allow); + } + } public sealed class ObjectRangeAttack : Packet { public override short Index @@ -6711,4 +6738,264 @@ protected override void WritePacket(BinaryWriter writer) writer.Write(Location.Y); } } + + public sealed class ItemCodexSync : Packet + { + public override short Index => (short)ServerPacketIds.ItemCodexSync; + + public struct Row + { + public int Id; + public string Name; + public short Found; + public short Required; + public bool Claimed; + public byte Bucket; // 0=Character,1=Limited,2=Event + + public List ReqItemIndices; + public List ReqStages; + public List ReqItemIcons; + public List ReqRegistered; + + public string RewardPreview; + public Stats Reward; + + public int RewardXP; + public byte Rarity; + + // time window/meta + public bool Active; + public bool KeepStats; + public long StartTicks; // -1 = null + public long EndTicks; // -1 = null + } + + public List Rows = new List(); + + protected override void ReadPacket(BinaryReader reader) + { + int c = reader.ReadInt32(); + Rows = new List(c); + + for (int i = 0; i < c; i++) + { + var r = new Row + { + Id = reader.ReadInt32(), + Name = reader.ReadString(), + Found = reader.ReadInt16(), + Required = reader.ReadInt16(), + Claimed = reader.ReadBoolean(), + Bucket = reader.ReadByte(), + + ReqItemIndices = new List(), + ReqStages = new List(), + ReqItemIcons = new List(), + ReqRegistered = new List() + }; + + int nReq = reader.ReadInt32(); + for (int k = 0; k < nReq; k++) r.ReqItemIndices.Add(reader.ReadInt32()); + + int nStages = reader.ReadInt32(); + for (int k = 0; k < nStages; k++) r.ReqStages.Add(reader.ReadSByte()); + + int nIco = reader.ReadInt32(); + for (int k = 0; k < nIco; k++) r.ReqItemIcons.Add(reader.ReadInt32()); + + int nReg = reader.ReadInt32(); + for (int k = 0; k < nReg; k++) r.ReqRegistered.Add(reader.ReadBoolean()); + + r.RewardPreview = reader.ReadString(); + r.Reward = new Stats(reader); + + r.RewardXP = reader.ReadInt32(); + r.Rarity = reader.ReadByte(); + + r.Active = reader.ReadBoolean(); + r.KeepStats = reader.ReadBoolean(); + r.StartTicks = reader.ReadInt64(); + r.EndTicks = reader.ReadInt64(); + + Rows.Add(r); + } + } + + protected override void WritePacket(BinaryWriter writer) + { + writer.Write(Rows.Count); + foreach (var r in Rows) + { + writer.Write(r.Id); + writer.Write(r.Name ?? string.Empty); + writer.Write(r.Found); + writer.Write(r.Required); + writer.Write(r.Claimed); + writer.Write(r.Bucket); + + writer.Write(r.ReqItemIndices?.Count ?? 0); + if (r.ReqItemIndices != null) + foreach (var ix in r.ReqItemIndices) writer.Write(ix); + + writer.Write(r.ReqStages?.Count ?? 0); + if (r.ReqStages != null) + foreach (var st in r.ReqStages) writer.Write(st); + + writer.Write(r.ReqItemIcons?.Count ?? 0); + if (r.ReqItemIcons != null) + foreach (var ico in r.ReqItemIcons) writer.Write(ico); + + writer.Write(r.ReqRegistered?.Count ?? 0); + if (r.ReqRegistered != null) + foreach (var done in r.ReqRegistered) writer.Write(done); + + writer.Write(r.RewardPreview ?? string.Empty); + (r.Reward ?? new Stats()).Save(writer); + + // NEW + writer.Write(r.RewardXP); + writer.Write(r.Rarity); + + writer.Write(r.Active); + writer.Write(r.KeepStats); + writer.Write(r.StartTicks); + writer.Write(r.EndTicks); + } + } + } + + public sealed class ItemCodexUpdate : Packet + { + public override short Index => (short)ServerPacketIds.ItemCodexUpdate; + + public int Id; + public short Found; + public short Required; + public bool Claimed; + + protected override void ReadPacket(BinaryReader reader) + { + Id = reader.ReadInt32(); + Found = reader.ReadInt16(); + Required = reader.ReadInt16(); + Claimed = reader.ReadBoolean(); + } + + protected override void WritePacket(BinaryWriter writer) + { + writer.Write(Id); + writer.Write(Found); + writer.Write(Required); + writer.Write(Claimed); + } + } + + public sealed class ItemCodexMark : Packet + { + public override short Index => (short)ServerPacketIds.ItemCodexMark; + + public int SetId; + public int ItemInfoId; + public sbyte Stage; + public bool Registered; + + protected override void ReadPacket(BinaryReader r) + { + SetId = r.ReadInt32(); + ItemInfoId = r.ReadInt32(); + Stage = r.ReadSByte(); + Registered = r.ReadBoolean(); + } + + protected override void WritePacket(BinaryWriter w) + { + w.Write(SetId); + w.Write(ItemInfoId); + w.Write(Stage); + w.Write(Registered); + } + } + + public sealed class GainedStone : Packet + { + public override short Index => (short)ServerPacketIds.GainedStone; + public uint Stone; + + protected override void ReadPacket(BinaryReader reader) + { + Stone = reader.ReadUInt32(); + } + + protected override void WritePacket(BinaryWriter writer) + { + writer.Write(Stone); + } + } + + public sealed class LoseStone : Packet + { + public override short Index => (short)ServerPacketIds.LoseStone; + public uint Stone; + + protected override void ReadPacket(BinaryReader reader) + { + Stone = reader.ReadUInt32(); + } + + protected override void WritePacket(BinaryWriter writer) + { + writer.Write(Stone); + } + } + + public sealed class GainedJade : Packet + { + public override short Index => (short)ServerPacketIds.GainedJade; + public uint Jade; + + protected override void ReadPacket(BinaryReader reader) + { + Jade = reader.ReadUInt32(); + } + + protected override void WritePacket(BinaryWriter writer) + { + writer.Write(Jade); + } + } + + public sealed class LoseJade : Packet + { + public override short Index => (short)ServerPacketIds.LoseJade; + public uint Jade; + + protected override void ReadPacket(BinaryReader reader) + { + Jade = reader.ReadUInt32(); + } + + protected override void WritePacket(BinaryWriter writer) + { + writer.Write(Jade); + } + } + public sealed class CodexCurrencyUpdate : Packet + { + public override short Index { get { return (short)ServerPacketIds.CodexCurrencyUpdate; } } + + public int Stone; + public int Jade; + + protected override void ReadPacket(BinaryReader reader) + { + Stone = reader.ReadInt32(); + Jade = reader.ReadInt32(); + } + + protected override void WritePacket(BinaryWriter writer) + { + writer.Write(Stone); + writer.Write(Jade); + } + } } \ No newline at end of file