using Newtonsoft.Json; using Oxide.Core; using Oxide.Game.Rust.Cui; using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using UnityEngine; namespace Oxide.Plugins { [Info("Kill Messages", "majin", "1.0.0")] [Description("CUI kill feed, death logging, a scoreboard, and a battle log for checking opponents' stats.")] public class KillMessages : RustPlugin { #region Fields private const string UiScore = "KillMessages.Score"; private const string UiProfile = "KillMessages.Profile"; private const string UiFeed = "KMFeed"; private const string DataFileName = "KillMessages/data"; private PluginConfig _config; private StoredData _data; private bool _dirty; private readonly List _feed = new List(); private string _lastFeedJson; // Bumped whenever stats change, so the (fairly expensive, scroll-view-heavy) // scoreboard JSON can be cached and reused instead of rebuilt on every view. private int _statsVersion; private string _scoreboardJsonCache; private int _scoreboardJsonCacheVersion = -1; #endregion #region Theme private static class Theme { public const string Bg = "0.05 0.06 0.07 1"; public const string Bg2 = "0.09 0.10 0.12 1"; public const string Surface = "0.13 0.14 0.16 1"; public const string Surface2 = "0.17 0.18 0.21 1"; public const string Surface3 = "0.21 0.22 0.25 1"; public const string Text = "0.92 0.93 0.95 1"; public const string TextMuted = "0.72 0.74 0.78 1"; public const string Muted = "0.55 0.57 0.61 1"; public const string Kill = "0.13 0.77 0.37 1"; public const string Death = "0.94 0.27 0.27 1"; public const string Warning = "0.92 0.70 0.03 1"; public const string PrimaryLo = "0.55 0.16 0.16 1"; public const string Accent = "0.20 0.55 0.80 1"; public static string Alpha(string rgb1, double a) { int lastSpace = rgb1.LastIndexOf(' '); return rgb1.Substring(0, lastSpace + 1) + a.ToString("0.###", CultureInfo.InvariantCulture); } } #endregion #region Config private class PluginConfig { [JsonProperty("Chat command - scoreboard")] public string ScoreboardCommand = "kills"; [JsonProperty("Chat command - battle log")] public string BattleLogCommand = "battlelog"; [JsonProperty("Kill feed message duration (seconds)")] public float FeedDurationSeconds = 8f; [JsonProperty("Max kill feed messages shown at once")] public int MaxFeedEntries = 5; [JsonProperty("Max encounters kept per player (battle log history)")] public int MaxEncountersPerPlayer = 40; [JsonProperty("Log player deaths to a log file")] public bool LogDeathsToFile = true; [JsonProperty("Show NPC/animal kills in the kill feed")] public bool ShowNpcKillsInFeed = true; [JsonProperty("Show other deaths (fall, suicide, etc) in the kill feed")] public bool ShowOtherDeathsInFeed = false; } protected override void LoadDefaultConfig() => _config = new PluginConfig(); protected override void LoadConfig() { base.LoadConfig(); try { _config = Config.ReadObject(); if (_config == null) throw new Exception("null config"); } catch { PrintWarning("Config file invalid or missing — regenerating with defaults."); LoadDefaultConfig(); } SaveConfig(); } protected override void SaveConfig() => Config.WriteObject(_config); #endregion #region Data private class PlayerStats { public string Name = ""; public int Kills; public int NpcKills; public int Deaths; public int Headshots; public double TotalKillDistance; public int Streak; public int BestStreak; public string LastSeenUtc = ""; } private class Encounter { public ulong OpponentId; public string OpponentName = ""; public bool Won; public bool Headshot; public string Weapon = ""; public float Distance; public string TimeUtc = ""; } private class StoredData { public Dictionary Stats = new Dictionary(); public Dictionary> Encounters = new Dictionary>(); } private class FeedEntry { public string Text; public float ExpiresAt; } private void SaveData() { Interface.Oxide.DataFileSystem.WriteObject(DataFileName, _data); } private void MarkDirty() => _dirty = true; #endregion #region Oxide Hooks private void Init() { cmd.AddChatCommand(_config?.ScoreboardCommand ?? "kills", this, nameof(CmdScoreboard)); cmd.AddChatCommand(_config?.BattleLogCommand ?? "battlelog", this, nameof(CmdBattleLog)); cmd.AddConsoleCommand("killmessages.close", this, nameof(CcmdClose)); cmd.AddConsoleCommand("killmessages.viewprofile", this, nameof(CcmdViewProfile)); cmd.AddConsoleCommand("killmessages.scoreboard", this, nameof(CcmdScoreboardButton)); } private void OnServerInitialized() { _data = Interface.Oxide.DataFileSystem.ReadObject(DataFileName) ?? new StoredData(); if (_data.Stats == null) _data.Stats = new Dictionary(); if (_data.Encounters == null) _data.Encounters = new Dictionary>(); timer.Every(1f, PruneFeed); } private void OnServerSave() { if (_dirty) { SaveData(); _dirty = false; } } private void Unload() { SaveData(); foreach (var p in BasePlayer.activePlayerList) { CuiHelper.DestroyUi(p, UiFeed); CuiHelper.DestroyUi(p, UiScore); CuiHelper.DestroyUi(p, UiProfile); } } private void OnPlayerConnected(BasePlayer player) { if (_lastFeedJson != null) CuiHelper.AddUi(player, _lastFeedJson); } private void OnEntityDeath(BaseCombatEntity entity, HitInfo info) { if (entity == null) return; var victim = entity as BasePlayer; BasePlayer attacker = null; try { attacker = info?.InitiatorPlayer; } catch { } bool victimIsRealPlayer = victim != null && !victim.IsNpc; bool attackerIsRealPlayer = attacker != null && !attacker.IsNpc; if (victimIsRealPlayer) { if (attackerIsRealPlayer && attacker.userID != victim.userID) RecordPvpKill(attacker, victim, info); else RecordOtherDeath(victim, info); } else if (attackerIsRealPlayer) { RecordNpcKill(attacker, entity, info); } } #endregion #region Kill / Death Handling private PlayerStats GetOrCreateStats(ulong id, string name) { if (!_data.Stats.TryGetValue(id, out var s)) { s = new PlayerStats { Name = name ?? "" }; _data.Stats[id] = s; } else if (!string.IsNullOrEmpty(name)) { s.Name = name; // keep the display name fresh across name changes } return s; } private void AddEncounter(ulong ownerId, Encounter e) { if (!_data.Encounters.TryGetValue(ownerId, out var list)) { list = new List(); _data.Encounters[ownerId] = list; } list.Insert(0, e); int max = Math.Max(1, _config.MaxEncountersPerPlayer); if (list.Count > max) list.RemoveRange(max, list.Count - max); } private void RecordPvpKill(BasePlayer attacker, BasePlayer victim, HitInfo info) { var aStats = GetOrCreateStats(attacker.userID, attacker.displayName); var vStats = GetOrCreateStats(victim.userID, victim.displayName); bool headshot = false; try { headshot = info?.isHeadshot ?? false; } catch { } float distance = Vector3.Distance(attacker.transform.position, victim.transform.position); string weapon = GetWeaponName(info); string nowUtc = DateTime.UtcNow.ToString("o"); aStats.Kills++; aStats.Streak++; if (aStats.Streak > aStats.BestStreak) aStats.BestStreak = aStats.Streak; if (headshot) aStats.Headshots++; aStats.TotalKillDistance += distance; aStats.LastSeenUtc = nowUtc; vStats.Deaths++; vStats.Streak = 0; vStats.LastSeenUtc = nowUtc; AddEncounter(attacker.userID, new Encounter { OpponentId = victim.userID, OpponentName = victim.displayName, Won = true, Headshot = headshot, Weapon = weapon, Distance = distance, TimeUtc = nowUtc }); AddEncounter(victim.userID, new Encounter { OpponentId = attacker.userID, OpponentName = attacker.displayName, Won = false, Headshot = headshot, Weapon = weapon, Distance = distance, TimeUtc = nowUtc }); _statsVersion++; MarkDirty(); string headshotTag = headshot ? " [HEADSHOT]" : ""; PushFeedEntry("" + attacker.displayName + " killed " + victim.displayName + " with " + weapon + headshotTag); if (_config.LogDeathsToFile) { LogToFile("deaths", string.Format(CultureInfo.InvariantCulture, "{0} was killed by {1} with {2}{3} at {4:0.0}m", victim.displayName, attacker.displayName, weapon, headshot ? " (headshot)" : "", distance), this); } } private static bool IsTrackableNpcKill(BaseEntity entity) { if (entity is BaseNpc) return true; if (entity is BradleyAPC) return true; if (entity is PatrolHelicopter) return true; if (entity is BasePlayer bp && bp.IsNpc) return true; return false; } private void RecordNpcKill(BasePlayer attacker, BaseCombatEntity entity, HitInfo info) { if (!IsTrackableNpcKill(entity)) return; var aStats = GetOrCreateStats(attacker.userID, attacker.displayName); aStats.NpcKills++; aStats.LastSeenUtc = DateTime.UtcNow.ToString("o"); _statsVersion++; MarkDirty(); if (_config.ShowNpcKillsInFeed) { string name = PrettifyEntityName(entity); PushFeedEntry("" + attacker.displayName + " killed a " + name); } } private void RecordOtherDeath(BasePlayer victim, HitInfo info) { var vStats = GetOrCreateStats(victim.userID, victim.displayName); vStats.Deaths++; vStats.Streak = 0; vStats.LastSeenUtc = DateTime.UtcNow.ToString("o"); _statsVersion++; MarkDirty(); string cause = DescribeOtherDeathCause(victim, info); if (_config.LogDeathsToFile) { LogToFile("deaths", string.Format(CultureInfo.InvariantCulture, "{0} died ({1})", victim.displayName, cause), this); } if (_config.ShowOtherDeathsInFeed) { PushFeedEntry("" + victim.displayName + " died (" + cause + ")"); } } #endregion #region Kill Feed (HUD, cached + broadcast) private void PushFeedEntry(string text) { _feed.Insert(0, new FeedEntry { Text = text, ExpiresAt = Time.realtimeSinceStartup + Math.Max(1f, _config.FeedDurationSeconds) }); int max = Math.Max(1, _config.MaxFeedEntries); if (_feed.Count > max) _feed.RemoveRange(max, _feed.Count - max); RenderFeedForAll(); } private void PruneFeed() { int removed = _feed.RemoveAll(e => e.ExpiresAt <= Time.realtimeSinceStartup); if (removed > 0) RenderFeedForAll(); } // Builds the feed JSON exactly once and re-sends the same string to every online player, // instead of re-serializing an identical container per player. private void RenderFeedForAll() { var c = new CuiElementContainer(); const int rowH = 24; const int pad = 6; int height = pad * 2 + rowH * Math.Max(_feed.Count, 1); c.Add(new CuiElement { Name = UiFeed, Parent = "Hud", Components = { new CuiRectTransformComponent { AnchorMin = "1 1", AnchorMax = "1 1", OffsetMin = "-420 " + (-height - 10), OffsetMax = "-10 -10" } } }); for (int i = 0; i < _feed.Count; i++) { float y2 = -(pad + rowH * i); float y1 = y2 - rowH + 2; string rowName = UiFeed + "_Row_" + i; c.Add(new CuiElement { Name = rowName, Parent = UiFeed, Components = { new CuiImageComponent { Color = Theme.Alpha(Theme.Bg, 0.72) }, new CuiRectTransformComponent { AnchorMin = "0 1", AnchorMax = "1 1", OffsetMin = "0 " + y1, OffsetMax = "0 " + y2 } } }); c.Add(new CuiLabel { Text = { Text = _feed[i].Text, FontSize = 12, Align = TextAnchor.MiddleLeft, Color = Theme.Text }, RectTransform = { AnchorMin = "0.03 0", AnchorMax = "0.97 1" } }, rowName); } string json = CuiHelper.ToJson(c); _lastFeedJson = json; foreach (var p in BasePlayer.activePlayerList) { CuiHelper.DestroyUi(p, UiFeed); CuiHelper.AddUi(p, json); } } #endregion #region Scoreboard private void CmdScoreboard(BasePlayer player, string command, string[] args) { RenderScoreboard(player); } private void CcmdScoreboardButton(ConsoleSystem.Arg arg) { var player = arg.Player(); if (player == null) return; CuiHelper.DestroyUi(player, UiProfile); RenderScoreboard(player); } private void RenderScoreboard(BasePlayer player) { CuiHelper.DestroyUi(player, UiScore); CuiHelper.AddUi(player, BuildScoreboardJson(player)); } private string BuildScoreboardJson(BasePlayer viewer) { if (_scoreboardJsonCache != null && _scoreboardJsonCacheVersion == _statsVersion) return _scoreboardJsonCache; var list = _data.Stats .Where(kv => kv.Value.Kills > 0 || kv.Value.Deaths > 0 || kv.Value.NpcKills > 0) .OrderByDescending(kv => kv.Value.Kills) .ThenByDescending(kv => kv.Value.Kills - kv.Value.Deaths) .ToList(); var c = new CuiElementContainer(); c.Add(new CuiPanel { Image = { Color = Theme.Alpha(Theme.Bg, 0.85) }, RectTransform = { AnchorMin = "0 0", AnchorMax = "1 1" }, CursorEnabled = true }, "Overlay", UiScore); AddPanel(c, UiScore, "Window", "0.5 0.5", "0.5 0.5", Theme.Alpha(Theme.Surface, 0.98), "-380 -260", "380 260"); const string win = "Window"; AddLabel(c, win, "Scoreboard", "0.03 0.90", "0.4 0.98", 18, TextAnchor.MiddleLeft, Theme.Text); AddButton(c, win, "My Battle Log", "killmessages.viewprofile self", "0.55 0.905", "0.775 0.965", Theme.Accent, Theme.Text, 11); AddButton(c, win, "✕", "killmessages.close", "0.91 0.905", "0.98 0.965", Theme.PrimaryLo); AddLabel(c, win, "Rank", "0.03 0.83", "0.11 0.90", 11, TextAnchor.MiddleLeft, Theme.Muted); AddLabel(c, win, "Player", "0.11 0.83", "0.5 0.90", 11, TextAnchor.MiddleLeft, Theme.Muted); AddLabel(c, win, "Kills", "0.5 0.83", "0.62 0.90", 11, TextAnchor.MiddleLeft, Theme.Muted); AddLabel(c, win, "Deaths", "0.62 0.83", "0.74 0.90", 11, TextAnchor.MiddleLeft, Theme.Muted); AddLabel(c, win, "K/D", "0.74 0.83", "0.86 0.90", 11, TextAnchor.MiddleLeft, Theme.Muted); AddLabel(c, win, "Streak", "0.86 0.83", "0.97 0.90", 11, TextAnchor.MiddleLeft, Theme.Muted); if (list.Count == 0) { AddLabel(c, win, "No kills recorded yet.", "0.1 0.5", "0.9 0.6", 13, TextAnchor.MiddleCenter, Theme.Muted); } else { AddRowScroll(c, win, "0.03 0.06", "0.97 0.82", list.Count, 26, "ScoreScroll", (i, rowName) => { var kv = list[i]; var s = kv.Value; double kd = s.Deaths == 0 ? s.Kills : (double)s.Kills / s.Deaths; AddLabel(c, rowName, "#" + (i + 1), "0.01 0", "0.10 1", 11, TextAnchor.MiddleLeft, Theme.Muted); AddButton(c, rowName, Truncate(s.Name, 24), "killmessages.viewprofile " + kv.Key, "0.10 0.08", "0.5 0.92", Theme.Surface3, Theme.Text, 11); AddLabel(c, rowName, s.Kills.ToString(), "0.5 0", "0.62 1", 11, TextAnchor.MiddleLeft, Theme.Text); AddLabel(c, rowName, s.Deaths.ToString(), "0.62 0", "0.74 1", 11, TextAnchor.MiddleLeft, Theme.Text); AddLabel(c, rowName, kd.ToString("0.00", CultureInfo.InvariantCulture), "0.74 0", "0.86 1", 11, TextAnchor.MiddleLeft, Theme.Text); AddLabel(c, rowName, s.Streak.ToString(), "0.86 0", "0.97 1", 11, TextAnchor.MiddleLeft, Theme.Warning); }); } _scoreboardJsonCache = CuiHelper.ToJson(c); _scoreboardJsonCacheVersion = _statsVersion; return _scoreboardJsonCache; } #endregion #region Battle Log private void CmdBattleLog(BasePlayer player, string command, string[] args) { if (args.Length == 0) { OpenProfile(player, player.userID); return; } var target = ResolveTarget(args[0]); if (target == null) { SendReply(player, "No matching player found in the stats history."); return; } OpenProfile(player, target.Value); } private void CcmdViewProfile(ConsoleSystem.Arg arg) { var player = arg.Player(); if (player == null) return; string raw = arg.GetString(0, ""); ulong targetId; if (raw == "self") targetId = player.userID; else if (!ulong.TryParse(raw, out targetId)) return; CuiHelper.DestroyUi(player, UiScore); OpenProfile(player, targetId); } private ulong? ResolveTarget(string term) { if (ulong.TryParse(term, out var id) && _data.Stats.ContainsKey(id)) return id; var online = BasePlayer.activePlayerList.FirstOrDefault(p => p.displayName.IndexOf(term, StringComparison.OrdinalIgnoreCase) >= 0); if (online != null) return online.userID; foreach (var kv in _data.Stats) { if (kv.Value.Name.IndexOf(term, StringComparison.OrdinalIgnoreCase) >= 0) return kv.Key; } return null; } private void OpenProfile(BasePlayer viewer, ulong targetId) { CuiHelper.DestroyUi(viewer, UiProfile); _data.Stats.TryGetValue(targetId, out var s); var onlineTarget = BasePlayer.FindByID(targetId); string name = s?.Name; if (string.IsNullOrEmpty(name)) name = onlineTarget?.displayName; if (string.IsNullOrEmpty(name)) name = "SteamID " + targetId; int kills = s?.Kills ?? 0; int deaths = s?.Deaths ?? 0; int npcKills = s?.NpcKills ?? 0; int streak = s?.Streak ?? 0; int bestStreak = s?.BestStreak ?? 0; double avgDist = (s != null && s.Kills > 0) ? s.TotalKillDistance / s.Kills : 0; double hsPct = (s != null && s.Kills > 0) ? 100.0 * s.Headshots / s.Kills : 0; double kd = deaths == 0 ? kills : (double)kills / deaths; bool showingSelf = viewer.userID == targetId; List shared = new List(); if (_data.Encounters.TryGetValue(viewer.userID, out var mine)) shared = mine.Where(e => e.OpponentId == targetId).ToList(); var c = new CuiElementContainer(); c.Add(new CuiPanel { Image = { Color = Theme.Alpha(Theme.Bg, 0.85) }, RectTransform = { AnchorMin = "0 0", AnchorMax = "1 1" }, CursorEnabled = true }, "Overlay", UiProfile); AddPanel(c, UiProfile, "Window", "0.5 0.5", "0.5 0.5", Theme.Alpha(Theme.Surface, 0.98), "-320 -240", "320 240"); const string win = "Window"; AddButton(c, win, "✕", "killmessages.close", "0.92 0.90", "0.99 0.98", Theme.PrimaryLo); AddLabel(c, win, showingSelf ? "Your Battle Log" : name + "'s Battle Log", "0.03 0.90", "0.85 0.98", 16, TextAnchor.MiddleLeft, Theme.Text); AddPanel(c, win, "Stats", "0.03 0.58", "0.97 0.88", Theme.Bg2); AddLabel(c, "Stats", "Kills: " + kills + " Deaths: " + deaths + " K/D: " + kd.ToString("0.00", CultureInfo.InvariantCulture), "0.03 0.58", "0.97 0.92", 12, TextAnchor.MiddleLeft, Theme.Text); AddLabel(c, "Stats", "Headshot rate: " + hsPct.ToString("0.0", CultureInfo.InvariantCulture) + "% Avg kill distance: " + avgDist.ToString("0.0", CultureInfo.InvariantCulture) + "m", "0.03 0.30", "0.97 0.58", 11, TextAnchor.MiddleLeft, Theme.TextMuted); AddLabel(c, "Stats", "NPC kills: " + npcKills + " Current streak: " + streak + " Best streak: " + bestStreak, "0.03 0.02", "0.97 0.30", 11, TextAnchor.MiddleLeft, Theme.TextMuted); AddLabel(c, win, showingSelf ? "Your recent encounters" : "Your encounters with " + name, "0.03 0.52", "0.97 0.57", 12, TextAnchor.MiddleLeft, Theme.Muted); if (shared.Count == 0) { string msg = showingSelf ? "No recorded encounters yet." : "You haven't crossed paths with " + name + " yet — the stats above are their overall record."; AddLabel(c, win, msg, "0.03 0.40", "0.97 0.51", 11, TextAnchor.MiddleLeft, Theme.Muted); } else { AddRowScroll(c, win, "0.03 0.15", "0.97 0.51", shared.Count, 22, "EncScroll", (i, rowName) => { var e = shared[i]; string resultTag = e.Won ? "WIN" : "LOSS"; string hs = e.Headshot ? " (HS)" : ""; string when = FormatRelativeTime(e.TimeUtc); AddLabel(c, rowName, resultTag + " " + e.Weapon + hs + " " + e.Distance.ToString("0.0", CultureInfo.InvariantCulture) + "m · " + when, "0.02 0", "0.98 1", 10, TextAnchor.MiddleLeft, Theme.Text); }); } AddButton(c, win, "Back to Scoreboard", "killmessages.scoreboard", "0.03 0.03", "0.32 0.12", Theme.Surface3, Theme.Text, 11); CuiHelper.AddUi(viewer, CuiHelper.ToJson(c)); } #endregion #region Chat / Console private void CcmdClose(ConsoleSystem.Arg arg) { var player = arg.Player(); if (player == null) return; CuiHelper.DestroyUi(player, UiScore); CuiHelper.DestroyUi(player, UiProfile); } #endregion #region CUI Helpers private static void AddPanel(CuiElementContainer c, string parent, string name, string anchorMin, string anchorMax, string color, string offsetMin = null, string offsetMax = null) { var panel = new CuiPanel { Image = { Color = color }, RectTransform = { AnchorMin = anchorMin, AnchorMax = anchorMax } }; if (offsetMin != null) panel.RectTransform.OffsetMin = offsetMin; if (offsetMax != null) panel.RectTransform.OffsetMax = offsetMax; c.Add(panel, parent, name); } private static void AddLabel(CuiElementContainer c, string parent, string text, string anchorMin, string anchorMax, int size = 12, TextAnchor align = TextAnchor.MiddleLeft, string color = Theme.Text) { c.Add(new CuiLabel { Text = { Text = text, FontSize = size, Align = align, Color = color }, RectTransform = { AnchorMin = anchorMin, AnchorMax = anchorMax } }, parent); } private static void AddButton(CuiElementContainer c, string parent, string text, string command, string anchorMin, string anchorMax, string color = Theme.Surface3, string textColor = Theme.Text, int size = 12) { c.Add(new CuiButton { Button = { Command = command, Color = color }, RectTransform = { AnchorMin = anchorMin, AnchorMax = anchorMax }, Text = { Text = text, FontSize = size, Align = TextAnchor.MiddleCenter, Color = textColor } }, parent); } // A vertically scrolling list of pixel-height rows inside a CuiScrollViewComponent. // Used for the scoreboard (can have lots of players) and the battle log's encounter list. private static void AddRowScroll(CuiElementContainer c, string parent, string anchorMin, string anchorMax, int rowCount, int rowHeightPx, string scrollName, Action buildRow) { int contentHeightPx = Math.Max(rowHeightPx, rowHeightPx * rowCount); c.Add(new CuiElement { Name = scrollName, Parent = parent, Components = { new CuiScrollViewComponent { MovementType = UnityEngine.UI.ScrollRect.MovementType.Clamped, Vertical = true, Horizontal = false, Inertia = true, Elasticity = 0.15f, DecelerationRate = 0.2f, ScrollSensitivity = 24f, ContentTransform = new CuiRectTransform { AnchorMin = "0 1", AnchorMax = "1 1", OffsetMin = "0 " + (-contentHeightPx), OffsetMax = "0 0" }, VerticalScrollbar = new CuiScrollbar { Size = 8f, AutoHide = false } }, new CuiImageComponent { Color = Theme.Bg2 }, new CuiRectTransformComponent { AnchorMin = anchorMin, AnchorMax = anchorMax } } }); for (int i = 0; i < rowCount; i++) { string rowName = scrollName + "_Row_" + i; float y1 = -(i + 1) * rowHeightPx; float y2 = -(i * rowHeightPx); c.Add(new CuiElement { Name = rowName, Parent = scrollName, Components = { new CuiImageComponent { Color = i % 2 == 0 ? Theme.Surface2 : Theme.Surface3 }, new CuiRectTransformComponent { AnchorMin = "0 1", AnchorMax = "1 1", OffsetMin = "0 " + y1, OffsetMax = "0 " + y2 } } }); buildRow(i, rowName); } } #endregion #region Helpers private static string Truncate(string text, int max) { if (string.IsNullOrEmpty(text)) return ""; return text.Length <= max ? text : text.Substring(0, max - 1).TrimEnd() + "…"; } private static string Prettify(string raw) { if (string.IsNullOrEmpty(raw)) return "unknown"; raw = raw.Replace(".entity", "").Replace(".deployed", "").Replace(".prefab", "").Replace("_", " ").Replace("npc ", ""); return CultureInfo.InvariantCulture.TextInfo.ToTitleCase(raw.Trim()); } private static string PrettifyEntityName(BaseEntity entity) { if (entity == null) return "something"; if (entity is BradleyAPC) return "Bradley APC"; if (entity is PatrolHelicopter) return "Patrol Helicopter"; if (entity is BasePlayer bp && !string.IsNullOrEmpty(bp.displayName)) return bp.displayName; string raw = entity.ShortPrefabName ?? entity.GetType().Name; return Prettify(raw); } private static string GetWeaponName(HitInfo info) { try { if (info == null) return "unknown"; var weaponEntity = info.Weapon as BaseEntity; var item = weaponEntity?.GetItem(); if (item?.info?.displayName != null) return item.info.displayName.english; if (weaponEntity != null) return Prettify(weaponEntity.ShortPrefabName); if (info.WeaponPrefab != null) return Prettify(info.WeaponPrefab.ShortPrefabName); } catch { } return "unknown"; } private static string DescribeOtherDeathCause(BasePlayer victim, HitInfo info) { try { if (info?.Initiator != null) { if (info.Initiator == victim) return "suicide"; return PrettifyEntityName(info.Initiator); } if (info?.damageTypes != null) { var major = info.damageTypes.GetMajorityDamageType(); return Prettify(major.ToString()); } } catch { } return "unknown causes"; } private static string FormatRelativeTime(string isoUtc) { if (!DateTime.TryParse(isoUtc, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dt)) return isoUtc; var span = DateTime.UtcNow - dt; if (span.TotalMinutes < 1) return "just now"; if (span.TotalMinutes < 60) return (int)span.TotalMinutes + "m ago"; if (span.TotalHours < 24) return (int)span.TotalHours + "h ago"; return (int)span.TotalDays + "d ago"; } #endregion } }