using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using Oxide.Core; namespace Oxide.Plugins { [Info("RaidLedger", "jitangmindset", "1.0.0")] [Description("Groups explosive building damage into searchable, persistent raid sessions.")] public sealed class RaidLedger : RustPlugin { private const string ViewPermission = "raidledger.view"; private const string PurgePermission = "raidledger.purge"; private PluginConfig _pluginConfig; private StoredData _storedData; private RaidTracker _tracker; private bool _dataHealthy; private sealed class PluginConfig { public int SessionGapSeconds = 300; public double MergeRadiusMeters = 75; public int RetentionDays = 30; public int MaximumStoredSessions = 2000; public int SaveIntervalSeconds = 60; public int MaximumCommandResults = 10; public double MaximumNearRadiusMeters = 500; public bool IgnoreDamageToOwnEntities = true; public bool TrackOwnerlessEntities = false; public bool BroadcastNewRaidSessions = false; public void Normalize() { SessionGapSeconds = Clamp(SessionGapSeconds, 30, 3600); MergeRadiusMeters = Clamp(MergeRadiusMeters, 10, 500); RetentionDays = Clamp(RetentionDays, 1, 365); MaximumStoredSessions = Clamp(MaximumStoredSessions, 100, 50000); SaveIntervalSeconds = Clamp(SaveIntervalSeconds, 15, 3600); MaximumCommandResults = Clamp(MaximumCommandResults, 1, 50); MaximumNearRadiusMeters = Clamp(MaximumNearRadiusMeters, 25, 2000); } private static int Clamp(int value, int minimum, int maximum) { return Math.Max(minimum, Math.Min(maximum, value)); } private static double Clamp(double value, double minimum, double maximum) { return Math.Max(minimum, Math.Min(maximum, value)); } } private sealed class StoredData { public List Sessions = new List(); } protected override void LoadDefaultConfig() { Config.WriteObject(new PluginConfig(), true); } private void Init() { permission.RegisterPermission(ViewPermission, this); permission.RegisterPermission(PurgePermission, this); LoadPluginConfig(); LoadData(); timer.Every(_pluginConfig.SaveIntervalSeconds, SaveData); } private void OnServerSave() { if (_tracker != null) { _tracker.Prune(UtcNowSeconds()); } SaveData(); } private void Unload() { SaveData(); } private void LoadPluginConfig() { try { _pluginConfig = Config.ReadObject(); if (_pluginConfig == null) { throw new InvalidOperationException("Configuration deserialized to null."); } _pluginConfig.Normalize(); Config.WriteObject(_pluginConfig, true); } catch (Exception exception) { PrintError("Configuration could not be read. Safe defaults are active for this run: " + exception.Message); _pluginConfig = new PluginConfig(); _pluginConfig.Normalize(); } } private void LoadData() { try { _storedData = Interface.Oxide.DataFileSystem.ReadObject(Name); if (_storedData == null) { _storedData = new StoredData(); } if (_storedData.Sessions == null) { _storedData.Sessions = new List(); } RaidTrackerOptions options = new RaidTrackerOptions( _pluginConfig.SessionGapSeconds, _pluginConfig.MergeRadiusMeters, _pluginConfig.RetentionDays, _pluginConfig.MaximumStoredSessions); _tracker = new RaidTracker(options, _storedData.Sessions, null); _tracker.Prune(UtcNowSeconds()); _dataHealthy = true; Puts(string.Format(CultureInfo.InvariantCulture, "Loaded {0} raid sessions.", _storedData.Sessions.Count)); } catch (Exception exception) { _storedData = new StoredData(); _tracker = new RaidTracker( new RaidTrackerOptions( _pluginConfig.SessionGapSeconds, _pluginConfig.MergeRadiusMeters, _pluginConfig.RetentionDays, _pluginConfig.MaximumStoredSessions), _storedData.Sessions, null); _dataHealthy = false; PrintError("Raid history could not be read. Persistence is disabled to avoid overwriting the damaged data file: " + exception.Message); } } private void SaveData() { if (!_dataHealthy || _storedData == null) { return; } try { Interface.Oxide.DataFileSystem.WriteObject(Name, _storedData); } catch (Exception exception) { _dataHealthy = false; PrintError("Raid history could not be saved. Further writes are disabled for this run: " + exception.Message); } } private void OnEntityTakeDamage(BaseCombatEntity entity, HitInfo info) { RecordExplosiveEvent(entity, info, false); } private void OnEntityDeath(BaseCombatEntity entity, HitInfo info) { RecordExplosiveEvent(entity, info, true); } private void RecordExplosiveEvent(BaseCombatEntity entity, HitInfo info, bool destroyed) { if (_tracker == null || entity == null || info == null || !(entity is DecayEntity)) { return; } BasePlayer attacker = info.InitiatorPlayer; if (attacker == null || !attacker.userID.IsSteamId()) { return; } if (info.damageTypes == null || !info.damageTypes.Has(Rust.DamageType.Explosion)) { return; } ulong ownerId = entity.OwnerID; if (ownerId == 0 && !_pluginConfig.TrackOwnerlessEntities) { return; } if (_pluginConfig.IgnoreDamageToOwnEntities && ownerId == attacker.userID) { return; } double damage = destroyed ? 0 : Math.Max(0, info.damageTypes.Total()); if (!destroyed && damage <= 0) { return; } string attackerName = string.IsNullOrWhiteSpace(attacker.displayName) ? attacker.UserIDString : attacker.displayName; string targetName = string.IsNullOrWhiteSpace(entity.ShortPrefabName) ? entity.GetType().Name : entity.ShortPrefabName; RaidEvent raidEvent = new RaidEvent { TimestampUtcSeconds = UtcNowSeconds(), AttackerId = attacker.userID, AttackerName = attackerName, TargetOwnerId = ownerId, WeaponName = ResolveWeaponName(info), TargetName = targetName, X = entity.transform.position.x, Y = entity.transform.position.y, Z = entity.transform.position.z, Damage = damage, Destroyed = destroyed }; RaidRecordResult result = _tracker.Record(raidEvent); if (result.NewSession && _pluginConfig.BroadcastNewRaidSessions) { PrintToChat(string.Format( CultureInfo.InvariantCulture, "[RaidLedger] New raid {0}: {1} attacking owner {2} near {3:0}, {4:0}.", result.Session.Id, attackerName, ownerId, raidEvent.X, raidEvent.Z)); } } private static string ResolveWeaponName(HitInfo info) { if (info.Weapon != null) { Item item = info.Weapon.GetItem(); if (item != null && item.info != null && !string.IsNullOrWhiteSpace(item.info.shortname)) { return item.info.shortname; } } if (info.WeaponPrefab != null && !string.IsNullOrWhiteSpace(info.WeaponPrefab.ShortPrefabName)) { return info.WeaponPrefab.ShortPrefabName; } if (info.Initiator != null && !string.IsNullOrWhiteSpace(info.Initiator.ShortPrefabName)) { return info.Initiator.ShortPrefabName; } return "explosive"; } [ChatCommand("raidlog")] private void RaidLogCommand(BasePlayer player, string command, string[] args) { HandleRaidCommand(player, args); } [ChatCommand("raidledger")] private void RaidLedgerCommand(BasePlayer player, string command, string[] args) { HandleRaidCommand(player, args); } private void HandleRaidCommand(BasePlayer player, string[] args) { if (player == null || !CanView(player)) { if (player != null) { SendReply(player, "[RaidLedger] You do not have permission to view raid history."); } return; } if (_tracker == null) { SendReply(player, "[RaidLedger] Raid history is not ready."); return; } if (args == null || args.Length == 0) { SendHelp(player); return; } string action = args[0].ToLowerInvariant(); if (action == "recent") { int count = ParseCount(args, 1); SendSessions(player, _tracker.Recent(count)); return; } if (action == "player") { if (args.Length < 2) { SendReply(player, "Usage: /raidlog player [count]"); return; } int count = ParseCount(args, 2); SendSessions(player, _tracker.ByAttacker(args[1], count)); return; } if (action == "near") { double radius = 100; if (args.Length >= 2 && !double.TryParse(args[1], NumberStyles.Float, CultureInfo.InvariantCulture, out radius)) { SendReply(player, "Usage: /raidlog near [radius] [count]"); return; } radius = Math.Max(1, Math.Min(_pluginConfig.MaximumNearRadiusMeters, radius)); int count = ParseCount(args, 2); SendSessions(player, _tracker.Near(player.transform.position.x, player.transform.position.z, radius, count)); return; } if (action == "show") { if (args.Length < 2) { SendReply(player, "Usage: /raidlog show "); return; } RaidSession session = _tracker.Find(args[1]); if (session == null) { SendReply(player, "[RaidLedger] Session not found."); return; } SendSessionDetails(player, session); return; } if (action == "purge") { HandlePurge(player, args); return; } if (action == "status") { SendReply(player, string.Format( CultureInfo.InvariantCulture, "[RaidLedger] {0} stored sessions; persistence {1}.", _tracker.Sessions.Count, _dataHealthy ? "healthy" : "disabled")); return; } SendHelp(player); } private void HandlePurge(BasePlayer player, string[] args) { if (!CanPurge(player)) { SendReply(player, "[RaidLedger] You do not have permission to purge history."); return; } int days; if (args.Length < 3 || !int.TryParse(args[1], out days) || days < 1 || !string.Equals(args[2], "confirm", StringComparison.OrdinalIgnoreCase)) { SendReply(player, "Usage: /raidlog purge confirm"); return; } long cutoff = UtcNowSeconds() - (long)days * 86400; int removed = _tracker.PurgeOlderThan(cutoff); SaveData(); SendReply(player, string.Format(CultureInfo.InvariantCulture, "[RaidLedger] Removed {0} sessions older than {1} days.", removed, days)); } private bool CanView(BasePlayer player) { return player.IsAdmin || permission.UserHasPermission(player.UserIDString, ViewPermission); } private bool CanPurge(BasePlayer player) { return player.IsAdmin || permission.UserHasPermission(player.UserIDString, PurgePermission); } private int ParseCount(string[] args, int index) { int count = Math.Min(5, _pluginConfig.MaximumCommandResults); int parsed; if (args.Length > index && int.TryParse(args[index], out parsed)) { count = Math.Max(1, Math.Min(_pluginConfig.MaximumCommandResults, parsed)); } return count; } private void SendHelp(BasePlayer player) { SendReply(player, "[RaidLedger] /raidlog recent [count]"); SendReply(player, "[RaidLedger] /raidlog player [count]"); SendReply(player, "[RaidLedger] /raidlog near [radius] [count]"); SendReply(player, "[RaidLedger] /raidlog show "); SendReply(player, "[RaidLedger] /raidlog status"); if (CanPurge(player)) { SendReply(player, "[RaidLedger] /raidlog purge confirm"); } } private void SendSessions(BasePlayer player, IReadOnlyList sessions) { if (sessions.Count == 0) { SendReply(player, "[RaidLedger] No matching raid sessions."); return; } foreach (RaidSession session in sessions) { string attackers = string.Join(", ", session.Attackers.Values.OrderBy(value => value).ToArray()); SendReply(player, string.Format( CultureInfo.InvariantCulture, "[RaidLedger] {0} | {1} ago | attackers: {2} | owner: {3} | hits: {4} | destroyed: {5} | near {6:0}, {7:0}", session.Id, FormatAge(UtcNowSeconds() - session.LastEventAtUtcSeconds), attackers, session.TargetOwnerId, session.DamageEvents, session.DestroyedEntities, session.CenterX, session.CenterZ)); } } private void SendSessionDetails(BasePlayer player, RaidSession session) { string attackers = string.Join(", ", session.Attackers.Select(pair => pair.Value + " (" + pair.Key.ToString(CultureInfo.InvariantCulture) + ")").ToArray()); string weapons = string.Join(", ", session.Weapons.OrderByDescending(pair => pair.Value).Select(pair => pair.Key + " x" + pair.Value.ToString(CultureInfo.InvariantCulture)).ToArray()); string targets = string.Join(", ", session.Targets.OrderByDescending(pair => pair.Value).Select(pair => pair.Key + " x" + pair.Value.ToString(CultureInfo.InvariantCulture)).ToArray()); SendReply(player, "[RaidLedger] Session " + session.Id); SendReply(player, "Attackers: " + attackers); SendReply(player, "Weapons: " + weapons); SendReply(player, "Targets: " + targets); SendReply(player, string.Format( CultureInfo.InvariantCulture, "Owner: {0}; damage: {1:0.##}; hits: {2}; destroyed: {3}; center: {4:0.0}, {5:0.0}, {6:0.0}", session.TargetOwnerId, session.TotalExplosiveDamage, session.DamageEvents, session.DestroyedEntities, session.CenterX, session.CenterY, session.CenterZ)); } private static string FormatAge(long seconds) { seconds = Math.Max(0, seconds); if (seconds < 60) { return seconds.ToString(CultureInfo.InvariantCulture) + "s"; } if (seconds < 3600) { return (seconds / 60).ToString(CultureInfo.InvariantCulture) + "m"; } if (seconds < 86400) { return (seconds / 3600).ToString(CultureInfo.InvariantCulture) + "h"; } return (seconds / 86400).ToString(CultureInfo.InvariantCulture) + "d"; } private static long UtcNowSeconds() { return DateTimeOffset.UtcNow.ToUnixTimeSeconds(); } } internal sealed class RaidTrackerOptions { public RaidTrackerOptions(int sessionGapSeconds, double mergeRadiusMeters, int retentionDays, int maximumStoredSessions) { if (sessionGapSeconds <= 0) { throw new ArgumentOutOfRangeException("sessionGapSeconds"); } if (mergeRadiusMeters <= 0) { throw new ArgumentOutOfRangeException("mergeRadiusMeters"); } if (retentionDays <= 0) { throw new ArgumentOutOfRangeException("retentionDays"); } if (maximumStoredSessions <= 0) { throw new ArgumentOutOfRangeException("maximumStoredSessions"); } SessionGapSeconds = sessionGapSeconds; MergeRadiusMeters = mergeRadiusMeters; RetentionDays = retentionDays; MaximumStoredSessions = maximumStoredSessions; } public int SessionGapSeconds { get; private set; } public double MergeRadiusMeters { get; private set; } public int RetentionDays { get; private set; } public int MaximumStoredSessions { get; private set; } } internal sealed class RaidEvent { public long TimestampUtcSeconds { get; set; } public ulong AttackerId { get; set; } public string AttackerName { get; set; } public ulong TargetOwnerId { get; set; } public string WeaponName { get; set; } public string TargetName { get; set; } public double X { get; set; } public double Y { get; set; } public double Z { get; set; } public double Damage { get; set; } public bool Destroyed { get; set; } } internal sealed class RaidSession { public string Id { get; set; } public long StartedAtUtcSeconds { get; set; } public long LastEventAtUtcSeconds { get; set; } public ulong TargetOwnerId { get; set; } public double CenterX { get; set; } public double CenterY { get; set; } public double CenterZ { get; set; } public int PositionSamples { get; set; } public int DamageEvents { get; set; } public int DestroyedEntities { get; set; } public double TotalExplosiveDamage { get; set; } public Dictionary Attackers { get; set; } public Dictionary Weapons { get; set; } public Dictionary Targets { get; set; } public RaidSession() { Attackers = new Dictionary(); Weapons = new Dictionary(StringComparer.OrdinalIgnoreCase); Targets = new Dictionary(StringComparer.OrdinalIgnoreCase); } public void Normalize() { if (Attackers == null) { Attackers = new Dictionary(); } if (Weapons == null) { Weapons = new Dictionary(StringComparer.OrdinalIgnoreCase); } else if (!Equals(Weapons.Comparer, StringComparer.OrdinalIgnoreCase)) { Weapons = new Dictionary(Weapons, StringComparer.OrdinalIgnoreCase); } if (Targets == null) { Targets = new Dictionary(StringComparer.OrdinalIgnoreCase); } else if (!Equals(Targets.Comparer, StringComparer.OrdinalIgnoreCase)) { Targets = new Dictionary(Targets, StringComparer.OrdinalIgnoreCase); } if (PositionSamples < 1) { PositionSamples = 1; } } public void Apply(RaidEvent raidEvent) { Normalize(); LastEventAtUtcSeconds = Math.Max(LastEventAtUtcSeconds, raidEvent.TimestampUtcSeconds); Attackers[raidEvent.AttackerId] = SafeLabel(raidEvent.AttackerName, raidEvent.AttackerId.ToString(CultureInfo.InvariantCulture)); int previousSamples = PositionSamples; int newSamples = previousSamples + 1; CenterX = (CenterX * previousSamples + raidEvent.X) / newSamples; CenterY = (CenterY * previousSamples + raidEvent.Y) / newSamples; CenterZ = (CenterZ * previousSamples + raidEvent.Z) / newSamples; PositionSamples = newSamples; if (raidEvent.Damage > 0) { DamageEvents++; TotalExplosiveDamage += raidEvent.Damage; Increment(Weapons, SafeLabel(raidEvent.WeaponName, "explosive")); Increment(Targets, SafeLabel(raidEvent.TargetName, "entity")); } if (raidEvent.Destroyed) { DestroyedEntities++; } } private static void Increment(Dictionary dictionary, string key) { int current; dictionary.TryGetValue(key, out current); dictionary[key] = current + 1; } private static string SafeLabel(string value, string fallback) { return string.IsNullOrWhiteSpace(value) ? fallback : value.Trim(); } } internal sealed class RaidRecordResult { public RaidRecordResult(RaidSession session, bool newSession) { Session = session; NewSession = newSession; } public RaidSession Session { get; private set; } public bool NewSession { get; private set; } } internal sealed class RaidTracker { private readonly RaidTrackerOptions _options; private readonly List _sessions; private readonly Func _idFactory; public RaidTracker(RaidTrackerOptions options, List sessions, Func idFactory) { _options = options ?? throw new ArgumentNullException("options"); _sessions = sessions ?? throw new ArgumentNullException("sessions"); _idFactory = idFactory ?? DefaultIdFactory; foreach (RaidSession session in _sessions) { session.Normalize(); } } public IReadOnlyList Sessions { get { return _sessions; } } public RaidRecordResult Record(RaidEvent raidEvent) { if (raidEvent == null) { throw new ArgumentNullException("raidEvent"); } if (raidEvent.TimestampUtcSeconds <= 0) { throw new ArgumentOutOfRangeException("raidEvent", "Timestamp must be positive."); } RaidSession session = FindMergeTarget(raidEvent); bool newSession = session == null; if (newSession) { session = CreateSession(raidEvent); _sessions.Add(session); EnforceMaximumStoredSessions(); } else { session.Apply(raidEvent); } return new RaidRecordResult(session, newSession); } public void Prune(long nowUtcSeconds) { long cutoff = nowUtcSeconds - (long)_options.RetentionDays * 86400; _sessions.RemoveAll(session => session.LastEventAtUtcSeconds < cutoff); if (_sessions.Count > _options.MaximumStoredSessions) { List newest = _sessions .OrderByDescending(session => session.LastEventAtUtcSeconds) .Take(_options.MaximumStoredSessions) .ToList(); _sessions.Clear(); _sessions.AddRange(newest.OrderBy(session => session.LastEventAtUtcSeconds)); } } public IReadOnlyList Recent(int count) { return _sessions .OrderByDescending(session => session.LastEventAtUtcSeconds) .Take(Math.Max(0, count)) .ToList(); } public IReadOnlyList ByAttacker(string query, int count) { if (string.IsNullOrWhiteSpace(query)) { return new List(); } ulong attackerId; bool hasId = ulong.TryParse(query, NumberStyles.None, CultureInfo.InvariantCulture, out attackerId); return _sessions .Where(session => hasId ? session.Attackers.ContainsKey(attackerId) : session.Attackers.Values.Any(name => name != null && name.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0)) .OrderByDescending(session => session.LastEventAtUtcSeconds) .Take(Math.Max(0, count)) .ToList(); } public IReadOnlyList Near(double x, double z, double radius, int count) { double radiusSquared = radius * radius; return _sessions .Where(session => DistanceSquared(session.CenterX, session.CenterZ, x, z) <= radiusSquared) .OrderByDescending(session => session.LastEventAtUtcSeconds) .Take(Math.Max(0, count)) .ToList(); } public RaidSession Find(string id) { return _sessions.FirstOrDefault(session => string.Equals(session.Id, id, StringComparison.OrdinalIgnoreCase)); } public int PurgeOlderThan(long cutoffUtcSeconds) { return _sessions.RemoveAll(session => session.LastEventAtUtcSeconds < cutoffUtcSeconds); } private RaidSession FindMergeTarget(RaidEvent raidEvent) { double radiusSquared = _options.MergeRadiusMeters * _options.MergeRadiusMeters; RaidSession best = null; for (int index = _sessions.Count - 1; index >= 0; index--) { RaidSession candidate = _sessions[index]; if (candidate.TargetOwnerId != raidEvent.TargetOwnerId || raidEvent.TimestampUtcSeconds < candidate.LastEventAtUtcSeconds) { continue; } if (raidEvent.TimestampUtcSeconds - candidate.LastEventAtUtcSeconds > _options.SessionGapSeconds) { continue; } if (DistanceSquared(candidate.CenterX, candidate.CenterZ, raidEvent.X, raidEvent.Z) > radiusSquared) { continue; } if (best == null || candidate.LastEventAtUtcSeconds > best.LastEventAtUtcSeconds) { best = candidate; } } return best; } private void EnforceMaximumStoredSessions() { if (_sessions.Count <= _options.MaximumStoredSessions) { return; } RaidSession oldest = _sessions.OrderBy(session => session.LastEventAtUtcSeconds).First(); _sessions.Remove(oldest); } private RaidSession CreateSession(RaidEvent raidEvent) { RaidSession session = new RaidSession { Id = CreateUniqueId(), StartedAtUtcSeconds = raidEvent.TimestampUtcSeconds, LastEventAtUtcSeconds = raidEvent.TimestampUtcSeconds, TargetOwnerId = raidEvent.TargetOwnerId, CenterX = raidEvent.X, CenterY = raidEvent.Y, CenterZ = raidEvent.Z, PositionSamples = 1 }; session.Attackers[raidEvent.AttackerId] = string.IsNullOrWhiteSpace(raidEvent.AttackerName) ? raidEvent.AttackerId.ToString(CultureInfo.InvariantCulture) : raidEvent.AttackerName.Trim(); if (raidEvent.Damage > 0) { session.DamageEvents = 1; session.TotalExplosiveDamage = raidEvent.Damage; session.Weapons[string.IsNullOrWhiteSpace(raidEvent.WeaponName) ? "explosive" : raidEvent.WeaponName.Trim()] = 1; session.Targets[string.IsNullOrWhiteSpace(raidEvent.TargetName) ? "entity" : raidEvent.TargetName.Trim()] = 1; } session.DestroyedEntities = raidEvent.Destroyed ? 1 : 0; return session; } private string CreateUniqueId() { for (int attempt = 0; attempt < 10; attempt++) { string id = _idFactory(); if (!string.IsNullOrWhiteSpace(id) && _sessions.All(session => !string.Equals(session.Id, id, StringComparison.OrdinalIgnoreCase))) { return id; } } throw new InvalidOperationException("Could not generate a unique raid session ID."); } private static string DefaultIdFactory() { return Guid.NewGuid().ToString("N").Substring(0, 8); } private static double DistanceSquared(double firstX, double firstZ, double secondX, double secondZ) { double x = firstX - secondX; double z = firstZ - secondZ; return x * x + z * z; } } }