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] ...