Иконка ресурса

FREE AchievementsUltimate : 0.6.1 0.6.1

Нет прав для скачивания
C#:
// ========================= AchievementsUltimate.cs =========================
// ========================= FULL (PART 1/2) =================================
//
// Вставь это как НАЧАЛО файла.
// Затем вставишь PART 2/2 СРАЗУ ПОСЛЕ.
//
// Финал включает:
// - Удалена корзина/склад
// - Вкладка "Топ 10 игроков"
// - Проценты владения достижениями (как Steam), учитывают repeatable (ClaimedCount)
// - Награды: sr_points / item / command + commands[]
// - В конфиге включение/выключение типов наград
// - Слева в ТОП: место игрока, 2 соседа, сколько до следующего места
// - Исправление tuple: Item1/Item2
// - Устойчивость после reload: _lastPos инициализируется для активных игроков на OnServerInitialized
//
// ===========================================================================

using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using Oxide.Core;
using Oxide.Core.Plugins;
using Oxide.Core.Libraries.Covalence;
using Oxide.Game.Rust.Cui;
using UnityEngine;
using UnityEngine.UI;

namespace Oxide.Plugins
{
    [Info("AchievementsUltimate", "Достижения", "0.6.1")]
    [Description("Достижения с UI, топ-10 игроков, редкость (проценты как Steam), награды sr/item/command, интеграция ServerRewards.")]
    public class AchievementsUltimate : RustPlugin
    {
        #region References
        [PluginReference] private Plugin ServerRewards;
        [PluginReference] private Plugin ImageLibrary;
        #endregion

        #region Const / Paths
        private const string DATA_DIR = "AchievementsUltimate";
        private const string ACH_FILE = DATA_DIR + "/Achievements";
        private const string PROG_FILE = DATA_DIR + "/PlayersProgress";

        private const string PERM_USE = "achievementsultimate.use";
        private const string PERM_ADMIN = "achievementsultimate.admin";

        private const string UI_LAYER = "UI.AchievementsUltimate";
        private const string UI_MODAL = "UI.AchievementsUltimate.Modal";
        #endregion

        #region Config
        private Configuration _config;

        private class Configuration
        {
            [JsonProperty("Команды открытия меню")]
            public List<string> Commands = new List<string> { "ach", "achi", "achievements" };

            [JsonProperty("Автосохранение прогресса (сек)")]
            public int AutoSaveSeconds = 120;

            [JsonProperty("Тик дистанции/плейтайма (сек)")]
            public int RuntimeTickSeconds = 5;

            [JsonProperty("Закрывать UI кликом по фону")]
            public bool CloseOnBackground = true;

            [JsonProperty("Разрешение на использование (пусто = всем)")]
            public string PermissionUse = PERM_USE;

            [JsonProperty("Награды")]
            public RewardsSettings Rewards = new RewardsSettings();

            [JsonProperty("UI Настройки")]
            public UiSettings UI = new UiSettings();

            public class RewardsSettings
            {
                [JsonProperty("Использовать ServerRewards (RP)")]
                public bool UseServerRewards = true;

                [JsonProperty("Название валюты (для текста)")]
                public string CurrencyName = "RP";

                [JsonProperty("Выдавать item-награды сразу (если нет места — дроп рядом)")]
                public bool GiveItemsInstantly = true;

                [JsonProperty("Включить награды sr_points")]
                public bool EnableSrPoints = true;

                [JsonProperty("Включить награды item")]
                public bool EnableItems = true;

                [JsonProperty("Включить награды command")]
                public bool EnableCommands = true;
            }

            public class UiSettings
            {
                [JsonProperty("Цвет фона")] public string Bg = "0 0 0 0.90";
                [JsonProperty("Цвет панели")] public string Panel = "0.10 0.10 0.11 0.98";
                [JsonProperty("Цвет вторичной панели")] public string Panel2 = "0.13 0.13 0.14 0.98";
                [JsonProperty("Цвет акцента")] public string Accent = "0.29 0.41 1.0 0.95";
                [JsonProperty("Цвет текста")] public string Text = "1 1 1 0.95";
                [JsonProperty("Цвет текста вторичный")] public string Text2 = "1 1 1 0.65";
                [JsonProperty("Цвет прогресса (фон)")] public string ProgressBg = "1 1 1 0.08";
                [JsonProperty("Цвет прогресса (заполн.)")] public string ProgressFill = "0.29 0.41 1.0 0.45";
                [JsonProperty("Размер заголовка")] public int TitleSize = 18;
                [JsonProperty("Размер обычного текста")] public int TextSize = 12;
            }
        }

        protected override void LoadDefaultConfig()
        {
            _config = new Configuration();
            SaveConfig();
        }

        protected override void LoadConfig()
        {
            base.LoadConfig();
            try
            {
                _config = Config.ReadObject<Configuration>();
                if (_config == null) throw new Exception("Config is null");
            }
            catch
            {
                PrintWarning("Ошибка чтения конфига, создаю дефолтный.");
                LoadDefaultConfig();
            }

            if (_config.Commands == null || _config.Commands.Count == 0)
                _config.Commands = new List<string> { "ach", "achi", "achievements" };

            if (_config.AutoSaveSeconds < 30) _config.AutoSaveSeconds = 30;
            if (_config.RuntimeTickSeconds < 1) _config.RuntimeTickSeconds = 1;

            if (_config.Rewards == null) _config.Rewards = new Configuration.RewardsSettings();

            SaveConfig();
        }

