using System; using System.Collections.Generic; using Newtonsoft.Json; using Oxide.Core; using Oxide.Core.Libraries; using Oxide.Core.Plugins; using UnityEngine; namespace Oxide.Plugins { [Info("BigKidsMapDump", "BigKids.pro", "1.0.1")] [Description("Sends this server's ore, collectables and player-spawn density to bigkids.pro, so the map page can show them. Requires BigKidsCore.")] public class BigKidsMapDump : RustPlugin { // Infrastructure (server token, HTTP) lives in BigKidsCore, exactly as it // does for DiscordMapVote. This plugin owns one thing: reading the world. [PluginReference] private Plugin BigKidsCore; private const int REQUIRED_CORE = 1; private const string PathNeeded = "/api/v1/map-dump/needed"; private const string PathStore = "/api/v1/map-dump"; private bool coreOk; private bool unloaded; private bool dumpInFlight; private Timer askTimer; #region Configuration private Configuration config; private class Configuration { // Ore and collectables come straight off the entity list. Player spawn // density does not exist as data at all: it has to be SAMPLED, and that // is the expensive half. Owners who only want the resource layer can // switch it off and pay almost nothing. [JsonProperty("Include player-spawn density (costs a few seconds at boot)")] public bool IncludeSpawns = true; [JsonProperty("Spawn samples")] public int SpawnSamples = 12000; // Sampling is chopped into slices spread across frames. A server that // stutters at boot is a server whose owner uninstalls the plugin. [JsonProperty("Spawn samples per frame")] public int SamplesPerFrame = 400; [JsonProperty("Ask bigkids.pro whether a dump is wanted every (minutes)")] public float AskEveryMinutes = 10f; } protected override void LoadDefaultConfig() => config = new Configuration(); protected override void LoadConfig() { base.LoadConfig(); try { config = Config.ReadObject(); if (config == null) LoadDefaultConfig(); } catch { PrintWarning("⚠ Config is invalid, loading defaults."); LoadDefaultConfig(); } SaveConfig(); } protected override void SaveConfig() => Config.WriteObject(config); #endregion #region Lifecycle / core link private void OnServerInitialized() => TryLinkCore(); private void Unload() { unloaded = true; askTimer?.Destroy(); askTimer = null; } private void OnPluginLoaded(Plugin plugin) { if (plugin?.Name == "BigKidsCore") TryLinkCore(); } private void OnPluginUnloaded(Plugin plugin) { if (plugin?.Name == "BigKidsCore") { coreOk = false; askTimer?.Destroy(); askTimer = null; PrintWarning("⚠ BigKidsCore unloaded - BigKidsMapDump is idle until it returns."); } } private void TryLinkCore() { if (BigKidsCore == null) { coreOk = false; PrintError("BigKidsCore not found - BigKidsMapDump is idle. Install BigKidsCore.cs."); return; } int v; try { v = Convert.ToInt32(BigKidsCore.Call("CoreApiVersion")); } catch { v = 0; } if (v < REQUIRED_CORE) { coreOk = false; PrintError($"BigKidsCore is too old (need API v{REQUIRED_CORE}+, found v{v}) - BigKidsMapDump is idle."); return; } coreOk = true; Puts($"✔ Linked to BigKidsCore (API v{v})."); // Nothing is sent uninvited. The site answers "needed" only when the // owner asked for it in his dashboard AND nobody has dumped this exact // world before — so a server normally does this once per wipe, or never. float every = Math.Max(60f, config.AskEveryMinutes * 60f); askTimer = timer.Every(every, AskIfNeeded); timer.Once(30f, AskIfNeeded); } #endregion #region Handshake private class NeededResponse { public string status; public bool needed; public string reason; } /// The site explains every refusal in `reason`; a bare HTTP code tells the /// owner nothing about what to do next. private class StoreResponse { public string status; public string reason; } private void AskIfNeeded() { if (!coreOk || unloaded || dumpInFlight) return; BigKidsCore.Call("ApiGet", PathNeeded, (Action)((code, response) => { if (unloaded || code != 200 || string.IsNullOrEmpty(response)) return; NeededResponse result; try { result = JsonConvert.DeserializeObject(response); } catch (Exception e) { PrintWarning("⚠ Could not read the dump request: " + e.Message); return; } if (result == null || !result.needed) return; Puts("→ bigkids.pro asked for this world's resource data."); StartDump(); })); } /// Console command for an owner who wants to send it by hand. Server /// console only: it is a heavy read of the whole world. [ConsoleCommand("bigkidsmapdump")] private void CmdDump(ConsoleSystem.Arg arg) { if (arg?.Connection != null) return; if (!coreOk) { Puts("✖ BigKidsCore is not ready."); return; } StartDump(); } #endregion #region World read private void StartDump() { // Printed here rather than at each call site, so a dump announces // itself exactly once no matter what asked for it. if (dumpInFlight) { Puts("→ A dump is already running."); return; } if (BaseNetworkable.serverEntities == null || BaseNetworkable.serverEntities.Count < 100) { PrintWarning("⚠ The world is not fully spawned yet — trying again later."); return; } dumpInFlight = true; Puts("→ Collecting this world's resource data..."); var ores = new List(); var collectables = new List(); // One pass over the entity list. Ore and collectables are ordinary // entities; nothing here reads a player, a building or an inventory. foreach (var net in BaseNetworkable.serverEntities) { var entity = net as BaseEntity; if (entity == null) continue; string name = entity.ShortPrefabName; if (string.IsNullOrEmpty(name)) continue; Vector3 p = entity.transform.position; var record = new { type = name, x = R(p.x), y = R(p.y), z = R(p.z) }; if (entity is OreResourceEntity || name.Contains("-ore")) ores.Add(record); else if (entity is CollectibleEntity || name.Contains("collectable") || name.Contains("collectible")) collectables.Add(record); } var monuments = new List(); if (TerrainMeta.Path != null && TerrainMeta.Path.Monuments != null) { foreach (var monument in TerrainMeta.Path.Monuments) { if (monument == null) continue; Vector3 p = monument.transform.position; monuments.Add(new { name = monument.name, x = R(p.x), y = R(p.y), z = R(p.z) }); } } if (!config.IncludeSpawns) { Send(ores, collectables, monuments, new List(), 0); return; } SampleSpawns(ores, collectables, monuments); } /// Player spawns are not stored anywhere — the game picks one on demand. /// The only way to map them is to ask for a lot of them and count where /// they land, which is why this is sampled rather than read. /// /// Sliced across frames on purpose: 12000 calls in one go is a visible /// freeze on a live server, and this plugin runs on other people's. private void SampleSpawns(List ores, List collectables, List monuments) { var grid = new Dictionary(); int target = Math.Max(1, config.SpawnSamples); int perFrame = Math.Max(50, config.SamplesPerFrame); int done = 0; Action step = null; step = () => { if (unloaded) { dumpInFlight = false; return; } int slice = Math.Min(perFrame, target - done); try { for (int i = 0; i < slice; i++) { var spawn = ServerMgr.FindSpawnPoint(null, 0UL); int gx = Mathf.RoundToInt(spawn.pos.x / 8f) * 8; int gz = Mathf.RoundToInt(spawn.pos.z / 8f) * 8; string key = gx + "," + gz; int[] cell; if (grid.TryGetValue(key, out cell)) cell[2]++; else grid[key] = new[] { gx, gz, 1 }; } } catch (Exception e) { PrintWarning("⚠ Spawn sampling failed: " + e.Message); Send(ores, collectables, monuments, new List(), 0); return; } done += slice; if (done >= target) { var spawns = new List(); foreach (var cell in grid.Values) spawns.Add(new[] { cell[0], cell[1], cell[2] }); Send(ores, collectables, monuments, spawns, done); return; } timer.Once(0.02f, () => step()); }; step(); } private static float R(float v) => Mathf.Round(v * 10f) / 10f; #endregion #region Send private void Send(List ores, List collectables, List monuments, List spawns, int spawnSamples) { // worldSize is reported; the seed is NOT. A server booted with // server.levelurl reports a cosmetic seed for every map, so the site // identifies the world by the map URL it already knows from the // heartbeat — never by anything we could get wrong here. var payload = new { worldSize = (int)World.Size, ores, collectables, monuments, spawns, spawnSamples, }; string body; try { body = JsonConvert.SerializeObject(payload); } catch (Exception e) { PrintWarning("⚠ Could not serialise the dump: " + e.Message); dumpInFlight = false; return; } Puts($"→ Sending {ores.Count} ore, {collectables.Count} collectables, " + $"{monuments.Count} monuments, {spawns.Count} spawn cells ({body.Length / 1024} KB)."); BigKidsCore.Call("ApiPost", PathStore, body, (Action)((code, response) => { dumpInFlight = false; if (unloaded) return; if (code == 200) { Puts("✔ Map data accepted by bigkids.pro."); return; } string why = ""; try { var refusal = JsonConvert.DeserializeObject(response ?? ""); if (refusal != null && !string.IsNullOrEmpty(refusal.reason)) why = " — " + refusal.reason; } catch { } PrintWarning($"⚠ bigkids.pro refused the dump (HTTP {code}{why}). It will be offered again later."); })); } #endregion } }