using System; using System.Collections.Generic; using System.Reflection; using Newtonsoft.Json; using Oxide.Core; using Oxide.Core.Libraries.Covalence; using UnityEngine; namespace Oxide.Plugins { [Info("RustyBase", "RustyBase.gg", "0.1.0")] [Description("Links players and streams server/base/raid telemetry to RustyBase.gg.")] public class RustyBase : CovalencePlugin { private PluginConfig _config; private readonly Dictionary _playerStats = new Dictionary(); private class PluginConfig { public string ApiUrl = "https://rustybase.gg"; public string ServerKey = ""; public string ServerName = "RustyBase Casual"; public string ServerCategory = "Modded"; public string Region = "US"; public string Description = "A Rust server linked with RustyBase.gg."; public string WebsiteUrl = ""; public string DiscordUrl = ""; public string OwnerSteamId = ""; public string WipeSchedule = "Monthly"; public bool Listed = true; public int PublicPort = 28015; public int MapSeed = 0; public int MapSize = 0; public string ImageUrl = ""; public string PublicIp = ""; public bool TrackNpcKills = false; } private class PlayerStats { public int Kills; public int Deaths; public int Shots; public int Hits; public int Headshots; public int ExplosivesUsed; public float PlayerDamage; public float StructureDamage; public double FirstDamageAt; public double LastDamageAt; public Dictionary BodyHits = new Dictionary(); public double LastSeen; } protected override void LoadDefaultConfig() { _config = new PluginConfig(); SaveConfig(); } protected override void LoadConfig() { base.LoadConfig(); try { _config = Config.ReadObject(); } catch { PrintWarning("Could not read config, creating a fresh RustyBase config."); _config = new PluginConfig(); } if (string.IsNullOrEmpty(_config.ServerKey)) { _config.ServerKey = Guid.NewGuid().ToString("N") + Guid.NewGuid().ToString("N").Substring(0, 8); SaveConfig(); } } protected override void SaveConfig() { Config.WriteObject(_config, true); } private void Init() { AddCovalenceCommand("link", nameof(LinkCommand)); AddCovalenceCommand("rustybase.link", nameof(LinkCommand)); AddCovalenceCommand("rustybase.register", nameof(RegisterCommand)); } private void OnServerInitialized() { RegisterServer(); timer.Once(25f, UploadMapSnapshot); timer.Every(60f, Heartbeat); timer.Every(3600f, UploadMapSnapshot); } private void LinkCommand(IPlayer player, string command, string[] args) { if (player == null || player.IsServer) { return; } var code = UnityEngine.Random.Range(100000, 999999).ToString(); Post("/api/plugin/link", new Dictionary { ["serverKey"] = _config.ServerKey, ["steamId"] = player.Id, ["playerName"] = player.Name, ["linkCode"] = code, ["stats"] = StatsFor(player.Id) }, (ok, body) => { player.Reply(ok ? "RustyBase link sent. If you are signed in on RustyBase.gg with this Steam account, this server is now in Command Center." : "RustyBase could not link right now. Ask an admin to check the RustyBase plugin config."); }); } private void RegisterCommand(IPlayer player, string command, string[] args) { if (player != null && !player.IsAdmin) { player.Reply("Only admins can register this server with RustyBase."); return; } RegisterServer(); player?.Reply("RustyBase registration sent."); } private void OnPlayerConnected(BasePlayer player) { if (player == null) return; var stats = StatsFor(player.UserIDString); stats.LastSeen = UnixNow(); PostPlayerStats(player); } private void OnPlayerDisconnected(BasePlayer player, string reason) { if (player == null) return; var stats = StatsFor(player.UserIDString); stats.LastSeen = UnixNow(); PostPlayerStats(player); } private void OnPlayerAttack(BasePlayer attacker, HitInfo info) { if (attacker == null || info == null) return; if (!_config.TrackNpcKills && IsNpcPlayer(attacker)) return; var stats = StatsFor(attacker.UserIDString); stats.Shots += 1; if (info.HitEntity is BasePlayer target) { if (!_config.TrackNpcKills && IsNpcPlayer(target)) return; stats.Hits += 1; stats.PlayerDamage += info.damageTypes.Total(); var area = HitArea(info); if (area == "head") stats.Headshots += 1; if (!stats.BodyHits.ContainsKey(area)) stats.BodyHits[area] = 0; stats.BodyHits[area] += 1; if (stats.FirstDamageAt <= 0) stats.FirstDamageAt = UnixNow(); stats.LastDamageAt = UnixNow(); } PostPlayerStats(attacker); } private void OnPlayerDeath(BasePlayer player, HitInfo info) { if (player == null) return; if (!_config.TrackNpcKills && IsNpcPlayer(player)) return; StatsFor(player.UserIDString).Deaths += 1; var attacker = info?.InitiatorPlayer; if (attacker != null && attacker != player && (_config.TrackNpcKills || !IsNpcPlayer(attacker))) { StatsFor(attacker.UserIDString).Kills += 1; PostPlayerStats(attacker); } PostPlayerStats(player); } private void OnEntityTakeDamage(BaseCombatEntity entity, HitInfo info) { var attacker = info?.InitiatorPlayer; if (entity == null || attacker == null) return; var damage = info.damageTypes.Total(); var stats = StatsFor(attacker.UserIDString); if (entity is BuildingBlock || entity is Door || entity is BuildingPrivlidge) { stats.StructureDamage += damage; PostPlayerStats(attacker); } } private void OnExplosiveThrown(BasePlayer player, BaseEntity entity, ThrownWeapon item) { CountExplosive(player, entity?.ShortPrefabName ?? item?.ShortPrefabName ?? "explosive"); } private void OnRocketLaunched(BasePlayer player, BaseEntity entity) { CountExplosive(player, entity?.ShortPrefabName ?? "rocket"); } private void OnEntityBuilt(Planner planner, GameObject gameObject) { var player = planner?.GetOwnerPlayer(); var entity = gameObject?.ToBaseEntity(); if (player == null || entity == null) return; if (entity is BuildingPrivlidge) { PostServerSnapshot(); } } private void OnEntityKill(BaseNetworkable entity) { var baseEntity = entity as BaseEntity; if (baseEntity == null) return; if (baseEntity is BuildingPrivlidge) { PostServerSnapshot(); } } private void CountExplosive(BasePlayer player, string explosive) { if (player == null) return; StatsFor(player.UserIDString).ExplosivesUsed += 1; PostPlayerStats(player); } private void RegisterServer() { Post("/api/plugin/server/register", ServerPayload(), (ok, body) => { if (!ok) PrintWarning($"RustyBase registration failed: {body}"); }); } private void UploadMapSnapshot() { try { int width; int height; Color background; var bytes = MapImageRenderer.Render(out width, out height, out background, 0.22f, true, false, 0); if (bytes == null || bytes.Length < 1000) { PrintWarning("RustyBase map render returned an empty image."); return; } Post("/api/plugin/server/map", new Dictionary { ["serverKey"] = _config.ServerKey, ["mapSeed"] = MapSeed(), ["mapSize"] = MapSize(), ["width"] = width, ["height"] = height, ["contentType"] = "image/jpeg", ["imageBase64"] = Convert.ToBase64String(bytes) }, (ok, body) => { if (!ok) PrintWarning($"RustyBase map upload failed: {body}"); }); } catch (Exception ex) { PrintWarning($"RustyBase map render failed: {ex.Message}"); } } private void Heartbeat() { Post("/api/plugin/telemetry", new Dictionary { ["serverKey"] = _config.ServerKey, ["type"] = "heartbeat", ["serverStats"] = ServerStats() }, null); } private Dictionary ServerPayload() { return new Dictionary { ["serverKey"] = _config.ServerKey, ["serverName"] = _config.ServerName, ["category"] = _config.ServerCategory, ["region"] = _config.Region, ["description"] = _config.Description, ["websiteUrl"] = _config.WebsiteUrl, ["discordUrl"] = _config.DiscordUrl, ["imageUrl"] = _config.ImageUrl, ["ownerSteamId"] = _config.OwnerSteamId, ["wipeSchedule"] = _config.WipeSchedule, ["listed"] = _config.Listed, ["ip"] = _config.PublicIp, ["port"] = _config.PublicPort, ["mapSeed"] = MapSeed(), ["mapSize"] = MapSize(), ["pluginVersion"] = Version.ToString(), ["stats"] = ServerStats() }; } private Dictionary ServerStats() { return new Dictionary { ["playersOnline"] = BasePlayer.activePlayerList.Count, ["playersMax"] = ConVar.Server.maxplayers, ["sleepers"] = BasePlayer.sleepingPlayerList.Count, ["trackedPlayers"] = _playerStats.Count, ["trackedTcs"] = ToolCupboards().Count, ["mapSeed"] = MapSeed(), ["mapSize"] = MapSize(), ["tcs"] = ToolCupboards(), ["mapMarkers"] = MapMarkers(), ["updatedAt"] = DateTime.UtcNow.ToString("O") }; } private List> ToolCupboards() { var tcs = new List>(); foreach (var cupboard in UnityEngine.Object.FindObjectsOfType()) { if (cupboard == null || cupboard.IsDestroyed || cupboard.Health() <= 0) continue; var authIds = new List(); var authNames = new List(); if (cupboard.authorizedPlayers != null) { authIds = AuthorizedSteamIds(cupboard); } tcs.Add(new Dictionary { ["id"] = cupboard.net?.ID.ToString() ?? "", ["position"] = Position(cupboard.transform.position), ["authorized"] = authIds.Count, ["authorizedSteamIds"] = authIds, ["authorizedNames"] = authNames, ["health"] = Math.Round(cupboard.Health(), 1), ["protectedMinutes"] = ProtectedMinutes(cupboard), ["upkeepItems"] = CupboardInventory(cupboard) }); } return tcs; } private List> MapMarkers() { var markers = new List>(); foreach (var tc in ToolCupboards()) { tc["kind"] = "tc"; tc["label"] = "TC"; markers.Add(tc); } foreach (var entity in UnityEngine.Object.FindObjectsOfType()) { if (entity == null || entity.IsDestroyed) continue; if (entity.OwnerID == 0) continue; var name = entity.ShortPrefabName ?? ""; var kind = ""; if (name.Contains("autoturret")) kind = "turret"; else if (name.Contains("smart.alarm") || name.Contains("smartalarm")) kind = "alarm"; else if (name == "electric.switch" || name == "smart.switch" || name.Contains("electrical.switch")) kind = "switch"; if (string.IsNullOrEmpty(kind)) continue; var tc = PrivilegeFor(entity); var tcAuth = AuthorizedSteamIds(tc); markers.Add(new Dictionary { ["id"] = entity.net?.ID.ToString() ?? "", ["kind"] = kind, ["label"] = kind, ["position"] = Position(entity.transform.position), ["ownerSteamId"] = entity.OwnerID.ToString(), ["tcId"] = tc?.net?.ID.ToString() ?? "", ["authorizedSteamIds"] = tcAuth, ["health"] = entity is BaseCombatEntity combat ? Math.Round(combat.Health(), 1) : 0 }); if (markers.Count >= 160) break; } return markers; } private void PostPlayerStats(BasePlayer player) { if (player == null || string.IsNullOrEmpty(player.UserIDString)) return; Post("/api/plugin/telemetry", new Dictionary { ["serverKey"] = _config.ServerKey, ["type"] = "player_stats", ["steamId"] = player.UserIDString, ["playerName"] = player.displayName, ["playerStats"] = StatsFor(player.UserIDString) }, null); } private void PostServerSnapshot() { Post("/api/plugin/telemetry", new Dictionary { ["serverKey"] = _config.ServerKey, ["type"] = "server_stats", ["serverStats"] = ServerStats() }, null); } private PlayerStats StatsFor(string steamId) { if (string.IsNullOrEmpty(steamId)) return new PlayerStats(); if (!_playerStats.TryGetValue(steamId, out var stats)) { stats = new PlayerStats(); _playerStats[steamId] = stats; } return stats; } private Dictionary Position(Vector3 value) { return new Dictionary { ["x"] = Math.Round(value.x, 2), ["y"] = Math.Round(value.y, 2), ["z"] = Math.Round(value.z, 2) }; } private int MapSeed() { return _config.MapSeed > 0 ? _config.MapSeed : ConVar.Server.seed; } private int MapSize() { return _config.MapSize > 0 ? _config.MapSize : ConVar.Server.worldsize; } private string HitArea(HitInfo info) { var bone = info.HitBone; if (bone == 698017942) return "head"; var name = StringPool.Get(bone) ?? ""; name = name.ToLowerInvariant(); if (name.Contains("head") || name.Contains("neck")) return "head"; if (name.Contains("spine") || name.Contains("chest") || name.Contains("pelvis") || name.Contains("stomach")) return "torso"; if (name.Contains("arm") || name.Contains("hand")) return "arms"; if (name.Contains("leg") || name.Contains("foot")) return "legs"; return "unknown"; } private bool IsNpcPlayer(BasePlayer player) { if (player == null) return false; return player.userID < 76561197960265728UL; } private double ProtectedMinutes(BuildingPrivlidge cupboard) { try { foreach (var method in typeof(BuildingPrivlidge).GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) { if (method.Name != "GetProtectedMinutes") continue; var parameters = method.GetParameters(); object value = null; if (parameters.Length == 0) { value = method.Invoke(cupboard, null); } else if (parameters.Length == 1 && parameters[0].ParameterType == typeof(bool)) { value = method.Invoke(cupboard, new object[] { true }); } if (value != null) return Math.Round(Convert.ToDouble(value), 2); } foreach (var fieldName in new[] { "protectedMinutes", "cachedProtectedMinutes" }) { var field = typeof(BuildingPrivlidge).GetField(fieldName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); if (field == null) continue; var value = field.GetValue(cupboard); if (value != null) return Math.Round(Convert.ToDouble(value), 2); } } catch { // Older server builds may not expose this helper; inventory data still shows upkeep resources. } return -1; } private List> CupboardInventory(BuildingPrivlidge cupboard) { var items = new List>(); try { if (cupboard.inventory == null || cupboard.inventory.itemList == null) return items; foreach (var item in cupboard.inventory.itemList) { if (item?.info == null || item.amount <= 0) continue; items.Add(new Dictionary { ["shortname"] = item.info.shortname, ["name"] = item.info.displayName?.english ?? item.info.shortname, ["amount"] = item.amount }); } } catch { // Inventory reads are best effort and should never break the plugin heartbeat. } return items; } private BuildingPrivlidge PrivilegeFor(BaseEntity entity) { try { return entity?.GetBuildingPrivilege(); } catch { return null; } } private List AuthorizedSteamIds(BuildingPrivlidge cupboard) { var ids = new List(); if (cupboard?.authorizedPlayers == null) return ids; foreach (var entry in cupboard.authorizedPlayers) { ids.Add(entry.ToString()); } return ids; } private void Post(string path, object payload, Action callback) { var url = $"{_config.ApiUrl.TrimEnd('/')}{path}"; var json = JsonConvert.SerializeObject(payload, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }); var headers = new Dictionary { ["Content-Type"] = "application/json", ["X-RustyBase-Server-Key"] = _config.ServerKey }; webrequest.Enqueue(url, json, (code, body) => { callback?.Invoke(code >= 200 && code < 300, body); }, this, Core.Libraries.RequestMethod.POST, headers, 10000); } private double UnixNow() { return DateTimeOffset.UtcNow.ToUnixTimeSeconds(); } } }