        protected override void SaveConfig() => Config.WriteObject(_config, true);
        #endregion

        #region Data models
        public class RewardDef
        {
            // "sr_points" | "item" | "command"
            [JsonProperty("type")] public string Type = "sr_points";

            // amount: sr_points/item, и %AMOUNT% для command
            [JsonProperty("amount")] public int Amount = 0;

            // item
            [JsonProperty("shortname")] public string ShortName = "";
            [JsonProperty("skin")] public ulong Skin = 0;
            [JsonProperty("name")] public string CustomName = "";

            // command (одиночная команда)
            [JsonProperty("command")] public string Command = "";

            // command (массив команд)
            [JsonProperty("commands")] public List<string> Commands = new List<string>();

            // необязательное название награды
            [JsonProperty("title")] public string Title = "";
        }

        public class AchievementDef
        {
            [JsonProperty("id")] public string Id;
            [JsonProperty("title")] public string Title;
            [JsonProperty("description")] public string Description;
            [JsonProperty("icon")] public string Icon;

            // "stat" или "builtin"
            [JsonProperty("source")] public string Source = "stat";
            [JsonProperty("key")] public string Key;

            [JsonProperty("target")] public long Target = 1;
            [JsonProperty("category")] public string Category = "Основное";
            [JsonProperty("repeatable")] public bool Repeatable = false;

            [JsonProperty("rewards")] public List<RewardDef> Rewards = new List<RewardDef>();
        }

        public class AchievementsData
        {
            [JsonProperty("achievements")]
            public List<AchievementDef> Achievements = new List<AchievementDef>();
        }

        public class PlayerProgress
        {
            [JsonProperty("p")] public Dictionary<string, long> Progress = new Dictionary<string, long>();
            [JsonProperty("d")] public HashSet<string> Done = new HashSet<string>();
            [JsonProperty("c")] public Dictionary<string, int> ClaimedCount = new Dictionary<string, int>();
        }

        public class PlayersProgressData
        {
            [JsonProperty("players")]
            public Dictionary<ulong, PlayerProgress> Players = new Dictionary<ulong, PlayerProgress>();
        }

        private AchievementsData _achievements;
        private PlayersProgressData _progress;
        #endregion

        #region State / Indexes / UI
        private readonly Dictionary<ulong, float> _uiCooldown = new Dictionary<ulong, float>();
        private readonly HashSet<ulong> _uiOpen = new HashSet<ulong>();

        private Dictionary<string, List<AchievementDef>> _indexStat = new Dictionary<string, List<AchievementDef>>(StringComparer.OrdinalIgnoreCase);
        private Dictionary<string, List<AchievementDef>> _indexBuiltin = new Dictionary<string, List<AchievementDef>>(StringComparer.OrdinalIgnoreCase);

        private readonly Dictionary<ulong, Vector3> _lastPos = new Dictionary<ulong, Vector3>();
        private readonly Dictionary<ulong, double> _distanceAcc = new Dictionary<ulong, double>();

        // Категории: безопасный cid -> displayName
        private readonly Dictionary<string, string> _categoryIdToName = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);

        private class UiState
        {
            public string Tab = "ach"; // "ach" | "stats"
            public string Category = "all";
            public bool ShowCompleted = true;
            public string Search = "";
        }

        private readonly Dictionary<ulong, UiState> _uiState = new Dictionary<ulong, UiState>();

        private UiState GetUiState(ulong userId)
        {
            if (!_uiState.TryGetValue(userId, out var st))
                _uiState[userId] = st = new UiState();
            return st;
        }

        private void MarkUiOpen(ulong userId, bool open)
        {
            if (open) _uiOpen.Add(userId);
            else _uiOpen.Remove(userId);
        }

        private bool IsUiOpen(ulong userId) => _uiOpen.Contains(userId);
        #endregion

        #region UI Engine helpers
        private static class UIB
        {
            public static string Panel(CuiElementContainer c, string parent, string name, string color, string aMin, string aMax, bool cursor = false)
            {
                return c.Add(new CuiPanel
                {
                    Image = { Color = color },
                    RectTransform = { AnchorMin = aMin, AnchorMax = aMax },
                    CursorEnabled = cursor
                }, parent, name);
            }

            public static string Label(CuiElementContainer c, string parent, string name, string text, int size, string color, string aMin, string aMax, TextAnchor align)
            {
                return c.Add(new CuiLabel
                {
                    RectTransform = { AnchorMin = aMin, AnchorMax = aMax },
                    Text = { Text = text, FontSize = size, Color = color, Align = align }
                }, parent, name);
            }

            public static string Button(CuiElementContainer c, string parent, string name, string cmd, string btnColor, string text, int size, string txtColor, string aMin, string aMax, TextAnchor align)
            {
                return c.Add(new CuiButton
                {
                    RectTransform = { AnchorMin = aMin, AnchorMax = aMax },
                    Button = { Color = btnColor, Command = cmd },
                    Text = { Text = text, FontSize = size, Color = txtColor, Align = align }
                }, parent, name);
            }
        }

        private static class UIScroll
        {
            public static string Create(CuiElementContainer c, string parent, string name, string aMin, string aMax, float contentHeightPx, out string content)
            {
                var view = c.Add(new CuiPanel
                {
                    Image = { Color = "0 0 0 0" },
                    RectTransform = { AnchorMin = aMin, AnchorMax = aMax }
                }, parent, name);

                c.Add(new CuiElement
                {
                    Parent = view,
                    Components =
                    {
                        new CuiScrollViewComponent
                        {
                            Vertical = true,
                            Horizontal = false,
                            MovementType = ScrollRect.MovementType.Elastic,
                            Elasticity = 0.15f,
                            DecelerationRate = 0.85f,
                            ScrollSensitivity = 30f,
                            Inertia = true,
                            ContentTransform = new CuiRectTransform
                            {
                                AnchorMin = "0 1",
                                AnchorMax = "1 1",
                                OffsetMin = $"0 -{Mathf.Max(50f, contentHeightPx)}",
                                OffsetMax = "0 0"
                            }
                        }
                    }
                });

                content = c.Add(new CuiPanel
                {
                    Image = { Color = "0 0 0 0" },
                    RectTransform =
                    {
                        AnchorMin = "0 1",
                        AnchorMax = "1 1",
                        OffsetMin = $"0 -{Mathf.Max(50f, contentHeightPx)}",
                        OffsetMax = "0 0"
                    }
                }, view, $"{name}.Content");

                return view;
            }
        }
        #endregion

        #region ImageLibrary Icons
        private readonly HashSet<string> _requestedImages = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        private bool HasImageLibrary => ImageLibrary != null;

        private bool IsUrl(string s)
        {
            if (string.IsNullOrEmpty(s)) return false;
            return s.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
                   s.StartsWith("https://", StringComparison.OrdinalIgnoreCase);
        }

        private string ResolveIconPng(string icon)
        {
            if (!HasImageLibrary) return null;
            if (string.IsNullOrEmpty(icon)) return null;

            try
            {
                if (IsUrl(icon))
                {
                    var key = "au_url_" + icon.GetHashCode();
                    EnsureImageUrl(icon, key);
                    return ImageLibrary.Call<string>("GetImage", key);
                }

                return ImageLibrary.Call<string>("GetImage", icon);
            }
            catch
            {
                return null;
            }
        }

        private void EnsureImageUrl(string url, string key)
        {
            if (!HasImageLibrary) return;
            if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(key)) return;

            if (_requestedImages.Contains(key)) return;
            _requestedImages.Add(key);

            try { ImageLibrary.Call("AddImage", url, key, (ulong)0); }
            catch { }
        }

        private void PreloadAchievementIcons()
        {
            if (!HasImageLibrary) return;
            if (_achievements?.Achievements == null) return;

            foreach (var a in _achievements.Achievements)
            {
                if (a == null) continue;
                if (string.IsNullOrEmpty(a.Icon)) continue;

                if (IsUrl(a.Icon))
                {
                    var key = "au_url_" + a.Icon.GetHashCode();
                    EnsureImageUrl(a.Icon, key);
                }
            }
        }
        #endregion

        #region GLOBAL STATS (проценты достижений + рейтинг)
        private readonly Dictionary<string, int> _achOwnersCount = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
        private int _achOwnersTotalPlayers = 0;
        private float _achOwnersNextRecalc = 0f;
        private const float ACH_STATS_CACHE_SECONDS = 120f;

        private void InvalidateAchievementStatsCache() => _achOwnersNextRecalc = 0f;

        private void EnsureAchievementStatsCache()
        {
            var now = Time.realtimeSinceStartup;
            if (now < _achOwnersNextRecalc && _achOwnersTotalPlayers > 0) return;

            _achOwnersNextRecalc = now + ACH_STATS_CACHE_SECONDS;
            _achOwnersCount.Clear();

            var playersDict = _progress?.Players;
            if (playersDict == null || playersDict.Count == 0)
            {
                _achOwnersTotalPlayers = 0;
                return;
            }

            _achOwnersTotalPlayers = playersDict.Count;

            foreach (var kv in playersDict)
            {
                var pp = kv.Value;
                if (pp == null) continue;

                // Non-repeatable: Done
                if (pp.Done != null)
                {
                    foreach (var achId in pp.Done)
                    {
                        if (string.IsNullOrEmpty(achId)) continue;
                        _achOwnersCount.TryGetValue(achId, out var cnt);
                        _achOwnersCount[achId] = cnt + 1;
                    }
                }

                // Repeatable: ClaimedCount>0 считается как "имеет"
                if (pp.ClaimedCount != null)
                {
                    foreach (var kv2 in pp.ClaimedCount)
                    {
                        var achId = kv2.Key;
                        if (string.IsNullOrEmpty(achId)) continue;
                        if (kv2.Value <= 0) continue;

                        if (pp.Done != null && pp.Done.Contains(achId)) continue;

                        _achOwnersCount.TryGetValue(achId, out var cnt);
                        _achOwnersCount[achId] = cnt + 1;
                    }
                }
            }
        }

        private float GetAchievementOwnedPercent(string achId)
        {
            if (string.IsNullOrEmpty(achId)) return 0f;
            EnsureAchievementStatsCache();
            if (_achOwnersTotalPlayers <= 0) return 0f;

            _achOwnersCount.TryGetValue(achId, out var owners);
            return Mathf.Clamp((owners * 100f) / _achOwnersTotalPlayers, 0f, 100f);
        }

        private string FormatPercent(float p) => (p >= 10f) ? $"{p:0.#}%" : $"{p:0.0}%";

        private string ResolveName(ulong userId)
        {
            var bp = BasePlayer.FindByID(userId) ?? BasePlayer.FindSleeping(userId);
            if (bp != null && !string.IsNullOrEmpty(bp.displayName)) return bp.displayName;

            var cov = covalence?.Players?.FindPlayerById(userId.ToString());
            if (cov != null && !string.IsNullOrEmpty(cov.Name)) return cov.Name;

            return userId.ToString();
        }

        private List<(ulong, int)> GetTopPlayersByAchievements(int take = 10)
        {
            var result = new List<(ulong, int)>();
            var playersDict = _progress?.Players;
            if (playersDict == null || playersDict.Count == 0) return result;

            foreach (var kv in playersDict)
                result.Add((kv.Key, kv.Value?.Done?.Count ?? 0));

            result.Sort((a, b) =>
            {
                var c = b.Item2.CompareTo(a.Item2);
                if (c != 0) return c;
                return a.Item1.CompareTo(b.Item1);
            });

            if (result.Count > take) result.RemoveRange(take, result.Count - take);
            return result;
        }

        private List<(ulong, int)> GetRankingAllByAchievements()
        {
            var result = new List<(ulong, int)>();
            var playersDict = _progress?.Players;
            if (playersDict == null || playersDict.Count == 0) return result;

            foreach (var kv in playersDict)
                result.Add((kv.Key, kv.Value?.Done?.Count ?? 0));

            result.Sort((a, b) =>
            {
                var c = b.Item2.CompareTo(a.Item2);
                if (c != 0) return c;
                return a.Item1.CompareTo(b.Item1);
            });

            return result;
        }

        private int GetPlayerRankByAchievements(ulong userId, out int totalPlayers, out int myDone)
        {
            totalPlayers = 0;
            myDone = 0;

            var playersDict = _progress?.Players;
            if (playersDict == null || playersDict.Count == 0) return 0;

            totalPlayers = playersDict.Count;

            if (playersDict.TryGetValue(userId, out var myProg) && myProg?.Done != null)
                myDone = myProg.Done.Count;

            int better = 0;
            foreach (var kv in playersDict)
            {
                if (kv.Key == userId) continue;
                var done = kv.Value?.Done?.Count ?? 0;
                if (done > myDone) better++;
            }

            return better + 1;
        }

        private bool TryGetNeighborsAndGap(ulong userId,
            out (ulong, int) above,
            out (ulong, int) me,
            out (ulong, int) below,
            out int needToNext)
        {
            above = default;
            me = default;
            below = default;
            needToNext = 0;

            var ranking = GetRankingAllByAchievements();
            if (ranking.Count == 0) return false;

            var idx = ranking.FindIndex(x => x.Item1 == userId);
            if (idx < 0) return false;

            me = ranking[idx];

            if (idx > 0) above = ranking[idx - 1];
            if (idx < ranking.Count - 1) below = ranking[idx + 1];

            if (idx > 0)
                needToNext = Math.Max(0, above.Item2 - me.Item2 + 1);

            return true;
        }
        #endregion

        #region Oxide lifecycle
        private void Init()
        {
            permission.RegisterPermission(PERM_USE, this);
            permission.RegisterPermission(PERM_ADMIN, this);

            LoadAllData();
            RebuildIndexes();

            foreach (var cmd in _config.Commands)
                AddCovalenceCommand(cmd, nameof(CmdOpen));

            AddCovalenceCommand("au.reload", nameof(CmdReload));
            AddCovalenceCommand("au.wipe", nameof(CmdWipe));
        }

        private void OnServerInitialized()
        {
            PreloadAchievementIcons();

            // устойчивость после reload: заполняем стартовые позиции для активных игроков
            foreach (var p in BasePlayer.activePlayerList)
            {
                if (p == null) continue;
                _lastPos[p.userID] = p.transform.position;
            }

            timer.Every(_config.AutoSaveSeconds, SaveProgress);
            timer.Every(_config.RuntimeTickSeconds, RuntimeTick);
        }

        private void Unload()
        {
            foreach (var p in BasePlayer.activePlayerList)
                DestroyUI(p);

            SaveAllData();
        }
        #endregion

        #region Data IO
        private void LoadAllData()
        {
            _achievements = Interface.Oxide.DataFileSystem.ReadObject<AchievementsData>(ACH_FILE) ?? new AchievementsData();
            _progress = Interface.Oxide.DataFileSystem.ReadObject<PlayersProgressData>(PROG_FILE) ?? new PlayersProgressData();

            if (_achievements.Achievements == null) _achievements.Achievements = new List<AchievementDef>();
            if (_progress.Players == null) _progress.Players = new Dictionary<ulong, PlayerProgress>();

            if (_achievements.Achievements.Count == 0)
            {
                _achievements.Achievements.Add(new AchievementDef
                {
                    Id = "kill_1",
                    Title = "Первое убийство",
                    Description = "Убей 1 игрока",
                    Source = "builtin",
                    Key = "kills",
                    Target = 1,
                    Category = "PvP",
                    Icon = "",
                    Rewards = new List<RewardDef>
                    {
                        new RewardDef { Type = "sr_points", Amount = 10, Title = "+10 RP" }
                    }
                });

                _achievements.Achievements.Add(new AchievementDef
                {
                    Id = "distance_5k",
                    Title = "Пробежка",
                    Description = "Пробеги 5000 метров",
                    Source = "builtin",
                    Key = "distance",
                    Target = 5000,
                    Category = "Общее",
                    Icon = "",
                    Rewards = new List<RewardDef>
                    {
                        new RewardDef { Type = "sr_points", Amount = 5, Title = "+5 RP" }
                    }
                });

                SaveAchievements();
            }

            InvalidateAchievementStatsCache();
            Puts($"[AU] Achievements: {_achievements.Achievements.Count}, Players progress: {_progress.Players.Count}");
        }

        private void SaveAllData()
        {
            SaveAchievements();
            SaveProgress();
        }

        private void SaveAchievements() => Interface.Oxide.DataFileSystem.WriteObject(ACH_FILE, _achievements);
        private void SaveProgress() => Interface.Oxide.DataFileSystem.WriteObject(PROG_FILE, _progress);
        #endregion

        #region Helpers
        private bool HasUse(BasePlayer player)
        {
            if (player == null) return false;
            if (string.IsNullOrEmpty(_config.PermissionUse)) return true;

            return permission.UserHasPermission(player.UserIDString, _config.PermissionUse) ||
                   permission.UserHasPermission(player.UserIDString, PERM_ADMIN);
        }

        private bool IsAdmin(BasePlayer player)
        {
            if (player == null) return false;
            return player.IsAdmin || permission.UserHasPermission(player.UserIDString, PERM_ADMIN);
        }

        private bool CooldownOk(ulong userId, float cd = 0.15f)
        {
            var now = Time.realtimeSinceStartup;
            if (_uiCooldown.TryGetValue(userId, out var last) && now - last < cd) return false;
            _uiCooldown[userId] = now;
            return true;
        }

        private PlayerProgress GetProgress(ulong userId)
        {
            if (!_progress.Players.TryGetValue(userId, out var pp))
                _progress.Players[userId] = pp = new PlayerProgress();

            if (pp.Progress == null) pp.Progress = new Dictionary<string, long>();
            if (pp.Done == null) pp.Done = new HashSet<string>();
            if (pp.ClaimedCount == null) pp.ClaimedCount = new Dictionary<string, int>();
            return pp;
        }

        private bool GiveServerRewardsPoints(ulong userId, int amount)
        {
            if (amount <= 0) return false;
            if (!_config.Rewards.UseServerRewards) return false;
            if (ServerRewards == null) return false;

            try
            {
                var res = ServerRewards.Call("AddPoints", userId, amount);
                return res == null || (res is bool b && b);
            }
            catch { return false; }
        }

        private bool TryGiveItemOrDrop(BasePlayer player, string shortname, int amount, ulong skin, string customName)
        {
            if (player == null || string.IsNullOrEmpty(shortname) || amount <= 0) return false;

            var def = ItemManager.FindItemDefinition(shortname);
            if (def == null) return false;

            var item = ItemManager.Create(def, amount, skin);
            if (item == null) return false;

            if (!string.IsNullOrEmpty(customName))
                item.name = customName;

            if (player.inventory?.containerMain != null && item.MoveToContainer(player.inventory.containerMain))
                return true;

            if (player.inventory?.containerBelt != null && item.MoveToContainer(player.inventory.containerBelt))
                return true;

            item.Drop(player.transform.position + new Vector3(0, 1f, 0), Vector3.zero);
            return true;
        }

        private string ReplaceRewardPlaceholders(string cmd, BasePlayer player, ulong userId, int totalAmount)
        {
            if (string.IsNullOrEmpty(cmd)) return cmd;

            var name = player != null ? player.displayName : ResolveName(userId);

            cmd = cmd.Replace("%STEAMID%", userId.ToString());
            cmd = cmd.Replace("%USERID%", userId.ToString());
            cmd = cmd.Replace("%PLAYERNAME%", name ?? userId.ToString());
            cmd = cmd.Replace("%AMOUNT%", totalAmount.ToString());

            return cmd;
        }

        private bool RunServerCommandSafe(string cmd)
        {
            if (string.IsNullOrWhiteSpace(cmd)) return false;

            try
            {
                ConsoleSystem.Run(ConsoleSystem.Option.Server, cmd);
                return true;
            }
            catch (Exception ex)
            {
                PrintWarning($"[AU] Ошибка выполнения команды награды: {cmd}\n{ex}");
                return false;
            }
        }

        private string GetCategoryId(string categoryName)
        {
            if (string.IsNullOrEmpty(categoryName)) categoryName = "Без категории";
            return "cat_" + categoryName.GetHashCode().ToString("X8");
        }
        #endregion

        #region Indexes / Progress Engine
        private void RebuildIndexes()
        {
            _indexStat = new Dictionary<string, List<AchievementDef>>(StringComparer.OrdinalIgnoreCase);
            _indexBuiltin = new Dictionary<string, List<AchievementDef>>(StringComparer.OrdinalIgnoreCase);

            _categoryIdToName.Clear();
            _categoryIdToName["all"] = "Все";

            foreach (var a in _achievements.Achievements)
            {
                if (a == null || string.IsNullOrEmpty(a.Id) || string.IsNullOrEmpty(a.Key)) continue;

                var catName = string.IsNullOrEmpty(a.Category) ? "Без категории" : a.Category;
                var cid = GetCategoryId(catName);
                if (!_categoryIdToName.ContainsKey(cid))
                    _categoryIdToName[cid] = catName;

                var src = (a.Source ?? "stat").ToLowerInvariant();
                if (src == "builtin")
                {
                    if (!_indexBuiltin.TryGetValue(a.Key, out var list))
                        _indexBuiltin[a.Key] = list = new List<AchievementDef>();
                    list.Add(a);
                }
                else
                {
                    if (!_indexStat.TryGetValue(a.Key, out var list))
                        _indexStat[a.Key] = list = new List<AchievementDef>();
                    list.Add(a);
                }
            }
        }

        private void AddStat(BasePlayer player, string key, long amount)
        {
            if (player == null || string.IsNullOrEmpty(key) || amount <= 0) return;
            if (_indexStat.TryGetValue(key, out var list))
                ApplyProgress(player.userID, list, amount);
        }

        private void AddBuiltin(BasePlayer player, string key, long amount)
        {
            if (player == null || string.IsNullOrEmpty(key) || amount <= 0) return;
            if (_indexBuiltin.TryGetValue(key, out var list))
                ApplyProgress(player.userID, list, amount);
        }

        private void ApplyProgress(ulong userId, List<AchievementDef> achievements, long add)
        {
            if (achievements == null || achievements.Count == 0) return;

            var pp = GetProgress(userId);

            foreach (var ach in achievements)
            {
                if (ach == null || string.IsNullOrEmpty(ach.Id)) continue;

                if (!ach.Repeatable && pp.Done.Contains(ach.Id))
                    continue;

                var target = Math.Max(1, ach.Target);
                pp.Progress.TryGetValue(ach.Id, out var cur);

                var next = cur + add;

                if (!ach.Repeatable)
                {
                    if (next >= target)
                    {
                        pp.Progress[ach.Id] = target;
                        pp.Done.Add(ach.Id);

                        InvalidateAchievementStatsCache();
                        GrantRewards(userId, ach, 1);
                    }
                    else pp.Progress[ach.Id] = next;
                }
                else
                {
                    int times = 0;
                    while (next >= target)
                    {
                        next -= target;
                        times++;
                    }

                    pp.Progress[ach.Id] = next;

                    if (times > 0)
                    {
                        pp.ClaimedCount.TryGetValue(ach.Id, out var claimed);
                        pp.ClaimedCount[ach.Id] = claimed + times;

                        InvalidateAchievementStatsCache();
                        GrantRewards(userId, ach, times);
                    }
                }
            }
        }

        private void GrantRewards(ulong userId, AchievementDef ach, int times)
        {
            if (ach == null || ach.Rewards == null || ach.Rewards.Count == 0 || times <= 0) return;

            var player = BasePlayer.FindByID(userId);

            foreach (var r in ach.Rewards)
            {
                if (r == null) continue;

                var type = (r.Type ?? "").ToLowerInvariant();
                var total = Math.Max(0, r.Amount) * times;

                if (type == "sr_points")
                {
                    if (!_config.Rewards.EnableSrPoints) continue;

                    if (total > 0)
                    {
                        if (GiveServerRewardsPoints(userId, total))
                            player?.ChatMessage($"<color=#7fb2ff>Награда:</color> {(string.IsNullOrEmpty(r.Title) ? $"+{total} {_config.Rewards.CurrencyName}" : r.Title)} за \"{ach.Title ?? ach.Id}\"");
                        else
                            player?.ChatMessage($"<color=#ff8080>ServerRewards недоступен:</color> RP не выдано (\"{ach.Title ?? ach.Id}\").");
                    }
                }
                else if (type == "item")
                {
                    if (!_config.Rewards.EnableItems) continue;

                    if (total > 0 && !string.IsNullOrEmpty(r.ShortName))
                    {
                        if (_config.Rewards.GiveItemsInstantly && player != null)
                        {
                            var ok = TryGiveItemOrDrop(player, r.ShortName, total, r.Skin, r.CustomName);
                            if (ok)
                                player.ChatMessage($"<color=#7fb2ff>Награда:</color> {(string.IsNullOrEmpty(r.Title) ? $"{r.ShortName} x{total}" : r.Title)} за \"{ach.Title ?? ach.Id}\"");
                            else
                                player.ChatMessage($"<color=#ff8080>Награда не выдана:</color> предмет не найден ({r.ShortName}).");
                        }
                    }
                }
                else if (type == "command")
                {
                    if (!_config.Rewards.EnableCommands) continue;

                    var cmds = new List<string>();

                    if (r.Commands != null && r.Commands.Count > 0)
                        cmds.AddRange(r.Commands.Where(x => !string.IsNullOrWhiteSpace(x)));

                    if (!string.IsNullOrWhiteSpace(r.Command))
                        cmds.Add(r.Command);

                    if (cmds.Count == 0) continue;

                    bool allOk = true;

                    foreach (var raw in cmds)
                    {
                        var cmd = ReplaceRewardPlaceholders(raw, player, userId, total);
                        var ok = RunServerCommandSafe(cmd);
                        if (!ok) allOk = false;
                    }

                    if (player != null)
                    {
                        if (!string.IsNullOrEmpty(r.Title))
                        {
                            if (allOk) player.ChatMessage($"<color=#7fb2ff>Награда:</color> {r.Title} за \"{ach.Title ?? ach.Id}\"");
                            else player.ChatMessage($"<color=#ff8080>Ошибка награды:</color> не все команды выполнились ({r.Title}).");
                        }
                        else if (!allOk)
                        {
                            player.ChatMessage($"<color=#ff8080>Ошибка награды:</color> не все команды выполнились (\"{ach.Title ?? ach.Id}\").");
                        }
                    }
                }
            }

            SaveProgress();

            if (player != null && IsUiOpen(userId))
                RedrawMainUI(player);
        }
        #endregion

        #region Runtime Tick (distance/playtime)
        private void RuntimeTick()
        {
            var dt = _config.RuntimeTickSeconds;

            foreach (var p in BasePlayer.activePlayerList)
            {
                if (p == null || !p.IsConnected) continue;

                // builtin: playtime
                AddBuiltin(p, "playtime", dt);

                // builtin: distance
                var pos = p.transform.position;
                if (_lastPos.TryGetValue(p.userID, out var last))
                {
                    var dist = Vector3.Distance(last, pos);
                    if (dist > 0.01f)
                    {
                        _distanceAcc.TryGetValue(p.userID, out var acc);
                        acc += dist;
                        _distanceAcc[p.userID] = acc;

                        var whole = (long)Math.Floor(acc);
                        if (whole >= 1)
                        {
                            _distanceAcc[p.userID] = acc - whole;
                            AddBuiltin(p, "distance", whole);
                        }
                    }
                }
                _lastPos[p.userID] = pos;
            }
        }
        #endregion

        // ========================= PART 2/2 BELOW =========================
        #region Commands (Covalence)
        private void CmdOpen(IPlayer cov, string cmd, string[] args)
        {
            var player = cov?.Object as BasePlayer;
            if (player == null) return;

            if (!HasUse(player))
            {
                cov.Reply("Нет прав.");
                return;
            }

            if (!CooldownOk(player.userID)) return;
            OpenMainUI(player);
        }

        private void CmdReload(IPlayer cov, string cmd, string[] args)
        {
            var player = cov?.Object as BasePlayer;
            if (player != null && !IsAdmin(player))
            {
                cov.Reply("Нет прав.");
                return;
            }

            LoadAllData();
            RebuildIndexes();
            PreloadAchievementIcons();
            InvalidateAchievementStatsCache();

            // после reload игроки уже онлайн — инициализируем lastPos
            foreach (var p in BasePlayer.activePlayerList)
            {
                if (p == null) continue;
                _lastPos[p.userID] = p.transform.position;
            }

            cov.Reply("AchievementsUltimate: данные перезагружены (Achievements/Progress).");
        }

        private void CmdWipe(IPlayer cov, string cmd, string[] args)
        {
            var player = cov?.Object as BasePlayer;
            if (player != null && !IsAdmin(player))
            {
                cov.Reply("Нет прав.");
                return;
            }

            if (args == null || args.Length < 1)
            {
                cov.Reply("Использование: au.wipe <steamid|all>");
                return;
            }

            if (args[0].Equals("all", StringComparison.OrdinalIgnoreCase))
            {
                _progress = new PlayersProgressData();
                SaveProgress();
                InvalidateAchievementStatsCache();
                cov.Reply("Очищен прогресс всех игроков.");
                return;
            }

            if (!ulong.TryParse(args[0], out var sid))
            {
                cov.Reply("Неверный steamid.");
                return;
            }

            _progress.Players.Remove(sid);
            SaveProgress();
            InvalidateAchievementStatsCache();

            cov.Reply($"Очищен прогресс игрока {sid}.");
        }
        #endregion

        #region UI Commands (Console)
        [ConsoleCommand("au.close")]
        private void CCmdClose(ConsoleSystem.Arg arg)
        {
            var player = arg?.Player();
            if (player == null) return;
            DestroyUI(player);
        }

        [ConsoleCommand("au.tab")]
        private void CCmdTab(ConsoleSystem.Arg arg)
        {
            var player = arg?.Player();
            if (player == null) return;
            if (!HasUse(player)) return;
            if (!CooldownOk(player.userID)) return;

            var tab = arg.Args != null && arg.Args.Length > 0 ? arg.Args[0] : "ach";
            var st = GetUiState(player.userID);

            st.Tab = (tab == "stats") ? "stats" : "ach";
            OpenMainUI(player);
        }

        [ConsoleCommand("au.cat")]
        private void CCmdCategory(ConsoleSystem.Arg arg)
        {
            var player = arg?.Player();
            if (player == null) return;
            if (!HasUse(player)) return;
            if (!CooldownOk(player.userID)) return;

            var cid = arg.Args != null && arg.Args.Length > 0 ? arg.Args[0] : "all";
            var st = GetUiState(player.userID);

            if (!_categoryIdToName.ContainsKey(cid)) cid = "all";
            st.Category = cid;

            OpenMainUI(player);
        }

        [ConsoleCommand("au.togglecompleted")]
        private void CCmdToggleCompleted(ConsoleSystem.Arg arg)
        {
            var player = arg?.Player();
            if (player == null) return;
            if (!HasUse(player)) return;
            if (!CooldownOk(player.userID)) return;

            var st = GetUiState(player.userID);
            st.ShowCompleted = !st.ShowCompleted;

            OpenMainUI(player);
        }

        [ConsoleCommand("au.search")]
        private void CCmdSearch(ConsoleSystem.Arg arg)
        {
            var player = arg?.Player();
            if (player == null) return;
            if (!HasUse(player)) return;
            if (!CooldownOk(player.userID, 0.05f)) return;

            var txt = arg.Args != null && arg.Args.Length > 0 ? string.Join(" ", arg.Args) : "";
            var st = GetUiState(player.userID);
            st.Search = txt ?? "";

            OpenMainUI(player);
        }
        #endregion

        #region UI Main
        private void DestroyUI(BasePlayer player)
        {
            if (player == null) return;

            CuiHelper.DestroyUi(player, UI_LAYER);
            CuiHelper.DestroyUi(player, UI_MODAL);
            MarkUiOpen(player.userID, false);
        }

        private void RedrawMainUI(BasePlayer player)
        {
            if (player == null) return;
            OpenMainUI(player);
        }

        private void OpenMainUI(BasePlayer player)
        {
            if (player == null) return;

            DestroyUI(player);
            MarkUiOpen(player.userID, true);

            var st = GetUiState(player.userID);
            var pp = GetProgress(player.userID);

            var c = new CuiElementContainer();

            var root = UIB.Panel(c, "Overlay", UI_LAYER, "0 0 0 0", "0 0", "1 1", true);

            if (_config.CloseOnBackground)
                UIB.Button(c, root, $"{root}.BgClose", "au.close", "0 0 0 0", "", 0, "0 0 0 0", "0 0", "1 1", TextAnchor.MiddleCenter);

            var main = UIB.Panel(c, root, $"{root}.Main", _config.UI.Panel, "0.14 0.12", "0.86 0.88", true);

            var header = UIB.Panel(c, main, $"{main}.Header", _config.UI.Panel2, "0 0.90", "1 1", false);
            UIB.Label(c, header, $"{header}.Title", "ДОСТИЖЕНИЯ", _config.UI.TitleSize, _config.UI.Text, "0.03 0", "0.80 1", TextAnchor.MiddleLeft);
            UIB.Button(c, header, $"{header}.Close", "au.close", "1 1 1 0.08", "✕", 14, _config.UI.Text, "0.94 0.18", "0.985 0.82", TextAnchor.MiddleCenter);

            var tabs = UIB.Panel(c, main, $"{main}.Tabs", "0 0 0 0", "0.02 0.84", "0.98 0.90", false);

            var tabAchColor = st.Tab == "ach" ? _config.UI.Accent : "1 1 1 0.08";
            var tabStatsColor = st.Tab == "stats" ? _config.UI.Accent : "1 1 1 0.08";

            UIB.Button(c, tabs, $"{tabs}.Ach", "au.tab ach", tabAchColor, "Достижения", 12, _config.UI.Text, "0.00 0", "0.49 1", TextAnchor.MiddleCenter);
            UIB.Button(c, tabs, $"{tabs}.Stats", "au.tab stats", tabStatsColor, "Топ 10 игроков", 12, _config.UI.Text, "0.51 0", "1.00 1", TextAnchor.MiddleCenter);

            var left = UIB.Panel(c, main, $"{main}.Left", _config.UI.Panel2, "0.02 0.04", "0.28 0.84", false);
            var right = UIB.Panel(c, main, $"{main}.Right", "0 0 0 0", "0.30 0.04", "0.98 0.84", false);

            var doneCount = pp.Done?.Count ?? 0;
            var totalCount = _achievements?.Achievements?.Count ?? 0;

            UIB.Label(c, left, $"{left}.Me", $"{player.displayName}", 13, _config.UI.Text, "0.06 0.92", "0.94 0.99", TextAnchor.UpperLeft);
            UIB.Label(c, left, $"{left}.Counts",
                $"Выполнено: <color={_config.UI.Text}>{doneCount}</color>/<color={_config.UI.Text}>{totalCount}</color>",
                12, _config.UI.Text2, "0.06 0.86", "0.94 0.92", TextAnchor.UpperLeft);

            if (st.Tab == "ach")
            {
                // Поиск
                UIB.Label(c, left, $"{left}.SearchLbl", "Поиск", 12, _config.UI.Text, "0.06 0.79", "0.34 0.85", TextAnchor.MiddleLeft);

                c.Add(new CuiElement
                {
                    Parent = left,
                    Name = $"{left}.SearchInput",
                    Components =
                    {
                        new CuiImageComponent { Color = "1 1 1 0.06" },
                        new CuiRectTransformComponent { AnchorMin = "0.34 0.79", AnchorMax = "0.94 0.85" }
                    }
                });

                c.Add(new CuiElement
                {
                    Parent = $"{left}.SearchInput",
                    Name = $"{left}.SearchInput.IF",
                    Components =
                    {
                        new CuiInputFieldComponent
                        {
                            Text = st.Search ?? "",
                            FontSize = 12,
                            Align = TextAnchor.MiddleLeft,
                            Command = "au.search",
                            CharsLimit = 64,
                            IsPassword 
// ... [Code preview truncated due to length. Full source in attached file] ...

Автор
15FPS
Скачиваний
0
Просмотры
1
Первый выпуск
Обновление
Оценка
0.00 звёзд 0 оценок
Сверху