using System; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; using System.Reflection; using System.Text; using System.Threading; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Oxide.Core; using Oxide.Core.Libraries; using Oxide.Core.Libraries.Covalence; using Oxide.Core.Plugins; using Oxide.Game.Rust; using Oxide.Game.Rust.Cui; using Oxide.Game.Rust.Libraries; using Facepunch; // Load-bearing despite looking unused: `BroadcastTeamChat` is an EXTENSION method Facepunch defines // in the CompanionServer namespace, not a member of RelationshipManager.PlayerTeam. Without this // using the plugin does not compile, and the error blames PlayerTeam rather than the namespace. // That it lives in CompanionServer is also the reason it works — it is the Rust+ app's own path. using CompanionServer; using UnityEngine; namespace Oxide.Plugins { [Info("Cerebrust", "CerebRUST", "0.20.2")] [Description("CerebRUST V2 — ingest init, stats, sessions, locations, gather/activity rollups, map entities, tool cupboards, teams, chat, combat telemetry, world events, MOTD, reports, wipe reminders, monuments, and the gameplay features (v0.8.52) with stack sizes now a separate mod from gather rates and settable per item (v0.9.0), chat commands (v0.8.60), in-game chat interception that removes Rust's green admin/owner names (v0.8.68), debounced sign/artwork capture for moderation (v0.8.69), live dashboard toggles with no o.reload (v0.8.70), inbound KickPlayer/BanPlayer/UnbanPlayer commands (v0.8.71-73), a native ban-list mirror (v0.8.72), message placeholders that work in every broadcast (v0.9.0), turret/SAM targeting alerts that name the grid and the defence's owner (v0.9.1), and VIP membership — a synced oxide permission group, a chat tag and /vip (v0.10.0), and tool cupboard contents with a per-resource upkeep breakdown plus the inbound AddUpkeep command (v0.11.0), and monument card puzzles — keycard swipes tracked for the leaderboard and puzzle resets announced in chat (v0.12.0), and CerebRUST now formats chat on every server rather than handing over to BetterChat when it is installed (v0.12.1), and team chat now reaches the Rust+ companion app again, in both directions (v0.12.2), and base inventory — every storage container on the server swept into the dashboard with its slot layout, plus an inbound RefreshEntity command to re-read one now (v0.13.0), now limited to player-placed storage so monument loot barrels and crates are no longer tracked (v0.13.3), and building blocks — every foundation, wall and floor on the server swept with its grade and rotation so the 3D map can draw the bases themselves (v0.14.0), and the inbound ReadServerConfig command so a server.cfg written from the dashboard takes effect without a restart (v0.15.0), and the inbound SyncServerAdmins command so a CerebRUST organisation is mirrored onto Rust's own owner and moderator list (v0.16.0), and terrain topology sampled alongside the heightmap so the dashboard can tell a road, a monument or a cliff from open buildable ground (v0.17.0), and the moderation verbs Rust never had — a mute the plugin holds itself and expires on time without the API, and a warning that is delivered and recorded rather than shouted at everybody (v0.18.0), and the graceful restart Rust already had — a countdown players can read, a forced save and a clean exit, plus writing the server name, blurb, website and header image onto the running server with no credentials at all (v0.19.0), and large god rocks reported as monuments so the dashboard maps show the best-known building spots on the map (v0.20.0), with the monuments ingest now saying out loud what it did and a cerebrust.doctor monuments command to re-send it without a reload (v0.20.1), matching the debug probe's substring prefab match so god rocks are actually found (v0.20.2)")] public class Cerebrust : RustPlugin { private const float RequestTimeoutSeconds = 15f; /// Stats heartbeat can still hit a cold/slow API; avoid pausing all ingest on the default 15s uMod limit. private const float StatsRequestTimeoutSeconds = 45f; /// Longer timeout for full-world JSON snapshots (map entities, cupboard upkeeps) to reduce spurious HTTP 0 from uMod. private const float HeavyIngestRequestTimeoutSeconds = 60f; /// Interval for /ingest/heartbeat (FPS, player counts + plugin_settings). Map entities, locations, commands, etc. use other timers. private const float StatsIntervalSeconds = 15f; private const float ActiveLocationIntervalSeconds = 2f; private const float SleeperLocationIntervalSeconds = 60f; private const float PositionEpsilonMeters = 0.01f; private const float IngestRecoveryInitialSeconds = 5f; private const float IngestRecoveryMaxSeconds = 120f; private const float IngestFailureLogCooldownSeconds = 60f; /// Warn if a single hydrate reads more than this many persisted posts (unbounded disk queue). private const int PendingIngestHydrateWarnLines = 20_000; /// Hard cap on the pending-ingest disk queue; overflow drops the oldest lines (ticket 0013 P5). private const int PendingIngestDiskMaxLines = 20_000; /// On overflow, trim down to this (drop-oldest) so trims amortize instead of firing per append. private const int PendingIngestDiskTrimToLines = 16_000; /// Cached line count of the disk queue (the queue file is a single global path). private static int _pendingDiskLineCount = -1; private const float MapUploadRetrySeconds = 300f; private const float MapUploadTimeoutSeconds = 180f; private const float CupboardUpkeepsIntervalSeconds = 300f; // --- Storage-container sweep (v0.13.0) ------------------------------------------------ // Deliberately the same 300s cadence as the cupboard snapshot: both ride the one // serverEntities walk, and one number to reason about beats two that drift apart. /// /// Seconds between chunk ticks of the container sweep. The sweep does not run inside the /// world walk — the walk only collects entity references (cheap), and sampling every /// container's inventory is then spread over many ticks so a late-wipe server with tens of /// thousands of containers never stalls the main thread for a visible fraction of a second. /// private const float ContainerSweepTickSeconds = 0.2f; /// Containers sampled per tick. Raise only against a measured collect_ms. private const int ContainerSweepPerTick = 40; /// Containers per POST. Must stay at or under the API's own cap. private const int ContainerSweepPerPost = 200; /// Entity ids per POST — the "I saw these" list, which is far cheaper per entry. private const int ContainerSweepSeenPerPost = 1500; /// Slots read from any one container. Nothing in Rust comes close. private const int ContainerMaxSlots = 128; // --- Building sweep (v0.14.0) --------------------------------------------------------- // Rides the same 300s world walk as cupboards and containers, for the same reason: one // cadence to reason about. The blocks are collected as bare references during the walk and // read across later ticks, exactly like containers. /// Seconds between chunk ticks of the building sweep. private const float BuildingSweepTickSeconds = 0.2f; /// /// Blocks whose transform is read per tick. Measured on a live server: reading position + /// rotation off a block costs ~0.4 us, so 400 is ~0.16 ms of a tick. Raise only against a /// measured collect_ms. /// private const int BuildingSweepBlocksPerTick = 400; /// Blocks per POST. Must stay at or under the API's own per-request ceiling. private const int BuildingSweepBlocksPerPost = 2000; /// Buildings per POST. Must stay at or under the API's own cap. private const int BuildingSweepBuildingsPerPost = 64; /// Building ids per POST — the "I saw these" list, far cheaper per entry. private const int BuildingSweepSeenPerPost = 1500; /// /// Blocks read from any one building. The API rejects a building carrying more, and a /// base this size is pathological — a day-15 census measured 140 blocks per building. /// private const int BuildingMaxBlocks = 8000; /// How often we walk serverEntities for map-entity snapshot (same pass as cupboard upkeeps when that interval elapses). private const float MapEntitiesScanIntervalSeconds = 10f; /// Catalog periodic_messages.interval lower clamp (minutes). private const int PeriodicMessagesMinIntervalMinutes = 1; /// Catalog periodic_messages.interval upper clamp (minutes). private const int PeriodicMessagesMaxIntervalMinutes = 1440; private static readonly string[] PluginDefaultPeriodicMessages = { "Welcome to our server!" }; /// Periodic POST of gather totals and active/AFK seconds to the API. private const float GatherActivityPostIntervalSeconds = 30f; /// /// Max seconds credited toward active/AFK per rollup tick (guards clock skew / long stalls). Rollup runs every /// ; credited elapsed is min(raw gap, this cap). /// private const int ActivitySecondsMaxCreditPerTick = 600; /// How often the plugin pulls outbound server_commands (broadcast, etc.). private const float ServerCommandsPollIntervalSeconds = 2f; private const int ServerCommandsPullLimit = 20; /// /// A pull callback uMod never delivers costs exactly this much dead command polling, so it is /// derived from the pull's own timeout rather than picked independently: /// is the longest a pull can legitimately be outstanding, /// and past it the callback is not coming. Measured in production on 2026-08-27, ~0.2% of /// pulls lose their callback outright — the API answered 78,008 of 78,028 with a 200 in /// single-digit milliseconds and returned no 5xx at all — which at the old flat 120f left /// command polling dead ~7% of the time and delayed an in-game broadcast by up to two minutes. /// private const float ServerCommandsChainBusyStaleSeconds = RequestTimeoutSeconds + 5f; /// /// Timeout for POST .../ingest/commands/ack (payload can be larger than pull). It is /// deliberately not part of the poll lock's budget — see . /// private const float ServerCommandsAckRequestTimeoutSeconds = 45f; /// Min seconds between full console warnings for stale command-chain auto-clear. private const float ServerCommandsStaleAutoClearWarningCooldownSeconds = 1800f; /// /// While is false, periodically ensure recovery/init is scheduled /// and clear a stuck init HTTP in-flight guard if the callback never runs. /// private const float IngestSelfHealWatchdogIntervalSeconds = 120f; /// /// If stays true past this (no callback), clear the guard and reschedule recovery. /// private const float IngestInitHttpStaleSeconds = 180f; /// /// SteamID64 used as the chat avatar for all CerebRUST-branded lines (server broadcast and per-player notices). /// Keep this aligned with API-driven BroadcastToServer commands so branding stays consistent. /// private const ulong CerebrustBroadcastAvatarSteamId = 76561198705638010UL; /// /// Rich-text prefix for CerebRUST chat lines; must match / . /// private static readonly string CerebrustChatPrefixRich = "[CR]"; /// /// Speaker tag for dashboard-issued broadcasts, so a line typed by a human admin reads /// differently from the automated CerebRUST lines that share the [CR] prefix. /// Only the API-driven BroadcastToServer command wears it. /// private static readonly string AdminBroadcastSpeakerRich = "ADMIN"; private const float AfkThresholdMinutes = 10f; private const float GatherPositionChangeThresholdMeters = 1f; private const ulong SteamId64Min = 76561197960265728UL; private static readonly JsonSerializerSettings CombatTelemetryJsonSerializerSettings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }; /// ShortPrefabName values sent to POST /api/v2/ingest/map-entities (entries lowercase; match ignores case). private static readonly HashSet MapTrackedShortPrefabs = new HashSet(StringComparer.OrdinalIgnoreCase) { "patrol_helicopter", "cargomarker", "ch47marker", "diesel_collectable", "codelockedhackablecrate", "codelockedhackablecrate_oilrig", }; private PluginConfig config; private Timer statsTimer; private Timer activeLocationsTimer; private Timer sleeperLocationsTimer; private Timer worldEntityScanTimer; private float lastCupboardUpkeepsSnapshotTime; // --- Storage-container sweep state ---------------------------------------------------- /// /// Content hash per container entity id. This is the whole reason the feature is affordable: /// a steady-state sweep sends thousands of entity ids and a handful of inventories, so the /// write volume tracks what players actually do rather than how many boxes exist. Lost on /// o.reload, which just means the next sweep sends everything once — self-correcting. /// private readonly Dictionary containerContentHashes = new Dictionary(); /// Entity prefabID → the item shortname that places it, for the dashboard's art. private readonly Dictionary deployableItemShortnameByPrefabId = new Dictionary(); private Timer containerSweepTimer; private List containerSweepQueue; private int containerSweepIndex; private List containerSweepSeen; // --- Building sweep state ------------------------------------------------------------- /// /// Content hash per Rust buildingID. One hash per *building*, not per block: a /// steady-state sweep therefore names a few hundred building ids and carries the blocks of /// the handful that actually changed. Lost on o.reload, which just means the next /// sweep sends everything once — self-correcting. /// private readonly Dictionary buildingContentHashes = new Dictionary(); private Timer buildingSweepTimer; private List>> buildingSweepQueue; private int buildingSweepIndex; private List buildingSweepSeen; private List buildingSweepChanged; private int buildingSweepChangedBlocks; private int buildingSweepPostedBuildings; /// Every building id seen across the *whole* sweep, for the end-of-sweep prune. private HashSet buildingSweepSeenAll; /// /// buildingID → the entity id of a cupboard covering it, filled during the same walk /// that collects the blocks. The cupboard branch of the walk already has every /// BuildingPrivlidge in hand, so this costs one dictionary write per cupboard rather /// than a second search per building. /// private readonly Dictionary cupboardEntityByBuilding = new Dictionary(); /// /// Every id seen across the *whole* sweep, unlike which is /// emptied on each POST. Used once at the end to prune , /// which would otherwise accumulate an entry per container ever destroyed — small per row, /// but it never stops growing on a server that runs for months. /// private HashSet containerSweepSeenAll; private List containerSweepChanged; private int containerSweepPostedContainers; private Timer mapUploadRetryTimer; private bool ingestReady; private readonly HashSet _pausedIngestEndpointFamilies = new HashSet(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _ingestEndpointRecoveryTimers = new Dictionary(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _ingestEndpointRecoveryDelaySeconds = new Dictionary(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _lastIngestEndpointFailureLogTime = new Dictionary(StringComparer.OrdinalIgnoreCase); private readonly Dictionary lastSentPositions = new Dictionary(); private readonly HashSet previousSleeperSteamIds = new HashSet(); private Timer gatherActivityTimer; private Timer serverCommandsTimer; private Timer periodicMessagesTimer; private Timer ingestSelfHealWatchdogTimer; private bool _ingestInitHttpInFlight; private float _ingestInitHttpStartedRealtime; private int _ingestInitConsecutiveFailures; private bool _serverCommandsChainBusy; /// Unity when was set; 0 if idle. private float _serverCommandsChainBusySinceRealtime; private float _lastServerCommandsStaleChainBusyLogRealtime; private int rollupWipeMapSeed; private readonly Dictionary gatherTotalsBySteam = new Dictionary(); /// Outstanding player-gather HTTP callbacks (periodic rollup only); skip new rollups while > 0. private int _gatherRollupHttpCallbacksPending; /// Outstanding player-activity HTTP callbacks; skip new activity rollups while > 0. private int _activityRollupHttpCallbacksPending; private readonly Dictionary activityTotalsBySteam = new Dictionary(); private readonly Dictionary positionTrackersForAfk = new Dictionary(); /// Fingerprint of periodic_messages.* cache so we only reset the timer when those settings change. private string _periodicMessagesScheduleFingerprint; private int _periodicMessageOrdinal; private bool _initWipeRecorded; /// Pending gather since last successful player-gather POST (deltas), not wipe lifetime. private sealed class GatherTotals { public long Wood; public long Stone; public long Metal; public long Sulfur; } /// Pending active/AFK seconds since last successful player-activity POST (deltas). private sealed class ActivityTotals { public long SecondsActive; public long SecondsAfk; public DateTime LastActiveUpdateUtc; } private sealed class PlayerPositionTracker { public Vector3 LastPosition; public DateTime LastMovedUtc; public PlayerPositionTracker(Vector3 position, DateTime nowUtc) { LastPosition = position; LastMovedUtc = nowUtc; } } private sealed class IngestPlayerGatherSample { [JsonProperty("steam_id")] public ulong SteamId { get; set; } [JsonProperty("wood_gathered")] public long WoodGathered { get; set; } [JsonProperty("stone_gathered")] public long StoneGathered { get; set; } [JsonProperty("metal_gathered")] public long MetalGathered { get; set; } [JsonProperty("sulfur_gathered")] public long SulfurGathered { get; set; } } private sealed class IngestPlayerGatherRequest { [JsonProperty("token")] public string Token { get; set; } [JsonProperty("samples")] public List Samples { get; set; } } private sealed class IngestPlayerActivitySample { [JsonProperty("steam_id")] public ulong SteamId { get; set; } [JsonProperty("seconds_active")] public long SecondsActive { get; set; } [JsonProperty("seconds_afk")] public long SecondsAfk { get; set; } } private sealed class IngestPlayerActivityRequest { [JsonProperty("token")] public string Token { get; set; } [JsonProperty("samples")] public List Samples { get; set; } } private readonly Queue pendingIngestPosts = new Queue(); private string lastMapUploadKey; // Sign moderation (ticket 0012): debounce rapid re-saves so we capture the *finished* image // once the player stops editing, rather than every intermediate save while still drawing. private const float SignUpdateDebounceSeconds = 8f; private readonly Dictionary _pendingSignEdits = new Dictionary(); private readonly Dictionary _signDebounceTimers = new Dictionary(); private sealed class PendingSignEdit { public Signage Sign; public ulong SteamId; public string Name; } /// /// Oxide SaveConfig often drops nested contents; we store minified JSON as a string /// but still emit a JSON object in the file. Accepts legacy {} object or string from disk. /// private sealed class PluginSettingsCacheJsonConverter : JsonConverter { public override bool CanConvert(Type objectType) { return objectType == typeof(string); } public override object ReadJson( JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer ) { switch (reader.TokenType) { case JsonToken.Null: return "{}"; case JsonToken.String: return string.IsNullOrWhiteSpace((string)reader.Value) ? "{}" : (string)reader.Value; case JsonToken.StartObject: { var obj = JObject.Load(reader); return obj.ToString(Formatting.None); } default: throw new JsonSerializationException( $"plugin_settings_cache expected object or string, got {reader.TokenType}" ); } } public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) { var s = value as string; if (string.IsNullOrWhiteSpace(s)) { writer.WriteStartObject(); writer.WriteEndObject(); return; } try { var parsed = JObject.Parse(s); parsed.WriteTo(writer); } catch (JsonException) { writer.WriteStartObject(); writer.WriteEndObject(); } } } [JsonObject(MemberSerialization.OptOut)] private sealed class PluginConfig { /// Round-trip unknown JSON keys (e.g. legacy gather_debug_log) across Oxide SaveConfig. [JsonExtensionData] public Dictionary ExtensionData { get; set; } public string ApiBaseUrl = "https://api.cerebrust.xyz"; public string IngestToken = ""; /// /// When true, logs periodic ingest / world-scan detail (default off — those lines are very chatty). /// [JsonProperty("verbose_logging", DefaultValueHandling = DefaultValueHandling.Include)] public bool VerboseLogging = false; /// /// Optional Discord webhook URL for raid-structure-damage-filter diagnostics (a temporary /// instrument, v0.8.64). When set, every ~10s the plugin posts a compact summary of how many /// raidable-structure hits it recorded vs dropped — and which filter dropped them — so we can /// see why explosive impacts aren't producing structure_damage without trawling the /// server logs. Empty = disabled (no cost on the hot path). /// [JsonProperty("raid_debug_webhook_url")] public string RaidDebugWebhookUrl = ""; // `report_cupboard_contents` was removed in v0.13.0. Observability is not something a // server opts out of — a toggle on it means the server that most needs the data is the // one that turned it off. Toggles are for features that *change* the game (gather rates, // stack sizes, chat formatting); telemetry is always on. Any surviving key in an // existing config file is harmless: it round-trips through ExtensionData and is ignored. // If cost ever needs a brake, the lever is an API-pushed cadence or batch cap, not a // user-facing switch. /// /// Last merged plugin_settings as minified JSON text; file still shows a JSON object (see converter). /// [JsonProperty("plugin_settings_cache", DefaultValueHandling = DefaultValueHandling.Include)] [JsonConverter(typeof(PluginSettingsCacheJsonConverter))] public string PluginSettingsCache = "{}"; } private sealed class IngestInitRequest { [JsonProperty("token")] public string Token { get; set; } [JsonProperty("ip_address")] public string IpAddress { get; set; } [JsonProperty("game_port")] public int GamePort { get; set; } [JsonProperty("query_port")] public int QueryPort { get; set; } [JsonProperty("name")] public string Name { get; set; } [JsonProperty("description")] public string Description { get; set; } [JsonProperty("header_image_url")] public string HeaderImageUrl { get; set; } [JsonProperty("version")] public string Version { get; set; } [JsonProperty("save_created_time")] public DateTime SaveCreatedTime { get; set; } [JsonProperty("world_seed")] public long WorldSeed { get; set; } [JsonProperty("world_size")] public int WorldSize { get; set; } } private sealed class IngestStatsRequest { [JsonProperty("token")] public string Token { get; set; } [JsonProperty("fps")] public float? Fps { get; set; } [JsonProperty("players_online")] public int PlayersOnline { get; set; } [JsonProperty("players_sleeping")] public int PlayersSleeping { get; set; } [JsonProperty("max_players")] public int MaxPlayers { get; set; } } private sealed class IngestPlayerConnectRequest { [JsonProperty("token")] public string Token { get; set; } [JsonProperty("steam_id")] public ulong SteamId { get; set; } [JsonProperty("ip_address")] public string IpAddress { get; set; } [JsonProperty("display_name")] public string DisplayName { get; set; } } private sealed class IngestPlayerDisconnectRequest { [JsonProperty("token")] public string Token { get; set; } [JsonProperty("steam_id")] public ulong SteamId { get; set; } [JsonProperty("disconnect_reason")] public string DisconnectReason { get; set; } /// Optional; used client-side for session chat when API display_name is absent. [JsonProperty("display_name")] public string DisplayName { get; set; } } private sealed class IngestPlayerDeathRequest { [JsonProperty("token")] public string Token { get; set; } [JsonProperty("victim_steam_id")] public ulong VictimSteamId { get; set; } [JsonProperty("killer_steam_id", NullValueHandling = NullValueHandling.Ignore)] public ulong? KillerSteamId { get; set; } [JsonProperty("killer_class")] public string KillerClass { get; set; } = "UNKNOWN"; [JsonProperty("is_suicide")] public bool IsSuicide { get; set; } [JsonProperty("majority_damage_type", NullValueHandling = NullValueHandling.Ignore)] public string MajorityDamageType { get; set; } [JsonProperty("initiator_short_prefab", NullValueHandling = NullValueHandling.Ignore)] public string InitiatorShortPrefab { get; set; } [JsonProperty("killer_initiator_label", NullValueHandling = NullValueHandling.Ignore)] public string KillerInitiatorLabel { get; set; } [JsonProperty("weapon_display_name", NullValueHandling = NullValueHandling.Ignore)] public string WeaponDisplayName { get; set; } [JsonProperty("distance_meters", NullValueHandling = NullValueHandling.Ignore)] public float? DistanceMeters { get; set; } [JsonProperty("victim_was_sleeping", NullValueHandling = NullValueHandling.Ignore)] public bool? VictimWasSleeping { get; set; } [JsonProperty("raw_payload", NullValueHandling = NullValueHandling.Ignore)] public object RawPayload { get; set; } [JsonProperty("happened_at", NullValueHandling = NullValueHandling.Ignore)] public string HappenedAtIso { get; set; } } private sealed class IngestPlayerNpcKillRequest { [JsonProperty("token")] public string Token { get; set; } [JsonProperty("killer_steam_id")] public ulong KillerSteamId { get; set; } [JsonProperty("victim_short_prefab")] public string VictimShortPrefab { get; set; } [JsonProperty("raw_payload", NullValueHandling = NullValueHandling.Ignore)] public object RawPayload { get; set; } [JsonProperty("happened_at", NullValueHandling = NullValueHandling.Ignore)] public string HappenedAtIso { get; set; } } private sealed class IngestPlayerPosition { [JsonProperty("steam_id")] public ulong SteamId { get; set; } [JsonProperty("x", NullValueHandling = NullValueHandling.Include)] public float? X { get; set; } [JsonProperty("y", NullValueHandling = NullValueHandling.Include)] public float? Y { get; set; } [JsonProperty("z", NullValueHandling = NullValueHandling.Include)] public float? Z { get; set; } [JsonProperty("display_name", NullValueHandling = NullValueHandling.Ignore)] public string DisplayName { get; set; } [JsonProperty("ip_address", NullValueHandling = NullValueHandling.Ignore)] public string IpAddress { get; set; } [JsonProperty("online")] public bool Online { get; set; } = true; [JsonProperty("rust_team_id", NullValueHandling = NullValueHandling.Ignore)] public long? RustTeamId { get; set; } } private sealed class IngestPlayerLocationsRequest { [JsonProperty("token")] public string Token { get; set; } [JsonProperty("positions")] public List Positions { get; set; } } private sealed class IngestInitResponse { [JsonProperty("server_id")] public long ServerId { get; set; } [JsonProperty("server_bound")] public bool ServerBound { get; set; } [JsonProperty("wipe_recorded")] public bool WipeRecorded { get; set; } [JsonProperty("map_image_url", NullValueHandling = NullValueHandling.Ignore)] public string MapImageUrl { get; set; } /// Set when the API already holds this wipe's terrain, so we skip the sample pass. [JsonProperty("terrain_url", NullValueHandling = NullValueHandling.Ignore)] public string TerrainUrl { get; set; } } private sealed class IngestMapRequest { [JsonProperty("token")] public string Token { get; set; } [JsonProperty("game_version")] public string GameVersion { get; set; } [JsonProperty("world_size")] public int WorldSize { get; set; } [JsonProperty("world_seed")] public long WorldSeed { get; set; } [JsonProperty("map_png_base64")] public string MapPngBase64 { get; set; } } private sealed class IngestSignRequest { [JsonProperty("token")] public string Token { get; set; } [JsonProperty("entity_id")] public long EntityId { get; set; } [JsonProperty("sign_type")] public string SignType { get; set; } [JsonProperty("x")] public float X { get; set; } [JsonProperty("y")] public float Y { get; set; } [JsonProperty("z")] public float Z { get; set; } [JsonProperty("image_png_base64")] public string ImagePngBase64 { get; set; } [JsonProperty("updated_by_steam_id")] public ulong UpdatedBySteamId { get; set; } [JsonProperty("updated_by_name", NullValueHandling = NullValueHandling.Ignore)] public string UpdatedByName { get; set; } } private sealed class IngestErrorResponse { [JsonProperty("error")] public string Error { get; set; } [JsonProperty("message")] public string Message { get; set; } } private sealed class IngestCupboardPlaceRequest { [JsonProperty("token")] public string Token { get; set; } [JsonProperty("entity_id")] public long EntityId { get; set; } [JsonProperty("owning_steam_id", NullValueHandling = NullValueHandling.Ignore)] public ulong? OwningSteamId { get; set; } [JsonProperty("x")] public float X { get; set; } [JsonProperty("y")] public float Y { get; set; } [JsonProperty("z")] public float Z { get; set; } [JsonProperty("remaining_upkeep_minutes")] public int RemainingUpkeepMinutes { get; set; } } private sealed class IngestCupboardEntityRequest { [JsonProperty("token")] public string Token { get; set; } [JsonProperty("entity_id")] public long EntityId { get; set; } } private sealed class IngestCupboardAuthRequest { [JsonProperty("token")] public string Token { get; set; } [JsonProperty("entity_id")] public long EntityId { get; set; } [JsonProperty("steam_id")] public ulong SteamId { get; set; } } private sealed class IngestTargetingAlertRequest { [JsonProperty("token")] public string Token { get; set; } // "turret" or "sam" — which kind of defence acquired the lock. [JsonProperty("source")] public string Source { get; set; } // net.ID of the connected tool cupboard, so the API can de-dupe/rate-limit per base. [JsonProperty("tc_entity_id")] public long TcEntityId { get; set; } // Everyone authed on that TC (owner + auth list). The API maps these to linked // Discord ids and DMs them. Deliberately carries NO victim identity. [JsonProperty("authed_steam_ids")] public List AuthedSteamIds { get; set; } // In-game grid the defence sits in ("K12"), so the DM can say WHERE. [JsonProperty("grid", NullValueHandling = NullValueHandling.Ignore)] public string Grid { get; set; } // Who placed the turret/SAM — a recipient is often authed on bases they did not // build, so the DM names the owner rather than saying "one of yours". [JsonProperty("owner_steam_id", NullValueHandling = NullValueHandling.Ignore)] public long? OwnerSteamId { get; set; } // Best-effort name for that owner; the API prefers the Steam persona it holds. [JsonProperty("owner_display_name", NullValueHandling = NullValueHandling.Ignore)] public string OwnerDisplayName { get; set; } } private sealed class IngestCupboardUpkeepsSnapshotItem { [JsonProperty("entity_id")] public long EntityId { get; set; } [JsonProperty("owning_steam_id", NullValueHandling = NullValueHandling.Ignore)] public ulong? OwningSteamId { get; set; } [JsonProperty("x")] public float X { get; set; } [JsonProperty("y")] public float Y { get; set; } [JsonProperty("z")] public float Z { get; set; } [JsonProperty("remaining_upkeep_minutes")] public int RemainingUpkeepMinutes { get; set; } [JsonProperty("authorized_steam_ids")] public List AuthorizedSteamIds { get; set; } // --- v0.11.0 contents block; every field omitted when reporting is off, because an // absent key is what tells the API this cupboard has no contents data at all. --- /// Slot count of the cupboard's own container (tc.inventory.capacity). [JsonProperty("inventory_capacity", NullValueHandling = NullValueHandling.Ignore)] public int? InventoryCapacity { get; set; } [JsonProperty("inventory_slots_used", NullValueHandling = NullValueHandling.Ignore)] public int? InventorySlotsUsed { get; set; } /// Minutes one CalculateUpkeepCostAmounts charge covers. [JsonProperty("upkeep_period_minutes", NullValueHandling = NullValueHandling.Ignore)] public int? UpkeepPeriodMinutes { get; set; } /// Every item in the cupboard, aggregated by shortname — not just upkeep resources. [JsonProperty("contents", NullValueHandling = NullValueHandling.Ignore)] public List Contents { get; set; } [JsonProperty("upkeep_cost", NullValueHandling = NullValueHandling.Ignore)] public List UpkeepCost { get; set; } } private sealed class IngestCupboardContentItem { [JsonProperty("shortname")] public string Shortname { get; set; } [JsonProperty("amount")] public int Amount { get; set; } /// Slots this shortname currently occupies — real stacks, not amount/stack_size. [JsonProperty("slots")] public int Slots { get; set; } /// /// Runtime ItemDefinition.stackable. Read live rather than assumed because the /// stack-sizes mod overwrites it in place (), so a /// hardcoded vanilla number would be wrong on most CerebRUST servers. /// [JsonProperty("stack_size")] public int StackSize { get; set; } } private sealed class IngestCupboardUpkeepCostItem { [JsonProperty("shortname")] public string Shortname { get; set; } [JsonProperty("cost_per_period")] public float CostPerPeriod { get; set; } /// /// Stack size for this resource even when the cupboard holds none of it. The empty cupboard /// is exactly the case where someone wants the top-up button, so the API cannot be left to /// infer a stack size only from what happens to be inside. /// [JsonProperty("stack_size")] public int StackSize { get; set; } } private sealed class IngestCupboardUpkeepsRequest { [JsonProperty("token")] public string Token { get; set; } [JsonProperty("cupboards")] public List Cupboards { get; set; } } /// One occupied slot in a storage container. private sealed class IngestContainerSlot { /// Item.position — the slot index, which is what lets the dashboard /// draw the container the way it looks in game. Not always dense, and on a tool cupboard /// it runs past the 24 visible slots. [JsonProperty("slot")] public int Slot { get; set; } [JsonProperty("shortname")] public string Shortname { get; set; } [JsonProperty("quantity")] public int Quantity { get; set; } [JsonProperty("display_name", NullValueHandling = NullValueHandling.Ignore)] public string DisplayName { get; set; } /// Read off the live definition — the stack-sizes mod rewrites it in place. [JsonProperty("stack_size")] public int StackSize { get; set; } /// 0-100, omitted for items with no condition. [JsonProperty("condition_pct", NullValueHandling = NullValueHandling.Ignore)] public float? ConditionPct { get; set; } [JsonProperty("skin_id", NullValueHandling = NullValueHandling.Ignore)] public string SkinId { get; set; } } /// One storage container and, when its contents changed, every occupied slot. private sealed class IngestContainerItem { [JsonProperty("entity_id")] public long EntityId { get; set; } /// The container entity's own prefab (woodbox_deployed, furnace). [JsonProperty("prefab_shortname")] public string PrefabShortname { get; set; } /// /// The *item* shortname of the deployable that places this entity. Sent alongside the /// prefab because the two genuinely differ for many deployables — woodbox_deployed /// is placed by the item box.wooden — and only the item shortname resolves /// against the item-art CDN the dashboard uses. /// [JsonProperty("item_shortname", NullValueHandling = NullValueHandling.Ignore)] public string ItemShortname { get; set; } /// Rust's own loot-panel name: which grid layout a renderer should draw. [JsonProperty("panel_name", NullValueHandling = NullValueHandling.Ignore)] public string PanelName { get; set; } [JsonProperty("x")] public float X { get; set; } [JsonProperty("y")] public float Y { get; set; } [JsonProperty("z")] public float Z { get; set; } [JsonProperty("capacity")] public int Capacity { get; set; } [JsonProperty("slots_used")] public int SlotsUsed { get; set; } [JsonProperty("owning_steam_id", NullValueHandling = NullValueHandling.Ignore)] public ulong? OwningSteamId { get; set; } /// The building privilege covering this container, if any. [JsonProperty("tool_cupboard_entity_id", NullValueHandling = NullValueHandling.Ignore)] public string ToolCupboardEntityId { get; set; } /// Rust buildingID — reassigned on building merge/split, so grouping only. [JsonProperty("building_id", NullValueHandling = NullValueHandling.Ignore)] public string BuildingId { get; set; } [JsonProperty("contents_hash")] public string ContentsHash { get; set; } [JsonProperty("items")] public List Items { get; set; } } /// One chunk of a container sweep. private sealed class IngestContainersRequest { [JsonProperty("token")] public string Token { get; set; } /// /// Every container this chunk covered, changed or not. Cheap per entry and it is what /// keeps a container out of the API's destroy reconcile — the sweep is spread over many /// POSTs, so no single one of them can be the authoritative world set. /// [JsonProperty("seen_entity_ids")] public List SeenEntityIds { get; set; } /// Only the containers whose contents hash changed since the last sweep. [JsonProperty("containers")] public List Containers { get; set; } } /// One placed building block: a foundation, wall, floor, roof or stair. private sealed class IngestBuildingBlockItem { [JsonProperty("entity_id")] public long EntityId { get; set; } /// The block's ShortPrefabNamewall, floor.triangle. [JsonProperty("prefab_shortname")] public string PrefabShortname { get; set; } /// /// The grade NAME, never the numeric enum value. Facepunch has renumbered /// BuildingGrade.Enum, so an ordinal on the wire would silently reinterpret every /// row the API already stored the day a grade is inserted into the middle of it. /// [JsonProperty("grade")] public string Grade { get; set; } [JsonProperty("skin_id", NullValueHandling = NullValueHandling.Ignore)] public string SkinId { get; set; } [JsonProperty("x")] public float X { get; set; } [JsonProperty("y")] public float Y { get; set; } [JsonProperty("z")] public float Z { get; set; } // Rotation is a quaternion, not Euler angles. Unity composes eulerAngles Z-X-Y and // three.js defaults to X-Y-Z, so a payload round-tripped through Euler arrives with a // whole class of bases rotated plausibly but wrongly. [JsonProperty("rot_x")] public float RotX { get; set; } [JsonProperty("rot_y")] public float RotY { get; set; } [JsonProperty("rot_z")] public float RotZ { get; set; } [JsonProperty("rot_w")] public float RotW { get; set; } } /// One building — the unit Rust itself groups blocks into. private sealed class IngestBuildingItem { /// /// Rust buildingID. Reassigned when buildings merge or split, but issued /// monotonically from maxBuildingID and never recycled, so a reassignment always /// looks like a new building rather than colliding with a stale one. /// [JsonProperty("building_id")] public long BuildingId { get; set; } [JsonProperty("tool_cupboard_entity_id", NullValueHandling = NullValueHandling.Ignore)] public string ToolCupboardEntityId { get; set; } [JsonProperty("content_hash")] public string ContentHash { get; set; } /// /// The COMPLETE block set for this building. A building is never split across POSTs, /// which is exactly what lets the API delete the blocks a payload does not mention /// instead of waiting a staleness window out — so a wall that came down in a raid is /// gone from the map on the next sweep. /// [JsonProperty("blocks")] public List Blocks { get; set; } } /// One chunk of a building sweep. private sealed class IngestBuildingsRequest { [JsonProperty("token")] public string Token { get; set; } /// /// Every building this chunk covered, changed or not. This is what collapses a /// steady-state sweep: one entry per base rather than one per wall. /// [JsonProperty("seen_building_ids")] public List SeenBuildingIds { get; set; } /// Only the buildings whose content hash changed since the last sweep. [JsonProperty("buildings")] public List Buildings { get; set; } } private sealed class IngestBuildingsResponse { [JsonProperty("unknown_building_ids")] public List UnknownBuildingIds { get; set; } } /// Single-container refresh — the write half of the RefreshEntity command. private sealed class IngestContainerRequest { [JsonProperty("token")] public string Token { get; set; } [JsonProperty("container")] public IngestContainerItem Container { get; set; } } private sealed class IngestContainersResponse { /// /// Entities we claimed to have seen that the API holds no row for. We drop their cached /// hashes so the next sweep re-sends them in full. Without this handshake the hash cache /// is a liability: after a wipe every container is "unchanged" as far as we know, and /// none of them would ever be sent. /// [JsonProperty("unknown_entity_ids")] public List UnknownEntityIds { get; set; } } /// /// Single-cupboard refresh posted straight after an AddUpkeep. Deliberately NOT the /// cupboard-upkeeps route: that one treats its list as the authoritative full world set /// and marks every cupboard it does not mention destroyed. /// private sealed class IngestCupboardContentsRequest { [JsonProperty("token")] public string Token { get; set; } [JsonProperty("cupboard")] public IngestCupboardUpkeepsSnapshotItem Cupboard { get; set; } } private sealed class IngestMapEntityItem { [JsonProperty("entity_id")] public long EntityId { get; set; } [JsonProperty("name")] public string Name { get; set; } [JsonProperty("x")] public float X { get; set; } [JsonProperty("y")] public float Y { get; set; } [JsonProperty("z")] public float Z { get; set; } } private sealed class IngestMapEntitiesRequest { [JsonProperty("token")] public string Token { get; set; } [JsonProperty("entities")] public List Entities { get; set; } } private sealed class IngestCupboardHookMisses { [JsonProperty("placed")] public List Placed { get; set; } [JsonProperty("destroyed")] public List Destroyed { get; set; } [JsonProperty("revived")] public List Revived { get; set; } [JsonProperty("auths_opened")] public int AuthsOpened { get; set; } [JsonProperty("auths_closed")] public int AuthsClosed { get; set; } } private sealed class IngestCupboardUpkeepsResponse { [JsonProperty("bootstrap")] public bool Bootstrap { get; set; } [JsonProperty("cupboards_seen")] public int CupboardsSeen { get; set; } [JsonProperty("cupboards_destroyed")] public int CupboardsDestroyed { get; set; } [JsonProperty("hook_misses")] public IngestCupboardHookMisses HookMisses { get; set; } [JsonProperty("discord_alert_sent")] public bool DiscordAlertSent { get; set; } } private sealed class IngestTeamEventItem { [JsonProperty("kind")] public string Kind { get; set; } [JsonProperty("rust_team_id")] public long RustTeamId { get; set; } [JsonProperty("recorded_at")] public string RecordedAtIso { get; set; } [JsonProperty("leader_steam_id", NullValueHandling = NullValueHandling.Ignore)] public long? LeaderSteamId { get; set; } [JsonProperty("member_steam_ids", NullValueHandling = NullValueHandling.Ignore)] public List MemberSteamIds { get; set; } [JsonProperty("member_steam_id", NullValueHandling = NullValueHandling.Ignore)] public long? MemberSteamId { get; set; } [JsonProperty("target_steam_id", NullValueHandling = NullValueHandling.Ignore)] public long? TargetSteamId { get; set; } } private sealed class IngestTeamEventsRequest { [JsonProperty("token")] public string Token { get; set; } [JsonProperty("events")] public List Events { get; set; } } private sealed class IngestTeamReconcileTeamItem { [JsonProperty("rust_team_id")] public long RustTeamId { get; set; } [JsonProperty("leader_steam_id")] public long LeaderSteamId { get; set; } [JsonProperty("member_steam_ids")] public List MemberSteamIds { get; set; } } private sealed class IngestTeamReconcileRequest { [JsonProperty("token")] public string Token { get; set; } [JsonProperty("teams")] public List Teams { get; set; } [JsonProperty("observed_at", NullValueHandling = NullValueHandling.Ignore)] public string ObservedAtIso { get; set; } } private sealed class IngestBanItem { [JsonProperty("steam_id")] public long SteamId { get; set; } [JsonProperty("display_name", NullValueHandling = NullValueHandling.Ignore)] public string DisplayName { get; set; } [JsonProperty("reason", NullValueHandling = NullValueHandling.Ignore)] public string Reason { get; set; } [JsonProperty("expires_at", NullValueHandling = NullValueHandling.Ignore)] public string ExpiresAtIso { get; set; } } private sealed class IngestBansReconcileRequest { [JsonProperty("token")] public string Token { get; set; } [JsonProperty("bans")] public List Bans { get; set; } [JsonProperty("observed_at", NullValueHandling = NullValueHandling.Ignore)] public string ObservedAtIso { get; set; } } private sealed class IngestBanEventRequest { [JsonProperty("token")] public string Token { get; set; } [JsonProperty("action")] public string Action { get; set; } [JsonProperty("steam_id")] public long SteamId { get; set; } [JsonProperty("display_name", NullValueHandling = NullValueHandling.Ignore)] public string DisplayName { get; set; } [JsonProperty("reason", NullValueHandling = NullValueHandling.Ignore)] public string Reason { get; set; } [JsonProperty("expires_at", NullValueHandling = NullValueHandling.Ignore)] public string ExpiresAtIso { get; set; } } private sealed class IngestChatMessageItem { [JsonProperty("channel")] public string Channel { get; set; } [JsonProperty("body")] public string Body { get; set; } [JsonProperty("sender_steam_id", NullValueHandling = NullValueHandling.Ignore)] public long? SenderSteamId { get; set; } [JsonProperty("server_message_name", NullValueHandling = NullValueHandling.Ignore)] public string ServerMessageName { get; set; } [JsonProperty("rust_team_id", NullValueHandling = NullValueHandling.Ignore)] public long? RustTeamId { get; set; } [JsonProperty("from_rust_plus", NullValueHandling = NullValueHandling.Ignore)] public bool? FromRustPlus { get; set; } [JsonProperty("recorded_at", NullValueHandling = NullValueHandling.Ignore)] public string RecordedAtIso { get; set; } [JsonProperty("sender_display_name", NullValueHandling = NullValueHandling.Ignore)] public string SenderDisplayName { get; set; } } private sealed class IngestChatMessagesRequest { [JsonProperty("token")] public string Token { get; set; } [JsonProperty("messages")] public List Messages { get; set; } } private sealed class PendingIngestPost { public string Label { get; set; } public string Url { get; set; } public string Body { get; set; } } private sealed class IngestServerEventItem { [JsonProperty("event_type")] public string EventType { get; set; } [JsonProperty("visibility")] public string Visibility { get; set; } [JsonProperty("recorded_at", NullValueHandling = NullValueHandling.Ignore)] public string RecordedAtIso { get; set; } [JsonProperty("actor_steam_id", NullValueHandling = NullValueHandling.Ignore)] public long? ActorSteamId { get; set; } [JsonProperty("metadata", NullValueHandling = NullValueHandling.Ignore)] public Dictionary Metadata { get; set; } } private sealed class IngestServerEventsRequest { [JsonProperty("token")] public string Token { get; set; } [JsonProperty("events")] public List Events { get; set; } } #region Config protected override void LoadDefaultConfig() { config = new PluginConfig(); InvalidatePluginSettingsCache(); SaveConfig(); } protected override void LoadConfig() { base.LoadConfig(); config = Config.ReadObject(); // config was just (re)assigned — drop any memoized parse tied to the previous instance. InvalidatePluginSettingsCache(); if (config == null) { LoadDefaultConfig(); } else { if (string.IsNullOrWhiteSpace(config.PluginSettingsCache)) { config.PluginSettingsCache = "{}"; } if (config.ExtensionData == null) { config.ExtensionData = new Dictionary(); } else { // Same key in ExtensionData is serialized *after* the typed member and overwrites it. config.ExtensionData.Remove("plugin_settings_cache"); } if (ParsePluginSettingsCache().Count == 0) { SaveConfig(); } } } /// /// uMod persists the typed object only when we it; /// the default SaveConfig path does not reliably flush our field. /// protected override void SaveConfig() { if (config == null) { return; } if (config.ExtensionData != null) { config.ExtensionData.Remove("plugin_settings_cache"); } Config.WriteObject(config); } /// Chatty diagnostic lines; gated by . private void VerbosePuts(string message) { if (IsVerboseLoggingEnabled()) { Puts(message); } } private static bool ExtensionDataBool(Dictionary ext, string key) { if (ext == null) { return false; } if (!ext.TryGetValue(key, out var t) || t == null || t.Type == JTokenType.Null) { return false; } return t.Type == JTokenType.Boolean && t.Value(); } private bool IsVerboseLoggingEnabled() { if (config == null) { return false; } if (config.VerboseLogging) { return true; } return ExtensionDataBool(config.ExtensionData, "gather_debug_log"); } // The nine gameplay-feature section slugs whose ``.enabled`` flag gates the hottest // hooks (OnEntitySpawned, gather, turret/SAM target, furnace Cook). Keep in sync with the // API's settings catalogue (servers/settings_catalog/mods). // // Adding a slug here is only half the job: it must also be handled in BOTH directions in // MaybeRefreshGameplayFeaturesOnSettingsMerge, or the feature will not toggle live and will // need an o.reload — the v0.8.70 bug where five features stayed dead on OFF->ON. private static readonly string[] GameplayFeatureSlugs = { "auto_fuel", "blueprint_share", "furnace_boost", "gather", "stack_sizes", "stack_recycling", "turret_auth", "sam_auth", "sort_button", }; // Memoized parse of config.PluginSettingsCache. The multi-KB blob was JObject.Parse'd on // EVERY ReadPluginSetting*/IsGameplayFeatureEnabled call — i.e. per entity spawn, per gather // hit, per turret tick — which is heavy main-thread GC pressure for values that only change // on the ~15s heartbeat merge. Cached here and invalidated only when the string changes // (config load / merge / unload). Ticket 0010. private JObject _settingsCacheParsed; // .enabled flags, rebuilt alongside the parsed cache, so IsGameplayFeatureEnabled is a // dictionary lookup on the hot path rather than a JSON index + string interpolation. private Dictionary _gameplayEnabledCache; private JObject ParsePluginSettingsCache() { if (_settingsCacheParsed != null) { return _settingsCacheParsed; } JObject parsed; if (config == null || string.IsNullOrWhiteSpace(config.PluginSettingsCache)) { parsed = new JObject(); } else { try { parsed = JObject.Parse(config.PluginSettingsCache); } catch (JsonException) { parsed = new JObject(); } } _settingsCacheParsed = parsed; RebuildGameplayEnabledCache(parsed); return parsed; } /// Drop the memoized parse so the next read re-parses the settings string. private void InvalidatePluginSettingsCache() { _settingsCacheParsed = null; _gameplayEnabledCache = null; } private void RebuildGameplayEnabledCache(JObject cache) { var dict = new Dictionary(GameplayFeatureSlugs.Length); foreach (var slug in GameplayFeatureSlugs) { dict[slug] = ReadBoolFromToken(cache[slug + ".enabled"], false); } _gameplayEnabledCache = dict; } private static bool ReadBoolFromToken(JToken token, bool defaultValue) { if (token == null || token.Type == JTokenType.Null) { return defaultValue; } if (token.Type == JTokenType.Boolean) { return token.Value(); } if (token.Type == JTokenType.Integer) { return token.Value() != 0; } if (token.Type == JTokenType.Float) { return Math.Abs(token.Value()) > double.Epsilon; } if (token.Type == JTokenType.String && bool.TryParse(token.Value(), out var pb)) { return pb; } return defaultValue; } private void PersistPluginSettingsCache(JObject cache) { if (config == null) { return; } var obj = cache ?? new JObject(); config.PluginSettingsCache = obj.ToString(Formatting.None); // Keep the memoized parse + enabled flags current with the string we just wrote, so a // dashboard toggle takes effect immediately (no re-parse needed). Ticket 0010. _settingsCacheParsed = obj; RebuildGameplayEnabledCache(obj); } private void TryMergePluginSettingsFromIngestResponseBody(string responseBody) { if (string.IsNullOrWhiteSpace(responseBody)) { return; } try { var root = JObject.Parse(responseBody); var ps = root["plugin_settings"] as JObject; if (ps == null || ps.Count == 0) { return; } var cache = ParsePluginSettingsCache(); foreach (var prop in ps.Properties()) { var name = prop.Name; var tok = prop.Value; if (tok == null || tok.Type == JTokenType.Null) { cache.Remove(name); continue; } cache[name] = tok.DeepClone(); } PersistPluginSettingsCache(cache); SaveConfig(); MaybeReschedulePeriodicIngestMessages(); MaybeRefreshGameplayFeaturesOnSettingsMerge(); } catch (Exception ex) { VerbosePuts($"CerebRUST plugin_settings merge skipped: {ex.Message}"); } } private bool ReadPluginSettingBool(string fullKey, bool defaultValue) { if (config == null) { return defaultValue; } return ReadBoolFromToken(ParsePluginSettingsCache()[fullKey], defaultValue); } private int ReadPluginSettingInt(string fullKey, int defaultValue) { if (config == null) { return defaultValue; } var token = ParsePluginSettingsCache()[fullKey]; if (token == null || token.Type == JTokenType.Null) { return defaultValue; } if (token.Type == JTokenType.Integer) { return (int)token.Value(); } if (token.Type == JTokenType.Float) { return (int)Math.Round(token.Value()); } if (token.Type == JTokenType.String && int.TryParse(token.Value(), out var parsed)) { return parsed; } return defaultValue; } private string ReadPluginSettingString(string fullKey, string defaultValue) { if (config == null) { return defaultValue; } var token = ParsePluginSettingsCache()[fullKey]; if (token == null || token.Type == JTokenType.Null) { return defaultValue; } if (token.Type == JTokenType.String) { var s = token.Value(); return string.IsNullOrWhiteSpace(s) ? defaultValue : s.Trim(); } return token.ToString(); } private double ReadPluginSettingDouble(string fullKey, double defaultValue) { if (config == null) { return defaultValue; } var token = ParsePluginSettingsCache()[fullKey]; if (token == null || token.Type == JTokenType.Null) { return defaultValue; } if (token.Type == JTokenType.Integer) { return token.Value(); } if (token.Type == JTokenType.Float) { return token.Value(); } if (token.Type == JTokenType.String && double.TryParse(token.Value(), out var parsed)) { return parsed; } return defaultValue; } private static readonly System.Text.RegularExpressions.Regex PluginTextTokenRegex = new System.Text.RegularExpressions.Regex( @"\{\{([a-zA-Z0-9_]+)\}\}", System.Text.RegularExpressions.RegexOptions.Compiled ); private static string FormatPluginTimeUntilWipe(double hours) { if (hours >= 168) { var weeks = (int)(hours / 168); var remDays = (int)((hours % 168) / 24); if (remDays > 0) { return $"{weeks}w {remDays}d"; } return weeks == 1 ? "1 week" : $"{weeks} weeks"; } if (hours >= 24) { var days = (int)(hours / 24); var remH = (int)(hours % 24); if (remH > 0) { return $"{days}d {remH}h"; } return days == 1 ? "1 day" : $"{days} days"; } var whole = (int)Math.Round(hours); return whole == 1 ? "1 hour" : $"{whole} hours"; } // Expand {{token}} placeholders in owner-authored text. // // Every token is resolved from live server state here rather than being passed in per call // site: the old signature took playerCount/hoursUntilWipe explicitly, so MOTD supported // {{player_count}} but not {{time_until_wipe}}, milestones supported the reverse, and // periodic messages supported neither despite the docs saying otherwise. Now the whole // vocabulary works wherever text is sent. // // Keep the token names in step with the API registry (cerebrust_api/placeholders.py) — the // dashboard glossary is generated from that list, so a name that differs here is a token // the product tells owners to use and that silently does nothing. // // An unknown or currently-unavailable token is left VERBATIM, never blanked: a message // reading "Join {{discord_join_url}}" is a visible prompt to go and set it, where "Join " // just looks broken. private string InterpolatePluginText(string template) { if (string.IsNullOrEmpty(template)) { return template; } return PluginTextTokenRegex.Replace( template, match => { var value = ResolvePlaceholder(match.Groups[1].Value); return value ?? match.Value; } ); } private string ResolvePlaceholder(string key) { switch (key) { case "player_count": return BasePlayer.activePlayerList.Count.ToString(CultureInfo.InvariantCulture); case "players_max": return ConVar.Server.maxplayers.ToString(CultureInfo.InvariantCulture); case "queued_players": return CurrentQueuedPlayerCount().ToString(CultureInfo.InvariantCulture); case "server_name": return ConVar.Server.hostname; case "in_game_time": return CurrentInGameTimeLabel(); case "server_time": return DateTime.Now.ToString("HH:mm", CultureInfo.InvariantCulture); case "time_until_wipe": { var hours = ReadPluginSettingDouble("context.hours_until", double.NaN); return double.IsNaN(hours) ? null : FormatPluginTimeUntilWipe(hours); } case "next_wipe": { var iso = ReadPluginSettingString("context.next_wipe_at", null); if (string.IsNullOrWhiteSpace(iso)) { return null; } DateTime parsed; if (!DateTime.TryParse(iso, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out parsed)) { return null; } return parsed.ToLocalTime().ToString("ddd d MMM, HH:mm", CultureInfo.InvariantCulture); } case "wipe_cycle": { var cycle = ReadPluginSettingString("context.wipe_cycle", null); if (string.IsNullOrWhiteSpace(cycle)) { return null; } return cycle == "biweekly" ? "Bi-weekly" : char.ToUpperInvariant(cycle[0]) + cycle.Substring(1); } case "cerebrust_url": return NullIfBlank(ReadPluginSettingString("context.cerebrust_url", null)); case "discord_join_url": return NullIfBlank(ReadPluginSettingString("context.discord_join_url", null)); default: return null; } } private static string NullIfBlank(string value) { return string.IsNullOrWhiteSpace(value) ? null : value; } private static int CurrentQueuedPlayerCount() { try { return ServerMgr.Instance?.connectionQueue?.Queued ?? 0; } catch (Exception) { return 0; } } private static string CurrentInGameTimeLabel() { try { var sky = TOD_Sky.Instance; if (sky == null) { return null; } var hour = sky.Cycle.Hour; var h = (int)hour; var m = (int)((hour - h) * 60f); return $"{h:00}:{m:00}"; } catch (Exception) { return null; } } private List ReadPluginSettingStringList(string fullKey, string[] defaultsWhenMissing) { if (config == null) { return new List(defaultsWhenMissing); } var token = ParsePluginSettingsCache()[fullKey]; if (token == null || token.Type == JTokenType.Null) { return new List(defaultsWhenMissing); } if (token.Type != JTokenType.Array) { return new List(defaultsWhenMissing); } var arr = (JArray)token; if (arr.Count == 0) { return new List(); } var list = new List(); foreach (var item in arr) { if (item == null || item.Type != JTokenType.String) { continue; } var s = item.Value(); if (!string.IsNullOrWhiteSpace(s)) { list.Add(s.Trim()); } } return list; } // Read an item_stack_list setting (stack_sizes.items): [{ "shortname": "wood", "size": 5000 }]. // Returns null when the key is absent so the caller can distinguish "no list from the API" // (fall back to the legacy keys) from "an owner deliberately cleared the list". private Dictionary ReadPluginSettingItemStacks(string fullKey) { if (config == null) { return null; } var token = ParsePluginSettingsCache()[fullKey]; if (token == null || token.Type != JTokenType.Array) { return null; } var result = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var entry in (JArray)token) { if (entry == null || entry.Type != JTokenType.Object) { continue; } var shortname = entry["shortname"]?.Value(); if (string.IsNullOrWhiteSpace(shortname)) { continue; } var sizeToken = entry["size"]; if (sizeToken == null) { continue; } int size; switch (sizeToken.Type) { case JTokenType.Integer: size = sizeToken.Value(); break; case JTokenType.Float: size = (int)Math.Round(sizeToken.Value()); break; case JTokenType.String: if (!int.TryParse(sizeToken.Value(), out size)) { continue; } break; default: continue; } result[shortname.Trim()] = Math.Max(1, size); } return result; } private string BuildPeriodicMessagesScheduleFingerprint() { var minutes = ReadPluginSettingInt("periodic_messages.interval", 5); var list = ReadPluginSettingStringList( "periodic_messages.messages", PluginDefaultPeriodicMessages ); return $"{minutes}\u001f{string.Join("\u001e", list)}"; } private void MaybeReschedulePeriodicIngestMessages() { if (!ingestReady) { return; } var nextFp = BuildPeriodicMessagesScheduleFingerprint(); if (nextFp == _periodicMessagesScheduleFingerprint) { return; } _periodicMessagesScheduleFingerprint = nextFp; RestartPeriodicMessagesTimer(); } private void RestartPeriodicMessagesTimer() { periodicMessagesTimer?.Destroy(); periodicMessagesTimer = null; if (!ingestReady) { return; } var minutes = Mathf.Clamp( ReadPluginSettingInt("periodic_messages.interval", 5), PeriodicMessagesMinIntervalMinutes, PeriodicMessagesMaxIntervalMinutes ); var messages = ReadPluginSettingStringList( "periodic_messages.messages", PluginDefaultPeriodicMessages ); if (messages.Count == 0) { VerbosePuts("CerebRUST periodic messages: disabled (empty list)."); return; } var intervalSeconds = minutes * 60f; periodicMessagesTimer = timer.Every(intervalSeconds, SendPeriodicMessageRound); VerbosePuts($"CerebRUST periodic messages: every {minutes}m, {messages.Count} line(s)."); } private void SendPeriodicMessageRound() { if (!ingestReady) { return; } var messages = ReadPluginSettingStringList( "periodic_messages.messages", PluginDefaultPeriodicMessages ); if (messages.Count == 0) { return; } var line = messages[_periodicMessageOrdinal % messages.Count]; _periodicMessageOrdinal++; // Admin-controlled catalog copy (not API-derived player text): allow Rust rich-text tags. var text = (line ?? string.Empty).Trim(); if (string.IsNullOrEmpty(text)) { return; } BroadcastToServer(text); } #endregion #region Oxide hooks private void OnServerInitialized() { LoadVipData(); // Before the first chat line: a muted player must stay muted across a restart, and the // chat hook is the first thing that can fire. LoadMuteData(); // Before ingest starts: the container sweep reads this map for every container it sends. BuildDeployableItemShortnameMap(); StartIngestSelfHealWatchdog(); RunIngestInit(); InitializeGameplayFeatures(); } /// /// Server console or F1 (admin): diagnostics. Subcommands: resetbusy — clear stuck command-poll chain flag. /// [ConsoleCommand("cerebrust.doctor")] private void ConsoleCmdCerebrustDoctor(ConsoleSystem.Arg arg) { if (arg?.Connection != null) { var player = arg.Player(); if (player == null || !player.IsAdmin) { arg.ReplyWith("CerebRUST doctor: use server console, RCON, or an admin F1 session."); return; } } if (arg != null && arg.HasArgs(1)) { var sub = arg.Args[0].ToString().Trim(); if (string.Equals(sub, "resetbusy", StringComparison.OrdinalIgnoreCase) || string.Equals(sub, "reset_chain_busy", StringComparison.OrdinalIgnoreCase)) { if (_serverCommandsChainBusy) { PrintWarning("[CerebRUST doctor] Clearing _serverCommandsChainBusy (was true)."); ClearServerCommandsChainBusy(); arg.ReplyWith("CerebRUST doctor: cleared _serverCommandsChainBusy."); } else { arg.ReplyWith("CerebRUST doctor: _serverCommandsChainBusy was already false."); } return; } if (string.Equals(sub, "monuments", StringComparison.OrdinalIgnoreCase)) { // Monuments post exactly once per plugin load, so without this the only way to // re-send them is o.reload — which is a blunt instrument for answering "did // that actually go?" and takes the whole plugin down with it. if (!ingestReady) { arg.ReplyWith("CerebRUST doctor: ingest is not ready yet; nothing to send."); return; } _monumentsIngestPosted = false; _worldPrefabMatches = null; PrintWarning("[CerebRUST doctor] Re-sending the monuments ingest by hand."); TryPostMonumentsIngest(); arg.ReplyWith("CerebRUST doctor: monuments re-send triggered — see the console for the outcome."); return; } if (string.Equals(sub, "unpauseall", StringComparison.OrdinalIgnoreCase) || string.Equals(sub, "unpause_all", StringComparison.OrdinalIgnoreCase)) { if (_pausedIngestEndpointFamilies.Count == 0) { arg.ReplyWith("CerebRUST doctor: no ingest endpoints are paused."); return; } var wasPaused = FormatPausedIngestEndpoints(); var count = _pausedIngestEndpointFamilies.Count; PrintWarning($"[CerebRUST doctor] Manually unpausing {count} ingest endpoint(s): {wasPaused}"); ClearAllIngestEndpointPauses(); FlushPendingIngestPosts(); var reply = $"CerebRUST doctor: unpaused {count} endpoint(s) ({wasPaused}); flushing pending disk queue" + (ingestReady ? "." : " will resume once init completes (ingestReady=false)."); Puts(reply); arg.ReplyWith(reply); return; } } var sb = new StringBuilder(); sb.AppendLine($"[CerebRUST doctor] {Name} v{Version}"); sb.AppendLine($" ingestReady={ingestReady} pausedEndpoints={FormatPausedIngestEndpoints()}"); sb.AppendLine( $" monuments: posted={_monumentsIngestPosted} onMap={(TerrainMeta.Path?.Monuments?.Count ?? -1)}" + $" godRocksCached={(_worldPrefabMatches == null ? "unscanned" : _worldPrefabMatches.Count.ToString())}" ); sb.AppendLine( $" init: inFlight={_ingestInitHttpInFlight} consecutiveFailures={_ingestInitConsecutiveFailures} " + $"nextRecoveryDelay={GetIngestEndpointRecoveryDelay("init"):0}s" ); var busyAge = ""; if (_serverCommandsChainBusy && _serverCommandsChainBusySinceRealtime > 0f) { busyAge = $" (held {UnityEngine.Time.realtimeSinceStartup - _serverCommandsChainBusySinceRealtime:0}s, stale≥{ServerCommandsChainBusyStaleSeconds:0}s auto-clears)"; } sb.AppendLine($" _serverCommandsChainBusy={_serverCommandsChainBusy}{busyAge}"); sb.AppendLine( $" timers: selfHealWatchdog={DescribeTimer(ingestSelfHealWatchdogTimer)} stats={DescribeTimer(statsTimer)} activeLoc={DescribeTimer(activeLocationsTimer)} " + $"sleeperLoc={DescribeTimer(sleeperLocationsTimer)} gather={DescribeTimer(gatherActivityTimer)} " + $"worldEntity={DescribeTimer(worldEntityScanTimer)} serverCommands={DescribeTimer(serverCommandsTimer)} " + $"endpointRecoveryTimers={_ingestEndpointRecoveryTimers.Count}" ); var tok = config?.IngestToken; var tokTrim = string.IsNullOrWhiteSpace(tok) ? null : tok.Trim(); sb.AppendLine( tokTrim == null ? " IngestToken: (missing)" : $" IngestToken: set, len={tokTrim.Length}" ); var baseUrl = (config?.ApiBaseUrl ?? string.Empty).TrimEnd('/'); sb.AppendLine($" ApiBaseUrl: {baseUrl}"); sb.AppendLine($" commandsPull: {baseUrl}/api/v2/ingest/commands/pull"); sb.AppendLine(" hint: if commands never run but other ingest works, try: cerebrust.doctor resetbusy" ); sb.AppendLine( " hint: to re-send monuments (e.g. after a plugin update), try: cerebrust.doctor monuments"); sb.AppendLine(" hint: to force-clear stuck paused endpoints (flushes queue), try: cerebrust.doctor unpauseall"); var msg = sb.ToString().TrimEnd(); Puts(msg); arg?.ReplyWith(msg); } private static string DescribeTimer(Timer t) { if (t == null) { return "null"; } return t.Destroyed ? "destroyed" : "active"; } private void MarkServerCommandsChainBusy() { _serverCommandsChainBusy = true; _serverCommandsChainBusySinceRealtime = UnityEngine.Time.realtimeSinceStartup; } private void ClearServerCommandsChainBusy() { _serverCommandsChainBusy = false; _serverCommandsChainBusySinceRealtime = 0f; } private void Unload() { statsTimer?.Destroy(); statsTimer = null; activeLocationsTimer?.Destroy(); activeLocationsTimer = null; sleeperLocationsTimer?.Destroy(); sleeperLocationsTimer = null; worldEntityScanTimer?.Destroy(); worldEntityScanTimer = null; StopContainerSweep(); StopBuildingSweep(); mapUploadRetryTimer?.Destroy(); mapUploadRetryTimer = null; gatherActivityTimer?.Destroy(); gatherActivityTimer = null; raidEventsFlushTimer?.Destroy(); raidEventsFlushTimer = null; combatLogFlushTimer?.Destroy(); combatLogFlushTimer = null; serverCommandsTimer?.Destroy(); serverCommandsTimer = null; periodicMessagesTimer?.Destroy(); periodicMessagesTimer = null; ingestSelfHealWatchdogTimer?.Destroy(); ingestSelfHealWatchdogTimer = null; StopWorldEvents(); StopPuzzleWatch(); StopWipeScheduler(); ClearPendingSignEdits(); ClearMotdSessionState(); _ingestInitHttpInFlight = false; _ingestInitHttpStartedRealtime = 0f; _ingestInitConsecutiveFailures = 0; ClearServerCommandsChainBusy(); _lastServerCommandsStaleChainBusyLogRealtime = 0f; ingestReady = false; lastMapUploadKey = null; AbortTerrainSamplePass(); terrainRetryTimer?.Destroy(); terrainRetryTimer = null; _terrainIngestKey = null; ClearAllIngestEndpointPauses(); lastSentPositions.Clear(); previousSleeperSteamIds.Clear(); gatherTotalsBySteam.Clear(); activityTotalsBySteam.Clear(); positionTrackersForAfk.Clear(); _raidDamageAccumulators.Clear(); _pendingRaidDiscreteEvents.Clear(); _pendingCombatHits.Clear(); rollupWipeMapSeed = 0; pendingIngestPosts.Clear(); _targetingAlertCooldowns.Clear(); TryDeletePendingIngestDiskFile(); UnloadGameplayFeatures(); InvalidatePluginSettingsCache(); } private void OnPlayerConnected(BasePlayer player) { if (!ingestReady || player == null || !IsPlausibleSteamId(player.userID)) { return; } var ipAddress = ExtractHostIp(player.net?.connection?.ipaddress); if (string.IsNullOrWhiteSpace(ipAddress)) { PrintWarning($"CerebRUST player-connect skipped: no IP for {player.displayName}"); return; } var payload = new IngestPlayerConnectRequest { Token = config.IngestToken.Trim(), SteamId = player.userID, IpAddress = ipAddress, DisplayName = player.displayName ?? string.Empty, }; var url = $"{config.ApiBaseUrl.TrimEnd('/')}/api/v2/ingest/player-connect"; PostIngestJson( "player-connect", url, JsonConvert.SerializeObject(payload), queueIfPaused: true, onIngestHttp200Bodies: (resp, req) => MaybeBroadcastCerebrustSessionIngestChat("player-connect", resp, req) ); } private void OnPlayerDisconnected(BasePlayer player, string reason) { if (player != null) { motdSentSteamIds.Remove(player.userID); } if (!ingestReady || player == null) { return; } lastSentPositions.Remove(player.userID); var payload = new IngestPlayerDisconnectRequest { Token = config.IngestToken.Trim(), SteamId = player.userID, DisconnectReason = reason ?? string.Empty, DisplayName = player.displayName ?? string.Empty, }; var url = $"{config.ApiBaseUrl.TrimEnd('/')}/api/v2/ingest/player-disconnect"; PostIngestJson( "player-disconnect", url, JsonConvert.SerializeObject(payload), queueIfPaused: true, onIngestHttp200Bodies: (resp, req) => MaybeBroadcastCerebrustSessionIngestChat("player-disconnect", resp, req) ); // Always send offline sample on disconnect, including when dead — otherwise the last // online=true location bucket sticks on the map until player-death tombstone (which may not fire). if (IsPlausibleSteamId(player.userID)) { var offlineSample = !player.IsAlive() ? ToTombstonePosition(player) : ToPosition(player, player.transform.position, online: false); PostPlayerLocations( "player-locations-disconnect", new List { offlineSample } ); CreditActivityForPlayerOnDisconnect(player); FlushGatherRollupForSteamOnDisconnect(player.userID); FlushActivityRollupForSteamOnDisconnect(player.userID); positionTrackersForAfk.Remove(player.userID); } } // Native ban/unban deltas → mirror into CerebRUST (server_bans). The game stays // source-of-truth; a full snapshot on init (PostBansReconcile) covers offline changes. private void OnPlayerBanned(string playerName, ulong steamId, string address, string reason, long expiry) { PostBanEvent("banned", steamId, playerName, reason, BanExpiryToIso(expiry)); } private void OnPlayerUnbanned(string playerName, ulong steamId, string address) { PostBanEvent("unbanned", steamId, playerName, reason: null, expiresAtIso: null); } private void OnDispenserGathered(ResourceDispenser dispenser, BasePlayer player, Item item) { if (player != null && item != null) { GameplayApplyDispenserGatherModifier(item); } TrackGatheredItem(player, item); } private void OnDispenserBonusReceived(ResourceDispenser dispenser, BasePlayer player, Item item) { if (player != null && item != null) { GameplayApplyDispenserGatherModifier(item); } TrackGatheredItem(player, item); } private void OnCollectiblePickup(CollectibleEntity entity, BasePlayer player) { if (player == null || player.IsNpc || entity == null) { return; } GameplayOnCollectiblePickup(entity, player); EnsureRollupWipeScope(); if (entity.itemList == null) { return; } foreach (var itemAmount in entity.itemList) { var itemName = itemAmount.itemDef?.shortname ?? string.Empty; var amount = (long)itemAmount.amount; ApplyGatherForShortname(player.userID, itemName, amount); } } /// /// Single hook for human POST …/player-death ingest and POST …/player-npc-kill when a real player kills an NPC / non-player combat entity. /// private void OnEntityDeath(BaseCombatEntity entity, HitInfo info) { if (!ingestReady || entity == null) { return; } // Raid tracking: a destroyed raidable structure/TC (has HitInfo → attacker). Additive; the // human/NPC-kill logic below is unaffected (it early-returns for structures). TryEmitRaidStructureDestroyed(entity, info); if (entity is BasePlayer victimHuman && !victimHuman.IsNpc && IsPlausibleSteamId(victimHuman.userID)) { PostHumanPlayerDeathIngest(victimHuman, info); return; } if (!ShouldIngestNpcKillVictim(entity, out var npcVictimPrefab)) { return; } var killerHuman = ResolveAttackerPlayerFromHit(info); if (killerHuman == null || killerHuman.IsNpc || !IsPlausibleSteamId(killerHuman.userID)) { return; } PostPlayerNpcKillIngest(killerHuman.userID, npcVictimPrefab, entity, info); } private void PostHumanPlayerDeathIngest(BasePlayer victim, HitInfo info) { var attacker = ResolveAttackerPlayerFromHit(info); var killerClass = ClassifyKillerClass(victim, info, attacker); var majorityDamage = TryMajorityDamageType(info); var isSuicide = killerClass == "SELF" || string.Equals(majorityDamage, "Suicide", StringComparison.Ordinal); ulong? killerSteamId = null; if (killerClass == "PLAYER" && attacker != null && !attacker.IsNpc && IsPlausibleSteamId(attacker.userID) && attacker.userID != victim.userID) { killerSteamId = attacker.userID; } // The killing blow is no longer sent here — the lethal hit is recorded as a full combat // row (is_killing_blow) via the RecordCombatHit / combat-log path, which has accurate // pre-death health and hit detail. This POST records only the player_deaths row. var initiatorPrefab = TryInitiatorShortPrefab(info); var initiatorLabel = TryInitiatorDisplayLabel(info); float? distanceMeters = null; if (attacker != null) { distanceMeters = Vector3.Distance(victim.transform.position, attacker.transform.position); } else if (info?.Initiator != null) { try { distanceMeters = Vector3.Distance( victim.transform.position, info.Initiator.transform.position ); } catch { // ignore } } var raw = BuildDeathRawPayload(victim, info); var payload = new IngestPlayerDeathRequest { Token = config.IngestToken.Trim(), VictimSteamId = victim.userID, KillerSteamId = killerSteamId, KillerClass = TruncateString(killerClass, 32) ?? "UNKNOWN", IsSuicide = isSuicide, MajorityDamageType = TruncateString(majorityDamage, 64), InitiatorShortPrefab = TruncateString(initiatorPrefab, 256), KillerInitiatorLabel = TruncateString(initiatorLabel, 192), WeaponDisplayName = TruncateString(TryWeaponDisplayName(info), 255), DistanceMeters = distanceMeters, VictimWasSleeping = victim.IsSleeping(), RawPayload = raw, HappenedAtIso = DateTime.UtcNow.ToString("o"), }; var deathUrl = $"{config.ApiBaseUrl.TrimEnd('/')}/api/v2/ingest/player-death"; PostIngestJson( "player-death", deathUrl, JsonConvert.SerializeObject(payload, CombatTelemetryJsonSerializerSettings), queueIfPaused: true ); } private void PostPlayerNpcKillIngest( ulong killerSteamId, string victimShortPrefab, BaseCombatEntity victimEntity, HitInfo info ) { var raw = new Dictionary { ["victim_entity"] = victimEntity.GetType().Name, ["majority_damage_type"] = TryMajorityDamageType(info) ?? string.Empty, }; var payload = new IngestPlayerNpcKillRequest { Token = config.IngestToken.Trim(), KillerSteamId = killerSteamId, VictimShortPrefab = victimShortPrefab, RawPayload = raw, HappenedAtIso = DateTime.UtcNow.ToString("o"), }; var url = $"{config.ApiBaseUrl.TrimEnd('/')}/api/v2/ingest/player-npc-kill"; PostIngestJson( "player-npc-kill", url, JsonConvert.SerializeObject(payload, CombatTelemetryJsonSerializerSettings), queueIfPaused: true ); } private static bool ShouldIngestNpcKillVictim(BaseCombatEntity entity, out string victimPrefab) { victimPrefab = null; if (entity == null) { return false; } var typeName = entity.GetType().Name; if (typeName.IndexOf("Corpse", StringComparison.OrdinalIgnoreCase) >= 0) { return false; } var prefab = entity.ShortPrefabName; if (string.IsNullOrEmpty(prefab) || prefab.IndexOf("corpse", StringComparison.OrdinalIgnoreCase) >= 0) { return false; } if (entity is BasePlayer npcPlayer && npcPlayer.IsNpc) { victimPrefab = TruncateString(prefab, 256); return true; } if (entity is BasePlayer) { return false; } victimPrefab = TruncateString(prefab, 256); return true; } private static BasePlayer ResolveAttackerPlayerFromHit(HitInfo info) { if (info == null) { return null; } if (info.InitiatorPlayer != null) { return info.InitiatorPlayer; } var initiator = info.Initiator; if (initiator is BasePlayer bpInit) { return bpInit; } var parent = initiator?.GetParentEntity(); if (parent is BasePlayer bpParent) { return bpParent; } return null; } private static string ClassifyKillerClass(BasePlayer victim, HitInfo info, BasePlayer attacker) { if (attacker != null && attacker == victim) { return "SELF"; } if (attacker != null && !attacker.IsNpc && IsPlausibleSteamId(attacker.userID)) { return "PLAYER"; } if (attacker != null && attacker.IsNpc) { return "NPC_ENTITY"; } var initiator = info?.Initiator; if (initiator is BasePlayer npcPlayer && npcPlayer.IsNpc) { return "NPC_ENTITY"; } if (initiator is BaseNpc) { return "NPC_ENTITY"; } if (string.Equals(TryMajorityDamageType(info), "Suicide", StringComparison.Ordinal)) { return "SELF"; } if (initiator == null) { return "ENVIRONMENT"; } return "UNKNOWN"; } private static string TryMajorityDamageType(HitInfo info) { try { if (info?.damageTypes == null) { return null; } return info.damageTypes.GetMajorityDamageType().ToString(); } catch { return null; } } private static string TryInitiatorShortPrefab(HitInfo info) { try { return info?.Initiator?.ShortPrefabName; } catch { return null; } } private static string TryInitiatorDisplayLabel(HitInfo info) { try { return info?.Initiator?.name; } catch { return null; } } private static string TryWeaponDisplayName(HitInfo info) { try { var item = info?.Weapon?.GetItem(); var english = item?.info?.displayName?.english; if (!string.IsNullOrWhiteSpace(english)) { return english.Trim(); } // Server builds no longer expose resourcePath on BaseEntity; use short prefab / Unity name. var weaponPrefab = info?.WeaponPrefab; if (weaponPrefab != null) { var shortPrefab = weaponPrefab.ShortPrefabName; if (!string.IsNullOrWhiteSpace(shortPrefab)) { return shortPrefab.Trim(); } var unityName = weaponPrefab.name; if (!string.IsNullOrWhiteSpace(unityName)) { return unityName.Trim(); } } } catch { // ignore } return null; } private static Dictionary BuildDeathRawPayload(BaseCombatEntity victimEntity, HitInfo info) { var d = new Dictionary { ["rust_protocol"] = Rust.Protocol.printable ?? string.Empty, ["victim_prefab"] = victimEntity.ShortPrefabName ?? string.Empty, }; try { if (info?.Initiator != null) { d["initiator_type"] = info.Initiator.GetType().Name; } } catch { // ignore } return d; } private static string TruncateString(string value, int maxLen) { if (string.IsNullOrWhiteSpace(value)) { return null; } var t = value.Trim(); return t.Length <= maxLen ? t : t.Substring(0, maxLen); } #region Raid tracking + combat log (v0.8.62) // Always-on (only ``ingestReady`` gates emission). Constants below are the operator-free tuning // knobs; the meta-raid assembly + significance floor live server-side. OnEntityTakeDamage is // hooked once and branches: a raidable structure/deployable → aggregated raid telemetry; a // player → full per-hit PvP combat log. Both branches batch and flush on a timer — this is one // of Rust's highest-frequency hooks, so filter hard and never POST per hit. private const float RaidEventsFlushIntervalSeconds = 10f; private const float CombatLogFlushIntervalSeconds = 10f; private const float RaidPresenceRadius = 40f; private const int RaidMaxBatch = 200; private static readonly string[] RaidableDeployableTokens = { "box.wooden", "woodbox", "coffinstorage", "furnace", "autoturret", "sam_static", "guntrap", "flameturret", "vendingmachine", "fridge", "locker", "storage_barrel", }; private Timer raidEventsFlushTimer; private Timer combatLogFlushTimer; private readonly Dictionary _raidDamageAccumulators = new Dictionary(); private readonly List _pendingRaidDiscreteEvents = new List(); private readonly List _pendingCombatHits = new List(); // Temporary raid-structure-damage-filter diagnostics (v0.8.64; gated on // PluginConfig.RaidDebugWebhookUrl). Per ~10s flush: how many raidable-structure hits were // recorded vs dropped, by drop reason (+ one sample per reason), posted to a Discord webhook. private readonly Dictionary _raidDebugDrops = new Dictionary(); private readonly Dictionary _raidDebugSamples = new Dictionary(); private int _raidDebugRecorded; private sealed class RaidDamageAccumulator { public ulong AttackerSteamId; public long? AttackerTeamId; public ulong? TargetOwnerSteamId; public long? TargetTcEntityId; public long? TargetBuildingId; public long? TargetEntityId; public string TargetCategory; public string TargetGrade; public string WeaponCategory; public string WeaponShortPrefab; public float TotalDamage; public int HitCount; public Vector3 LastPos; public DateTime LastTime; public string Grid; public string Monument; public HashSet DefendersPresent; } private sealed class IngestRaidEventItem { [JsonProperty("kind")] public string Kind { get; set; } [JsonProperty("happened_at")] public string HappenedAtIso { get; set; } [JsonProperty("attacker_steam_id", NullValueHandling = NullValueHandling.Ignore)] public long? AttackerSteamId { get; set; } [JsonProperty("attacker_team_id", NullValueHandling = NullValueHandling.Ignore)] public long? AttackerTeamId { get; set; } [JsonProperty("target_owner_steam_id", NullValueHandling = NullValueHandling.Ignore)] public long? TargetOwnerSteamId { get; set; } [JsonProperty("target_tc_entity_id", NullValueHandling = NullValueHandling.Ignore)] public long? TargetTcEntityId { get; set; } [JsonProperty("target_building_id", NullValueHandling = NullValueHandling.Ignore)] public long? TargetBuildingId { get; set; } [JsonProperty("target_entity_id", NullValueHandling = NullValueHandling.Ignore)] public long? TargetEntityId { get; set; } [JsonProperty("target_category")] public string TargetCategory { get; set; } [JsonProperty("target_grade", NullValueHandling = NullValueHandling.Ignore)] public string TargetGrade { get; set; } [JsonProperty("weapon_category")] public string WeaponCategory { get; set; } [JsonProperty("weapon_short_prefab", NullValueHandling = NullValueHandling.Ignore)] public string WeaponShortPrefab { get; set; } [JsonProperty("damage_amount", NullValueHandling = NullValueHandling.Ignore)] public float? DamageAmount { get; set; } [JsonProperty("hit_count")] public int HitCount { get; set; } [JsonProperty("is_destroyed")] public bool IsDestroyed { get; set; } [JsonProperty("is_tc")] public bool IsTc { get; set; } [JsonProperty("world_x", NullValueHandling = NullValueHandling.Ignore)] public float? WorldX { get; set; } [JsonProperty("world_y", NullValueHandling = NullValueHandling.Ignore)] public float? WorldY { get; set; } [JsonProperty("world_z", NullValueHandling = NullValueHandling.Ignore)] public float? WorldZ { get; set; } [JsonProperty("grid", NullValueHandling = NullValueHandling.Ignore)] public string Grid { get; set; } [JsonProperty("monument", NullValueHandling = NullValueHandling.Ignore)] public string Monument { get; set; } [JsonProperty("defenders_active_present", NullValueHandling = NullValueHandling.Ignore)] public List DefendersActivePresent { get; set; } } private sealed class IngestRaidEventsRequest { [JsonProperty("token")] public string Token { get; set; } [JsonProperty("events")] public List Events { get; set; } } private sealed class IngestCombatHitItem { [JsonProperty("attacker_steam_id")] public long AttackerSteamId { get; set; } [JsonProperty("victim_steam_id")] public long VictimSteamId { get; set; } [JsonProperty("happened_at")] public string HappenedAtIso { get; set; } [JsonProperty("weapon_short_prefab", NullValueHandling = NullValueHandling.Ignore)] public string WeaponShortPrefab { get; set; } [JsonProperty("weapon_display_name", NullValueHandling = NullValueHandling.Ignore)] public string WeaponDisplayName { get; set; } [JsonProperty("damage_type", NullValueHandling = NullValueHandling.Ignore)] public string DamageType { get; set; } [JsonProperty("damage_amount", NullValueHandling = NullValueHandling.Ignore)] public float? DamageAmount { get; set; } [JsonProperty("distance_meters", NullValueHandling = NullValueHandling.Ignore)] public float? DistanceMeters { get; set; } [JsonProperty("hit_area", NullValueHandling = NullValueHandling.Ignore)] public string HitArea { get; set; } [JsonProperty("is_headshot")] public bool IsHeadshot { get; set; } [JsonProperty("is_killing_blow")] public bool IsKillingBlow { get; set; } [JsonProperty("health_start", NullValueHandling = NullValueHandling.Ignore)] public float? HealthStart { get; set; } [JsonProperty("health_end", NullValueHandling = NullValueHandling.Ignore)] public float? HealthEnd { get; set; } [JsonProperty("was_victim_sleeping")] public bool WasVictimSleeping { get; set; } [JsonProperty("attacker_was_sleeping")] public bool AttackerWasSleeping { get; set; } } private sealed class IngestCombatLogRequest { [JsonProperty("token")] public string Token { get; set; } [JsonProperty("hits")] public List Hits { get; set; } } // Hooked once; branches by victim type. Read-only side effect (returns void) — never alters damage. private void OnEntityTakeDamage(BaseCombatEntity entity, HitInfo info) { if (!ingestReady || entity == null || info == null) { return; } var attacker = ResolveAttackerPlayerFromHit(info); if (attacker == null || attacker.IsNpc || !IsPlausibleSteamId(attacker.userID)) { return; // decay / NPC / heli / environmental — no player attacker } if (entity is BasePlayer victim && !victim.IsNpc && IsPlausibleSteamId(victim.userID)) { if (victim.userID != attacker.userID) { RecordCombatHit(attacker, victim, info); } return; } RecordRaidStructureDamage(attacker, entity, info); } private void RecordCombatHit(BasePlayer attacker, BasePlayer victim, HitInfo info) { float damage = 0f; try { damage = info.damageTypes != null ? info.damageTypes.Total() : 0f; } catch { // ignore } float healthStart = victim.health; float healthEnd = healthStart - damage; // The lethal hit is recorded here too (flagged is_killing_blow). OnEntityTakeDamage fires // before health is applied, so victim.health is accurate — unlike the player-death path, // which fires post-death. The death row itself is still emitted by PostHumanPlayerDeathIngest. bool isKillingBlow = healthEnd <= 0f; string hitArea = null; bool headshot = false; try { hitArea = info.boneName; headshot = info.isHeadshot; } catch { // ignore } float? distance = null; try { distance = Vector3.Distance(attacker.transform.position, victim.transform.position); } catch { // ignore } _pendingCombatHits.Add( new IngestCombatHitItem { AttackerSteamId = SteamToLong(attacker.userID), VictimSteamId = SteamToLong(victim.userID), HappenedAtIso = DateTime.UtcNow.ToString("o"), WeaponShortPrefab = TruncateString(TryWeaponShortPrefab(info), 64), WeaponDisplayName = TruncateString(TryWeaponDisplayName(info), 255), DamageType = TruncateString(TryMajorityDamageType(info), 64), DamageAmount = damage, DistanceMeters = distance, HitArea = TruncateString(hitArea, 32), IsHeadshot = headshot, IsKillingBlow = isKillingBlow, HealthStart = healthStart, HealthEnd = healthEnd < 0f ? 0f : healthEnd, WasVictimSleeping = victim.IsSleeping(), AttackerWasSleeping = attacker.IsSleeping(), } ); if (_pendingCombatHits.Count >= RaidMaxBatch) { FlushCombatLog(); } } private void RecordRaidStructureDamage(BasePlayer attacker, BaseCombatEntity entity, HitInfo info) { var category = RaidTargetCategory(entity); if (category == null) { return; // not a raidable structure/deployable } var weaponCategory = RaidWeaponCategory(info); if (weaponCategory == null) { // A raidable structure hit by something we don't count (bullets / other chip damage). RaidDebugDrop("weapon_filtered", entity, info, entity.OwnerID); return; } ulong owner = entity.OwnerID; if (!IsPlausibleSteamId(owner)) { RaidDebugDrop("owner_invalid", entity, info, owner); return; // unowned / world entity } if (owner == attacker.userID) { RaidDebugDrop("self_owned", entity, info, owner); return; // the attacker's own base } var authed = GetAuthedPlayersOnConnectedTc(entity); if (authed.Contains(attacker.userID) || IsSameTeam(attacker.userID, owner) || AttackerSharesTeamWithAny(attacker.userID, authed)) { RaidDebugDrop("authorized", entity, info, owner); return; // the attacker is authorized on this base → not a raid } var privilege = entity.GetBuildingPrivilege(); long? tcId = privilege != null ? (long?)(long)privilege.net.ID.Value : null; var block = entity as BuildingBlock; long? buildingId = block != null ? (long?)block.buildingID : null; string grade = block != null ? block.grade.ToString() : null; if (block != null && block.grade == BuildingGrade.Enum.Twigs) { RaidDebugDrop("twig", entity, info, owner); return; // shooting out twig during the build phase is griefing, not a raid } float damage = 0f; try { damage = info.damageTypes != null ? info.damageTypes.Total() : 0f; } catch { // ignore } var pos = entity.transform.position; string baseKey = tcId?.ToString() ?? buildingId?.ToString() ?? owner.ToString(); string key = attacker.userID + "|" + baseKey + "|" + weaponCategory; if (!_raidDamageAccumulators.TryGetValue(key, out var acc)) { acc = new RaidDamageAccumulator { AttackerSteamId = attacker.userID, AttackerTeamId = attacker.currentTeam != 0 ? (long?)(long)attacker.currentTeam : null, TargetOwnerSteamId = owner, TargetTcEntityId = tcId, TargetBuildingId = buildingId, TargetEntityId = (long)entity.net.ID.Value, TargetCategory = category, TargetGrade = grade, WeaponCategory = weaponCategory, WeaponShortPrefab = TryWeaponShortPrefab(info), Grid = GetGridPosition(pos), Monument = ResolveMonumentShortname(pos, entity), DefendersPresent = CollectPresentDefenders(entity, authed), }; _raidDamageAccumulators[key] = acc; } acc.TotalDamage += damage; acc.HitCount += 1; acc.LastPos = pos; acc.LastTime = DateTime.UtcNow; acc.TargetEntityId = (long)entity.net.ID.Value; if (grade != null) { acc.TargetGrade = grade; } if (!string.IsNullOrEmpty(config?.RaidDebugWebhookUrl)) { _raidDebugRecorded += 1; } if (_raidDamageAccumulators.Count >= RaidMaxBatch) { FlushRaidEvents(); } } // Structure destruction — the strong "raid outcome" + TC-destroyed end signal. Called from // OnEntityDeath (which carries the HitInfo/attacker) for any raidable entity a player destroyed. private void TryEmitRaidStructureDestroyed(BaseCombatEntity entity, HitInfo info) { if (!ingestReady || entity == null) { return; } var category = RaidTargetCategory(entity); if (category == null) { return; } var attacker = ResolveAttackerPlayerFromHit(info); if (attacker == null || attacker.IsNpc || !IsPlausibleSteamId(attacker.userID)) { return; } ulong owner = entity.OwnerID; if (!IsPlausibleSteamId(owner) || owner == attacker.userID) { return; } var authed = GetAuthedPlayersOnConnectedTc(entity); if (authed.Contains(attacker.userID) || IsSameTeam(attacker.userID, owner) || AttackerSharesTeamWithAny(attacker.userID, authed)) { return; } bool isTc = entity is BuildingPrivlidge; var privilege = entity.GetBuildingPrivilege(); long? tcId = privilege != null ? (long?)(long)privilege.net.ID.Value : (isTc ? (long?)(long)entity.net.ID.Value : null); var block = entity as BuildingBlock; long? buildingId = block != null ? (long?)block.buildingID : null; string grade = block != null ? block.grade.ToString() : null; if (block != null && block.grade == BuildingGrade.Enum.Twigs) { return; // twig teardown is not a raid outcome (matches the damage-path filter) } var pos = entity.transform.position; _pendingRaidDiscreteEvents.Add( new IngestRaidEventItem { Kind = "structure_destroyed", HappenedAtIso = DateTime.UtcNow.ToString("o"), AttackerSteamId = SteamToLong(attacker.userID), AttackerTeamId = attacker.currentTeam != 0 ? (long?)(long)attacker.currentTeam : null, TargetOwnerSteamId = (long)owner, TargetTcEntityId = tcId, TargetBuildingId = buildingId, TargetEntityId = (long)entity.net.ID.Value, TargetCategory = category, TargetGrade = grade, WeaponCategory = RaidWeaponCategory(info) ?? "other", WeaponShortPrefab = TruncateString(TryWeaponShortPrefab(info), 64), IsDestroyed = true, IsTc = isTc, HitCount = 1, WorldX = (float)Math.Round(pos.x, 1), WorldY = (float)Math.Round(pos.y, 1), WorldZ = (float)Math.Round(pos.z, 1), Grid = GetGridPosition(pos), DefendersActivePresent = ToLongList(CollectPresentDefenders(entity, authed)), } ); } // OnRocketLaunched is a unique hook (safe to add fresh). OnExplosiveThrown already exists for // supply signals; that method is widened to BaseEntity + branches (see below) so we don't create // a conflicting overload (Oxide allows one method per hook name). private void OnRocketLaunched(BasePlayer player, BaseEntity entity) { if (!ingestReady || player == null || entity == null || !IsPlausibleSteamId(player.userID)) { return; } var shortPrefab = entity.ShortPrefabName ?? string.Empty; if (shortPrefab.IndexOf("smoke", StringComparison.OrdinalIgnoreCase) >= 0) { return; // smoke rocket is not a raid tool } EmitExplosiveUsed(player, entity, shortPrefab); } private void EmitExplosiveUsed(BasePlayer player, BaseEntity entity, string weaponShortPrefab) { var pos = entity.transform.position; _pendingRaidDiscreteEvents.Add( new IngestRaidEventItem { Kind = "explosive_used", HappenedAtIso = DateTime.UtcNow.ToString("o"), AttackerSteamId = SteamToLong(player.userID), AttackerTeamId = player.currentTeam != 0 ? (long?)(long)player.currentTeam : null, TargetCategory = "other", WeaponCategory = "explosive", WeaponShortPrefab = TruncateString(weaponShortPrefab, 64), HitCount = 1, WorldX = (float)Math.Round(pos.x, 1), WorldY = (float)Math.Round(pos.y, 1), WorldZ = (float)Math.Round(pos.z, 1), Grid = GetGridPosition(pos), } ); if (_pendingRaidDiscreteEvents.Count >= RaidMaxBatch) { FlushRaidEvents(); } } private static string RaidTargetCategory(BaseCombatEntity entity) { if (entity == null) { return null; } if (entity is BuildingPrivlidge) { return "tool_cupboard"; } var shortPrefab = entity.ShortPrefabName ?? string.Empty; if (shortPrefab.IndexOf("wall.external", StringComparison.OrdinalIgnoreCase) >= 0) { return "external_wall"; } if (entity is BuildingBlock) { return "building_block"; } if (entity is Door) { return "door"; } if (shortPrefab.IndexOf("window.bars", StringComparison.OrdinalIgnoreCase) >= 0 || shortPrefab.IndexOf("shutter", StringComparison.OrdinalIgnoreCase) >= 0 || shortPrefab.IndexOf("floor.ladder.hatch", StringComparison.OrdinalIgnoreCase) >= 0 || shortPrefab.IndexOf("floor.frame", StringComparison.OrdinalIgnoreCase) >= 0 || shortPrefab.IndexOf("wall.frame", StringComparison.OrdinalIgnoreCase) >= 0) { return "window"; } if (IsRaidableDeployable(shortPrefab)) { return "deployable"; } return null; } private static bool IsRaidableDeployable(string shortPrefab) { if (string.IsNullOrEmpty(shortPrefab)) { return false; } foreach (var token in RaidableDeployableTokens) { if (shortPrefab.IndexOf(token, StringComparison.OrdinalIgnoreCase) >= 0) { return true; } } return false; } private static string RaidWeaponCategory(HitInfo info) { var majority = TryMajorityDamageType(info); // "Cannon" is a raid-family damage type introduced by the Siege update; explosive raid // hits (C4 / rockets / cannonballs) frequently report it as the majority component, so it // must count as a raid weapon or ~half of every explosive raid is silently discarded // (confirmed via the v0.8.64 raid-debug webhook: heavy raids showed hundreds of // `weapon_filtered` hits with majority damage type "Cannon"). if (string.Equals(majority, "Explosion", StringComparison.Ordinal) || string.Equals(majority, "Cannon", StringComparison.Ordinal)) { return "explosive"; } if (string.Equals(majority, "Heat", StringComparison.Ordinal)) { return "fire"; } if (string.Equals(majority, "Slash", StringComparison.Ordinal) || string.Equals(majority, "Blunt", StringComparison.Ordinal) || string.Equals(majority, "Stab", StringComparison.Ordinal)) { return "melee"; } return null; } private static string TryWeaponShortPrefab(HitInfo info) { try { var shortPrefab = info?.WeaponPrefab?.ShortPrefabName; if (!string.IsNullOrWhiteSpace(shortPrefab)) { return shortPrefab; } return info?.Weapon?.GetItem()?.info?.shortname; } catch { return null; } } private bool IsSameTeam(ulong a, ulong b) { if (a == b) { return true; } var team = RelationshipManager.ServerInstance?.FindPlayersTeam(a); return team?.members != null && team.members.Contains(b); } private bool AttackerSharesTeamWithAny(ulong attacker, List others) { var team = RelationshipManager.ServerInstance?.FindPlayersTeam(attacker); if (team?.members == null) { return false; } foreach (var other in others) { if (team.members.Contains(other)) { return true; } } return false; } private HashSet CollectPresentDefenders(BaseEntity entity, List authed) { var present = new HashSet(); if (entity == null || authed == null || authed.Count == 0) { return present; } var pos = entity.transform.position; foreach (var id in authed) { var player = BasePlayer.FindByID(id); if (player == null || !player.IsConnected || player.IsSleeping()) { continue; } try { if (Vector3.Distance(player.transform.position, pos) <= RaidPresenceRadius) { present.Add(id); } } catch { // ignore } } return present; } private static List ToLongList(HashSet ids) { if (ids == null || ids.Count == 0) { return null; } var list = new List(ids.Count); foreach (var id in ids) { list.Add((long)id); } return list; } // --- raid-damage-filter diagnostics (temporary, v0.8.64) ----------------------------------- // Records why a hit on a *raidable* structure was dropped instead of producing a // structure_damage event. Only reached after RaidTargetCategory passed, so mining nodes / // barrels / animals never enter here — the counts are all genuine base structures. private void RaidDebugDrop(string reason, BaseCombatEntity entity, HitInfo info, ulong owner) { if (string.IsNullOrEmpty(config?.RaidDebugWebhookUrl)) { return; } _raidDebugDrops.TryGetValue(reason, out var count); _raidDebugDrops[reason] = count + 1; if (!_raidDebugSamples.ContainsKey(reason)) { string sample; switch (reason) { case "weapon_filtered": sample = (TryMajorityDamageType(info) ?? "?") + " via " + (TryWeaponShortPrefab(info) ?? "?"); break; case "owner_invalid": sample = (entity?.ShortPrefabName ?? "?") + " owner=" + owner; break; default: sample = entity?.ShortPrefabName ?? "?"; break; } _raidDebugSamples[reason] = sample; } } private void FlushRaidDebug() { if (string.IsNullOrEmpty(config?.RaidDebugWebhookUrl)) { return; } if (_raidDebugRecorded == 0 && _raidDebugDrops.Count == 0) { return; } var dropped = 0; foreach (var kv in _raidDebugDrops) { dropped += kv.Value; } var sb = new StringBuilder(); sb.Append($"🧱 **CerebRUST raid-debug** (last ~10s): recorded=**{_raidDebugRecorded}** dropped=**{dropped}**"); foreach (var kv in _raidDebugDrops) { _raidDebugSamples.TryGetValue(kv.Key, out var sample); sb.Append($"\n• `{kv.Key}` ×{kv.Value}"); if (!string.IsNullOrEmpty(sample)) { sb.Append($" e.g. {sample}"); } } _raidDebugRecorded = 0; _raidDebugDrops.Clear(); _raidDebugSamples.Clear(); PostRaidDebugToDiscord(sb.ToString()); } private void PostRaidDebugToDiscord(string content) { var url = config?.RaidDebugWebhookUrl; if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(content)) { return; } if (content.Length > 1900) { content = content.Substring(0, 1900); } var payload = JsonConvert.SerializeObject( new Dictionary { ["content"] = content } ); var headers = new Dictionary { ["Content-Type"] = "application/json" }; try { webrequest.Enqueue( url, payload, (code, response) => { if (code != 200 && code != 204) { PrintWarning($"CerebRUST raid-debug webhook POST failed ({code})."); } }, this, RequestMethod.POST, headers, 15f ); } catch (Exception ex) { PrintWarning($"CerebRUST raid-debug webhook error: {ex.Message}"); } } private void FlushRaidEvents() { if (!ingestReady) { return; } // Emit the raid-damage-filter diagnostics before the empty-batch early-return below: when // every hit is being dropped, there are no real events, but that is exactly what we want to // see reported. FlushRaidDebug(); var events = new List(); foreach (var acc in _raidDamageAccumulators.Values) { events.Add( new IngestRaidEventItem { Kind = "structure_damage", HappenedAtIso = acc.LastTime.ToString("o"), AttackerSteamId = SteamToLong(acc.AttackerSteamId), AttackerTeamId = acc.AttackerTeamId, TargetOwnerSteamId = acc.TargetOwnerSteamId.HasValue ? (long?)(long)acc.TargetOwnerSteamId.Value : null, TargetTcEntityId = acc.TargetTcEntityId, TargetBuildingId = acc.TargetBuildingId, TargetEntityId = acc.TargetEntityId, TargetCategory = acc.TargetCategory, TargetGrade = acc.TargetGrade, WeaponCategory = acc.WeaponCategory, WeaponShortPrefab = TruncateString(acc.WeaponShortPrefab, 64), DamageAmount = acc.TotalDamage, HitCount = acc.HitCount, WorldX = (float)Math.Round(acc.LastPos.x, 1), WorldY = (float)Math.Round(acc.LastPos.y, 1), WorldZ = (float)Math.Round(acc.LastPos.z, 1), Grid = acc.Grid, Monument = TruncateString(acc.Monument, 64), DefendersActivePresent = ToLongList(acc.DefendersPresent), } ); } _raidDamageAccumulators.Clear(); if (_pendingRaidDiscreteEvents.Count > 0) { events.AddRange(_pendingRaidDiscreteEvents); _pendingRaidDiscreteEvents.Clear(); } if (events.Count == 0) { return; } var url = $"{config.ApiBaseUrl.TrimEnd('/')}/api/v2/ingest/raid-events"; for (int i = 0; i < events.Count; i += RaidMaxBatch) { var chunk = events.GetRange(i, Math.Min(RaidMaxBatch, events.Count - i)); var payload = new IngestRaidEventsRequest { Token = config.IngestToken.Trim(), Events = chunk, }; PostIngestJson( "raid-events", url, JsonConvert.SerializeObject(payload), queueIfPaused: true, requestTimeoutSeconds: HeavyIngestRequestTimeoutSeconds ); } } private void FlushCombatLog() { if (!ingestReady || _pendingCombatHits.Count == 0) { return; } var hits = new List(_pendingCombatHits); _pendingCombatHits.Clear(); var url = $"{config.ApiBaseUrl.TrimEnd('/')}/api/v2/ingest/combat-log"; for (int i = 0; i < hits.Count; i += RaidMaxBatch) { var chunk = hits.GetRange(i, Math.Min(RaidMaxBatch, hits.Count - i)); var payload = new IngestCombatLogRequest { Token = config.IngestToken.Trim(), Hits = chunk, }; PostIngestJson( "combat-log", url, JsonConvert.SerializeObject(payload), queueIfPaused: true, requestTimeoutSeconds: HeavyIngestRequestTimeoutSeconds ); } } #endregion private void OnEntitySpawned(BaseNetworkable entity) { GameplayOnEntitySpawned(entity); var tc = entity as BuildingPrivlidge; if (!ingestReady || tc == null || tc.IsDestroyed) { return; } PostCupboardPlace(tc, "cupboard-place-spawn"); } private void OnEntityKill(BaseNetworkable entity) { GameplayOnEntityKill(entity); var tc = entity as BuildingPrivlidge; if (!ingestReady || tc == null) { return; } PostCupboardDestroy(tc, "cupboard-destroy-kill"); } private void OnCupboardAuthorize(BuildingPrivlidge cupboard, BasePlayer player) { if (!ingestReady || cupboard == null || player == null) { return; } PostCupboardAuth(cupboard, player.userID, "cupboard-auth"); } private void OnCupboardDeauthorize(BuildingPrivlidge cupboard, BasePlayer player) { if (!ingestReady || cupboard == null || player == null) { return; } PostCupboardDeauth(cupboard, player.userID, "cupboard-deauth"); } private void OnCupboardClearList(BuildingPrivlidge cupboard, BasePlayer player) { GameplayOnCupboardClearList(cupboard, player); if (!ingestReady || cupboard == null) { return; } PostCupboardDeauthAll(cupboard, "cupboard-deauth-all"); } private static string IngestUtcNowIso() { return DateTime.UtcNow.ToString("o"); } private static long SteamToLong(ulong steamId) { return unchecked((long)steamId); } private void PostServerEvent(string label, IngestServerEventItem item) { if (item == null) { return; } PostServerEvents(label, new List { item }); } private void PostServerEvents(string label, List events) { if (!ingestReady || events == null || events.Count == 0) { return; } var payload = new IngestServerEventsRequest { Token = config.IngestToken.Trim(), Events = events, }; var url = $"{config.ApiBaseUrl.TrimEnd('/')}/api/v2/ingest/server-events"; PostIngestJson( label, url, JsonConvert.SerializeObject(payload), queueIfPaused: true ); } private void PostTeamEvents(string label, List events) { if (!ingestReady || events == null || events.Count == 0) { return; } var payload = new IngestTeamEventsRequest { Token = config.IngestToken.Trim(), Events = events, }; var url = $"{config.ApiBaseUrl.TrimEnd('/')}/api/v2/ingest/team-events"; PostIngestJson( label, url, JsonConvert.SerializeObject(payload), queueIfPaused: true, requestTimeoutSeconds: HeavyIngestRequestTimeoutSeconds ); } private List CollectTeamSnapshots() { var list = new List(); var rm = RelationshipManager.ServerInstance; if (rm?.teams == null) { return list; } foreach (var kv in rm.teams) { var team = kv.Value; if (team?.members == null || team.members.Count == 0) { continue; } var memberIds = new List(); foreach (var m in team.members) { if (IsPlausibleSteamId(m)) { memberIds.Add(SteamToLong(m)); } } if (memberIds.Count == 0) { continue; } if (!IsPlausibleSteamId(team.teamLeader)) { continue; } list.Add( new IngestTeamReconcileTeamItem { RustTeamId = (long)team.teamID, LeaderSteamId = SteamToLong(team.teamLeader), MemberSteamIds = memberIds, } ); } return list; } private void PostTeamReconcileSnapshot(string label) { if (!ingestReady) { return; } var teams = CollectTeamSnapshots(); var payload = new IngestTeamReconcileRequest { Token = config.IngestToken.Trim(), Teams = teams, ObservedAtIso = IngestUtcNowIso(), }; var url = $"{config.ApiBaseUrl.TrimEnd('/')}/api/v2/ingest/team-reconcile"; PostIngestJson( label, url, JsonConvert.SerializeObject(payload), queueIfPaused: true, requestTimeoutSeconds: HeavyIngestRequestTimeoutSeconds ); } /// Snapshot the server's native ban list (covalence IPlayer.IsBanned) for parity. private List CollectNativeBans() { var list = new List(); var manager = covalence?.Players; if (manager == null) { return list; } foreach (var p in manager.All) { if (p == null || !p.IsBanned) { continue; } if (!ulong.TryParse(p.Id, out var steamId) || !IsPlausibleSteamId(steamId)) { continue; } list.Add( new IngestBanItem { SteamId = SteamToLong(steamId), DisplayName = string.IsNullOrWhiteSpace(p.Name) ? null : p.Name, ExpiresAtIso = BanTimeRemainingToIso(p.BanTimeRemaining), } ); } return list; } /// Full native ban-list snapshot → the API reconciles adds and removals. private void PostBansReconcile(string label) { if (!ingestReady) { return; } var payload = new IngestBansReconcileRequest { Token = config.IngestToken.Trim(), Bans = CollectNativeBans(), ObservedAtIso = IngestUtcNowIso(), }; var url = $"{config.ApiBaseUrl.TrimEnd('/')}/api/v2/ingest/bans-reconcile"; PostIngestJson( label, url, JsonConvert.SerializeObject(payload), queueIfPaused: true, requestTimeoutSeconds: HeavyIngestRequestTimeoutSeconds ); } /// Single ban/unban delta from the OnPlayerBanned / OnPlayerUnbanned hooks. private void PostBanEvent( string action, ulong steamId, string displayName, string reason, string expiresAtIso ) { if (!ingestReady || !IsPlausibleSteamId(steamId)) { return; } var payload = new IngestBanEventRequest { Token = config.IngestToken.Trim(), Action = action, SteamId = SteamToLong(steamId), DisplayName = string.IsNullOrWhiteSpace(displayName) ? null : displayName, Reason = string.IsNullOrWhiteSpace(reason) ? null : reason, ExpiresAtIso = expiresAtIso, }; var url = $"{config.ApiBaseUrl.TrimEnd('/')}/api/v2/ingest/ban-event"; PostIngestJson(label: $"ban-event-{action}", url: url, body: JsonConvert.SerializeObject(payload), queueIfPaused: true); } /// Native temp bans are effectively permanent in Rust; MaxValue → null (permanent). private static string BanTimeRemainingToIso(TimeSpan remaining) { if (remaining >= TimeSpan.MaxValue || remaining <= TimeSpan.Zero) { return null; } return DateTime.UtcNow.Add(remaining).ToString("o"); } /// Convert an OnPlayerBanned Unix-seconds expiry (0 = permanent) to an ISO string. private static string BanExpiryToIso(long expiryUnixSeconds) { if (expiryUnixSeconds <= 0L) { return null; } return DateTimeOffset.FromUnixTimeSeconds(expiryUnixSeconds).UtcDateTime.ToString("o"); } private void PostChatMessages(string label, List messages) { if (!ingestReady || messages == null || messages.Count == 0) { return; } var payload = new IngestChatMessagesRequest { Token = config.IngestToken.Trim(), Messages = messages, }; var url = $"{config.ApiBaseUrl.TrimEnd('/')}/api/v2/ingest/chat-messages"; PostIngestJson( label, url, JsonConvert.SerializeObject(payload), queueIfPaused: true, requestTimeoutSeconds: HeavyIngestRequestTimeoutSeconds ); } private void IngestChatFromPlayer( ulong steamId, string displayName, string message, ConVar.Chat.ChatChannel channel, long? rustTeamId, bool? fromRustPlus ) { if (!ingestReady || string.IsNullOrWhiteSpace(message) || !IsPlausibleSteamId(steamId)) { return; } string ch; long? teamId = null; switch (channel) { case ConVar.Chat.ChatChannel.Team: ch = "team"; teamId = rustTeamId; if (teamId == null) { var t = RelationshipManager.ServerInstance?.FindPlayersTeam(steamId); if (t != null) { teamId = (long)t.teamID; } } break; default: ch = "global"; break; } var item = new IngestChatMessageItem { Channel = ch, Body = message.Trim(), SenderSteamId = SteamToLong(steamId), RustTeamId = teamId, FromRustPlus = fromRustPlus, RecordedAtIso = IngestUtcNowIso(), SenderDisplayName = string.IsNullOrWhiteSpace(displayName) ? null : displayName.Trim(), }; PostChatMessages($"chat-{ch}", new List { item }); } private void OnTeamCreated(BasePlayer player, RelationshipManager.PlayerTeam team) { if (!ingestReady || team == null || !IsPlausibleSteamId(team.teamLeader)) { return; } var memberIds = new List(); if (team.members != null) { foreach (var m in team.members) { if (IsPlausibleSteamId(m)) { memberIds.Add(SteamToLong(m)); } } } var ev = new IngestTeamEventItem { Kind = "created", RustTeamId = (long)team.teamID, RecordedAtIso = IngestUtcNowIso(), LeaderSteamId = SteamToLong(team.teamLeader), MemberSteamIds = memberIds, }; PostTeamEvents("team-created", new List { ev }); } private void OnTeamAcceptInvite(RelationshipManager.PlayerTeam team, BasePlayer player) { GameplayOnTeamAcceptInvite(team, player); if (!ingestReady || team == null || player == null || !IsPlausibleSteamId(player.userID)) { return; } var ev = new IngestTeamEventItem { Kind = "invite_accepted", RustTeamId = (long)team.teamID, RecordedAtIso = IngestUtcNowIso(), MemberSteamId = SteamToLong(player.userID), }; PostTeamEvents("team-invite-accepted", new List { ev }); } private void OnTeamLeave(RelationshipManager.PlayerTeam team, BasePlayer player) { if (!ingestReady || team == null || player == null || !IsPlausibleSteamId(player.userID)) { return; } var ev = new IngestTeamEventItem { Kind = "leave", RustTeamId = (long)team.teamID, RecordedAtIso = IngestUtcNowIso(), MemberSteamId = SteamToLong(player.userID), }; PostTeamEvents("team-leave", new List { ev }); } private void OnTeamKick(RelationshipManager.PlayerTeam team, BasePlayer player, ulong target) { if (!ingestReady || team == null || !IsPlausibleSteamId(target)) { return; } var ev = new IngestTeamEventItem { Kind = "kick", RustTeamId = (long)team.teamID, RecordedAtIso = IngestUtcNowIso(), TargetSteamId = SteamToLong(target), }; PostTeamEvents("team-kick", new List { ev }); } private void OnTeamDisbanded(RelationshipManager.PlayerTeam team) { if (!ingestReady || team == null) { return; } var ev = new IngestTeamEventItem { Kind = "disbanded", RustTeamId = (long)team.teamID, RecordedAtIso = IngestUtcNowIso(), }; PostTeamEvents("team-disbanded", new List { ev }); } private object OnPlayerChat(BasePlayer player, string message, ConVar.Chat.ChatChannel channel) { if (player == null || string.IsNullOrWhiteSpace(message)) { return null; } // Our own team re-broadcast coming back around (see _rebroadcastingTeamChat): let it // through untouched — it is already ingested and already formatted. if (_rebroadcastingTeamChat) { return null; } // Ingested *before* the mute check, deliberately. What a muted player tried to say is // exactly what an admin needs in order to decide whether the mute was right — dropping // it would make the moderation record thinner the moment moderation started. It reaches // the dashboard and the staff Discord feed; it just never reaches the other players. IngestChatFromPlayer( player.userID, player.displayName, message, channel, channel == ConVar.Chat.ChatChannel.Team ? (long?)player.Team?.teamID : null, null ); MuteEntry mute; if (IsMuted(player.userID, out mute)) { NotifyMuted(player, mute); return true; } // Re-broadcast the line ourselves with a fixed name colour so admins/owners do NOT get // Rust's default green name (ported from ThistleChat, now with the VIP tag in front), // then block the default chat system — its green admin colouring is the reason we intercept. BroadcastPlayerChat(player, message, channel); return true; } private object OnPlayerOfflineChat( ulong playerId, string playerName, string message, ConVar.Chat.ChatChannel channel ) { if (string.IsNullOrWhiteSpace(playerName) || string.IsNullOrWhiteSpace(message)) { return null; } // Our own team re-broadcast coming back around (see _rebroadcastingTeamChat). if (_rebroadcastingTeamChat) { return null; } // Rust+ (companion app) offline chat is always team-only. var team = RelationshipManager.ServerInstance?.FindPlayersTeam(playerId); long? rustTeam = team != null ? (long?)team.teamID : null; IngestChatFromPlayer(playerId, playerName, message, channel, rustTeam, true); // A mute covers the Rust+ companion app too. It would otherwise be the obvious way // round one — a muted player alt-tabs to their phone and carries on talking to their // team. There is nobody in game to send the notice to, so this only blocks. MuteEntry offlineMute; if (IsMuted(playerId, out offlineMute)) { return true; } // Re-broadcast with the [RUST+] tag + fixed name colour (no green admins), then block. BroadcastOfflinePlayerChat(playerId, playerName, message, channel, team); return true; } // Default in-game chat name colour (#55aaff), used for everyone so admins/owners never get // Rust's default green name. A VIP may be painted a different colour by the owner // (VipPersistedData.NameColor) — which is still not green, and still not Rust's choice. // Only ChatEntry.Color and CerebrustChatNameColorTag read the bare form. private const string CerebrustChatNameColorHex = "5af"; /// /// The same colour in the form Rust's own chat path takes it — with the leading #. /// BroadcastTeamChat (CompanionServer's extension on a team) colours the name itself /// from this parameter rather than being handed rich text, which is what lets one call /// serve two audiences that render differently (see ). /// If team-chat names ever come out white in game, the # is the first thing to try /// dropping — vanilla's own strings are the only evidence for it. /// private const string CerebrustChatNameColorTag = "#" + CerebrustChatNameColorHex; /// Marks a line that arrived from the Rust+ companion app rather than in game. private const string RustPlusChatNamePrefix = "[RUST+] "; /// /// Set only while we are inside our own BroadcastTeamChat call. Which Facepunch method /// Oxide patches the chat hooks onto is not something this repo can see, and if either one /// sits on BroadcastTeamChat itself then our re-broadcast re-enters the hook that /// produced it — an unbounded chat loop that takes the server with it. The guard costs a /// bool on the main thread and turns that failure into a no-op. Re-entry returns /// null, never true: at that point the line is ours and must be allowed /// through, not blocked. /// private bool _rebroadcastingTeamChat; // Re-emit intercepted player chat under the SENDER's identity (name + Steam avatar). This // deliberately bypasses the CerebRUST BroadcastToServer/[CR] branding (which is for // plugin/system messages) — mirrored player chat must look like the player, not the plugin. private void BroadcastPlayerChat(BasePlayer sender, string message, ConVar.Chat.ChatChannel channel) { if (channel == ConVar.Chat.ChatChannel.Team) { var team = sender.Team; if (team != null) { // TWO calls, and both are needed — this is the shape BetterChat has always had. // // `BroadcastTeamChat` is the Rust+ (companion app) fanout and ONLY that: it // pushes the line to app clients and records it in the team's chat log, but it // sends nothing to connected game clients. v0.13.1 called it alone and team // chat vanished in game while still arriving in the app. // // Name and colour are separate parameters here because the app renders no rich // text. That is why a VIP's colour covers the whole name rather than just the // tag: one colour over one plain string is the only styling both renderers can // express, so global chat, team chat and Rust+ all show the same thing. _rebroadcastingTeamChat = true; try { team.BroadcastTeamChat( sender.userID, ChatDisplayNameFor(sender.userID, sender.displayName), message, ChatNameColorTagFor(sender.userID)); } finally { _rebroadcastingTeamChat = false; } // ...and the in-game copy, which is the same rich-text `chat.add` the global // branch below sends, narrowed to the team. SendChatAddToTeam( team, channel, sender.UserIDString, $"{ChatDisplayNameFor(sender.userID, sender.displayName)}: {message}"); } } else { // Global chat is not carried by Rust+ at all, so there is no app fanout to make — // but the rendering is deliberately identical to the team path above. string formatted = $"{ChatDisplayNameFor(sender.userID, sender.displayName)}: {message}"; foreach (BasePlayer p in BasePlayer.activePlayerList) { p.SendConsoleCommand("chat.add", (int)channel, sender.UserIDString, formatted); } } // We blocked `ConVar.Chat.sayAs`, which is what normally records the line, so the // history entry is still ours to write. RecordChatHistory(channel, sender.UserIDString, sender.displayName, $"{sender.displayName}: {message}"); } private void BroadcastOfflinePlayerChat( ulong playerId, string playerName, string message, ConVar.Chat.ChatChannel channel, RelationshipManager.PlayerTeam team ) { if (team != null) { // Same two calls as in-game team chat, for the same two audiences. Without the // first, a line typed in the app reached the sender's teammates in game but never // any other app client; without the second it reaches the app and nobody in game. // The [RUST+] prefix rides the name, so it shows in the app too; that is redundant // there (everything in the app is from the app) but harmless, and it is what makes // the line readable in game. _rebroadcastingTeamChat = true; try { team.BroadcastTeamChat( playerId, $"{RustPlusChatNamePrefix}{playerName}", message, CerebrustChatNameColorTag); } finally { _rebroadcastingTeamChat = false; } SendChatAddToTeam( team, channel, playerId.ToString(), $"{RustPlusChatNamePrefix}{playerName}: {message}"); } RecordChatHistory(channel, playerId.ToString(), playerName, $"{RustPlusChatNamePrefix}{playerName}: {message}"); } /// /// Sends one already-formatted `chat.add` line to every connected member of — the in-game half of a team message, which /// BroadcastTeamChat does not do. /// /// /// Filtering by team reference, rather than /// walking the team's own member list or its connections, is deliberate: it uses only /// symbols this plugin already calls elsewhere. Every Facepunch symbol here is unverifiable /// until a live server compiles the file, and this path has already cost two broken /// releases. The scan is a few hundred entries at most, once per team message. /// private void SendChatAddToTeam( RelationshipManager.PlayerTeam team, ConVar.Chat.ChatChannel channel, string senderUserIdString, string formatted) { foreach (BasePlayer p in BasePlayer.activePlayerList) { if (p == null || p.Team != team) { continue; } p.SendConsoleCommand("chat.add", (int)channel, senderUserIdString, formatted); } } private void RecordChatHistory( ConVar.Chat.ChatChannel channel, string userId, string username, string message) { var entry = new ConVar.Chat.ChatEntry { Channel = channel, Message = message, UserId = userId, Username = username, Color = CerebrustChatNameColorHex, Time = (int)DateTimeOffset.UtcNow.ToUnixTimeSeconds(), }; ConVar.Chat.Record(entry); } private object OnPlayerActionBroadcast(BasePlayer player, string action) { if (action.Contains("gave")) { return true; } return null; } private object OnServerMessage(string message, string name) { if (message.Contains("gave") && name == "SERVER") { return true; } if (!ingestReady || string.IsNullOrWhiteSpace(message)) { return null; } var item = new IngestChatMessageItem { Channel = "server", Body = message.Trim(), ServerMessageName = string.IsNullOrWhiteSpace(name) ? "SERVER" : name.Trim(), RecordedAtIso = IngestUtcNowIso(), }; PostChatMessages("chat-server", new List { item }); return null; } #endregion #region Ingest core (HTTP, retry/backoff, self-heal, pending queue) private void StartIngestSelfHealWatchdog() { ingestSelfHealWatchdogTimer?.Destroy(); ingestSelfHealWatchdogTimer = timer.Every( IngestSelfHealWatchdogIntervalSeconds, IngestSelfHealWatchdogTick ); } private void IngestSelfHealWatchdogTick() { if (ingestReady) { return; } if (_ingestInitHttpInFlight && _ingestInitHttpStartedRealtime > 0f) { var age = UnityEngine.Time.realtimeSinceStartup - _ingestInitHttpStartedRealtime; if (age >= IngestInitHttpStaleSeconds) { PrintWarning( $"CerebRUST ingest init HTTP callback overdue ({age:0}s); clearing in-flight guard — rescheduling recovery." ); _ingestInitHttpInFlight = false; _ingestInitHttpStartedRealtime = 0f; ResetIngestEndpointRecoveryDelay("init"); ScheduleIngestEndpointRecovery("init"); } } if ( !_ingestEndpointRecoveryTimers.ContainsKey("init") && !_ingestInitHttpInFlight ) { if (ShouldLogIngestEndpointFailure("init")) { PrintWarning( "CerebRUST ingest not ready and no init recovery timer is scheduled — scheduling API health check." ); _lastIngestEndpointFailureLogTime["init"] = UnityEngine.Time.realtimeSinceStartup; } ResetIngestEndpointRecoveryDelay("init"); ScheduleIngestEndpointRecovery("init"); } } /// /// After a failed /ingest/init (anything short of a parsed success + ), /// schedule init recovery with exponential backoff so we keep retrying even when /// /healthz stays 200. /// private void BumpInitRetryBackoffIfNotReady() { if (ingestReady) { return; } _ingestInitConsecutiveFailures++; _ingestEndpointRecoveryDelaySeconds["init"] = Mathf.Min( IngestRecoveryInitialSeconds * Mathf.Pow(2f, Mathf.Min(_ingestInitConsecutiveFailures - 1, 8)), IngestRecoveryMaxSeconds ); } private void ScheduleInitFailureRecovery() { BumpInitRetryBackoffIfNotReady(); if (ShouldLogIngestEndpointFailure("init")) { PrintWarning( $"CerebRUST ingest init incomplete; next scheduled retry in ~{GetIngestEndpointRecoveryDelay("init"):0}s " + $"(init failure #{_ingestInitConsecutiveFailures})." ); _lastIngestEndpointFailureLogTime["init"] = UnityEngine.Time.realtimeSinceStartup; } ScheduleIngestEndpointRecovery("init"); } private void RunIngestInit() { if (_ingestInitHttpInFlight) { VerbosePuts("CerebRUST ingest init skipped: a request is already in flight."); return; } if (string.IsNullOrWhiteSpace(config.IngestToken)) { PrintError("IngestToken is missing — set it in oxide/config/Cerebrust.json"); return; } if (!TryParsePort(ConVar.Server.port.ToString(), "game", out var gamePort)) { return; } if (!TryParsePort(ConVar.Server.queryport.ToString(), "query", out var queryPort)) { return; } _ingestInitHttpInFlight = true; _ingestInitHttpStartedRealtime = UnityEngine.Time.realtimeSinceStartup; var payload = new IngestInitRequest { Token = config.IngestToken.Trim(), IpAddress = covalence.Server.Address.ToString(), GamePort = gamePort, QueryPort = queryPort, Name = ConVar.Server.hostname, Description = ConVar.Server.description, HeaderImageUrl = ConVar.Server.headerimage, Version = GetRustProtocolMajor(), SaveCreatedTime = SaveRestore.SaveCreatedTime.ToUniversalTime(), WorldSeed = World.Seed, WorldSize = (int)World.Size, }; var url = $"{config.ApiBaseUrl.TrimEnd('/')}/api/v2/ingest/init"; PostJson(url, JsonConvert.SerializeObject(payload), OnIngestInitComplete); } private void OnIngestInitComplete(int code, string response) { _ingestInitHttpInFlight = false; _ingestInitHttpStartedRealtime = 0f; var url = $"{config.ApiBaseUrl.TrimEnd('/')}/api/v2/ingest/init"; if (code == 200) { try { var ok = JsonConvert.DeserializeObject(response); if (ok != null) { Puts("CerebRUST ingest initialized."); VerbosePuts( $"CerebRUST ingest init OK server_id={ok.ServerId} " + $"server_bound={ok.ServerBound} " + $"wipe_recorded={ok.WipeRecorded} ({url})" ); if (!string.IsNullOrWhiteSpace(ok.MapImageUrl)) { RememberMapUploadKey(); } if (!string.IsNullOrWhiteSpace(ok.TerrainUrl)) { _terrainIngestKey = CurrentTerrainIngestKey(); } TryMergePluginSettingsFromIngestResponseBody(response); _initWipeRecorded = ok.WipeRecorded; StartIngestLoops(); TryUploadMapImage(ok.WipeRecorded); return; } } catch (JsonException ex) { PrintError($"CerebRUST ingest init: invalid JSON ({ex.Message})"); ScheduleInitFailureRecovery(); return; } PrintWarning($"CerebRUST ingest init: unexpected body: {response}"); ScheduleInitFailureRecovery(); return; } if (IsTransientIngestFailure(code, response)) { EnterIngestPaused("init", url, code, response); return; } HandleIngestResponse("init", url, code, response); ScheduleInitFailureRecovery(); } private void StartIngestLoops() { ClearAllIngestEndpointPauses(); _ingestInitConsecutiveFailures = 0; statsTimer?.Destroy(); activeLocationsTimer?.Destroy(); sleeperLocationsTimer?.Destroy(); gatherActivityTimer?.Destroy(); gatherActivityTimer = null; ingestReady = true; SendStatsHeartbeat(); statsTimer = timer.Every(StatsIntervalSeconds, SendStatsHeartbeat); SendActivePlayerLocations(); activeLocationsTimer = timer.Every( ActiveLocationIntervalSeconds, SendActivePlayerLocations ); SendSleeperPlayerLocations(); sleeperLocationsTimer = timer.Every( SleeperLocationIntervalSeconds, SendSleeperPlayerLocations ); gatherActivityTimer?.Destroy(); gatherActivityTimer = timer.Every(GatherActivityPostIntervalSeconds, SendGatherAndActivityRollups); raidEventsFlushTimer?.Destroy(); raidEventsFlushTimer = timer.Every(RaidEventsFlushIntervalSeconds, FlushRaidEvents); combatLogFlushTimer?.Destroy(); combatLogFlushTimer = timer.Every(CombatLogFlushIntervalSeconds, FlushCombatLog); RunWorldEntityIngestPass(includeCupboardUpkeeps: true); lastCupboardUpkeepsSnapshotTime = UnityEngine.Time.realtimeSinceStartup; worldEntityScanTimer?.Destroy(); worldEntityScanTimer = timer.Every(MapEntitiesScanIntervalSeconds, WorldEntityScanTimerCallback); serverCommandsTimer?.Destroy(); serverCommandsTimer = timer.Every(ServerCommandsPollIntervalSeconds, PollServerCommands); _periodicMessagesScheduleFingerprint = BuildPeriodicMessagesScheduleFingerprint(); RestartPeriodicMessagesTimer(); FlushPendingIngestPosts(); InitWorldLocationHelpers(); TryPostMonumentsIngest(); TryPostTerrainIngest(); StartWipeScheduler(); // After InitWorldLocationHelpers: the scan keys groups on the monument cache. StartPuzzleWatch(); timer.Once(3f, () => PostTeamReconcileSnapshot("team-reconcile-boot")); timer.Once(3f, () => PostBansReconcile("bans-reconcile-boot")); } private void TryUploadMapImage(bool wipeRecorded) { if (!ingestReady) { return; } var uploadKey = CurrentMapUploadKey(); if (lastMapUploadKey == uploadKey) { return; } var mapBase64 = CaptureMapImage(); if (string.IsNullOrEmpty(mapBase64)) { if (wipeRecorded) { ScheduleMapUploadRetry(); } return; } var payload = new IngestMapRequest { Token = config.IngestToken.Trim(), GameVersion = GetRustProtocolMajor(), WorldSize = (int)World.Size, WorldSeed = World.Seed, MapPngBase64 = mapBase64, }; var url = $"{config.ApiBaseUrl.TrimEnd('/')}/api/v2/ingest/map"; PostIngestJson( "map", url, JsonConvert.SerializeObject(payload), queueIfPaused: true, requestTimeoutSeconds: MapUploadTimeoutSeconds ); } private void ScheduleMapUploadRetry() { mapUploadRetryTimer?.Destroy(); mapUploadRetryTimer = timer.Once(MapUploadRetrySeconds, () => TryUploadMapImage(wipeRecorded: true)); } private string CurrentMapUploadKey() { return $"{GetRustProtocolMajor()}_{World.Size}_{World.Seed}"; } private void RememberMapUploadKey() { lastMapUploadKey = CurrentMapUploadKey(); } /// /// Reads map_{version}_{size}_{seed}.png from the server root. /// world.rendermap always writes map_{size}_{seed}.png; we copy that into the versioned cache. /// private string CaptureMapImage() { try { var gameVersion = GetRustProtocolMajor(); var dataPath = UnityEngine.Application.dataPath; var serverRoot = System.IO.Directory.GetParent(dataPath)?.FullName ?? dataPath; var versionedFilename = $"map_{gameVersion}_{World.Size}_{World.Seed}.png"; var versionedPath = System.IO.Path.Combine(serverRoot, versionedFilename); var rendermapFilename = $"map_{World.Size}_{World.Seed}.png"; var rendermapPath = System.IO.Path.Combine(serverRoot, rendermapFilename); if (!System.IO.File.Exists(versionedPath)) { var playerCount = BasePlayer.activePlayerList?.Count ?? 0; if (playerCount > 0) { PrintWarning( $"CerebRUST map upload deferred: {playerCount} players online " + "(world.rendermap is unsafe with players connected)" ); return null; } VerbosePuts( $"CerebRUST generating map with world.rendermap " + $"(cache as {versionedFilename})..." ); ConsoleSystem.Run(ConsoleSystem.Option.Server, "world.rendermap"); System.Threading.Thread.Sleep(500); if (!System.IO.File.Exists(rendermapPath)) { PrintWarning( $"CerebRUST map file not created after world.rendermap: {rendermapFilename}" ); return null; } System.IO.File.Copy(rendermapPath, versionedPath, overwrite: true); } var imageBytes = System.IO.File.ReadAllBytes(versionedPath); var base64 = Convert.ToBase64String(imageBytes); VerbosePuts($"CerebRUST map loaded: {versionedFilename} ({imageBytes.Length / 1024}KB)"); return base64; } catch (Exception ex) { PrintWarning($"CerebRUST map capture failed: {ex.Message}"); return null; } } #region Sign moderation (ticket 0012) // Fires whenever a player saves a painted sign. We debounce per-sign: each save (re)starts an // 8s timer, and only when edits stop do we read the *current* (finished) texture and post it. // This collapses a burst of "still drawing" saves into a single upload of the final artwork. private void OnSignUpdated(Signage sign, BasePlayer player) { if (!ingestReady || sign == null || player == null || sign.net == null) { return; } ulong netId = sign.net.ID.Value; _pendingSignEdits[netId] = new PendingSignEdit { Sign = sign, SteamId = (ulong)player.userID, Name = player.displayName, }; if (_signDebounceTimers.TryGetValue(netId, out var existing)) { existing?.Destroy(); } _signDebounceTimers[netId] = timer.Once( SignUpdateDebounceSeconds, () => FlushPendingSign(netId) ); } private void FlushPendingSign(ulong netId) { _signDebounceTimers.Remove(netId); if (!_pendingSignEdits.TryGetValue(netId, out var pending)) { return; } _pendingSignEdits.Remove(netId); if (!ingestReady) { return; } var sign = pending.Sign; if (sign == null || sign.IsDestroyed || sign.net == null) { return; } uint textureId = 0; if (sign.textureIDs != null && sign.textureIDs.Length > 0) { textureId = sign.textureIDs[0]; } if (textureId == 0) { return; // nothing painted (cleared sign) } byte[] imageBytes; try { imageBytes = FileStorage.server.Get(textureId, FileStorage.Type.png, sign.net.ID); } catch (Exception ex) { VerbosePuts($"CerebRUST sign image read failed: {ex.Message}"); return; } if (imageBytes == null || imageBytes.Length == 0) { return; } var pos = sign.transform.position; var payload = new IngestSignRequest { Token = config.IngestToken.Trim(), EntityId = (long)netId, SignType = GetSignTypeName(sign), X = pos.x, Y = pos.y, Z = pos.z, ImagePngBase64 = Convert.ToBase64String(imageBytes), UpdatedBySteamId = pending.SteamId, UpdatedByName = pending.Name, }; var url = $"{config.ApiBaseUrl.TrimEnd('/')}/api/v2/ingest/sign-update"; PostIngestJson( "sign-update", url, JsonConvert.SerializeObject(payload), queueIfPaused: false, requestTimeoutSeconds: HeavyIngestRequestTimeoutSeconds ); } private string GetSignTypeName(Signage sign) { string p = sign.PrefabName ?? ""; if (p.Contains("sign.small.wood")) return "Small Wooden Sign"; if (p.Contains("sign.medium.wood")) return "Medium Wooden Sign"; if (p.Contains("sign.large.wood")) return "Large Wooden Sign"; if (p.Contains("sign.huge.wood")) return "Huge Wooden Sign"; if (p.Contains("sign.pictureframe")) return "Picture Frame"; if (p.Contains("sign.neon")) return "Neon Sign"; if (p.Contains("sign.post")) return "Sign Post"; if (p.Contains("banner")) return "Banner"; if (p.Contains("photoframe")) return "Photo Frame"; var parts = p.Split('/'); return parts.Length > 0 ? parts[parts.Length - 1].Replace(".prefab", "") : "Sign"; } private void ClearPendingSignEdits() { foreach (var t in _signDebounceTimers.Values) { t?.Destroy(); } _signDebounceTimers.Clear(); _pendingSignEdits.Clear(); } #endregion private void SendStatsHeartbeat() { if (!ingestReady || IsIngestEndpointPaused("heartbeat")) { return; } var fps = Performance.current.frameRate; var payload = new IngestStatsRequest { Token = config.IngestToken.Trim(), Fps = fps > 0f ? fps : (float?)null, PlayersOnline = BasePlayer.activePlayerList.Count, PlayersSleeping = BasePlayer.sleepingPlayerList.Count, MaxPlayers = ConVar.Server.maxplayers, }; var url = $"{config.ApiBaseUrl.TrimEnd('/')}/api/v2/ingest/heartbeat"; PostIngestJson( "heartbeat", url, JsonConvert.SerializeObject(payload), queueIfPaused: false, requestTimeoutSeconds: StatsRequestTimeoutSeconds, onIngestHttp200Bodies: (resp, _req) => { LogHeartbeatResponseBody(resp); TryMergePluginSettingsFromIngestResponseBody(resp); } ); } /// Debug: log raw heartbeat JSON so operators can confirm plugin_settings from the API. private void LogHeartbeatResponseBody(string response) { if (string.IsNullOrEmpty(response)) { VerbosePuts("CerebRUST ingest heartbeat response: (empty)"); return; } const int maxLen = 4096; var text = response.Length <= maxLen ? response : response.Substring(0, maxLen) + "…"; VerbosePuts($"CerebRUST ingest heartbeat response: {text}"); } private void SendActivePlayerLocations() { if (!ingestReady || IsIngestEndpointPaused("player-locations")) { return; } var updates = CollectActiveLocationUpdates(); PostPlayerLocations("player-locations-active", updates); } private void SendSleeperPlayerLocations() { if (!ingestReady || IsIngestEndpointPaused("player-locations")) { return; } var currentSleepers = new HashSet(); foreach (var player in BasePlayer.sleepingPlayerList) { if (player == null || !IsPlausibleSteamId(player.userID)) { continue; } currentSleepers.Add(player.userID); } var activeSteam = new HashSet(); foreach (var player in BasePlayer.activePlayerList) { if (player == null || !IsPlausibleSteamId(player.userID)) { continue; } activeSteam.Add(player.userID); } if (previousSleeperSteamIds.Count > 0) { var vanished = new List(); foreach (var sid in previousSleeperSteamIds) { if (currentSleepers.Contains(sid)) { continue; } if (activeSteam.Contains(sid)) { continue; } vanished.Add(sid); } if (vanished.Count > 0) { PostVanishedSleeperTombstones(vanished); } } previousSleeperSteamIds.Clear(); foreach (var sid in currentSleepers) { previousSleeperSteamIds.Add(sid); } var updates = CollectSleeperLocationUpdates(); PostPlayerLocations("player-locations-sleepers", updates); } private void PostVanishedSleeperTombstones(List steamIds) { const int maxPerRequest = 200; for (var i = 0; i < steamIds.Count; i += maxPerRequest) { var chunk = steamIds.GetRange(i, Math.Min(maxPerRequest, steamIds.Count - i)); var positions = new List(chunk.Count); foreach (var sid in chunk) { positions.Add( new IngestPlayerPosition { SteamId = sid, X = null, Y = null, Z = null, Online = false, } ); } PostPlayerLocations("player-locations-vanished-sleepers", positions); } } private List CollectActiveLocationUpdates() { var updates = new List(); foreach (var player in BasePlayer.activePlayerList) { if (player == null || !IsPlausibleSteamId(player.userID)) { continue; } var position = player.transform.position; if ( lastSentPositions.TryGetValue(player.userID, out var lastSent) && !PositionChanged(lastSent, position) ) { continue; } lastSentPositions[player.userID] = position; updates.Add(ToPosition(player, position, online: true)); } return updates; } private List CollectSleeperLocationUpdates() { var updates = new List(); foreach (var player in BasePlayer.sleepingPlayerList) { if (player == null || !IsPlausibleSteamId(player.userID)) { continue; } var position = player.transform.position; lastSentPositions[player.userID] = position; updates.Add(ToPosition(player, position, online: false)); } return updates; } private void PostPlayerLocations(string label, List positions) { if (positions == null || positions.Count == 0) { return; } var payload = new IngestPlayerLocationsRequest { Token = config.IngestToken.Trim(), Positions = positions, }; var url = $"{config.ApiBaseUrl.TrimEnd('/')}/api/v2/ingest/player-locations"; PostIngestJson( label, url, JsonConvert.SerializeObject( payload, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Include } ) ); } private void EnsureRollupWipeScope() { var seed = ConVar.Server.seed; if (rollupWipeMapSeed == seed) { return; } rollupWipeMapSeed = seed; gatherTotalsBySteam.Clear(); activityTotalsBySteam.Clear(); positionTrackersForAfk.Clear(); } private GatherTotals GetOrCreateGatherTotals(ulong steamId) { EnsureRollupWipeScope(); if (!gatherTotalsBySteam.TryGetValue(steamId, out var row)) { row = new GatherTotals(); gatherTotalsBySteam[steamId] = row; } return row; } private ActivityTotals GetOrCreateActivityTotals(ulong steamId) { EnsureRollupWipeScope(); if (!activityTotalsBySteam.TryGetValue(steamId, out var row)) { row = new ActivityTotals { LastActiveUpdateUtc = DateTime.UtcNow }; activityTotalsBySteam[steamId] = row; } return row; } private void TrackGatheredItem(BasePlayer player, Item item) { if (!ingestReady || player == null || player.IsNpc || item == null) { return; } if (!IsPlausibleSteamId(player.userID)) { return; } var shortname = item.info?.shortname ?? string.Empty; ApplyGatherForShortname(player.userID, shortname, item.amount); } private void ApplyGatherForShortname(ulong steamId, string itemName, long amount) { if (amount <= 0 || string.IsNullOrEmpty(itemName)) { return; } var stats = GetOrCreateGatherTotals(steamId); if (itemName.IndexOf("wood", StringComparison.OrdinalIgnoreCase) >= 0) { stats.Wood += amount; } else if ( string.Equals(itemName, "stones", StringComparison.OrdinalIgnoreCase) || itemName.IndexOf("stones", StringComparison.OrdinalIgnoreCase) >= 0 ) { stats.Stone += amount; } else if ( ( string.Equals(itemName, "metal.ore", StringComparison.OrdinalIgnoreCase) || itemName.IndexOf("metal.ore", StringComparison.OrdinalIgnoreCase) >= 0 ) && itemName.IndexOf("hq.metal.ore", StringComparison.OrdinalIgnoreCase) < 0 ) { stats.Metal += amount; } else if ( string.Equals(itemName, "sulfur.ore", StringComparison.OrdinalIgnoreCase) || itemName.IndexOf("sulfur.ore", StringComparison.OrdinalIgnoreCase) >= 0 ) { stats.Sulfur += amount; } } private void ApplyGatherAckForSamples(IEnumerable posted) { foreach (var s in posted) { ApplyGatherAckForSteam(s.SteamId, s.WoodGathered, s.StoneGathered, s.MetalGathered, s.SulfurGathered); } } private void ApplyGatherAckForSteam(ulong steamId, long wood, long stone, long metal, long sulfur) { if (!gatherTotalsBySteam.TryGetValue(steamId, out var g)) { return; } g.Wood = Math.Max(0, g.Wood - wood); g.Stone = Math.Max(0, g.Stone - stone); g.Metal = Math.Max(0, g.Metal - metal); g.Sulfur = Math.Max(0, g.Sulfur - sulfur); if (g.Wood == 0 && g.Stone == 0 && g.Metal == 0 && g.Sulfur == 0) { gatherTotalsBySteam.Remove(steamId); } } private void TryApplyGatherAckFromQueuedPlayerGather(string label, string requestBody) { if (string.IsNullOrEmpty(requestBody)) { return; } if (!label.StartsWith("player-gather", StringComparison.OrdinalIgnoreCase)) { return; } try { var req = JsonConvert.DeserializeObject(requestBody); if (req?.Samples == null || req.Samples.Count == 0) { return; } ApplyGatherAckForSamples(req.Samples); } catch (JsonException) { // ignore malformed replay body } } private void ApplyActivityAckForSamples(IEnumerable posted) { foreach (var s in posted) { ApplyActivityAckForSteam(s.SteamId, s.SecondsActive, s.SecondsAfk); } } private void ApplyActivityAckForSteam(ulong steamId, long secondsActive, long secondsAfk) { if (!activityTotalsBySteam.TryGetValue(steamId, out var a)) { return; } a.SecondsActive = Math.Max(0, a.SecondsActive - secondsActive); a.SecondsAfk = Math.Max(0, a.SecondsAfk - secondsAfk); } private void TryApplyActivityAckFromQueuedPlayerActivity(string label, string requestBody) { if (string.IsNullOrEmpty(requestBody)) { return; } if (!label.StartsWith("player-activity", StringComparison.OrdinalIgnoreCase)) { return; } try { var req = JsonConvert.DeserializeObject(requestBody); if (req?.Samples == null || req.Samples.Count == 0) { return; } ApplyActivityAckForSamples(req.Samples); } catch (JsonException) { // ignore malformed replay body } } private bool IsPlayerAfk(ulong steamId) { if (!positionTrackersForAfk.TryGetValue(steamId, out var tracker)) { return false; } var minutesSinceMove = (DateTime.UtcNow - tracker.LastMovedUtc).TotalMinutes; return minutesSinceMove >= AfkThresholdMinutes; } private void UpdateActiveTimeForSteam(ulong steamId) { if (!activityTotalsBySteam.TryGetValue(steamId, out var stats)) { return; } var now = DateTime.UtcNow; var elapsed = (int)(now - stats.LastActiveUpdateUtc).TotalSeconds; stats.LastActiveUpdateUtc = now; if (elapsed <= 0) { return; } // Rollup runs every GatherActivityPostIntervalSeconds (~30s). A 1–5s cap only made sense for a much faster tick. var credit = Math.Min(elapsed, ActivitySecondsMaxCreditPerTick); if (IsPlayerAfk(steamId)) { stats.SecondsAfk += credit; } else { stats.SecondsActive += credit; } } private void UpdateAllActiveTimeForOnlinePlayers() { foreach (var player in BasePlayer.activePlayerList) { if (player == null || player.IsNpc || !IsPlausibleSteamId(player.userID)) { continue; } GetOrCreateActivityTotals(player.userID); UpdateActiveTimeForSteam(player.userID); } } /// Credit elapsed active/AFK seconds before disconnect flush (rollup may not have ticked yet). private void CreditActivityForPlayerOnDisconnect(BasePlayer player) { if (player == null || !IsPlausibleSteamId(player.userID)) { return; } EnsureRollupWipeScope(); GetOrCreateActivityTotals(player.userID); UpdateActiveTimeForSteam(player.userID); } private void CheckPlayerPositionsForAfk() { if (!ingestReady) { return; } EnsureRollupWipeScope(); var now = DateTime.UtcNow; foreach (var player in BasePlayer.activePlayerList) { if (player == null || player.IsNpc || !IsPlausibleSteamId(player.userID)) { continue; } var currentPos = player.transform.position; if (positionTrackersForAfk.TryGetValue(player.userID, out var tracker)) { var distance = Vector3.Distance( new Vector3(tracker.LastPosition.x, 0f, tracker.LastPosition.z), new Vector3(currentPos.x, 0f, currentPos.z) ); if (distance >= GatherPositionChangeThresholdMeters) { tracker.LastPosition = currentPos; tracker.LastMovedUtc = now; } } else { positionTrackersForAfk[player.userID] = new PlayerPositionTracker(currentPos, now); } } var onlineIds = new HashSet(); foreach (var p in BasePlayer.activePlayerList) { if (p != null && !p.IsNpc && IsPlausibleSteamId(p.userID)) { onlineIds.Add(p.userID); } } foreach (var id in positionTrackersForAfk.Keys.ToList()) { if (!onlineIds.Contains(id)) { positionTrackersForAfk.Remove(id); } } } private void SendGatherAndActivityRollups() { if (!ingestReady) { return; } EnsureRollupWipeScope(); CheckPlayerPositionsForAfk(); UpdateAllActiveTimeForOnlinePlayers(); if ( IsIngestEndpointPaused("player-gather") && IsIngestEndpointPaused("player-activity") ) { return; } if (!IsIngestEndpointPaused("player-gather")) { PostGatherRollupBatches(); } if (!IsIngestEndpointPaused("player-activity")) { PostActivityRollupBatches(); } } private static HashSet BuildOnlinePlausibleSteamIds() { var ids = new HashSet(); foreach (var p in BasePlayer.activePlayerList) { if (p != null && !p.IsNpc && IsPlausibleSteamId(p.userID)) { ids.Add(p.userID); } } return ids; } /// /// One-shot gather POST for a disconnecting player. Pending deltas are cleared only after HTTP 200 /// (or after a successful replay from the pending-ingest disk queue). /// private void FlushGatherRollupForSteamOnDisconnect(ulong steamId) { if (!ingestReady || IsIngestEndpointPaused("player-gather") || !IsPlausibleSteamId(steamId)) { return; } if (!gatherTotalsBySteam.TryGetValue(steamId, out var g)) { return; } if (g.Wood == 0 && g.Stone == 0 && g.Metal == 0 && g.Sulfur == 0) { gatherTotalsBySteam.Remove(steamId); return; } var wPosted = g.Wood; var stPosted = g.Stone; var mPosted = g.Metal; var suPosted = g.Sulfur; var payload = new IngestPlayerGatherRequest { Token = config.IngestToken.Trim(), Samples = new List { new IngestPlayerGatherSample { SteamId = steamId, WoodGathered = wPosted, StoneGathered = stPosted, MetalGathered = mPosted, SulfurGathered = suPosted, }, }, }; var url = $"{config.ApiBaseUrl.TrimEnd('/')}/api/v2/ingest/player-gather"; PostIngestJson( "player-gather-disconnect", url, JsonConvert.SerializeObject(payload), queueIfPaused: true, onHttp200: () => ApplyGatherAckForSteam(steamId, wPosted, stPosted, mPosted, suPosted) ); } /// /// One-shot activity POST for a disconnecting player. Pending deltas are cleared only after HTTP 200 /// (or after a successful replay from the pending-ingest disk queue). /// private void FlushActivityRollupForSteamOnDisconnect(ulong steamId) { if (!ingestReady || IsIngestEndpointPaused("player-activity") || !IsPlausibleSteamId(steamId)) { return; } if (!activityTotalsBySteam.TryGetValue(steamId, out var a)) { return; } if (a.SecondsActive == 0 && a.SecondsAfk == 0) { return; } var saPosted = a.SecondsActive; var afkPosted = a.SecondsAfk; var payload = new IngestPlayerActivityRequest { Token = config.IngestToken.Trim(), Samples = new List { new IngestPlayerActivitySample { SteamId = steamId, SecondsActive = saPosted, SecondsAfk = afkPosted, }, }, }; var url = $"{config.ApiBaseUrl.TrimEnd('/')}/api/v2/ingest/player-activity"; PostIngestJson( "player-activity-disconnect", url, JsonConvert.SerializeObject(payload), queueIfPaused: true, onHttp200: () => ApplyActivityAckForSteam(steamId, saPosted, afkPosted) ); } private void PostGatherRollupBatches() { if (Interlocked.CompareExchange(ref _gatherRollupHttpCallbacksPending, 0, 0) != 0) { return; } const int maxPerRequest = 200; var onlineSteam = BuildOnlinePlausibleSteamIds(); var samples = new List(); foreach (var kvp in gatherTotalsBySteam) { if (!onlineSteam.Contains(kvp.Key)) { continue; } var g = kvp.Value; if (g.Wood == 0 && g.Stone == 0 && g.Metal == 0 && g.Sulfur == 0) { continue; } samples.Add( new IngestPlayerGatherSample { SteamId = kvp.Key, WoodGathered = g.Wood, StoneGathered = g.Stone, MetalGathered = g.Metal, SulfurGathered = g.Sulfur, } ); } if (samples.Count == 0) { return; } var baseUrl = $"{config.ApiBaseUrl.TrimEnd('/')}/api/v2/ingest/player-gather"; for (var i = 0; i < samples.Count; i += maxPerRequest) { var chunk = samples.GetRange(i, Math.Min(maxPerRequest, samples.Count - i)); var payload = new IngestPlayerGatherRequest { Token = config.IngestToken.Trim(), Samples = chunk, }; var chunkCopy = new List(chunk); PostIngestJson( $"player-gather-{i / maxPerRequest}", baseUrl, JsonConvert.SerializeObject(payload), queueIfPaused: false, onHttp200: () => ApplyGatherAckForSamples(chunkCopy), onGatherHttpFlightDone: () => Interlocked.Decrement(ref _gatherRollupHttpCallbacksPending) ); } } private void PostActivityRollupBatches() { if (Interlocked.CompareExchange(ref _activityRollupHttpCallbacksPending, 0, 0) != 0) { return; } const int maxPerRequest = 200; var samples = new List(); foreach (var player in BasePlayer.activePlayerList) { if (player == null || player.IsNpc || !IsPlausibleSteamId(player.userID)) { continue; } var a = GetOrCreateActivityTotals(player.userID); if (a.SecondsActive == 0 && a.SecondsAfk == 0) { continue; } samples.Add( new IngestPlayerActivitySample { SteamId = player.userID, SecondsActive = a.SecondsActive, SecondsAfk = a.SecondsAfk, } ); } if (samples.Count == 0) { return; } var baseUrl = $"{config.ApiBaseUrl.TrimEnd('/')}/api/v2/ingest/player-activity"; for (var i = 0; i < samples.Count; i += maxPerRequest) { var chunk = samples.GetRange(i, Math.Min(maxPerRequest, samples.Count - i)); var payload = new IngestPlayerActivityRequest { Token = config.IngestToken.Trim(), Samples = chunk, }; var chunkCopy = new List(chunk); PostIngestJson( $"player-activity-{i / maxPerRequest}", baseUrl, JsonConvert.SerializeObject(payload), queueIfPaused: false, onHttp200: () => ApplyActivityAckForSamples(chunkCopy), onActivityHttpFlightDone: () => Interlocked.Decrement(ref _activityRollupHttpCallbacksPending) ); } } private void PostIngestJson( string label, string url, string body, bool queueIfPaused = false, float? requestTimeoutSeconds = null, Action onHttp200 = null, Action onGatherHttpFlightDone = null, Action onActivityHttpFlightDone = null, Action onIngestHttp200Bodies = null ) { if (IsIngestEndpointPaused(label)) { if (queueIfPaused) { EnqueuePendingIngest(label, url, body); } else if (label == "map-entities" || label == "cupboard-upkeeps" || label == "heartbeat") { VerbosePuts( $"CerebRUST ingest trace {label} skipped (endpoint {IngestEndpointFamily(label)} paused, not queued)" ); } return; } var trackGatherFlight = onGatherHttpFlightDone != null; if (trackGatherFlight) { Interlocked.Increment(ref _gatherRollupHttpCallbacksPending); } var trackActivityFlight = onActivityHttpFlightDone != null; if (trackActivityFlight) { Interlocked.Increment(ref _activityRollupHttpCallbacksPending); } var sw = Stopwatch.StartNew(); PostJson( url, body, (code, response) => { try { sw.Stop(); if (label == "map-entities" || label == "cupboard-upkeeps" || label == "heartbeat") { var respLen = response != null ? response.Length : 0; VerbosePuts( $"CerebRUST ingest trace {label} http={code} wall_ms={sw.ElapsedMilliseconds} req_chars={body.Length} resp_chars={respLen}" ); } if (code == 200) { if (label == "map") { OnMapUploadSucceeded(); } else if (label == "cupboard-upkeeps") { LogCupboardUpkeepsResponse(response); } onHttp200?.Invoke(); onIngestHttp200Bodies?.Invoke(response ?? string.Empty, body); OnIngestRequestSucceeded(label); return; } if (IsTransientIngestFailure(code, response)) { if (queueIfPaused) { EnterIngestPaused(label, url, code, response); EnqueuePendingIngest(label, url, body); } else { EnterIngestPaused(label, url, code, response); } return; } LogIngestFailure(label, url, code, response); } finally { if (trackGatherFlight) { onGatherHttpFlightDone?.Invoke(); } if (trackActivityFlight) { onActivityHttpFlightDone?.Invoke(); } } }, requestTimeoutSeconds ?? RequestTimeoutSeconds ); } private void OnMapUploadSucceeded() { RememberMapUploadKey(); mapUploadRetryTimer?.Destroy(); mapUploadRetryTimer = null; Puts("CerebRUST map image uploaded."); } private void OnIngestRequestSucceeded(string label) { if (!ingestReady) { return; } var family = IngestEndpointFamily(label); if (!_pausedIngestEndpointFamilies.Contains(family)) { return; } ClearIngestEndpointPause(family); Puts($"CerebRUST ingest endpoint {family} reachable again."); FlushPendingIngestPostsForFamily(family); } private static string SanitizeRustRichTextFragment(string value) { if (string.IsNullOrEmpty(value)) { return string.Empty; } return value.Replace("<", string.Empty).Replace(">", string.Empty); } /// /// Shown to the connecting player after ingest player-connect succeeds (same path as join broadcast). /// private const string CerebrustProtectionNoticeText = "This server is protected by CerebRUST."; private void TrySendCerebrustProtectionNoticeFromPlayerConnectRequest(string requestBody) { if (string.IsNullOrWhiteSpace(requestBody)) { return; } try { var req = JsonConvert.DeserializeObject(requestBody); if (req == null || !IsPlausibleSteamId(req.SteamId)) { return; } var target = BasePlayer.FindByID(req.SteamId); if (target == null || !target.IsConnected) { return; } SendToPlayer(target, CerebrustProtectionNoticeText); } catch (Exception ex) { VerbosePuts($"CerebRUST protection notice skipped: {ex.Message}"); } } private static string TryReadLocationLabelFromPlayerConnectResponse(JObject resp) { if (resp == null) { return null; } string StringFromToken(JToken t) { if (t == null || t.Type == JTokenType.Null || t.Type != JTokenType.String) { return null; } var s = t.Value(); return string.IsNullOrWhiteSpace(s) ? null : s.Trim(); } foreach ( var path in new[] { "location_label", "LocationLabel", "data.location_label", "Data.location_label", "result.location_label", } ) { var fromPath = StringFromToken(resp.SelectToken(path)); if (fromPath != null) { return fromPath; } } var prop = resp.Properties() .FirstOrDefault(p => string.Equals(p.Name, "location_label", StringComparison.OrdinalIgnoreCase) ); return StringFromToken(prop?.Value); } private void MaybeBroadcastCerebrustSessionIngestChat(string label, string responseBody, string requestBody) { if (label != "player-connect" && label != "player-disconnect") { return; } if (label == "player-connect" && !ReadPluginSettingBool("session.announce_joiners", true)) { return; } if (label == "player-disconnect" && !ReadPluginSettingBool("session.announce_leavers", true)) { return; } if (string.IsNullOrWhiteSpace(responseBody)) { return; } try { if (label == "player-connect") { var req = JsonConvert.DeserializeObject(requestBody ?? "{}"); if (req == null) { return; } var resp = JObject.Parse(responseBody); var location = TryReadLocationLabelFromPlayerConnectResponse(resp); var country = string.IsNullOrWhiteSpace(location) ? "Unknown" : location.Trim(); var display = !string.IsNullOrWhiteSpace(req.DisplayName) ? req.DisplayName : req.SteamId.ToString(); var namePart = SanitizeRustRichTextFragment(display); var countryPart = SanitizeRustRichTextFragment(country); BroadcastToServer( $"{namePart} connected from {countryPart}." ); TrySendCerebrustProtectionNoticeFromPlayerConnectRequest(requestBody); return; } if (label == "player-disconnect") { var resp = JObject.Parse(responseBody); var nameToken = resp["display_name"]; var fromApi = nameToken != null && nameToken.Type != JTokenType.Null ? nameToken.Value() : null; var req = JsonConvert.DeserializeObject( requestBody ?? "{}" ); string display; if (!string.IsNullOrWhiteSpace(fromApi)) { display = fromApi; } else if (req != null && !string.IsNullOrWhiteSpace(req.DisplayName)) { display = req.DisplayName.Trim(); } else { display = req?.SteamId.ToString() ?? "?"; } var namePart = SanitizeRustRichTextFragment(display); BroadcastToServer($"{namePart} disconnected."); } } catch (Exception ex) { VerbosePuts($"CerebRUST ingest broadcast skipped ({label}): {ex.Message}"); } } private void HandleIngestResponse(string label, string url, int code, string response, string requestBody = null) { if (code == 200) { TryMergePluginSettingsFromIngestResponseBody(response); TryApplyGatherAckFromQueuedPlayerGather(label, requestBody); TryApplyActivityAckFromQueuedPlayerActivity(label, requestBody); MaybeBroadcastCerebrustSessionIngestChat(label, response ?? string.Empty, requestBody ?? string.Empty); OnIngestRequestSucceeded(label); return; } if (IsTransientIngestFailure(code, response)) { EnterIngestPaused(label, url, code, response); return; } LogIngestFailure(label, url, code, response); } private static bool IsTransientIngestFailure(int code, string response) { if (code == 0 || code == 404 || code == 408 || code == 429 || code >= 500) { return true; } if (string.IsNullOrWhiteSpace(response)) { return false; } var trimmed = response.TrimStart(); return trimmed.StartsWith(" x, StringComparer.OrdinalIgnoreCase)); } private float GetIngestEndpointRecoveryDelay(string family) { if (_ingestEndpointRecoveryDelaySeconds.TryGetValue(family, out var delay)) { return delay; } return IngestRecoveryInitialSeconds; } private void ResetIngestEndpointRecoveryDelay(string family) { _ingestEndpointRecoveryDelaySeconds[family] = IngestRecoveryInitialSeconds; } private void BumpIngestEndpointRecoveryDelay(string family) { var current = GetIngestEndpointRecoveryDelay(family); _ingestEndpointRecoveryDelaySeconds[family] = Mathf.Min( current * 2f, IngestRecoveryMaxSeconds ); } private void ClearIngestEndpointPause(string family) { _pausedIngestEndpointFamilies.Remove(family); if (_ingestEndpointRecoveryTimers.TryGetValue(family, out var recoveryTimer)) { recoveryTimer?.Destroy(); _ingestEndpointRecoveryTimers.Remove(family); } ResetIngestEndpointRecoveryDelay(family); } private void ClearAllIngestEndpointPauses() { foreach (var recoveryTimer in _ingestEndpointRecoveryTimers.Values) { recoveryTimer?.Destroy(); } _ingestEndpointRecoveryTimers.Clear(); _pausedIngestEndpointFamilies.Clear(); _ingestEndpointRecoveryDelaySeconds.Clear(); _lastIngestEndpointFailureLogTime.Clear(); } private void ScheduleIngestEndpointRecovery(string family) { if (_ingestEndpointRecoveryTimers.TryGetValue(family, out var existing)) { existing?.Destroy(); } var delay = GetIngestEndpointRecoveryDelay(family); _ingestEndpointRecoveryTimers[family] = timer.Once(delay, () => TryRecoverIngestEndpoint(family)); } private void TryRecoverIngestEndpoint(string family) { _ingestEndpointRecoveryTimers.Remove(family); if (string.Equals(family, "init", StringComparison.OrdinalIgnoreCase) || !ingestReady) { TryRecoverInitViaHealthz(); return; } if (!_pausedIngestEndpointFamilies.Contains(family)) { return; } _pausedIngestEndpointFamilies.Remove(family); if (FlushPendingIngestPostsForFamily(family) > 0) { return; } if (!TriggerIngestEndpointProbe(family)) { _pausedIngestEndpointFamilies.Add(family); BumpIngestEndpointRecoveryDelay(family); ScheduleIngestEndpointRecovery(family); } } private void TryRecoverInitViaHealthz() { var healthUrl = $"{config.ApiBaseUrl.TrimEnd('/')}/api/v2/healthz"; GetJson(healthUrl, (code, response) => { if (code == 200) { if (!ingestReady) { Puts("CerebRUST ingest API is up; retrying init."); ClearIngestEndpointPause("init"); RunIngestInit(); return; } ClearIngestEndpointPause("init"); return; } if (ShouldLogIngestEndpointFailure("init")) { PrintWarning( $"CerebRUST ingest init recovery: healthz HTTP {code}; " + $"next retry in {GetIngestEndpointRecoveryDelay("init"):0}s." ); _lastIngestEndpointFailureLogTime["init"] = UnityEngine.Time.realtimeSinceStartup; } BumpIngestEndpointRecoveryDelay("init"); ScheduleIngestEndpointRecovery("init"); }); } private bool TriggerIngestEndpointProbe(string family) { if (!ingestReady) { return false; } switch (family) { case "heartbeat": SendStatsHeartbeat(); return true; case "player-locations": SendActivePlayerLocations(); SendSleeperPlayerLocations(); return true; case "player-gather": PostGatherRollupBatches(); return true; case "player-activity": PostActivityRollupBatches(); return true; case "map-entities": RunWorldEntityIngestPass(includeCupboardUpkeeps: false); return true; case "cupboard-upkeeps": case "containers": // One pass probes both: the cupboard snapshot and the container sweep ride the // same walk, so probing either family re-runs the same work. RunWorldEntityIngestPass(includeCupboardUpkeeps: true); return true; case "commands-pull": PollServerCommands(); return true; case "monuments": TryPostMonumentsIngest(); return true; case "map": TryUploadMapImage(wipeRecorded: true); return true; case "terrain": TryPostTerrainIngest(); return true; default: // Event-driven families (chat, player-connect/disconnect, world-events) have no // periodic self-probe. Returning true tells the recovery caller "nothing to probe, // consider this recovered" so it leaves the family unpaused instead of silently // re-pausing forever. Safe because these are all queueIfPaused: true — a genuinely // still-broken endpoint re-pauses (and re-queues) on its next real post. return true; } } private bool ShouldLogIngestEndpointFailure(string family) { if (!_lastIngestEndpointFailureLogTime.TryGetValue(family, out var last)) { return true; } return UnityEngine.Time.realtimeSinceStartup - last >= IngestFailureLogCooldownSeconds; } private static string PendingIngestDiskPath => Path.Combine(Interface.Oxide.DataDirectory, "Cerebrust", "ingest_pending.jsonl"); private static void TryDeletePendingIngestDiskFile() { try { var path = PendingIngestDiskPath; if (File.Exists(path)) { File.Delete(path); } _pendingDiskLineCount = 0; } catch (Exception ex) { Interface.Oxide.LogWarning($"CerebRUST could not delete ingest pending disk queue: {ex.Message}"); } } private void AppendPendingIngestDiskLine(string label, string url, string body) { try { var path = PendingIngestDiskPath; var dir = Path.GetDirectoryName(path); if (!string.IsNullOrEmpty(dir)) { Directory.CreateDirectory(dir); } if (_pendingDiskLineCount < 0) { _pendingDiskLineCount = CountPendingIngestDiskLines(path); } var line = JsonConvert.SerializeObject( new PendingIngestPost { Label = label, Url = url, Body = body } ); File.AppendAllText(path, line + Environment.NewLine); _pendingDiskLineCount++; // Size-cap: a long API outage on a chatty server would otherwise grow this file // unbounded (one synchronous main-thread write per queued post). Drop-oldest on // overflow (ticket 0013 P5). if (_pendingDiskLineCount > PendingIngestDiskMaxLines) { TrimPendingIngestDiskFile(path); } } catch (Exception ex) { PrintWarning($"CerebRUST could not append ingest pending disk queue: {ex.Message}"); } } private static int CountPendingIngestDiskLines(string path) { if (!File.Exists(path)) { return 0; } try { return File.ReadAllLines(path).Length; } catch { return 0; } } private void TrimPendingIngestDiskFile(string path) { try { var lines = File.ReadAllLines(path); if (lines.Length <= PendingIngestDiskTrimToLines) { _pendingDiskLineCount = lines.Length; return; } var dropped = lines.Length - PendingIngestDiskTrimToLines; var keep = new string[PendingIngestDiskTrimToLines]; Array.Copy(lines, dropped, keep, 0, PendingIngestDiskTrimToLines); File.WriteAllLines(path, keep); _pendingDiskLineCount = keep.Length; PrintWarning( $"CerebRUST ingest pending disk queue exceeded {PendingIngestDiskMaxLines} lines; " + $"dropped {dropped} oldest (API backlog — check API availability)." ); } catch (Exception ex) { PrintWarning($"CerebRUST could not trim ingest pending disk queue: {ex.Message}"); } } private void HydratePendingIngestFromDisk() { var path = PendingIngestDiskPath; if (!File.Exists(path)) { return; } string[] lines; try { lines = File.ReadAllLines(path); } catch (Exception ex) { PrintWarning($"CerebRUST could not read ingest pending disk queue: {ex.Message}"); return; } try { File.Delete(path); _pendingDiskLineCount = 0; } catch (Exception ex) { PrintWarning($"CerebRUST could not truncate ingest pending disk queue: {ex.Message}"); } if (lines.Length > PendingIngestHydrateWarnLines) { PrintWarning( $"CerebRUST ingest pending disk queue has {lines.Length} lines; " + "consider investigating API availability (large backlog)." ); } foreach (var line in lines) { if (string.IsNullOrWhiteSpace(line)) { continue; } try { var post = JsonConvert.DeserializeObject(line.Trim()); if (post != null && !string.IsNullOrEmpty(post.Url) && post.Body != null) { pendingIngestPosts.Enqueue(post); } } catch (Exception ex) { PrintWarning($"CerebRUST skipped bad ingest pending line: {ex.Message}"); } } } private void EnqueuePendingIngest(string label, string url, string body) { AppendPendingIngestDiskLine(label, url, body); } private void FlushPendingIngestPosts() { if (!ingestReady) { return; } HydratePendingIngestFromDisk(); var deferred = new List(); while (pendingIngestPosts.Count > 0) { var pending = pendingIngestPosts.Dequeue(); if (IsIngestEndpointPaused(pending.Label)) { deferred.Add(pending); continue; } PostJson( pending.Url, pending.Body, (code, response) => HandleIngestResponse(pending.Label, pending.Url, code, response, pending.Body) ); } foreach (var pending in deferred) { pendingIngestPosts.Enqueue(pending); } } private int FlushPendingIngestPostsForFamily(string family) { if (!ingestReady) { return 0; } HydratePendingIngestFromDisk(); var sent = 0; var deferred = new List(); while (pendingIngestPosts.Count > 0) { var pending = pendingIngestPosts.Dequeue(); if (!string.Equals(IngestEndpointFamily(pending.Label), family, StringComparison.OrdinalIgnoreCase)) { deferred.Add(pending); continue; } if (IsIngestEndpointPaused(pending.Label)) { deferred.Add(pending); continue; } sent++; PostJson( pending.Url, pending.Body, (code, response) => HandleIngestResponse(pending.Label, pending.Url, code, response, pending.Body) ); } foreach (var pending in deferred) { pendingIngestPosts.Enqueue(pending); } return sent; } private static IngestPlayerPosition ToTombstonePosition(BasePlayer player) { return new IngestPlayerPosition { SteamId = player.userID, X = null, Y = null, Z = null, Online = false, DisplayName = !string.IsNullOrWhiteSpace(player.displayName) ? player.displayName : null, }; } private static IngestPlayerPosition ToPosition( BasePlayer player, Vector3 position, bool online ) { string ipAddress = null; if (online) { ipAddress = ExtractHostIp(player?.net?.connection?.ipaddress); } return new IngestPlayerPosition { SteamId = player.userID, X = position.x, Y = position.y, Z = position.z, Online = online, DisplayName = !string.IsNullOrWhiteSpace(player.displayName) ? player.displayName : null, IpAddress = online && !string.IsNullOrWhiteSpace(ipAddress) ? ipAddress : null, RustTeamId = player.Team != null ? (long)player.Team.teamID : (long?)null, }; } private static bool PositionChanged(Vector3 previous, Vector3 current) { return (previous - current).sqrMagnitude > PositionEpsilonMeters * PositionEpsilonMeters; } private sealed class ServerCommandsPullEnvelope { [JsonProperty("commands")] public List Commands { get; set; } } private sealed class ServerCommandWorkItem { [JsonProperty("id")] public long Id { get; set; } [JsonProperty("method_name")] public string MethodName { get; set; } [JsonProperty("payload")] public JObject Payload { get; set; } } private sealed class ServerCommandAckItem { [JsonProperty("id")] public long Id { get; set; } [JsonProperty("status")] public string Status { get; set; } [JsonProperty("error", NullValueHandling = NullValueHandling.Ignore)] public string Error { get; set; } /// /// What the command actually did, for commands whose real figures only exist after /// execution (AddUpkeep). The API stores it verbatim and must never reject an ack /// over it — by the time this is sent the side effect has already happened. /// [JsonProperty("result", NullValueHandling = NullValueHandling.Ignore)] public Dictionary Result { get; set; } } private void PollServerCommands() { if (!ingestReady || IsIngestEndpointPaused("commands-pull")) { return; } if (_serverCommandsChainBusy) { var now = UnityEngine.Time.realtimeSinceStartup; if ( _serverCommandsChainBusySinceRealtime > 0f && now - _serverCommandsChainBusySinceRealtime >= ServerCommandsChainBusyStaleSeconds ) { if ( _lastServerCommandsStaleChainBusyLogRealtime <= 0f || now - _lastServerCommandsStaleChainBusyLogRealtime >= ServerCommandsStaleAutoClearWarningCooldownSeconds ) { PrintWarning( "[CerebRUST] Auto-clearing stale _serverCommandsChainBusy " + $"(≥{ServerCommandsChainBusyStaleSeconds:0}s; the commands/pull callback never fired). " + "This is the uMod webrequest layer dropping a callback, not the API — measured at ~0.2% " + "of pulls, with the API answering every one of them 200 in single-digit milliseconds. " + "Command polling has already resumed; nothing is lost and no action is needed." ); _lastServerCommandsStaleChainBusyLogRealtime = now; } else { VerbosePuts( "[CerebRUST] Auto-clearing stale _serverCommandsChainBusy (throttled full warning; " + "enable verbose_logging for every occurrence)." ); } ClearServerCommandsChainBusy(); } else { return; } } MarkServerCommandsChainBusy(); var baseUrl = config.ApiBaseUrl.TrimEnd('/'); var url = $"{baseUrl}/api/v2/ingest/commands/pull"; var pullPayload = new Dictionary { ["token"] = config.IngestToken.Trim(), ["limit"] = ServerCommandsPullLimit, }; var body = JsonConvert.SerializeObject(pullPayload); PostJson( url, body, (code, response) => { try { if (code != 200) { HandleIngestResponse("commands-pull", url, code, response, body); return; } HandleIngestResponse("commands-pull", url, code, response, body); ServerCommandsPullEnvelope envelope; try { envelope = JsonConvert.DeserializeObject(response); } catch (JsonException ex) { PrintError($"CerebRUST commands-pull: invalid JSON ({ex.Message})"); return; } var items = envelope?.Commands ?? new List(); List results; try { results = ExecuteServerCommandWorkItems(items); } catch (Exception ex) { PrintError($"CerebRUST commands-pull: execute failed ({ex.Message})"); return; } if (results.Count == 0) { return; } var ackUrl = $"{baseUrl}/api/v2/ingest/commands/ack"; var ackPayload = new Dictionary { ["token"] = config.IngestToken.Trim(), ["results"] = results, }; var ackBody = JsonConvert.SerializeObject( ackPayload, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore } ); // Fire-and-forget: the ack is deliberately outside the poll lock, which the // finally below releases as soon as this callback returns. Holding the lock // across the ack put a second, longer timeout on the critical path for no // safety — the API claims rows with FOR UPDATE SKIP LOCKED and flips them to // `processing` inside the pull's own transaction, so a poll that overlaps an // in-flight ack can never be handed a command that is already executing here. PostJson( ackUrl, ackBody, (ackCode, ackResp) => HandleIngestResponse("commands-ack", ackUrl, ackCode, ackResp, ackBody), ServerCommandsAckRequestTimeoutSeconds ); } catch (Exception ex) { PrintError($"CerebRUST commands-pull callback: {ex}"); } finally { ClearServerCommandsChainBusy(); } }, RequestTimeoutSeconds ); } /// Fallback kick reason when a KickPlayer command carries no reason. private const string CerebrustDefaultKickReason = "Kicked by CerebRUST."; /// Fallback ban reason when a BanPlayer command carries no reason. private const string CerebrustDefaultBanReason = "Banned by CerebRUST."; private List ExecuteServerCommandWorkItems(List items) { var results = new List(); foreach (var item in items) { if (item == null) { continue; } var id = item.Id; var method = item.MethodName; if (string.IsNullOrWhiteSpace(method)) { results.Add( new ServerCommandAckItem { Id = id, Status = "failed", Error = "method_name is missing", } ); continue; } if (method == "BroadcastToServer") { try { var msgToken = item.Payload?["message"]; var message = msgToken?.Type == JTokenType.String ? msgToken.Value() : msgToken?.ToString(); if (string.IsNullOrWhiteSpace(message)) { results.Add( new ServerCommandAckItem { Id = id, Status = "failed", Error = "payload.message is required", } ); continue; } BroadcastToServer($"{AdminBroadcastSpeakerRich}: {message.Trim()}"); results.Add(new ServerCommandAckItem { Id = id, Status = "completed" }); } catch (Exception ex) { results.Add( new ServerCommandAckItem { Id = id, Status = "failed", Error = ex.Message, } ); } continue; } if (method == "KickPlayer") { try { var steamToken = item.Payload?["steam_id"]; ulong steamId = 0; if (steamToken != null && steamToken.Type != JTokenType.Null) { if (steamToken.Type == JTokenType.Integer) { steamId = steamToken.Value(); } else { ulong.TryParse(steamToken.ToString(), out steamId); } } if (!IsPlausibleSteamId(steamId)) { results.Add( new ServerCommandAckItem { Id = id, Status = "failed", Error = "payload.steam_id is required", } ); continue; } var reasonToken = item.Payload?["reason"]; var reason = reasonToken?.Type == JTokenType.String ? reasonToken.Value() : reasonToken?.ToString(); reason = string.IsNullOrWhiteSpace(reason) ? CerebrustDefaultKickReason : reason.Trim(); var target = BasePlayer.FindByID(steamId) ?? BasePlayer.FindSleeping(steamId); if (target == null) { // Player already gone — the goal (off the server) is already satisfied. results.Add(new ServerCommandAckItem { Id = id, Status = "completed" }); continue; } target.Kick(reason); results.Add(new ServerCommandAckItem { Id = id, Status = "completed" }); } catch (Exception ex) { results.Add( new ServerCommandAckItem { Id = id, Status = "failed", Error = ex.Message, } ); } continue; } if (method == "BanPlayer") { try { var steamToken = item.Payload?["steam_id"]; ulong steamId = 0; if (steamToken != null && steamToken.Type != JTokenType.Null) { if (steamToken.Type == JTokenType.Integer) { steamId = steamToken.Value(); } else { ulong.TryParse(steamToken.ToString(), out steamId); } } if (!IsPlausibleSteamId(steamId)) { results.Add( new ServerCommandAckItem { Id = id, Status = "failed", Error = "payload.steam_id is required", } ); continue; } var reasonToken = item.Payload?["reason"]; var reason = reasonToken?.Type == JTokenType.String ? reasonToken.Value() : reasonToken?.ToString(); reason = string.IsNullOrWhiteSpace(reason) ? CerebrustDefaultBanReason : reason.Trim(); var target = covalence?.Players?.FindPlayerById(steamId.ToString()); if (target == null) { results.Add( new ServerCommandAckItem { Id = id, Status = "failed", Error = "player not found", } ); continue; } if (target.IsBanned) { // Already banned in-game — the goal is satisfied (idempotent). results.Add(new ServerCommandAckItem { Id = id, Status = "completed" }); continue; } // Native Rust bans are permanent regardless of duration; pass a default // TimeSpan so covalence records a permanent ban. The OnPlayerBanned hook // fires from here and re-feeds the mirror via /ingest/ban-event. target.Ban(reason); results.Add(new ServerCommandAckItem { Id = id, Status = "completed" }); } catch (Exception ex) { results.Add( new ServerCommandAckItem { Id = id, Status = "failed", Error = ex.Message, } ); } continue; } if (method == "UnbanPlayer") { try { var steamToken = item.Payload?["steam_id"]; ulong steamId = 0; if (steamToken != null && steamToken.Type != JTokenType.Null) { if (steamToken.Type == JTokenType.Integer) { steamId = steamToken.Value(); } else { ulong.TryParse(steamToken.ToString(), out steamId); } } if (!IsPlausibleSteamId(steamId)) { results.Add( new ServerCommandAckItem { Id = id, Status = "failed", Error = "payload.steam_id is required", } ); continue; } var target = covalence?.Players?.FindPlayerById(steamId.ToString()); if (target == null) { results.Add( new ServerCommandAckItem { Id = id, Status = "failed", Error = "player not found", } ); continue; } if (!target.IsBanned) { // Not banned in-game — the goal is satisfied (idempotent). results.Add(new ServerCommandAckItem { Id = id, Status = "completed" }); continue; } // The resulting OnPlayerUnbanned hook re-feeds the mirror via /ingest/ban-event. target.Unban(); results.Add(new ServerCommandAckItem { Id = id, Status = "completed" }); } catch (Exception ex) { results.Add( new ServerCommandAckItem { Id = id, Status = "failed", Error = ex.Message, } ); } continue; } if (method == "SyncVipMembers") { try { var error = HandleSyncVipMembers(item.Payload); results.Add( error == null ? new ServerCommandAckItem { Id = id, Status = "completed" } : new ServerCommandAckItem { Id = id, Status = "failed", Error = error, } ); } catch (Exception ex) { results.Add( new ServerCommandAckItem { Id = id, Status = "failed", Error = ex.Message, } ); } continue; } if (method == "SyncMutedPlayers") { try { var error = HandleSyncMutedPlayers(item.Payload); results.Add( error == null ? new ServerCommandAckItem { Id = id, Status = "completed" } : new ServerCommandAckItem { Id = id, Status = "failed", Error = error, } ); } catch (Exception ex) { results.Add( new ServerCommandAckItem { Id = id, Status = "failed", Error = ex.Message, } ); } continue; } if (method == "WarnPlayer") { try { var error = HandleWarnPlayer(item.Payload); results.Add( error == null ? new ServerCommandAckItem { Id = id, Status = "completed" } : new ServerCommandAckItem { Id = id, Status = "failed", Error = error, } ); } catch (Exception ex) { results.Add( new ServerCommandAckItem { Id = id, Status = "failed", Error = ex.Message, } ); } continue; } if (method == "SetServerIdentity") { try { Dictionary result; var error = HandleSetServerIdentity(item.Payload, out result); results.Add( error == null ? new ServerCommandAckItem { Id = id, Status = "completed", Result = result, } : new ServerCommandAckItem { Id = id, Status = "failed", Error = error, } ); } catch (Exception ex) { results.Add( new ServerCommandAckItem { Id = id, Status = "failed", Error = ex.Message, } ); } continue; } if (method == "RestartServer") { try { var error = HandleRestartServer(item.Payload); results.Add( error == null ? new ServerCommandAckItem { Id = id, Status = "completed" } : new ServerCommandAckItem { Id = id, Status = "failed", Error = error, } ); } catch (Exception ex) { results.Add( new ServerCommandAckItem { Id = id, Status = "failed", Error = ex.Message, } ); } continue; } if (method == "CancelRestart") { try { var error = HandleCancelRestart(); results.Add( error == null ? new ServerCommandAckItem { Id = id, Status = "completed" } : new ServerCommandAckItem { Id = id, Status = "failed", Error = error, } ); } catch (Exception ex) { results.Add( new ServerCommandAckItem { Id = id, Status = "failed", Error = ex.Message, } ); } continue; } if (method == "AddUpkeep") { try { Dictionary result; var error = HandleAddUpkeep(item.Payload, out result); results.Add( error == null ? new ServerCommandAckItem { Id = id, Status = "completed", Result = result, } : new ServerCommandAckItem { Id = id, Status = "failed", Error = error, Result = result, } ); } catch (Exception ex) { results.Add( new ServerCommandAckItem { Id = id, Status = "failed", Error = ex.Message, } ); } continue; } if (method == "SyncServerAdmins") { try { var error = HandleSyncServerAdmins(item.Payload, out var adminResult); results.Add( error == null ? new ServerCommandAckItem { Id = id, Status = "completed", Result = adminResult, } : new ServerCommandAckItem { Id = id, Status = "failed", Error = error, } ); } catch (Exception ex) { results.Add( new ServerCommandAckItem { Id = id, Status = "failed", Error = ex.Message, } ); } continue; } if (method == "ReadServerConfig") { try { var error = HandleReadServerConfig(); results.Add( error == null ? new ServerCommandAckItem { Id = id, Status = "completed" } : new ServerCommandAckItem { Id = id, Status = "failed", Error = error, } ); } catch (Exception ex) { results.Add( new ServerCommandAckItem { Id = id, Status = "failed", Error = ex.Message, } ); } continue; } if (method == "RefreshEntity") { try { var error = HandleRefreshEntity(item.Payload); results.Add( error == null ? new ServerCommandAckItem { Id = id, Status = "completed" } : new ServerCommandAckItem { Id = id, Status = "failed", Error = error, } ); } catch (Exception ex) { results.Add( new ServerCommandAckItem { Id = id, Status = "failed", Error = ex.Message, } ); } continue; } results.Add( new ServerCommandAckItem { Id = id, Status = "failed", Error = $"unsupported method_name: {method}", } ); } return results; } /// /// Server-wide chat with CerebRUST prefix colour and avatar. /// All server-wide player-visible chat must go through this method. /// private void BroadcastToServer(string message) { if (string.IsNullOrWhiteSpace(message)) { return; } // Interpolating at the choke point rather than per call site is what makes placeholders // work everywhere text is sent — including periodic messages, which supported none, and // BroadcastToServer commands issued from the dashboard, which also supported none. var text = InterpolatePluginText(message); Server.Broadcast($"{CerebrustChatPrefixRich} {text}", CerebrustBroadcastAvatarSteamId); } /// /// Per-player chat with the same prefix colour and Steam avatar as . /// Uses the Oxide Player library from (same as Thistle: Player.Message(player, line, steamID64)). /// All direct player-visible chat must go through this method. /// private void SendToPlayer(BasePlayer player, string message) { if (!ingestReady || player == null || !player.IsConnected || string.IsNullOrWhiteSpace(message)) { return; } Player.Message( player, $"{CerebrustChatPrefixRich} {InterpolatePluginText(message)}", CerebrustBroadcastAvatarSteamId ); } private void PostJson( string url, string body, Action callback, float requestTimeoutSeconds = RequestTimeoutSeconds ) { var headers = new Dictionary { ["Accept"] = "application/json", ["Content-Type"] = "application/json", }; webrequest.Enqueue( url, body, callback, this, RequestMethod.POST, headers, requestTimeoutSeconds ); } private void GetJson(string url, Action callback) { var headers = new Dictionary { ["Accept"] = "application/json" }; webrequest.Enqueue( url, null, callback, this, RequestMethod.GET, headers, RequestTimeoutSeconds ); } private void LogIngestFailure(string label, string url, int code, string response) { try { var err = JsonConvert.DeserializeObject(response); if (err != null && !string.IsNullOrEmpty(err.Message)) { PrintError($"CerebRUST ingest {label} failed: HTTP {code} — {err.Message}"); return; } } catch (JsonException) { // fall through } PrintError( $"CerebRUST ingest {label} failed: HTTP {code} ({url}) " + FormatIngestFailureDetail(url, response) ); } private static string FormatIngestFailureDetail(string url, string response) { if (string.IsNullOrWhiteSpace(response)) { return "body=(empty)"; } var trimmed = response.TrimStart(); if (trimmed.StartsWith(" 240) { return $"body={response.Substring(0, 240)}…"; } return $"body={response}"; } private static string ExtractHostIp(string playerAddress) { if (string.IsNullOrWhiteSpace(playerAddress)) { return string.Empty; } var trimmed = playerAddress.Trim(); // IPv6 with brackets + port, e.g. [::ffff:5.6.7.8]:28015 or [2001:db8::1]:28015 if (trimmed.StartsWith("[", StringComparison.Ordinal)) { var closing = trimmed.IndexOf(']', 1); if (closing > 1) { return trimmed.Substring(1, closing - 1); } } // Unbracketed IPv6 has multiple ':'; do not strip on last colon (that would truncate the address). if (trimmed.Count(c => c == ':') > 1) { return trimmed; } var lastColon = trimmed.LastIndexOf(':'); if (lastColon > 0) { return trimmed.Substring(0, lastColon); } return trimmed; } private static string GetRustProtocolMajor() { var printable = Rust.Protocol.printable ?? string.Empty; var dot = printable.IndexOf('.'); return dot >= 0 ? printable.Substring(0, dot) : printable; } private bool TryParsePort(string value, string label, out int port) { if (int.TryParse(value, out port) && port > 0 && port <= 65535) { return true; } PrintError($"CerebRUST ingest: invalid {label} port '{value}'"); port = 0; return false; } #endregion #region Tool cupboards and map-tracked entities /// /// Single walk of on a fixed cadence: always collect map-entity /// snapshot; periodically also collect cupboard upkeep snapshot (same loop, no second enumeration). /// private void WorldEntityScanTimerCallback() { if (!ingestReady) { return; } var now = UnityEngine.Time.realtimeSinceStartup; var includeCupboards = (now - lastCupboardUpkeepsSnapshotTime) >= CupboardUpkeepsIntervalSeconds; if (includeCupboards) { lastCupboardUpkeepsSnapshotTime = now; } var mapPaused = IsIngestEndpointPaused("map-entities"); var cupboardPaused = !includeCupboards || IsIngestEndpointPaused("cupboard-upkeeps"); if (mapPaused && cupboardPaused) { return; } VerbosePuts($"CerebRUST world-scan tick realtime_since_startup={now:F1}s include_cupboards={includeCupboards}"); RunWorldEntityIngestPass(includeCupboards); } private void RunWorldEntityIngestPass(bool includeCupboardUpkeeps) { if (!ingestReady) { return; } var swCollect = Stopwatch.StartNew(); CollectWorldIngestSnapshot( includeCupboardUpkeeps, out var cupboards, out var containers, out var buildingBlocks, out var mapEntities, out var scannedIterations ); swCollect.Stop(); VerbosePuts( $"CerebRUST world-scan collect_ms={swCollect.ElapsedMilliseconds} scanned_iterations={scannedIterations} map_tracked={mapEntities.Count} cupboards={(cupboards?.Count ?? -1)} containers={(containers?.Count ?? -1)} building_blocks={(buildingBlocks?.Count ?? -1)} include_cupboards={includeCupboardUpkeeps}" ); PostMapEntitiesSnapshot(mapEntities); if (includeCupboardUpkeeps && cupboards != null) { PostCupboardUpkeepsSnapshot(cupboards); } // The walk only collected references — reading every inventory is spread over the ticks // that follow, so the pass above stays as cheap as it was before containers existed. if (containers != null) { StartContainerSweep(containers); } if (buildingBlocks != null) { StartBuildingSweep(buildingBlocks); } } private void PostMapEntitiesSnapshot(List entities) { var payload = new IngestMapEntitiesRequest { Token = config.IngestToken.Trim(), Entities = entities ?? new List(), }; var url = $"{config.ApiBaseUrl.TrimEnd('/')}/api/v2/ingest/map-entities"; var json = JsonConvert.SerializeObject(payload); VerbosePuts( $"CerebRUST map-entities POST enqueued count={payload.Entities.Count} json_chars={json.Length} timeout_s={HeavyIngestRequestTimeoutSeconds}" ); PostIngestJson( "map-entities", url, json, queueIfPaused: false, requestTimeoutSeconds: HeavyIngestRequestTimeoutSeconds ); } private void PostCupboardUpkeepsSnapshot(List cupboards) { var payload = new IngestCupboardUpkeepsRequest { Token = config.IngestToken.Trim(), Cupboards = cupboards, }; var url = $"{config.ApiBaseUrl.TrimEnd('/')}/api/v2/ingest/cupboard-upkeeps"; var json = JsonConvert.SerializeObject(payload); VerbosePuts( $"CerebRUST cupboard-upkeeps POST enqueued count={payload.Cupboards.Count} json_chars={json.Length} timeout_s={HeavyIngestRequestTimeoutSeconds}" ); PostIngestJson( "cupboard-upkeeps", url, json, queueIfPaused: false, requestTimeoutSeconds: HeavyIngestRequestTimeoutSeconds ); } private void CollectWorldIngestSnapshot( bool includeCupboardUpkeeps, out List cupboards, out List containers, out List buildingBlocks, out List mapEntities, out int scannedIterations ) { cupboards = includeCupboardUpkeeps ? new List() : null; // Container sampling rides the same cadence as the cupboard snapshot, and collects only // references here — the inventories are read across later ticks (StartContainerSweep). containers = includeCupboardUpkeeps ? new List(256) : null; // Blocks likewise: references only. A census of a live server measured 5,451 blocks // against 121,318 server entities — 4.5% — so this branch is cheap, and the expensive // part (reading a transform per block) is spread over BuildingSweepTick. buildingBlocks = includeCupboardUpkeeps ? new List(4096) : null; if (includeCupboardUpkeeps) { cupboardEntityByBuilding.Clear(); } mapEntities = new List(64); scannedIterations = 0; foreach (var entity in BaseNetworkable.serverEntities) { scannedIterations++; if (entity == null || entity.IsDestroyed) { continue; } if (includeCupboardUpkeeps) { // Before the cupboard branch: a BuildingPrivlidge *is* a StorageContainer, and // a cupboard is a container like any other as far as stored loot is concerned. var sc = entity as StorageContainer; if (sc != null && sc.inventory != null && IsPlayerPlacedContainer(sc)) { containers.Add(sc); } var tc = entity as BuildingPrivlidge; if (tc != null) { cupboards.Add(BuildCupboardSnapshotItem(tc)); // The cupboard branch already holds every privilege on the server, so // "which cupboard covers this building" costs one dictionary write here // rather than a second search once the buildings are grouped. if (tc.net != null) { cupboardEntityByBuilding[tc.buildingID] = tc.net.ID.Value; } continue; } var block = entity as BuildingBlock; if (block != null && block.net != null) { buildingBlocks.Add(block); continue; } } var be = entity as BaseEntity; if (be == null || be.net == null) { continue; } var shortPrefab = be.ShortPrefabName; if (string.IsNullOrEmpty(shortPrefab) || !MapTrackedShortPrefabs.Contains(shortPrefab)) { continue; } var position = be.transform.position; mapEntities.Add( new IngestMapEntityItem { EntityId = (long)be.net.ID.Value, Name = shortPrefab.ToLowerInvariant(), X = (float)Math.Round(position.x, 1), Y = (float)Math.Round(position.y, 1), Z = (float)Math.Round(position.z, 1), } ); } } private IngestCupboardUpkeepsSnapshotItem BuildCupboardSnapshotItem(BuildingPrivlidge tc) { var position = tc.transform.position; var authorizedSteamIds = CollectAuthorizedSteamIds(tc); ulong? ownerSteamId = TryGetOwnerSteamId(tc); var item = new IngestCupboardUpkeepsSnapshotItem { EntityId = GetCupboardEntityId(tc), OwningSteamId = ownerSteamId, X = (float)Math.Round(position.x, 1), Y = (float)Math.Round(position.y, 1), Z = (float)Math.Round(position.z, 1), RemainingUpkeepMinutes = GetCupboardUpkeepMinutes(tc), AuthorizedSteamIds = authorizedSteamIds, }; // Sampled inside the world walk that is already running — no second pass over the // entity list for what is the same set of cupboards. var sample = SampleCupboardInventory(tc); if (sample != null) { item.InventoryCapacity = sample.Capacity; item.InventorySlotsUsed = sample.SlotsUsed; item.UpkeepPeriodMinutes = sample.PeriodMinutes; item.Contents = sample.Contents; item.UpkeepCost = sample.UpkeepCost; } return item; } /// /// Push one cupboard's state after a top-up so the dashboard stops showing the pre-click /// contents. Deliberately its own endpoint: cupboard-upkeeps reconciles destroys /// against the full world set it is given, so re-posting a single cupboard there would mark /// every other cupboard on the server destroyed. /// private void PostCupboardContents(BuildingPrivlidge tc, string label) { if (tc == null || tc.IsDestroyed) { return; } var payload = new IngestCupboardContentsRequest { Token = config.IngestToken.Trim(), Cupboard = BuildCupboardSnapshotItem(tc), }; var url = $"{config.ApiBaseUrl.TrimEnd('/')}/api/v2/ingest/cupboard-contents"; PostIngestJson(label, url, JsonConvert.SerializeObject(payload), queueIfPaused: true); } #region Storage container sweep /// /// Build the entity-prefab → deployable-item-shortname map once at load. /// /// The dashboard renders a container using Rust item art, which is addressed by *item* /// shortname — and for a lot of deployables that is not the entity's prefab name /// (woodbox_deployed is placed by the item box.wooden, fridge.deployed /// by fridge). Walking ItemManager.itemList for ItemModDeployable is /// the game's own answer to that mapping; the sort button already does the same walk for a /// different reason. /// /// private void BuildDeployableItemShortnameMap() { deployableItemShortnameByPrefabId.Clear(); foreach (var itemDef in ItemManager.itemList) { if (itemDef == null || string.IsNullOrEmpty(itemDef.shortname)) { continue; } var deployable = itemDef.GetComponent(); if (deployable == null || deployable.entityPrefab == null) { continue; } var entity = deployable.entityPrefab.GetEntity(); if (entity == null) { continue; } deployableItemShortnameByPrefabId[entity.prefabID] = itemDef.shortname; } VerbosePuts( $"CerebRUST containers: {deployableItemShortnameByPrefabId.Count} deployable item mappings" ); } /// /// True when a container is something a player deployed, rather than world furniture. /// /// The test is simply whether the prefab resolves to a *placeable item* — the same /// ItemModDeployable map that fills item_shortname. That is not a heuristic /// dressed up as one: a container you can pick up and put in a box is by definition one /// somebody placed. Loot barrels, monument crates, road signs, hobo barrels, vehicle fuel /// tanks and engine bays resolve to nothing, because none of them is an item. /// /// /// This is the sweep's cheapest possible filter point — a rejected container costs one /// dictionary lookup and never has its inventory hashed, built or posted. On the live /// server it drops ~9,700 of ~10,400 containers, holding 2% of the stored explosives. /// The standing count understates it: monument loot respawns with a *fresh network id*, so /// each respawn wrote a row the API then retired — 407,082 of 407,812 container rows over /// one 13-day wipe. Monument loot is the server's own, respawns on a timer, and tells an /// admin nothing about who is dangerous. /// /// /// A BuildingPrivlidge is kept whichever way the lookup goes. Every stock cupboard /// is item-backed, but the Bases page is *built* on cupboards, and a modded one whose /// prefab is not item-backed would otherwise lose its own inventory while still appearing /// as a base — a far worse failure than carrying a handful of extra rows. /// /// private bool IsPlayerPlacedContainer(StorageContainer sc) { if (sc is BuildingPrivlidge) { return true; } return deployableItemShortnameByPrefabId.ContainsKey(sc.prefabID); } /// /// Order-independent FNV-1a hash of a container's slots. /// /// XOR-combined per slot rather than folded in sequence, because itemList order is /// not stable and a hash that changes when two items swap places in the list would defeat /// the whole point. The slot index is inside each per-slot hash, so two genuinely different /// layouts cannot collide by cancellation. /// /// private static ulong ComputeContainerHash(ItemContainer container) { const ulong offsetBasis = 14695981039346656037UL; const ulong prime = 1099511628211UL; ulong combined = offsetBasis ^ (ulong)container.capacity; var itemList = container.itemList; if (itemList == null) { return combined; } foreach (var item in itemList) { if (item?.info == null) { continue; } ulong h = offsetBasis; h = (h ^ (ulong)item.position) * prime; h = (h ^ (ulong)item.info.itemid) * prime; h = (h ^ (ulong)item.amount) * prime; h = (h ^ item.skin) * prime; if (item.hasCondition) { // Quantised — raw float condition drifts on every repair tick and would make // every damaged item in the world a "change" on every sweep. h = (h ^ (ulong)(int)Math.Round(item.conditionNormalized * 1000f)) * prime; } combined ^= h; } return combined; } private static float? ConditionPercent(Item item) { if (!item.hasCondition) { return null; } return (float)Math.Round(item.conditionNormalized * 100f, 1); } /// /// Read one container into its wire shape. Returns null when the entity has no usable /// inventory, which callers skip rather than treat as an error. /// private IngestContainerItem BuildContainerItem(StorageContainer sc, ulong hash) { if (sc == null || sc.IsDestroyed || sc.net == null || sc.inventory == null) { return null; } var position = sc.transform.position; var item = new IngestContainerItem { EntityId = (long)sc.net.ID.Value, PrefabShortname = sc.ShortPrefabName, PanelName = sc.panelName, X = (float)Math.Round(position.x, 1), Y = (float)Math.Round(position.y, 1), Z = (float)Math.Round(position.z, 1), Capacity = sc.inventory.capacity, ContentsHash = hash.ToString("x16"), Items = new List(8), }; string itemShortname; if (deployableItemShortnameByPrefabId.TryGetValue(sc.prefabID, out itemShortname)) { item.ItemShortname = itemShortname; } var ownerId = sc.OwnerID; if (ownerId > 0 && IsPlausibleSteamId(ownerId)) { item.OwningSteamId = ownerId; } var privilege = sc.GetBuildingPrivilege(); if (privilege != null && privilege.net != null) { item.ToolCupboardEntityId = privilege.net.ID.Value.ToString(); item.BuildingId = privilege.buildingID.ToString(); } var itemList = sc.inventory.itemList; if (itemList != null) { foreach (var slotItem in itemList) { if (slotItem?.info == null || string.IsNullOrEmpty(slotItem.info.shortname)) { continue; } if (item.Items.Count >= ContainerMaxSlots) { break; } item.Items.Add( new IngestContainerSlot { Slot = slotItem.position, Shortname = slotItem.info.shortname, Quantity = slotItem.amount, DisplayName = slotItem.info.displayName?.english, StackSize = Math.Max(1, slotItem.info.stackable), ConditionPct = ConditionPercent(slotItem), SkinId = slotItem.skin > 0 ? slotItem.skin.ToString() : null, } ); } } item.SlotsUsed = item.Items.Count; return item; } /// /// Begin a sweep over the containers the world walk just collected. /// /// Guarded against overlap: on a server slow enough that a sweep has not finished by the /// time the next one is due, starting a second would double the cost at exactly the moment /// the box can least afford it. The in-flight one is left to finish instead. /// /// private void StartContainerSweep(List containers) { if (containerSweepTimer != null) { VerbosePuts( $"CerebRUST container sweep still running at index {containerSweepIndex}/" + $"{(containerSweepQueue?.Count ?? 0)}; skipping this cycle" ); return; } if (containers == null || containers.Count == 0) { return; } containerSweepQueue = containers; containerSweepIndex = 0; containerSweepPostedContainers = 0; containerSweepSeen = new List(ContainerSweepSeenPerPost); containerSweepSeenAll = new HashSet(); containerSweepChanged = new List(32); containerSweepTimer = timer.Every(ContainerSweepTickSeconds, ContainerSweepTick); } private void ContainerSweepTick() { if (containerSweepQueue == null) { StopContainerSweep(); return; } var sw = Stopwatch.StartNew(); var processed = 0; while (containerSweepIndex < containerSweepQueue.Count && processed < ContainerSweepPerTick) { var sc = containerSweepQueue[containerSweepIndex]; containerSweepIndex++; processed++; // A sweep now spans seconds rather than one frame, so entities genuinely disappear // mid-pass. A destroyed one is simply not "seen" — the API's staleness reconcile // will retire it. if (sc == null || sc.IsDestroyed || sc.net == null || sc.inventory == null) { continue; } var entityId = sc.net.ID.Value; containerSweepSeen.Add((long)entityId); containerSweepSeenAll.Add(entityId); var hash = ComputeContainerHash(sc.inventory); ulong known; if (containerContentHashes.TryGetValue(entityId, out known) && known == hash) { continue; } var built = BuildContainerItem(sc, hash); if (built == null) { continue; } containerContentHashes[entityId] = hash; containerSweepChanged.Add(built); } var done = containerSweepIndex >= containerSweepQueue.Count; if ( done || containerSweepChanged.Count >= ContainerSweepPerPost || containerSweepSeen.Count >= ContainerSweepSeenPerPost ) { FlushContainerSweepChunk(); } if (done) { PruneContainerHashes(); VerbosePuts( $"CerebRUST container sweep complete containers={containerSweepQueue.Count} " + $"posted={containerSweepPostedContainers} cached_hashes={containerContentHashes.Count} " + $"last_tick_ms={sw.ElapsedMilliseconds}" ); StopContainerSweep(); } } /// /// Forget hashes for containers that no longer exist. Only ever called after a *complete* /// sweep — pruning on a partial one would drop live containers and force a full re-send of /// everything next cycle, which is the exact cost the cache exists to avoid. /// private void PruneContainerHashes() { if (containerSweepSeenAll == null || containerContentHashes.Count == 0) { return; } List stale = null; foreach (var entityId in containerContentHashes.Keys) { if (!containerSweepSeenAll.Contains(entityId)) { (stale ?? (stale = new List())).Add(entityId); } } if (stale == null) { return; } foreach (var entityId in stale) { containerContentHashes.Remove(entityId); } } private void FlushContainerSweepChunk() { if ( containerSweepSeen == null || (containerSweepSeen.Count == 0 && containerSweepChanged.Count == 0) ) { return; } var payload = new IngestContainersRequest { Token = config.IngestToken.Trim(), SeenEntityIds = containerSweepSeen, Containers = containerSweepChanged, }; containerSweepPostedContainers += containerSweepChanged.Count; containerSweepSeen = new List(ContainerSweepSeenPerPost); containerSweepChanged = new List(32); var url = $"{config.ApiBaseUrl.TrimEnd('/')}/api/v2/ingest/containers"; PostIngestJson( "containers", url, JsonConvert.SerializeObject(payload), queueIfPaused: true, requestTimeoutSeconds: HeavyIngestRequestTimeoutSeconds, onIngestHttp200Bodies: (responseBody, _requestBody) => ApplyContainersResponse(responseBody) ); } /// /// Drop the cached hashes for containers the API says it has no row for, so the next sweep /// re-sends them in full. This is what makes the hash cache safe to trust: after a wipe our /// cache still holds last wipe's hashes, every container looks unchanged, and without this /// handshake nothing would ever be sent again. /// private void ApplyContainersResponse(string responseBody) { if (string.IsNullOrEmpty(responseBody)) { return; } try { var parsed = JsonConvert.DeserializeObject(responseBody); if (parsed?.UnknownEntityIds == null || parsed.UnknownEntityIds.Count == 0) { return; } foreach (var raw in parsed.UnknownEntityIds) { ulong entityId; if (ulong.TryParse(raw, out entityId)) { containerContentHashes.Remove(entityId); } } VerbosePuts( $"CerebRUST containers: dropped {parsed.UnknownEntityIds.Count} cached hashes the API did not know" ); } catch (Exception ex) { // A response we cannot read must never cost the sweep; the next one re-sends. VerbosePuts($"CerebRUST containers: unreadable response ({ex.Message})"); } } private void StopContainerSweep() { containerSweepTimer?.Destroy(); containerSweepTimer = null; containerSweepQueue = null; containerSweepSeen = null; containerSweepSeenAll = null; containerSweepChanged = null; containerSweepIndex = 0; } /// /// Post one container immediately — the RefreshEntity command's write path. Goes to /// /ingest/container, not /ingest/containers: the latter's seen list is /// what the API's reconcile reads, and a one-entity sweep would say nothing about the rest. /// private void PostSingleContainer(StorageContainer sc, string label) { if (sc == null || sc.IsDestroyed || sc.inventory == null) { return; } // The same gate the sweep uses. Without it a RefreshEntity aimed at a loot barrel // would re-create the one row the sweep is no longer sending — one hand-typed entity // id is all it takes to reintroduce what this filter exists to keep out. if (!IsPlayerPlacedContainer(sc)) { return; } var hash = ComputeContainerHash(sc.inventory); var built = BuildContainerItem(sc, hash); if (built == null) { return; } if (sc.net != null) { containerContentHashes[sc.net.ID.Value] = hash; } var payload = new IngestContainerRequest { Token = config.IngestToken.Trim(), Container = built, }; var url = $"{config.ApiBaseUrl.TrimEnd('/')}/api/v2/ingest/container"; PostIngestJson(label, url, JsonConvert.SerializeObject(payload), queueIfPaused: true); } /// /// Locate any storage container by network id. Linear like /// and for the same reason — it runs once per operator /// click, never on a sweep tick. /// private static StorageContainer FindStorageContainerByEntityId(ulong entityId) { foreach (var entity in BaseNetworkable.serverEntities) { if (entity == null || entity.IsDestroyed || entity.net == null) { continue; } if (entity.net.ID.Value != entityId) { continue; } return entity as StorageContainer; } return null; } /// /// Handle a SyncServerAdmins command: make Rust's own admin list match CerebRUST's /// organization. Returns null on success, or the reason to ack failed. /// /// /// Goes through ServerUsers rather than writing users.cfg, and that is the /// whole point of doing it here. users.cfg is a file Rust *writes* — it persists the /// auth list from memory whenever it changes — so an SFTP write races the server for it, /// and would not take effect anyway because users load through ServerUsers.Load() /// rather than through the cfg script. Setting them in memory applies immediately and lets /// the server persist its own file. /// /// /// The payload is absolute. Anyone holding owner or moderator auth who is not in it /// is removed — that is what makes demoting somebody in the dashboard actually take their /// powers away, rather than leaving a removed admin with god mode indefinitely. An owner /// who would rather manage the list by hand turns the whole thing off in CerebRUST, and /// then no command is ever queued. /// /// /// ⚠️ UserGroup.Banned is never touched. Bans live in the same file and are /// mirrored the other way — from the game to CerebRUST — by the native ban mirror. Writing /// them from here would create the second source of truth that mirror exists to avoid. /// /// /// Carries a result body, like AddUpkeep: the reconcile is destructive by /// design and on by default, so *whose* access was granted or revoked is the one thing the /// ack can report that the caller could not already know. /// /// /// /// Written into the notes column of every entry CerebRUST manages, so an owner reading /// users.cfg can tell at a glance which lines are ours and which they added. /// private const string ADMIN_SYNC_NOTES = "CerebRUST"; private string HandleSyncServerAdmins( JObject payload, out Dictionary result ) { result = null; if (payload == null) { return "payload is required"; } var wanted = new Dictionary(); var names = new Dictionary(); var error = CollectAdmins( payload["owners"] as JArray, ServerUsers.UserGroup.Owner, wanted, names ); if (error != null) { return error; } error = CollectAdmins( payload["moderators"] as JArray, ServerUsers.UserGroup.Moderator, wanted, names ); if (error != null) { return error; } var added = new List(); var removed = new List(); // Tracked separately from added/removed because a label-only refresh writes without // being an access change — and it still has to be persisted. var wrote = false; // Snapshot first: removing while enumerating ServerUsers' own collection is asking for // trouble, and the two groups are read separately because Banned must be left alone. var current = new List(); current.AddRange(ServerUsers.GetAll(ServerUsers.UserGroup.Owner)); current.AddRange(ServerUsers.GetAll(ServerUsers.UserGroup.Moderator)); foreach (var existing in current) { ServerUsers.UserGroup group; if (!wanted.TryGetValue(existing.steamid, out group)) { ServerUsers.Remove(existing.steamid); removed.Add(existing.steamid.ToString()); wrote = true; continue; } if (group != existing.group) { // Remove before re-adding at the new level rather than trusting Set to move // somebody between groups. Being left in both would silently keep a demoted // owner at auth 2, which is the exact failure this whole command exists to // prevent — not worth saving one call to find out. ServerUsers.Remove(existing.steamid); wrote = true; } } foreach (var pair in wanted) { string name; if (!names.TryGetValue(pair.Key, out name) || string.IsNullOrEmpty(name)) { name = "unnamed"; } var existing = ServerUsers.Get(pair.Key); var levelChanged = existing == null || existing.group != pair.Value; // Rewrite when the *label* is stale too, not only when the level is. Skipping on // level alone left anyone who already held auth before CerebRUST took over sitting // as `"unnamed" "no reason"` forever, while everybody it promoted got a real name — // one list, two formats, and no way to tell who put an entry there. if ( !levelChanged && existing.username == name && existing.notes == ADMIN_SYNC_NOTES ) { continue; } ServerUsers.Set(pair.Key, pair.Value, name, ADMIN_SYNC_NOTES); wrote = true; // Only a level change counts as granting access. A refreshed display name is not // an access change, and reporting it as one would make the audit useless for the // question it exists to answer. if (levelChanged) { added.Add(pair.Key.ToString()); } } if (wrote) { // Persist so the change survives a restart without waiting for the server to // decide to save on its own. ServerUsers.Save(); } result = new Dictionary { { "granted", added }, { "revoked", removed }, }; VerbosePuts( $"CerebRUST SyncServerAdmins granted={added.Count} revoked={removed.Count}" ); return null; } /// Read one side of the admin payload into the wanted-state maps. private string CollectAdmins( JArray entries, ServerUsers.UserGroup group, Dictionary wanted, Dictionary names ) { if (entries == null) { return null; } foreach (var entry in entries) { var raw = entry?["steam_id"]?.ToString(); ulong steamId; // Parsed from a string, never a JSON number: a SteamID64 exceeds 2^53 and would // lose its low digits on the way through. if (string.IsNullOrEmpty(raw) || !ulong.TryParse(raw, out steamId)) { return $"invalid steam_id: {raw}"; } wanted[steamId] = group; var name = entry["name"]?.ToString(); if (!string.IsNullOrEmpty(name)) { names[steamId] = name; } } return null; } /// /// Handle a ReadServerConfig command: re-run server.cfg now. /// Returns null on success, or the reason to ack failed. /// /// /// The second half of "live and durable". CerebRUST writes server.cfg over SFTP so a /// config change survives a restart; this makes it take effect immediately, without one. /// /// /// The command takes no payload, and that is the whole reason it is safe to /// allow-list. Every other inbound method names a target — a player, an entity, an amount — /// and has to be validated. There is nothing here to aim: it re-reads a file the API has /// just written through a credential the owner supplied. It is not a console passthrough /// and must never grow one. /// /// /// ⚠️ server.readcfg applies but never un-applies. It executes the file; it has no /// notion of reverting a convar the file no longer mentions, so a removed line keeps its /// running value until restart. The API reports those keys rather than claiming success. /// /// /// No result body: the file is the API's own, so there is nothing the ack could /// report that the caller does not already know. /// /// private string HandleReadServerConfig() { try { ConsoleSystem.Run(ConsoleSystem.Option.Server.Quiet(), "server.readcfg"); } catch (Exception ex) { return "server.readcfg failed: " + ex.Message; } VerbosePuts("CerebRUST ReadServerConfig: re-read server.cfg"); return null; } /// /// The identity convars this plugin will set on a running server, in the order they are /// applied. Fixed in code — the command carries named fields, never a convar name, so this /// can never become a console passthrough. /// private static readonly string[] IdentityConvars = { "server.hostname", "server.description", "server.url", "server.headerimage", }; /// /// Handle a SetServerIdentity command: change the server's name, blurb, website and /// header image on the **running** server, with no credentials of any kind. /// /// /// These are writable at runtime, which is what makes "set my server name from the /// dashboard" work on every server rather than only on ones with SFTP or a panel. /// /// ⚠️ It is not durable by itself. A command-line convar wins at boot, so on a stock /// Pterodactyl egg this reverts on the next restart — which is why the API writes the file /// and the panel variable too and reports which of the three landed. Do not "simplify" that /// into one destination: which one sticks is a property of somebody else's host. /// /// Convars are resolved **by name at runtime** rather than assigned as static fields. /// ConVar.Server.url is not referenced anywhere else in this plugin, and binding a /// member Facepunch might rename would fail the whole plugin's compile — taking every /// unrelated telemetry feature down to save one field. Same reasoning as resolving /// TerrainTopology.Enum by name. An unresolvable convar is reported, not guessed. /// private string HandleSetServerIdentity(JObject payload, out Dictionary result) { result = null; if (payload == null) { return "payload is required"; } var applied = new List(); var unavailable = new List(); foreach (var convar in IdentityConvars) { // The payload field is the bare name: `server.hostname` arrives as `hostname`. var field = convar.Substring("server.".Length); var token = payload[field]; if (token == null || token.Type == JTokenType.Null) { continue; } if (!ServerCommandExists(convar)) { unavailable.Add(convar); continue; } var value = token.ToString(); try { ConsoleSystem.Run(ConsoleSystem.Option.Server.Quiet(), convar, value); applied.Add(convar); } catch (Exception ex) { VerbosePuts($"CerebRUST SetServerIdentity: {convar} failed: {ex.Message}"); unavailable.Add(convar); } } if (applied.Count == 0 && unavailable.Count == 0) { return "payload named no identity fields"; } // Reported rather than inferred: the API's pre-click list is what it *asked* for, and // only the ack can say what the running server accepted. Same shape as AddUpkeep. result = new Dictionary { { "applied", applied }, { "unavailable", unavailable }, }; return applied.Count > 0 ? null : "no identity convar could be set on this server"; } /// /// Minimum and maximum countdown a RestartServer command may ask for. The floor is /// not politeness: it is the window in which somebody can still call the restart off. /// private const int MinRestartCountdownSeconds = 30; private const int MaxRestartCountdownSeconds = 3600; private const int DefaultRestartCountdownSeconds = 300; /// /// Look up a server console command by name, so a command this build does not have is /// reported rather than silently doing nothing. /// /// /// Same discipline as resolving TerrainTopology.Enum by name: this plugin has no /// compiler in front of it, and a Facepunch rename must cost one feature rather than the /// whole plugin's compile — or, worse, an ack that says "completed" over a no-op. /// /// ⚠️ ConsoleSystem.Index.Server.Find is the one API in v0.19.0 that had never been /// called from this plugin before. It is long-standing and widely used across uMod plugins, /// but it was not compile-verified here — this workstation has no Rust server. If a /// v0.19.0 reload ever fails to compile, this line is the first place to look. /// private static bool ServerCommandExists(string name) { try { return ConsoleSystem.Index.Server.Find(name) != null; } catch { return false; } } /// /// Handle a RestartServer command: Rust's own graceful restart, with a countdown /// players can see, a forced save and a clean exit. /// /// /// ⚠️ This is deliberately the mechanism rather than a panel power cycle. A panel restart /// signals the process and kills it on a timeout; everything since the last save — bases /// placed, blueprints learnt, inventories — goes with it. The panel is for starting a /// server that did not come back, which is the one thing this cannot do. /// /// The reason is broadcast rather than passed to restart as an argument: whether /// that command accepts one is a Facepunch detail that varies, and a chat line is a path /// that already works on every build. /// private string HandleRestartServer(JObject payload) { var seconds = DefaultRestartCountdownSeconds; if (payload != null) { var token = payload["countdown_seconds"]; if (token != null && token.Type != JTokenType.Null) { int parsed; if (!int.TryParse(token.ToString(), out parsed)) { return "payload.countdown_seconds must be an integer"; } seconds = parsed; } } if (seconds < MinRestartCountdownSeconds || seconds > MaxRestartCountdownSeconds) { return $"payload.countdown_seconds must be between {MinRestartCountdownSeconds} and {MaxRestartCountdownSeconds}"; } if (!ServerCommandExists("restart")) { return "this server has no `restart` console command"; } if (payload != null) { var messageToken = payload["message"]; if (messageToken != null && messageToken.Type != JTokenType.Null) { var message = messageToken.ToString(); if (!string.IsNullOrWhiteSpace(message)) { BroadcastToServer(message); } } } ConsoleSystem.Run(ConsoleSystem.Option.Server.Quiet(), "restart", seconds); VerbosePuts($"CerebRUST RestartServer: restart in {seconds}s"); return null; } /// /// Handle a CancelRestart command: restart -1, which calls off a countdown /// already running. The reason a scheduled restart is acceptable at all. /// /// /// Acked completed whether or not a countdown was running: Rust does not report that, and /// "there was nothing to cancel" and "cancelled" leave the server in the same state, which /// is the state the caller asked for. /// private string HandleCancelRestart() { if (!ServerCommandExists("restart")) { return "this server has no `restart` console command"; } ConsoleSystem.Run(ConsoleSystem.Option.Server.Quiet(), "restart", -1); VerbosePuts("CerebRUST CancelRestart: restart cancelled"); return null; } /// /// Handle a RefreshEntity command: re-read one entity now instead of waiting for the /// 300s sweep. Returns null on success, or the reason to ack failed. /// /// /// No result body, unlike AddUpkeep: there the real figure only exists after /// execution, whereas here the refresh *is* the POST that follows, so the ack has nothing to /// report that the ingest row will not carry a moment later. /// private string HandleRefreshEntity(JObject payload) { if (payload == null) { return "payload is required"; } var entityToken = payload["entity_id"]; ulong entityId = 0; if ( entityToken == null || entityToken.Type == JTokenType.Null || !ulong.TryParse(entityToken.ToString(), out entityId) || entityId == 0 ) { return "payload.entity_id is required"; } var sc = FindStorageContainerByEntityId(entityId); if (sc == null) { // Worth failing loudly: the API reads this as "gone" and can retire the row now // rather than waiting for it to age out of the sweep. return $"container {entityId} no longer exists"; } if (!IsPlayerPlacedContainer(sc)) { // Acked failed rather than silently doing nothing: the operator asked for a refresh // and is owed the reason there will never be one for this entity. return $"container {entityId} ({sc.ShortPrefabName}) is world loot, not player storage"; } PostSingleContainer(sc, "container-refresh"); var tc = sc as BuildingPrivlidge; if (tc != null) { // A cupboard is a container like any other here, but it also owns upkeep columns // that only the cupboard route writes. PostCupboardContents(tc, "cupboard-contents"); } VerbosePuts($"CerebRUST RefreshEntity entity={entityId} prefab={sc.ShortPrefabName}"); return null; } #endregion #region Building sweep /// /// Group the walk's blocks by buildingID and start the chunked sweep. /// /// Grouping is one pass over the collected references and happens here rather than inside /// the world walk, so the walk stays a pure collection pass. Rust's own /// BuildingManager holds the same grouping, but deriving it from the blocks costs a /// dictionary insert each and uses only API this plugin already depends on elsewhere. /// /// private void StartBuildingSweep(List blocks) { if (buildingSweepTimer != null) { VerbosePuts( $"CerebRUST building sweep still running at index {buildingSweepIndex}/" + $"{(buildingSweepQueue?.Count ?? 0)}; skipping this cycle" ); return; } if (blocks == null || blocks.Count == 0) { // A world with no blocks is a freshly wiped server. Drop the cache so the first // real sweep sends everything, and post nothing: an empty `seen_building_ids` gives // the API no rows to stamp, so it would not advance anything anyway. The API's // reconcile measures staleness against the wipe's own newest sighting, so the old // wipe's rows simply stay as they were — which is correct, since they belong to a // different wipe. Same behaviour as the container sweep, and the safe direction: // a stale row beats deleting a server's bases because one sweep found nothing. buildingContentHashes.Clear(); return; } var grouped = new Dictionary>(256); foreach (var block in blocks) { // buildingID 0 is "not attached to a building" — Rust issues real ids from // maxBuildingID upwards. Grouping on it would collect every unattached block on the // server into one enormous pseudo-building that no cupboard covers and that trips // the per-building ceiling on a busy server. if (block == null || block.IsDestroyed || block.buildingID == 0) { continue; } List bucket; if (!grouped.TryGetValue(block.buildingID, out bucket)) { bucket = new List(128); grouped[block.buildingID] = bucket; } bucket.Add(block); } buildingSweepQueue = new List>>(grouped); buildingSweepIndex = 0; buildingSweepPostedBuildings = 0; buildingSweepChangedBlocks = 0; buildingSweepSeen = new List(BuildingSweepSeenPerPost); buildingSweepSeenAll = new HashSet(); buildingSweepChanged = new List(16); buildingSweepTimer = timer.Every(BuildingSweepTickSeconds, BuildingSweepTick); } private void BuildingSweepTick() { if (buildingSweepQueue == null) { StopBuildingSweep(); return; } var sw = Stopwatch.StartNew(); var blocksProcessed = 0; while ( buildingSweepIndex < buildingSweepQueue.Count && blocksProcessed < BuildingSweepBlocksPerTick ) { var entry = buildingSweepQueue[buildingSweepIndex]; buildingSweepIndex++; var buildingId = entry.Key; var blocks = entry.Value; blocksProcessed += blocks.Count; buildingSweepSeen.Add((long)buildingId); buildingSweepSeenAll.Add(buildingId); // The hash covers only what can change for a block that is already placed: // identity, shape, grade and skin. Position and rotation are immutable once built, // and health moves on every repair tick — including it would make every base on a // populated server "changed" on every sweep, which is the whole cost this avoids. var hash = ComputeBuildingHash(blocks); ulong known; if (buildingContentHashes.TryGetValue(buildingId, out known) && known == hash) { continue; } var built = BuildBuildingItem(buildingId, blocks, hash); if (built == null) { continue; } buildingContentHashes[buildingId] = hash; buildingSweepChanged.Add(built); buildingSweepChangedBlocks += built.Blocks.Count; // Flush mid-tick so one large base cannot push a chunk past the API's ceiling. if ( buildingSweepChanged.Count >= BuildingSweepBuildingsPerPost || buildingSweepChangedBlocks >= BuildingSweepBlocksPerPost ) { FlushBuildingSweepChunk(); } } var done = buildingSweepIndex >= buildingSweepQueue.Count; if (done || buildingSweepSeen.Count >= BuildingSweepSeenPerPost) { FlushBuildingSweepChunk(); } if (done) { PruneBuildingHashes(); VerbosePuts( $"CerebRUST building sweep complete buildings={buildingSweepQueue.Count} " + $"posted={buildingSweepPostedBuildings} cached_hashes={buildingContentHashes.Count} " + $"last_tick_ms={sw.ElapsedMilliseconds}" ); StopBuildingSweep(); } } /// /// Build one building's payload. Returns null when nothing usable survived — a base whose /// blocks were all destroyed mid-sweep is simply not mentioned, and the API's staleness /// reconcile retires it. /// private IngestBuildingItem BuildBuildingItem(uint buildingId, List blocks, ulong hash) { var items = new List(blocks.Count); foreach (var block in blocks) { if (block == null || block.IsDestroyed || block.net == null) { // A sweep spans seconds rather than one frame, so entities genuinely disappear // mid-pass. Dropping it here is correct: this payload is authoritative for the // building, so the API deletes the row too. continue; } if (items.Count >= BuildingMaxBlocks) { // The API rejects a building carrying more, and losing the tail of one // pathological base beats losing the whole chunk it travels in. PrintWarning( $"CerebRUST building {buildingId} has more than {BuildingMaxBlocks} blocks; truncating" ); break; } var prefab = block.ShortPrefabName; if (string.IsNullOrEmpty(prefab)) { // The API requires a prefab shortname and 400s the whole request without one, // so one nameless block would cost every base travelling in the same chunk. // Should never happen; dropping the block is the cheap way to guarantee it // cannot. continue; } var transform = block.transform; var position = transform.position; var rotation = transform.rotation; items.Add( new IngestBuildingBlockItem { EntityId = (long)block.net.ID.Value, PrefabShortname = prefab, Grade = block.grade.ToString(), SkinId = block.skinID > 0 ? block.skinID.ToString() : null, X = (float)Math.Round(position.x, 2), Y = (float)Math.Round(position.y, 2), Z = (float)Math.Round(position.z, 2), RotX = (float)Math.Round(rotation.x, 4), RotY = (float)Math.Round(rotation.y, 4), RotZ = (float)Math.Round(rotation.z, 4), RotW = (float)Math.Round(rotation.w, 4), } ); } if (items.Count == 0) { return null; } ulong cupboardEntityId; var hasCupboard = cupboardEntityByBuilding.TryGetValue(buildingId, out cupboardEntityId); return new IngestBuildingItem { BuildingId = (long)buildingId, ToolCupboardEntityId = hasCupboard ? cupboardEntityId.ToString() : null, ContentHash = hash.ToString("x16"), Blocks = items, }; } /// /// XOR-combined per block, so list order does not matter — Rust hands the blocks back in /// whatever order the entity walk found them, and an order-sensitive hash would report a /// change every sweep. /// private static ulong ComputeBuildingHash(List blocks) { const ulong offsetBasis = 14695981039346656037UL; const ulong prime = 1099511628211UL; ulong combined = offsetBasis ^ (ulong)blocks.Count; foreach (var block in blocks) { if (block == null || block.IsDestroyed || block.net == null) { continue; } ulong h = offsetBasis; h = (h ^ block.net.ID.Value) * prime; h = (h ^ block.prefabID) * prime; h = (h ^ (ulong)(int)block.grade) * prime; h = (h ^ block.skinID) * prime; combined ^= h; } return combined; } private void FlushBuildingSweepChunk() { if ( buildingSweepSeen == null || (buildingSweepSeen.Count == 0 && buildingSweepChanged.Count == 0) ) { return; } var payload = new IngestBuildingsRequest { Token = config.IngestToken.Trim(), SeenBuildingIds = buildingSweepSeen, Buildings = buildingSweepChanged, }; buildingSweepPostedBuildings += buildingSweepChanged.Count; buildingSweepSeen = new List(BuildingSweepSeenPerPost); buildingSweepChanged = new List(16); buildingSweepChangedBlocks = 0; var url = $"{config.ApiBaseUrl.TrimEnd('/')}/api/v2/ingest/buildings"; PostIngestJson( "buildings", url, JsonConvert.SerializeObject(payload), queueIfPaused: true, requestTimeoutSeconds: HeavyIngestRequestTimeoutSeconds, onIngestHttp200Bodies: (responseBody, _requestBody) => ApplyBuildingsResponse(responseBody) ); } /// /// Drop the cached hashes for buildings the API says it has no row for, so the next sweep /// re-sends them in full. Same handshake as containers, and load-bearing for the same /// reason: after a wipe our cache holds last wipe's hashes, every base looks unchanged, and /// without this nothing would ever be sent again. /// private void ApplyBuildingsResponse(string responseBody) { if (string.IsNullOrEmpty(responseBody)) { return; } try { var parsed = JsonConvert.DeserializeObject(responseBody); if (parsed?.UnknownBuildingIds == null || parsed.UnknownBuildingIds.Count == 0) { return; } foreach (var raw in parsed.UnknownBuildingIds) { uint buildingId; if (uint.TryParse(raw, out buildingId)) { buildingContentHashes.Remove(buildingId); } } VerbosePuts( $"CerebRUST buildings: dropped {parsed.UnknownBuildingIds.Count} cached hashes the API did not know" ); } catch (Exception ex) { // A response we cannot read must never cost the sweep; the next one re-sends. VerbosePuts($"CerebRUST buildings: unreadable response ({ex.Message})"); } } /// /// Forget hashes for buildings that no longer exist. Only ever called after a *complete* /// sweep — pruning on a partial one would drop live buildings and force a full re-send of /// everything next cycle, which is the exact cost the cache exists to avoid. /// private void PruneBuildingHashes() { if (buildingSweepSeenAll == null || buildingContentHashes.Count == 0) { return; } List stale = null; foreach (var buildingId in buildingContentHashes.Keys) { if (!buildingSweepSeenAll.Contains(buildingId)) { (stale ?? (stale = new List())).Add(buildingId); } } if (stale == null) { return; } foreach (var buildingId in stale) { buildingContentHashes.Remove(buildingId); } } private void StopBuildingSweep() { buildingSweepTimer?.Destroy(); buildingSweepTimer = null; buildingSweepQueue = null; buildingSweepSeen = null; buildingSweepSeenAll = null; buildingSweepChanged = null; buildingSweepIndex = 0; buildingSweepChangedBlocks = 0; } #endregion private void PostCupboardPlace(BuildingPrivlidge tc, string label) { if (tc == null || tc.IsDestroyed) { return; } var position = tc.transform.position; ulong? ownerSteamId = TryGetOwnerSteamId(tc); var payload = new IngestCupboardPlaceRequest { Token = config.IngestToken.Trim(), EntityId = GetCupboardEntityId(tc), OwningSteamId = ownerSteamId, X = (float)Math.Round(position.x, 1), Y = (float)Math.Round(position.y, 1), Z = (float)Math.Round(position.z, 1), RemainingUpkeepMinutes = GetCupboardUpkeepMinutes(tc), }; var url = $"{config.ApiBaseUrl.TrimEnd('/')}/api/v2/ingest/cupboard-place"; PostIngestJson(label, url, JsonConvert.SerializeObject(payload), queueIfPaused: true); } private void PostCupboardDestroy(BuildingPrivlidge tc, string label) { var payload = new IngestCupboardEntityRequest { Token = config.IngestToken.Trim(), EntityId = GetCupboardEntityId(tc), }; var url = $"{config.ApiBaseUrl.TrimEnd('/')}/api/v2/ingest/cupboard-destroy"; PostIngestJson(label, url, JsonConvert.SerializeObject(payload), queueIfPaused: true); } private void PostCupboardAuth(BuildingPrivlidge tc, ulong steamId, string label) { var payload = new IngestCupboardAuthRequest { Token = config.IngestToken.Trim(), EntityId = GetCupboardEntityId(tc), SteamId = steamId, }; var url = $"{config.ApiBaseUrl.TrimEnd('/')}/api/v2/ingest/cupboard-auth"; PostIngestJson(label, url, JsonConvert.SerializeObject(payload), queueIfPaused: true); } private void PostCupboardDeauth(BuildingPrivlidge tc, ulong steamId, string label) { var payload = new IngestCupboardAuthRequest { Token = config.IngestToken.Trim(), EntityId = GetCupboardEntityId(tc), SteamId = steamId, }; var url = $"{config.ApiBaseUrl.TrimEnd('/')}/api/v2/ingest/cupboard-deauth"; PostIngestJson(label, url, JsonConvert.SerializeObject(payload), queueIfPaused: true); } private void PostCupboardDeauthAll(BuildingPrivlidge tc, string label) { var payload = new IngestCupboardEntityRequest { Token = config.IngestToken.Trim(), EntityId = GetCupboardEntityId(tc), }; var url = $"{config.ApiBaseUrl.TrimEnd('/')}/api/v2/ingest/cupboard-deauth-all"; PostIngestJson(label, url, JsonConvert.SerializeObject(payload), queueIfPaused: true); } private static long GetCupboardEntityId(BuildingPrivlidge tc) { return (long)tc.net.ID.Value; } private static bool IsPlausibleSteamId(ulong steamId) { return steamId >= SteamId64Min; } private static ulong? TryGetOwnerSteamId(BuildingPrivlidge tc) { if (tc == null || tc.OwnerID == 0) { return null; } if (!IsPlausibleSteamId(tc.OwnerID)) { return null; } return tc.OwnerID; } /// /// Oxide exposes authorizedPlayers as enumerable SteamID64 (ulong) values. /// Filter out small ids that are not real Steam accounts. /// private static List CollectAuthorizedSteamIds(BuildingPrivlidge tc) { var steamIds = new List(); if (tc?.authorizedPlayers == null) { return steamIds; } foreach (ulong visitorId in tc.authorizedPlayers) { if (IsPlausibleSteamId(visitorId)) { steamIds.Add(visitorId); } } return steamIds; } private static int GetCupboardUpkeepMinutes(BuildingPrivlidge tc) { float protectedMinutes = tc.GetProtectedMinutes(true); return protectedMinutes > 0f ? (int)Math.Round(protectedMinutes) : 0; } #region Cupboard contents + upkeep top-up (v0.11.0) /// /// Upkeep period assumed until a stocked cupboard lets us derive the real one. Facepunch's /// default is an hour; this is only ever a placeholder, never asserted as truth. /// private const int DefaultUpkeepPeriodMinutes = 60; /// Hard ceiling on one AddUpkeep, regardless of what the API asks for. private const int AddUpkeepMaxMinutesCeiling = 1440; private int _upkeepPeriodMinutes = DefaultUpkeepPeriodMinutes; private bool _upkeepPeriodLearned; /// /// A cupboard's inventory reduced to the numbers the top-up maths needs. Built once and read /// by both the snapshot and AddUpkeep, so the two can never disagree about the state /// they are reasoning over. /// private sealed class CupboardInventorySample { public int Capacity; public int SlotsUsed; public int FreeSlots; public int PeriodMinutes; /// Aggregated per shortname — every item, not only upkeep resources. public List Contents = new List(); public List UpkeepCost = new List(); /// Room left inside stacks that already exist, per shortname. public Dictionary PartialRoom = new Dictionary(); public Dictionary StackSize = new Dictionary(); public Dictionary Amount = new Dictionary(); } /// /// Read a cupboard's container and its per-resource upkeep cost. Returns null when the entity /// has no usable inventory, which the callers treat as "no contents data" rather than as an /// error — a cupboard we cannot read must not blank the rest of the snapshot. /// private CupboardInventorySample SampleCupboardInventory(BuildingPrivlidge tc) { if (tc == null || tc.IsDestroyed || tc.inventory == null) { return null; } var sample = new CupboardInventorySample { Capacity = tc.inventory.capacity }; var itemList = tc.inventory.itemList; if (itemList != null) { foreach (var item in itemList) { if (item?.info == null) { continue; } var shortname = item.info.shortname; if (string.IsNullOrEmpty(shortname)) { continue; } sample.SlotsUsed++; // Read stackable off the live definition: the stack-sizes mod overwrites it in // place, so anything cached or hardcoded would be wrong on a modded server. var stack = Math.Max(1, item.info.stackable); sample.StackSize[shortname] = stack; int amount; sample.Amount[shortname] = (sample.Amount.TryGetValue(shortname, out amount) ? amount : 0) + item.amount; if (item.amount < stack) { int room; sample.PartialRoom[shortname] = (sample.PartialRoom.TryGetValue(shortname, out room) ? room : 0) + (stack - item.amount); } } } sample.FreeSlots = Math.Max(0, sample.Capacity - sample.SlotsUsed); foreach (var pair in sample.Amount) { int slots = 0; if (itemList != null) { foreach (var item in itemList) { if (item?.info != null && item.info.shortname == pair.Key) { slots++; } } } sample.Contents.Add( new IngestCupboardContentItem { Shortname = pair.Key, Amount = pair.Value, Slots = slots, StackSize = sample.StackSize[pair.Key], } ); } var costs = Facepunch.Pool.Get>(); try { tc.CalculateUpkeepCostAmounts(costs); foreach (var cost in costs) { if (cost?.itemDef == null || cost.amount <= 0f) { continue; } var shortname = cost.itemDef.shortname; if (string.IsNullOrEmpty(shortname)) { continue; } var stack = Math.Max(1, cost.itemDef.stackable); // Record the stack size even when the cupboard holds none of this resource — the // empty cupboard is precisely the case the top-up button exists for. sample.StackSize[shortname] = stack; sample.UpkeepCost.Add( new IngestCupboardUpkeepCostItem { Shortname = shortname, CostPerPeriod = cost.amount, StackSize = stack, } ); } } finally { Facepunch.Pool.FreeUnmanaged(ref costs); } sample.PeriodMinutes = ResolveUpkeepPeriodMinutes(tc, sample); return sample; } /// /// Derive the upkeep period from numbers the game already gave us, rather than naming a convar /// we cannot verify from here. Protection time is linear in the cheapest resource's ratio, so /// period = protectedMinutes / min(amount/cost) recovers whatever period this server /// actually uses. One stocked cupboard teaches it for the whole process; until then the /// default stands in. /// private int ResolveUpkeepPeriodMinutes(BuildingPrivlidge tc, CupboardInventorySample sample) { if (_upkeepPeriodLearned || sample.UpkeepCost.Count == 0) { return _upkeepPeriodMinutes; } float minRatio = float.MaxValue; foreach (var cost in sample.UpkeepCost) { int held; sample.Amount.TryGetValue(cost.Shortname, out held); var ratio = held / cost.CostPerPeriod; if (ratio < minRatio) { minRatio = ratio; } } if (minRatio <= 0f || minRatio == float.MaxValue) { return _upkeepPeriodMinutes; } float protectedMinutes = tc.GetProtectedMinutes(true); if (protectedMinutes <= 0f) { return _upkeepPeriodMinutes; } var derived = (int)Math.Round(protectedMinutes / minRatio); if (derived <= 0 || derived > 24 * 60) { // Outside anything plausible — keep the default rather than trust a bad division. return _upkeepPeriodMinutes; } _upkeepPeriodMinutes = derived; _upkeepPeriodLearned = true; VerbosePuts($"CerebRUST upkeep period derived from live cupboard state: {derived} min"); return derived; } /// /// How long the cupboard protects the building right now: the minimum runway across /// upkeep resources. Derived from the same contents the snapshot reports, so the dashboard's /// runway column and this maths can never disagree. /// private static double CurrentProtectionMinutes(CupboardInventorySample sample) { if (sample.UpkeepCost.Count == 0 || sample.PeriodMinutes <= 0) { return 0d; } var lowest = double.MaxValue; foreach (var cost in sample.UpkeepCost) { int held; sample.Amount.TryGetValue(cost.Shortname, out held); var minutes = held / cost.CostPerPeriod * sample.PeriodMinutes; if (minutes < lowest) { lowest = minutes; } } return lowest == double.MaxValue ? 0d : lowest; } /// /// Whole items to add so overall protection rises by . /// /// /// Protection is the minimum runway across resources, so only the resources that /// would run out before the new target actually gate it. A cupboard with 82 metal fragments /// (6d 20h) and 3,191 stone (15d 6h) needs 12 more fragments for another day and nothing /// else — the stone is already eight days past the line, and topping it up cannot move a /// minimum it is not setting. Hence topping each resource up to a target level /// rather than adding a period's worth to every resource, which is what the first cut did /// and why it asked for 209 stone that bought nothing. /// /// Rounds up per resource: depositing a fraction of an item is not a thing, and rounding /// down would deliver slightly less than the number the UI promised. /// private static Dictionary UpkeepItemsForMinutes( CupboardInventorySample sample, int minutes ) { var needed = new Dictionary(); if (minutes <= 0 || sample.PeriodMinutes <= 0) { return needed; } var target = CurrentProtectionMinutes(sample) + minutes; foreach (var cost in sample.UpkeepCost) { var required = (int)Math.Ceiling(target / sample.PeriodMinutes * cost.CostPerPeriod); int held; sample.Amount.TryGetValue(cost.Shortname, out held); var shortfall = required - held; if (shortfall > 0) { needed[cost.Shortname] = shortfall; } } return needed; } /// /// Does worth of upkeep physically fit? Every resource competes for /// the same free slots, so this cannot be answered per resource — it is a packing question, /// which is why the callers binary-search over it rather than dividing. Monotone in /// (a higher target never lowers a resource's shortfall), which is /// what makes that search valid. /// private static bool UpkeepMinutesFit(CupboardInventorySample sample, int minutes) { var needed = UpkeepItemsForMinutes(sample, minutes); var slotsNeeded = 0; foreach (var pair in needed) { int partialRoom; sample.PartialRoom.TryGetValue(pair.Key, out partialRoom); var overflow = Math.Max(0, pair.Value - partialRoom); if (overflow == 0) { continue; } int stack; if (!sample.StackSize.TryGetValue(pair.Key, out stack) || stack <= 0) { stack = 1; } slotsNeeded += (int)Math.Ceiling((double)overflow / stack); } return slotsNeeded <= sample.FreeSlots; } /// /// Largest number of minutes, up to , that fits in the cupboard. /// Zero when nothing fits — including the "no upkeep cost at all" case, where a top-up would /// be meaningless rather than merely impossible. /// private static int AddableUpkeepMinutes(CupboardInventorySample sample, int maxMinutes) { if (sample == null || sample.UpkeepCost.Count == 0 || maxMinutes <= 0) { return 0; } if (UpkeepMinutesFit(sample, maxMinutes)) { return maxMinutes; } var low = 0; var high = maxMinutes; while (low < high) { var mid = low + ((high - low + 1) / 2); if (UpkeepMinutesFit(sample, mid)) { low = mid; } else { high = mid - 1; } } return low; } #endregion private void LogCupboardUpkeepsResponse(string response) { if (string.IsNullOrWhiteSpace(response)) { return; } try { var body = JsonConvert.DeserializeObject(response); if (body == null) { return; } if (body.Bootstrap) { VerbosePuts( $"CerebRUST cupboard-upkeeps BOOTSTRAP complete: " + $"seen={body.CupboardsSeen} (existing TCs ingested; hook-miss Discord alerts " + "start on the next 5m snapshot)" ); return; } var misses = body.HookMisses; if (misses == null) { return; } var hasMisses = (misses.Placed != null && misses.Placed.Count > 0) || (misses.Destroyed != null && misses.Destroyed.Count > 0) || (misses.Revived != null && misses.Revived.Count > 0) || misses.AuthsOpened > 0 || misses.AuthsClosed > 0; if (!hasMisses) { return; } var discordNote = body.DiscordAlertSent ? " Discord alert sent." : " (Discord webhook not configured — check API logs/secret.)"; PrintWarning( "CerebRUST cupboard-upkeeps HOOK MISS — snapshot corrected state hooks should " + $"have recorded. destroyed={FormatEntityIdList(misses.Destroyed)} " + $"placed={FormatEntityIdList(misses.Placed)} " + $"revived={FormatEntityIdList(misses.Revived)} " + $"auths_opened={misses.AuthsOpened} auths_closed={misses.AuthsClosed}" + discordNote ); } catch (JsonException ex) { PrintWarning($"CerebRUST cupboard-upkeeps: could not parse response ({ex.Message})"); } } private static string FormatEntityIdList(List entityIds) { if (entityIds == null || entityIds.Count == 0) { return "[]"; } if (entityIds.Count <= 8) { return "[" + string.Join(", ", entityIds) + "]"; } return "[" + string.Join(", ", entityIds.GetRange(0, 8)) + $", …+{entityIds.Count - 8}]"; } #endregion #region Feature batch (MOTD, reports, monuments, wipe scheduler, world events) private readonly HashSet motdSentSteamIds = new HashSet(); private void ClearMotdSessionState() { motdSentSteamIds.Clear(); } private void OnPlayerSleepEnded(BasePlayer player) { if (!ingestReady || player == null || !player.IsConnected) { return; } if (!ReadPluginSettingBool("motd.enabled", true)) { return; } if (ReadPluginSettingBool("motd.once_per_session", true)) { if (motdSentSteamIds.Contains(player.userID)) { return; } motdSentSteamIds.Add(player.userID); } SendMotdLines(player); } private void SendMotdLines(BasePlayer player) { var lines = ReadPluginSettingStringList("motd.lines", new string[0]); if (lines.Count == 0) { return; } var initialDelaySecs = ReadPluginSettingInt("motd.initial_delay_secs", 1); const int lineDelayMs = 150; for (var i = 0; i < lines.Count; i++) { var index = i; var delay = initialDelaySecs + (lineDelayMs / 1000f) * index; timer.Once( delay, () => { if (player == null || !player.IsConnected) { return; } var line = lines[index]; if (string.IsNullOrWhiteSpace(line)) { return; } SendToPlayer(player, line); } ); } } private sealed class IngestPlayerReportRequest { [JsonProperty("token")] public string Token { get; set; } [JsonProperty("reporter_steam_id")] public long ReporterSteamId { get; set; } [JsonProperty("target_steam_id")] public long TargetSteamId { get; set; } [JsonProperty("subject")] public string Subject { get; set; } [JsonProperty("message")] public string Message { get; set; } [JsonProperty("report_type")] public string ReportType { get; set; } } private void OnPlayerReported( BasePlayer reporter, string targetName, string targetId, string subject, string message, string type ) { if (!ingestReady || reporter == null) { return; } if (string.IsNullOrWhiteSpace(targetName) || string.IsNullOrWhiteSpace(targetId)) { return; } if (string.IsNullOrWhiteSpace(subject) || string.IsNullOrWhiteSpace(message)) { return; } if (!ulong.TryParse(targetId, out var targetSteamUlong) || !IsPlausibleSteamId(targetSteamUlong)) { VerbosePuts($"CerebRUST player-report skipped: invalid target steam id {targetId}"); return; } if (!IsPlausibleSteamId(reporter.userID)) { return; } var payload = new IngestPlayerReportRequest { Token = config.IngestToken.Trim(), ReporterSteamId = SteamToLong(reporter.userID), TargetSteamId = SteamToLong(targetSteamUlong), Subject = subject.Trim(), Message = message.Trim(), ReportType = string.IsNullOrWhiteSpace(type) ? "unknown" : type.Trim(), }; var url = $"{config.ApiBaseUrl.TrimEnd('/')}/api/v2/ingest/player-reports"; PostIngestJson( "player-report", url, JsonConvert.SerializeObject(payload), queueIfPaused: true ); } private bool _monumentsIngestPosted; private sealed class IngestMonumentItem { [JsonProperty("shortname")] public string Shortname { get; set; } [JsonProperty("display_name")] public string DisplayName { get; set; } [JsonProperty("x")] public float X { get; set; } [JsonProperty("y")] public float Y { get; set; } [JsonProperty("z")] public float Z { get; set; } [JsonProperty("radius", NullValueHandling = NullValueHandling.Ignore)] public float? Radius { get; set; } // Full oriented extents, so the dashboard can draw a footprint box rather than a circle. // Bounds are local to the monument, hence the separate centre offset and yaw. [JsonProperty("bounds_size_x", NullValueHandling = NullValueHandling.Ignore)] public float? BoundsSizeX { get; set; } [JsonProperty("bounds_size_y", NullValueHandling = NullValueHandling.Ignore)] public float? BoundsSizeY { get; set; } [JsonProperty("bounds_size_z", NullValueHandling = NullValueHandling.Ignore)] public float? BoundsSizeZ { get; set; } [JsonProperty("bounds_center_x", NullValueHandling = NullValueHandling.Ignore)] public float? BoundsCenterX { get; set; } [JsonProperty("bounds_center_y", NullValueHandling = NullValueHandling.Ignore)] public float? BoundsCenterY { get; set; } [JsonProperty("bounds_center_z", NullValueHandling = NullValueHandling.Ignore)] public float? BoundsCenterZ { get; set; } /// Yaw in degrees; monuments are placed level, so pitch/roll are not sent. [JsonProperty("rotation_y", NullValueHandling = NullValueHandling.Ignore)] public float? RotationY { get; set; } } private sealed class IngestMonumentsRequest { [JsonProperty("token")] public string Token { get; set; } [JsonProperty("monuments")] public List Monuments { get; set; } } /// /// Post the wipe's monuments, once per plugin load. /// /// ⚠️ **Every exit says why, unconditionally.** This runs once a load and took an hour to /// diagnose from the outside when it silently did nothing: the guards returned without a /// word, the "no monuments" branch was gated behind verbose logging, and `PostIngestJson` /// queues without logging when the endpoint family is paused. One line per load is a /// trivial cost against that. /// private void TryPostMonumentsIngest() { if (!ingestReady) { Puts("CerebRUST monuments: not posted — ingest is not ready yet."); return; } if (_monumentsIngestPosted) { Puts("CerebRUST monuments: already posted this load; use cerebrust.doctor monuments to re-send."); return; } var monuments = CollectMonumentIngestItems(); if (monuments.Count == 0) { Puts("CerebRUST monuments: not posted — the map reported no monuments."); return; } var godRocks = 0; foreach (var item in monuments) { if (string.Equals(item.DisplayName, GodRockDisplayName, StringComparison.Ordinal)) { godRocks++; } } Puts( $"CerebRUST monuments: posting {monuments.Count} ({godRocks} god rock(s))" + (IsIngestEndpointPaused("monuments") ? " — endpoint paused, so this is being queued to disk rather than sent now." : ".") ); var payload = new IngestMonumentsRequest { Token = config.IngestToken.Trim(), Monuments = monuments, }; var url = $"{config.ApiBaseUrl.TrimEnd('/')}/api/v2/ingest/monuments"; PostIngestJson( "monuments", url, JsonConvert.SerializeObject(payload), queueIfPaused: true, requestTimeoutSeconds: HeavyIngestRequestTimeoutSeconds, onHttp200: () => { _monumentsIngestPosted = true; } ); } private List CollectMonumentIngestItems() { var list = new List(); if (TerrainMeta.Path?.Monuments == null) { return list; } foreach (var monument in TerrainMeta.Path.Monuments) { if (monument == null) { continue; } var shortname = ExtractMonumentShortname(monument.name); if (string.IsNullOrWhiteSpace(shortname)) { continue; } var position = monument.transform.position; var bounds = MonumentBoundsOrNull(monument); list.Add( new IngestMonumentItem { Shortname = shortname, DisplayName = MonumentDisplayName(monument), X = (float)Math.Round(position.x, 1), Y = (float)Math.Round(position.y, 1), Z = (float)Math.Round(position.z, 1), Radius = MonumentRadius(monument), BoundsSizeX = bounds?.size.x, BoundsSizeY = bounds?.size.y, BoundsSizeZ = bounds?.size.z, BoundsCenterX = bounds?.center.x, BoundsCenterY = bounds?.center.y, BoundsCenterZ = bounds?.center.z, RotationY = MonumentYaw(monument), } ); } list.AddRange(CollectGodRockMonuments()); return list; } /// /// Large god rocks, as monuments. /// /// A god rock is not in TerrainMeta.Path.Monuments — it is a placed world prefab — /// so the sweep above cannot see it. But it is one of the best-known places on a Rust map /// to build, which is exactly what a monument list is for from a player's side. /// /// The technique is plugin/debug/DebugRockProbe.cs, proven on a live server (seed /// 1889795671): World.Serialization.world.prefabs is populated on a procedural map /// and its positions are in the same world space as live transforms, so a prefab path is a /// complete answer to "where are all of these". /// /// ⚠️ **Reflection is hoisted out of the loop on purpose.** The list is ~35,000 entries and /// looking a field up per entry costs about half a second — the probe measured it. The /// StringPool lookup is memoised per distinct id for the same reason. /// /// ⚠️ **This never throws into the monuments ingest.** Any failure returns an empty list, /// so a Facepunch rename costs the god rocks and not the real monuments beside them. /// private List CollectGodRockMonuments() { var found = new List(); try { foreach (var instance in ReadWorldPrefabPositions(GodRockPrefabPath)) { var position = instance.Position; var bounds = GodRockBounds(position, instance.Path); found.Add( new IngestMonumentItem { Shortname = ExtractMonumentShortname(instance.Path), DisplayName = GodRockDisplayName, X = (float)Math.Round(position.x, 1), Y = (float)Math.Round(position.y, 1), Z = (float)Math.Round(position.z, 1), // Measured off the rock's own colliders, never a guessed circle. Null // when nothing was found, because the honest alternative to measuring // is nothing rather than a round number. Radius = bounds.HasValue ? (float)Math.Round(Math.Max(bounds.Value.extents.x, bounds.Value.extents.z), 1) : (float?)null, BoundsSizeX = bounds?.size.x, BoundsSizeY = bounds?.size.y, BoundsSizeZ = bounds?.size.z, // A world-space AABB, so the centre is an offset from the prefab origin // and there is no yaw to apply. A MonumentInfo's bounds are monument- // local and rotated; these are not, and sending a rotation as well // would have the dashboard apply one twice. BoundsCenterX = bounds.HasValue ? bounds.Value.center.x - position.x : (float?)null, BoundsCenterY = bounds.HasValue ? bounds.Value.center.y - position.y : (float?)null, BoundsCenterZ = bounds.HasValue ? bounds.Value.center.z - position.z : (float?)null, RotationY = 0f, } ); } if (found.Count == 0) { // ⚠️ Distinguishes "this seed has none" from "the path stopped matching", which // is exactly the pair that was indistinguishable when the match was wrong. Puts( $"CerebRUST monuments: no god rocks matched \"{GodRockPrefabPath}\" " + $"in {_worldPrefabsRead} world prefab(s)." ); } } catch (Exception e) { PrintWarning( $"CerebRUST god rock scan failed ({e.GetType().Name}: {e.Message}); " + "monuments ingest continues without them."); return new List(); } return found; } /// /// The union of this formation's own World-layer colliders, in world space. /// /// A god rock has no MonumentInfo and so no reported bounds. Matching on /// transform.root.name is what keeps a neighbouring rock's geometry out of the box: /// the runtime root GameObject is named for the prefab path, which is the join the probe /// established on a live map. /// private static Bounds? GodRockBounds(Vector3 origin, string rootName) { var worldLayer = LayerMask.NameToLayer("World"); if (worldLayer < 0) { return null; } var hits = Physics.OverlapSphere( origin, GodRockBoundsSearchRadius, 1 << worldLayer, QueryTriggerInteraction.Ignore); Bounds? box = null; foreach (var collider in hits) { if (collider == null || collider.transform == null) { continue; } if (!string.Equals(collider.transform.root.name, rootName, StringComparison.OrdinalIgnoreCase)) { continue; } if (box.HasValue) { var grown = box.Value; grown.Encapsulate(collider.bounds); box = grown; } else { box = collider.bounds; } } return box; } /// /// Every world-space position at which was placed. /// /// Cached for the wipe — the world layout does not change, and the monuments ingest retries /// until it gets a 200. /// private static List ReadWorldPrefabPositions(string prefabPathFragment) { // Keyed by the fragment it was built for. There is one caller today, and a cache that // silently answers a different question than it was asked is the kind of thing the // second caller discovers the hard way. if (_worldPrefabMatches != null && string.Equals(_worldPrefabFragment, prefabPathFragment, StringComparison.Ordinal)) { return _worldPrefabMatches; } var result = new List(); _worldPrefabMatches = result; _worldPrefabFragment = prefabPathFragment; _worldPrefabsRead = 0; var worldType = FindGameType("World"); if (worldType == null) { return result; } object serialization = null; var prop = worldType.GetProperty("Serialization", BindingFlags.Public | BindingFlags.Static); if (prop != null) { serialization = prop.GetValue(null, null); } if (serialization == null) { var staticField = worldType.GetField("Serialization", BindingFlags.Public | BindingFlags.Static); if (staticField != null) { serialization = staticField.GetValue(null); } } if (serialization == null) { return result; } var worldField = serialization.GetType().GetField("world", BindingFlags.Public | BindingFlags.Instance); var worldData = worldField == null ? null : worldField.GetValue(serialization); var prefabsField = worldData == null ? null : worldData.GetType().GetField("prefabs", BindingFlags.Public | BindingFlags.Instance); var prefabs = prefabsField == null ? null : prefabsField.GetValue(worldData) as System.Collections.IEnumerable; if (prefabs == null) { return result; } MethodInfo stringPoolGet = null; var poolType = FindGameType("StringPool"); if (poolType != null) { stringPoolGet = poolType.GetMethod( "Get", BindingFlags.Public | BindingFlags.Static, null, new[] { typeof(uint) }, null); } FieldInfo idField = null; FieldInfo posField = null; FieldInfo vx = null; FieldInfo vy = null; FieldInfo vz = null; var pathById = new Dictionary(); foreach (var entry in prefabs) { if (entry == null) { continue; } if (idField == null) { var entryType = entry.GetType(); idField = entryType.GetField("id", BindingFlags.Public | BindingFlags.Instance); posField = entryType.GetField("position", BindingFlags.Public | BindingFlags.Instance); if (posField != null) { var vectorType = posField.FieldType; vx = vectorType.GetField("x"); vy = vectorType.GetField("y"); vz = vectorType.GetField("z"); } if (idField == null || posField == null || vx == null || vy == null || vz == null) { return result; } } var id = Convert.ToUInt32(idField.GetValue(entry)); string path; if (!pathById.TryGetValue(id, out path)) { path = stringPoolGet == null ? null : stringPoolGet.Invoke(null, new object[] { id }) as string; path = path ?? string.Empty; pathById[id] = path; } _worldPrefabsRead++; // ⚠️ **Substring, not equality — the probe's rule, and the reason this found // nothing at first.** StringPool resolves to the *full* asset path // (`assets/.../v3_rock_formations_large/rock_formation_a.prefab`), so comparing it // to the folder-and-file fragment we know matches never fires. The probe has // always used IndexOf; this is the same test, and it is the load-bearing line. if (path.IndexOf(prefabPathFragment, StringComparison.OrdinalIgnoreCase) < 0) { continue; } var value = posField.GetValue(entry); if (value == null) { continue; } result.Add( new WorldPrefabInstance { // The full resolved path, because the runtime root GameObject is named for // it — that is the join between this list and the colliders in the world, // and the fragment would not match it either. Path = path, Position = new Vector3( Convert.ToSingle(vx.GetValue(value)), Convert.ToSingle(vy.GetValue(value)), Convert.ToSingle(vz.GetValue(value))), }); } return result; } /// Resolve a game type by name without assuming which assembly holds it. private static Type FindGameType(string name) { try { var direct = Type.GetType(name, false) ?? Type.GetType(name + ", Assembly-CSharp", false); if (direct != null) { return direct; } } catch { // Fall through to the assembly scan. } foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) { try { var found = asm.GetType(name, false); if (found != null) { return found; } } catch { // A dynamic assembly can refuse GetType; keep looking. } } return null; } private static string MonumentDisplayName(MonumentInfo monument) { if (monument?.displayPhrase != null) { var translated = monument.displayPhrase.translated; if (!string.IsNullOrWhiteSpace(translated)) { return translated.Trim(); } var english = monument.displayPhrase.english; if (!string.IsNullOrWhiteSpace(english)) { return english.Trim(); } } return ExtractMonumentShortname(monument?.name); } private static string ExtractMonumentShortname(string prefabPath) { if (string.IsNullOrWhiteSpace(prefabPath)) { return string.Empty; } var trimmed = prefabPath.Trim(); var slash = trimmed.LastIndexOf('/'); var segment = slash >= 0 ? trimmed.Substring(slash + 1) : trimmed; if (segment.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) { segment = segment.Substring(0, segment.Length - 7); } return segment.ToLowerInvariant(); } /// Bounds can throw on odd prefabs; a missing box is fine, the map falls back to radius. private static Bounds? MonumentBoundsOrNull(MonumentInfo monument) { try { return monument.Bounds; } catch { return null; } } private static float? MonumentYaw(MonumentInfo monument) { try { return (float)Math.Round(monument.transform.rotation.eulerAngles.y, 2); } catch { return null; } } private static float MonumentRadius(MonumentInfo monument) { try { var size = monument.Bounds.size; var horizontal = Mathf.Max(size.x, size.z); return (float)Math.Round(Mathf.Clamp(horizontal * 0.5f, 80f, 250f), 1); } catch { return 120f; } } #region Terrain heightmap + biome ingest (once per wipe) /// Samples per axis. 4.4 m/sample on a 4500 map; above HeightMap.res buys nothing. private const int TerrainSampleResolution = 1024; /// /// Rows sampled per timer tick. Biome sampling costs 4 calls per point on top of the height /// read, so keep this small — a full 1024-row pass takes ~6s of wall clock and never more /// than a couple of ms of any one frame. /// private const int TerrainSampleRowsPerTick = 8; private const float TerrainSampleTickSeconds = 0.05f; /// ~8 MiB of base64 in one body; the 15s default would never finish it. private const float TerrainUploadTimeoutSeconds = 180f; private const float TerrainIngestRetrySeconds = 300f; /// Rust TerrainBiome.Enum masks, ordered to match the RGBA channels we emit. private static readonly int[] TerrainBiomeMasks = { 1, 2, 4, 8 }; /// /// What this build of the plugin samples. Sent with the payload so the API can tell a wipe /// captured before topology existed from one whose server simply cannot produce it, and ask /// for exactly one re-sample rather than looping forever against an older plugin. /// private const int TerrainSampleSchema = 2; /// /// Our own topology flags, one bit each. **These are ours and never Facepunch's.** /// TerrainTopology.Enum is a bitfield whose values are not documented and have been /// renumbered before — the same trap BuildingGrade.Enum set, where a stored ordinal /// silently reinterprets every historical row. So the names are resolved against the /// running game at load and mapped onto these, which we own and can keep stable forever. /// private const byte TopologyFlagRoad = 1 << 0; private const byte TopologyFlagRail = 1 << 1; private const byte TopologyFlagMonument = 1 << 2; private const byte TopologyFlagBuilding = 1 << 3; private const byte TopologyFlagCliff = 1 << 4; private const byte TopologyFlagWater = 1 << 5; private const byte TopologyFlagBeach = 1 << 6; /// /// Which TerrainTopology.Enum names feed each of our flags. /// /// Only the surface itself, never its "-side" companion: Roadside and /// Railside are the strips beside a road and are perfectly buildable — refusing them /// would delete some of the most sought-after ground on any map. Road and /// Rail are the ones Rust will not let you place a foundation on. /// /// /// Every name here is verified against a live server. Runway was in the first cut, /// taken from a wiki page that lists topology layers, and does not exist in the enum — the /// resolver warned and dropped it, which is precisely what resolving by name is for. /// Airfield is a Monument anyway, so nothing was lost by removing it. /// /// private static readonly Dictionary TopologyFlagSources = new Dictionary { { TopologyFlagRoad, new[] { "Road" } }, { TopologyFlagRail, new[] { "Rail" } }, { TopologyFlagMonument, new[] { "Monument" } }, { TopologyFlagBuilding, new[] { "Building" } }, { TopologyFlagCliff, new[] { "Cliff", "Cliffside" } }, { TopologyFlagWater, new[] { "Ocean", "Lake", "River", "Swamp" } }, { TopologyFlagBeach, new[] { "Beach" } }, }; private string _terrainIngestKey; private bool _terrainSamplingInProgress; private Timer terrainSampleTimer; private Timer terrainRetryTimer; // Pass-scoped sampling state (allocated on start, released on completion/abort). private float[] _terrainHeightScratch; private byte[] _terrainBiomeScratch; private byte[] _terrainTopologyScratch; private int _terrainSampleRow; private float _terrainMinY; private float _terrainMaxY; private bool _terrainBiomeAvailable; private bool _terrainTopologyAvailable; /// Resolved TerrainTopology.Enum bit → our flag byte, built once per pass. private static readonly List> _topologyMaskPairs = new List>(); private string CurrentTerrainIngestKey() { return $"{GetRustProtocolMajor()}_{World.Size}_{World.Seed}"; } /// /// Starts a chunked terrain sample, unless this wipe's terrain is already uploaded or a pass /// is running. Unlike the map PNG this needs no empty server — it is a pure in-process read. /// private void TryPostTerrainIngest() { if (!ingestReady || _terrainSamplingInProgress) { return; } if (_terrainIngestKey == CurrentTerrainIngestKey()) { return; } if (TerrainMeta.HeightMap == null) { VerbosePuts("CerebRUST terrain ingest skipped: no TerrainMeta.HeightMap."); return; } BeginTerrainSamplePass(); } private void BeginTerrainSamplePass() { var cells = TerrainSampleResolution * TerrainSampleResolution; _terrainHeightScratch = new float[cells]; _terrainBiomeScratch = new byte[cells * 4]; _terrainSampleRow = 0; _terrainMinY = float.MaxValue; _terrainMaxY = float.MinValue; _terrainBiomeAvailable = TerrainBiomeSamplingWorks(); _terrainTopologyAvailable = TerrainTopologySamplingWorks(); if (_terrainTopologyAvailable) { _terrainTopologyScratch = new byte[cells]; } _terrainSamplingInProgress = true; if (!_terrainBiomeAvailable) { PrintWarning("CerebRUST terrain: biome sampling unavailable; sending height only."); } if (!_terrainTopologyAvailable) { PrintWarning( "CerebRUST terrain: topology sampling unavailable; roads and monuments will not " + "be excluded from buildable-ground searches." ); } terrainSampleTimer?.Destroy(); terrainSampleTimer = timer.Every(TerrainSampleTickSeconds, TerrainSampleTick); } /// /// Resolve the topology mask by **name** and probe once, rather than wrapping a million calls. /// /// Binding TerrainTopology.Enum members at compile time would be the obvious thing and /// is the wrong thing: a renamed member would stop the whole plugin compiling, taking every /// unrelated telemetry feature down with it. Resolving by name means an unknown member simply /// drops out of the mask, which costs one exclusion rather than the server's observability. /// /// private bool TerrainTopologySamplingWorks() { _topologyMaskPairs.Clear(); try { if (TerrainMeta.TopologyMap == null) { return false; } var enumType = typeof(TerrainTopology.Enum); var missing = new List(); foreach (var pair in TopologyFlagSources) { foreach (var name in pair.Value) { if (!Enum.IsDefined(enumType, name)) { missing.Add(name); continue; } var bit = (int)Enum.Parse(enumType, name); if (bit != 0) { _topologyMaskPairs.Add(new KeyValuePair(bit, pair.Key)); } } } if (missing.Count > 0) { PrintWarning( "CerebRUST terrain: unknown TerrainTopology names [" + string.Join(", ", missing.ToArray()) + "]; those exclusions are off until the mapping is updated." ); } if (_topologyMaskPairs.Count == 0) { return false; } // One real read, same reasoning as the biome probe. TerrainMeta.TopologyMap.GetTopology(Vector3.zero); return true; } catch (Exception ex) { VerbosePuts($"CerebRUST terrain topology probe failed: {ex.Message}"); _topologyMaskPairs.Clear(); return false; } } /// Probe once rather than wrapping a million calls: a Rust API change costs us biome, not height. private bool TerrainBiomeSamplingWorks() { try { if (TerrainMeta.BiomeMap == null) { return false; } TerrainMeta.BiomeMap.GetBiome(Vector3.zero, TerrainBiomeMasks[0]); return true; } catch (Exception ex) { VerbosePuts($"CerebRUST terrain biome probe failed: {ex.Message}"); return false; } } private void TerrainSampleTick() { if (!_terrainSamplingInProgress) { terrainSampleTimer?.Destroy(); terrainSampleTimer = null; return; } try { var res = TerrainSampleResolution; var worldSize = (float)World.Size; var half = worldSize * 0.5f; var step = res > 1 ? worldSize / (res - 1) : 0f; var endRow = Math.Min(res, _terrainSampleRow + TerrainSampleRowsPerTick); for (var row = _terrainSampleRow; row < endRow; row++) { // Row 0 is the +Z (north) edge so the grid matches image row order and the API // can write the PNG without a vertical flip. var z = half - row * step; var rowBase = row * res; for (var col = 0; col < res; col++) { var x = -half + col * step; var probe = new Vector3(x, 0f, z); var height = TerrainMeta.HeightMap.GetHeight(probe); _terrainHeightScratch[rowBase + col] = height; if (height < _terrainMinY) { _terrainMinY = height; } if (height > _terrainMaxY) { _terrainMaxY = height; } if (_terrainBiomeAvailable) { var biomeBase = (rowBase + col) * 4; for (var b = 0; b < TerrainBiomeMasks.Length; b++) { var weight = TerrainMeta.BiomeMap.GetBiome(probe, TerrainBiomeMasks[b]); _terrainBiomeScratch[biomeBase + b] = (byte) Mathf.Clamp(Mathf.RoundToInt(weight * 255f), 0, 255); } } if (_terrainTopologyAvailable) { var topology = TerrainMeta.TopologyMap.GetTopology(probe); byte flags = 0; for (var t = 0; t < _topologyMaskPairs.Count; t++) { var pair = _topologyMaskPairs[t]; if ((topology & pair.Key) != 0) { flags |= pair.Value; } } _terrainTopologyScratch[rowBase + col] = flags; } } } _terrainSampleRow = endRow; if (_terrainSampleRow < res) { return; } } catch (Exception ex) { PrintWarning($"CerebRUST terrain sampling failed: {ex.Message}"); AbortTerrainSamplePass(); ScheduleTerrainIngestRetry(); return; } terrainSampleTimer?.Destroy(); terrainSampleTimer = null; PostSampledTerrain(); } private void PostSampledTerrain() { string heightBase64; string biomeBase64; string topologyBase64; float minY; float maxY; try { minY = _terrainMinY; maxY = _terrainMaxY; heightBase64 = EncodeTerrainHeights(_terrainHeightScratch, minY, maxY); biomeBase64 = _terrainBiomeAvailable ? Convert.ToBase64String(_terrainBiomeScratch) : null; topologyBase64 = _terrainTopologyAvailable ? Convert.ToBase64String(_terrainTopologyScratch) : null; } catch (Exception ex) { PrintWarning($"CerebRUST terrain encode failed: {ex.Message}"); AbortTerrainSamplePass(); ScheduleTerrainIngestRetry(); return; } finally { ReleaseTerrainScratch(); } _terrainSamplingInProgress = false; var terrainSize = TerrainSizeOrZero(); var payload = new IngestTerrainRequest { Token = config.IngestToken.Trim(), GameVersion = GetRustProtocolMajor(), WorldSize = (int)World.Size, WorldSeed = World.Seed, Resolution = TerrainSampleResolution, MinY = minY, MaxY = maxY, TerrainSizeX = terrainSize.x, TerrainSizeY = terrainSize.y, TerrainSizeZ = terrainSize.z, HeightmapBase64 = heightBase64, BiomemapBase64 = biomeBase64, TopologymapBase64 = topologyBase64, SampleSchema = TerrainSampleSchema, }; var key = CurrentTerrainIngestKey(); var url = $"{config.ApiBaseUrl.TrimEnd('/')}/api/v2/ingest/terrain"; // queueIfPaused stays false on purpose: FlushPendingIngestPosts replays queued bodies on // the default 15s timeout, which a multi-MB terrain body can never complete. The retry // timer and the endpoint probe cover an outage instead. PostIngestJson( "terrain", url, JsonConvert.SerializeObject(payload), queueIfPaused: false, requestTimeoutSeconds: TerrainUploadTimeoutSeconds, onHttp200: () => { _terrainIngestKey = key; terrainRetryTimer?.Destroy(); terrainRetryTimer = null; Puts($"CerebRUST terrain uploaded ({TerrainSampleResolution}x{TerrainSampleResolution})."); } ); ScheduleTerrainIngestRetry(); } /// Quantises to uint16 big-endian over the observed range, so precision tracks the actual map. private static string EncodeTerrainHeights(float[] heights, float minY, float maxY) { var span = maxY - minY; var scale = span > 0f ? 65535f / span : 0f; var bytes = new byte[heights.Length * 2]; for (var i = 0; i < heights.Length; i++) { var q = (int)Math.Round((heights[i] - minY) * scale); if (q < 0) { q = 0; } else if (q > 65535) { q = 65535; } bytes[i * 2] = (byte)((q >> 8) & 0xFF); bytes[i * 2 + 1] = (byte)(q & 0xFF); } return Convert.ToBase64String(bytes); } private static Vector3 TerrainSizeOrZero() { try { return TerrainMeta.Size; } catch { return Vector3.zero; } } private void ScheduleTerrainIngestRetry() { terrainRetryTimer?.Destroy(); terrainRetryTimer = timer.Once(TerrainIngestRetrySeconds, TryPostTerrainIngest); } private void AbortTerrainSamplePass() { terrainSampleTimer?.Destroy(); terrainSampleTimer = null; _terrainSamplingInProgress = false; ReleaseTerrainScratch(); } private void ReleaseTerrainScratch() { _terrainHeightScratch = null; _terrainBiomeScratch = null; _terrainTopologyScratch = null; } private sealed class IngestTerrainRequest { [JsonProperty("token")] public string Token { get; set; } [JsonProperty("game_version")] public string GameVersion { get; set; } [JsonProperty("world_size")] public int WorldSize { get; set; } // long, not int: World.Seed is a uint and can exceed int.MaxValue. Matches // IngestInitRequest / IngestMapRequest. [JsonProperty("world_seed")] public long WorldSeed { get; set; } [JsonProperty("resolution")] public int Resolution { get; set; } [JsonProperty("min_y")] public float MinY { get; set; } [JsonProperty("max_y")] public float MaxY { get; set; } [JsonProperty("terrain_size_x")] public float TerrainSizeX { get; set; } [JsonProperty("terrain_size_y")] public float TerrainSizeY { get; set; } [JsonProperty("terrain_size_z")] public float TerrainSizeZ { get; set; } [JsonProperty("heightmap_base64")] public string HeightmapBase64 { get; set; } [JsonProperty("biomemap_base64", NullValueHandling = NullValueHandling.Ignore)] public string BiomemapBase64 { get; set; } /// One flag byte per sample; omitted entirely when topology is unavailable. [JsonProperty("topologymap_base64", NullValueHandling = NullValueHandling.Ignore)] public string TopologymapBase64 { get; set; } /// /// What this build samples. The API stores it so it can ask a wipe captured by an older /// build to re-sample exactly once, without looping against a plugin that cannot do more. /// [JsonProperty("sample_schema")] public int SampleSchema { get; set; } } #endregion private const float WipeSchedulerMaxLatenessHours = 0.5f; private const string WipeSchedulerDataFileName = "CerebrustWipeScheduler"; private Timer _wipeSchedulerHourlyTimer; private WipeSchedulerPersistedData _wipeSchedulerData; private sealed class WipeMilestoneSetting { public int OffsetHours { get; set; } public string Message { get; set; } } private sealed class WipeSchedulerPersistedData { public string ActiveWipeAtIso { get; set; } public Dictionary LastExecuted { get; set; } = new Dictionary(); } private void StartWipeScheduler() { StopWipeScheduler(); LoadWipeSchedulerData(); PruneWipeSchedulerDataForCurrentWipe(); var secondsUntilNextHour = GetSecondsUntilNextUtcHour(); timer.Once( secondsUntilNextHour, () => { RunWipeSchedulerHourlyPass(); _wipeSchedulerHourlyTimer = timer.Every(3600f, RunWipeSchedulerHourlyPass); } ); timer.Once(5f, RunWipeSchedulerHourlyPass); } private void StopWipeScheduler() { _wipeSchedulerHourlyTimer?.Destroy(); _wipeSchedulerHourlyTimer = null; } private void LoadWipeSchedulerData() { try { _wipeSchedulerData = Interface.Oxide.DataFileSystem.ReadObject( WipeSchedulerDataFileName ); } catch (Exception ex) { PrintWarning($"CerebRUST wipe scheduler data load failed: {ex.Message}"); } if (_wipeSchedulerData == null) { _wipeSchedulerData = new WipeSchedulerPersistedData(); } if (_wipeSchedulerData.LastExecuted == null) { _wipeSchedulerData.LastExecuted = new Dictionary(); } } private void SaveWipeSchedulerData() { if (_wipeSchedulerData == null) { return; } Interface.Oxide.DataFileSystem.WriteObject(WipeSchedulerDataFileName, _wipeSchedulerData); } private void PruneWipeSchedulerDataForCurrentWipe() { var nextWipeAt = ReadPluginSettingString("context.next_wipe_at", null); if (string.IsNullOrWhiteSpace(nextWipeAt)) { return; } if (_wipeSchedulerData.ActiveWipeAtIso == nextWipeAt) { return; } _wipeSchedulerData.ActiveWipeAtIso = nextWipeAt; _wipeSchedulerData.LastExecuted.Clear(); SaveWipeSchedulerData(); } private void RunWipeSchedulerHourlyPass() { if (!ingestReady) { return; } PruneWipeSchedulerDataForCurrentWipe(); var nextWipeAt = ReadPluginSettingString("context.next_wipe_at", null); if (string.IsNullOrWhiteSpace(nextWipeAt)) { return; } var hoursUntil = ReadPluginSettingDouble("context.hours_until", -1); if (hoursUntil < 0) { return; } CheckWipeMilestones(nextWipeAt, hoursUntil); if (ReadPluginSettingBool("wipe_schedule.hourly_countdown_enabled", true)) { CheckWipeHourlyCountdown(nextWipeAt); } } private void CheckWipeMilestones(string nextWipeAt, double hoursUntil) { var milestones = ReadPluginSettingMilestones(); if (milestones.Count == 0) { return; } if (!DateTime.TryParse(nextWipeAt, null, System.Globalization.DateTimeStyles.RoundtripKind, out var wipeAtUtc)) { return; } if (wipeAtUtc.Kind == DateTimeKind.Unspecified) { wipeAtUtc = DateTime.SpecifyKind(wipeAtUtc, DateTimeKind.Utc); } else { wipeAtUtc = wipeAtUtc.ToUniversalTime(); } var now = DateTime.UtcNow; foreach (var milestone in milestones) { if (hoursUntil > milestone.OffsetHours) { continue; } var triggerTime = wipeAtUtc.AddHours(-milestone.OffsetHours); var hoursLate = (now - triggerTime).TotalHours; if (hoursLate > WipeSchedulerMaxLatenessHours) { MarkWipeSchedulerExecuted(nextWipeAt, MilestoneTaskKey(milestone.OffsetHours)); continue; } var taskKey = MilestoneTaskKey(milestone.OffsetHours); if (IsWipeSchedulerExecuted(nextWipeAt, taskKey)) { continue; } var message = milestone.Message; if (!string.IsNullOrWhiteSpace(message)) { BroadcastToServer(message); } MarkWipeSchedulerExecuted(nextWipeAt, taskKey); } } // No hoursUntil parameter: the {{time_until_wipe}} token resolves itself at broadcast time // now, from the same context.hours_until the caller read. private void CheckWipeHourlyCountdown(string nextWipeAt) { var hourBucket = DateTime.UtcNow.ToString("yyyy-MM-ddTHH"); var taskKey = $"hourly|{hourBucket}"; if (IsWipeSchedulerExecuted(nextWipeAt, taskKey)) { return; } var message = ReadPluginSettingString( "wipe_schedule.hourly_countdown_message", "Wipe in {{time_until_wipe}}!" ); if (!string.IsNullOrWhiteSpace(message)) { BroadcastToServer(message); } MarkWipeSchedulerExecuted(nextWipeAt, taskKey); } private static string MilestoneTaskKey(int offsetHours) { return $"milestone|{offsetHours}"; } private bool IsWipeSchedulerExecuted(string nextWipeAt, string taskKey) { var composite = $"{nextWipeAt}|{taskKey}"; return _wipeSchedulerData.LastExecuted.ContainsKey(composite); } private void MarkWipeSchedulerExecuted(string nextWipeAt, string taskKey) { var composite = $"{nextWipeAt}|{taskKey}"; _wipeSchedulerData.LastExecuted[composite] = DateTime.UtcNow.ToString("o"); SaveWipeSchedulerData(); } private List ReadPluginSettingMilestones() { var list = new List(); if (config == null) { return list; } var token = ParsePluginSettingsCache()["wipe_schedule.milestones"]; if (token == null || token.Type != JTokenType.Array) { return list; } foreach (var item in (JArray)token) { if (item == null || item.Type != JTokenType.Object) { continue; } var obj = (JObject)item; var offsetToken = obj["offset_hours"]; if (offsetToken == null || offsetToken.Type != JTokenType.Integer) { continue; } var messageToken = obj["message"]; if (messageToken == null || messageToken.Type != JTokenType.String) { continue; } var message = messageToken.Value(); if (string.IsNullOrWhiteSpace(message)) { continue; } list.Add( new WipeMilestoneSetting { OffsetHours = (int)offsetToken.Value(), Message = message.Trim(), } ); } return list; } private static float GetSecondsUntilNextUtcHour() { var now = DateTime.UtcNow; var nextHour = new DateTime(now.Year, now.Month, now.Day, now.Hour, 0, 0, DateTimeKind.Utc).AddHours(1); return (float)(nextHour - now).TotalSeconds; } #endregion #region World location helpers /// Monuments excluded from player-facing location labels (display name, case-insensitive). private static readonly HashSet MinorMonumentDisplayNames = new HashSet( StringComparer.OrdinalIgnoreCase ) { "Underground Cave", "Substation", "Ice Lake", "Wild Swamp", "Jungle Ruin", "Water Well", "Train Tunnel", "jungle swamp", }; private static readonly string[] MinorMonumentDisplayPrefixes = { "Oasis", "Lake", "Canyon" }; private static readonly string[] MinorMonumentShortnamePrefixes = { "oasis", "lake", "canyon" }; /// /// The large hollow rock formation players call a god rock. /// /// One exact path, not a fragment: the other letters in v3_rock_formations_large/ are /// different models, and this is the one with an interior worth building in. Matched /// case-insensitively against the world prefab list and, at runtime, against the root /// GameObject's name — the probe established that those are the same string. /// private const string GodRockPrefabPath = "v3_rock_formations_large/rock_formation_a.prefab"; /// What a player calls it. The shortname stays derived from the prefab path. private const string GodRockDisplayName = "Large God Rock"; /// /// How far to look for a god rock's own colliders when measuring its footprint. /// /// Generous rather than fitted — the formation is tens of metres across and the match is on /// the collider's root name, so over-reaching costs a few discarded hits and under-reaching /// would silently clip the box. Runs a handful of times, once per wipe. /// private const float GodRockBoundsSearchRadius = 40f; /// How many world prefabs the last scan walked, so "none matched" can say so. private static int _worldPrefabsRead; /// Which fragment was built for. private static string _worldPrefabFragment; /// One placed world prefab: its resolved StringPool path, and where it stands. private sealed class WorldPrefabInstance { public string Path; public Vector3 Position; } /// /// Matches for , read once per wipe. Static so a plugin /// reload is what clears it, which is also how the probe behaved. /// private static List _worldPrefabMatches; private sealed class CachedMonument { public string Shortname; public string DisplayName; public Vector3 Position; public float Radius; } private readonly List _cachedMonuments = new List(); private bool _worldLocationHelpersReady; private bool _hasOilRig; private bool _hasLargeRig; private bool _hasExcavator; private Vector3 _oilRigPos; private Vector3 _largeRigPos; private Vector3 _excavatorPos; private void InitWorldLocationHelpers() { _cachedMonuments.Clear(); _worldLocationHelpersReady = false; _hasOilRig = false; _hasLargeRig = false; _hasExcavator = false; if (TerrainMeta.Path?.Monuments == null) { return; } foreach (var monument in TerrainMeta.Path.Monuments) { if (monument == null) { continue; } var shortname = ExtractMonumentShortname(monument.name); if (string.IsNullOrWhiteSpace(shortname)) { continue; } var position = monument.transform.position; _cachedMonuments.Add( new CachedMonument { Shortname = shortname, DisplayName = MonumentDisplayName(monument), Position = position, Radius = MonumentRadius(monument), } ); var prefabName = monument.name ?? string.Empty; if (prefabName.IndexOf("excavator_1.prefab", StringComparison.OrdinalIgnoreCase) >= 0) { _hasExcavator = true; _excavatorPos = monument.transform.localToWorldMatrix.MultiplyPoint3x4(new Vector3(20f, 0f, -30f)); } else if (prefabName.IndexOf("oilrig_1.prefab", StringComparison.OrdinalIgnoreCase) >= 0) { _hasLargeRig = true; _largeRigPos = position; } else if (prefabName.IndexOf("oilrig_2.prefab", StringComparison.OrdinalIgnoreCase) >= 0) { _hasOilRig = true; _oilRigPos = position; } } _worldLocationHelpersReady = true; VerbosePuts($"CerebRUST world helpers ready: {_cachedMonuments.Count} monument(s)."); } private string GetGridPosition(Vector3 position) { var gridCoords = MapHelper.PositionToGrid(position); return MapHelper.GridToString(gridCoords); } private string GetCompassDirection(Vector3 position) { var direction = (position - Vector3.zero).normalized; var angle = Mathf.Atan2(direction.x, direction.z) * Mathf.Rad2Deg; if (angle < 0f) { angle += 360f; } if (angle >= 337.5f || angle < 22.5f) { return "North"; } if (angle < 67.5f) { return "North East"; } if (angle < 112.5f) { return "East"; } if (angle < 157.5f) { return "South East"; } if (angle < 202.5f) { return "South"; } if (angle < 247.5f) { return "South West"; } if (angle < 292.5f) { return "West"; } return "North West"; } private string GetLocationString(Vector3 position, BaseEntity entity = null, bool showCoords = false) { if (!_worldLocationHelpersReady) { return GetGridPosition(position); } string location; if (entity != null && IsAtCargoShip(entity)) { location = "Cargo Ship"; } else if (IsAtLargeRig(position)) { location = "Large Oil Rig"; } else if (IsAtOilRig(position)) { location = "Oil Rig"; } else if (IsAtExcavator(position)) { location = "Excavator"; } else { var monument = FindNearestMonument(position); location = monument != null ? monument.DisplayName : GetGridPosition(position); } if (!string.IsNullOrEmpty(location) && location != GetGridPosition(position)) { location += $" ({GetGridPosition(position)})"; } if (showCoords) { location += $" ({position.x:F0}, {position.y:F0}, {position.z:F0})"; } return location; } private static bool IsMajorMonumentForLocation(string displayName, string shortname) { var display = (displayName ?? string.Empty).Trim(); if (!string.IsNullOrEmpty(display)) { if (MinorMonumentDisplayNames.Contains(display)) { return false; } foreach (var prefix in MinorMonumentDisplayPrefixes) { if (display.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) { return false; } } } var shortNorm = (shortname ?? string.Empty).Trim(); if (!string.IsNullOrEmpty(shortNorm)) { foreach (var prefix in MinorMonumentShortnamePrefixes) { if (shortNorm.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) { return false; } } } return !string.IsNullOrEmpty(display) || !string.IsNullOrEmpty(shortNorm); } private CachedMonument FindNearestMonument(Vector3 position) { CachedMonument best = null; var bestDist = float.MaxValue; foreach (var monument in _cachedMonuments) { if (!IsMajorMonumentForLocation(monument.DisplayName, monument.Shortname)) { continue; } var dist = Vector2.Distance( new Vector2(position.x, position.z), new Vector2(monument.Position.x, monument.Position.z) ); if (dist <= monument.Radius && dist < bestDist) { best = monument; bestDist = dist; } } return best; } private string ResolveMonumentShortname(Vector3 position, BaseEntity entity = null) { if (entity != null && IsAtCargoShip(entity)) { return "cargo_ship"; } if (IsAtLargeRig(position)) { return "large_oil_rig"; } if (IsAtOilRig(position)) { return "oil_rig"; } if (IsAtExcavator(position)) { return "excavator_1"; } return FindNearestMonument(position)?.Shortname; } private bool IsAtCargoShip(BaseEntity entity) { return entity != null && entity.GetComponentInParent() != null; } private bool IsAtOilRig(Vector3 position) { return _worldLocationHelpersReady && _hasOilRig && Distance2D(_oilRigPos, position) <= 60f; } private bool IsAtLargeRig(Vector3 position) { return _worldLocationHelpersReady && _hasLargeRig && Distance2D(_largeRigPos, position) <= 75f; } private bool IsAtExcavator(Vector3 position) { return _worldLocationHelpersReady && _hasExcavator && Distance2D(_excavatorPos, position) <= 145f; } private static float Distance2D(Vector3 a, Vector3 b) { return Vector2.Distance(new Vector2(a.x, a.z), new Vector2(b.x, b.z)); } private Dictionary BuildWorldEventMetadata( Vector3 position, BaseEntity entity = null, bool includeDirection = false ) { var meta = new Dictionary { ["world_x"] = Math.Round(position.x, 1), ["world_y"] = Math.Round(position.y, 1), ["world_z"] = Math.Round(position.z, 1), ["grid"] = GetGridPosition(position), }; if (includeDirection) { meta["direction"] = GetCompassDirection(position); } var monumentShort = ResolveMonumentShortname(position, entity); if (!string.IsNullOrWhiteSpace(monumentShort)) { meta["monument"] = monumentShort; } return meta; } private void EmitWorldEvent( string label, string eventType, string visibility, Vector3 position, string broadcastMessage, BaseEntity entity = null, ulong? actorSteamId = null, bool includeDirection = false, Action> enrichMetadata = null ) { if (!ingestReady) { return; } var metadata = BuildWorldEventMetadata(position, entity, includeDirection); enrichMetadata?.Invoke(metadata); var item = new IngestServerEventItem { EventType = eventType, Visibility = visibility, RecordedAtIso = IngestUtcNowIso(), Metadata = metadata, }; if (actorSteamId.HasValue && IsPlausibleSteamId(actorSteamId.Value)) { item.ActorSteamId = SteamToLong(actorSteamId.Value); } PostServerEvent(label, item); if (!string.IsNullOrWhiteSpace(broadcastMessage)) { BroadcastToServer(broadcastMessage); } } #endregion #region WorldEvents private sealed class SupplyDropTracker { public string PlayerName; public ulong PlayerId; public SupplySignal Signal; public CargoPlane Plane; public SupplyDrop Drop; public float CreatedTime; } private sealed class DieselAddTracker { public ulong PlayerId; public ulong EntityId; public int Amount; public float Timestamp; } private readonly HashSet _trackedSupplyDrops = new HashSet(); private readonly HashSet _processedSupplyDropIds = new HashSet(); private readonly HashSet _landedSupplyDropIds = new HashSet(); private DieselAddTracker _lastDieselAdd; private Timer _worldEventsCleanupTimer; private void StopWorldEvents() { _worldEventsCleanupTimer?.Destroy(); _worldEventsCleanupTimer = null; _trackedSupplyDrops.Clear(); _processedSupplyDropIds.Clear(); _landedSupplyDropIds.Clear(); _lastDieselAdd = null; } private void EnsureWorldEventsCleanupTimer() { if (_worldEventsCleanupTimer != null) { return; } _worldEventsCleanupTimer = timer.Every(300f, CleanupStaleSupplyDropTrackers); } private void CleanupStaleSupplyDropTrackers() { if (_trackedSupplyDrops.Count == 0) { return; } var now = UnityEngine.Time.time; var stale = new List(); foreach (var tracker in _trackedSupplyDrops) { if (now - tracker.CreatedTime > 1800f) { stale.Add(tracker); } } foreach (var tracker in stale) { _trackedSupplyDrops.Remove(tracker); } } private SupplyDropTracker FindSupplyTrackerByPlane(CargoPlane plane) { foreach (var tracker in _trackedSupplyDrops) { if (tracker.Plane == plane) { return tracker; } } return null; } private SupplyDropTracker FindSupplyTrackerBySignal(SupplySignal signal) { foreach (var tracker in _trackedSupplyDrops) { if (tracker.Signal == signal) { return tracker; } } return null; } private SupplyDropTracker FindSupplyTrackerByDrop(SupplyDrop drop) { foreach (var tracker in _trackedSupplyDrops) { if (tracker.Drop == drop) { return tracker; } } return null; } private void OnEntitySpawned(PatrolHelicopter heli) { if (!ingestReady) { return; } NextTick(() => { if (heli == null || heli.IsDestroyed) { return; } var pos = heli.transform.position; var direction = GetCompassDirection(pos); EmitWorldEvent( "world-event-patrol-heli-spawn", "patrol_heli_spawn", "public", pos, $"Patrol Helicopter inbound from the {direction}.", includeDirection: true ); }); } private void OnEntityDeath(PatrolHelicopter heli, HitInfo info) { if (!ingestReady || heli == null) { return; } var pos = heli.transform.position; var location = GetLocationString(pos, null, false); var actor = info?.InitiatorPlayer?.userID; EmitWorldEvent( "world-event-patrol-heli-destroyed", "patrol_heli_destroyed", "public", pos, $"Patrol Helicopter destroyed at {location}.", actorSteamId: actor ); } private void OnEntityKill(PatrolHelicopter heli) { if (!ingestReady || heli == null || heli.IsDestroyed) { return; } var pos = heli.transform.position; var direction = GetCompassDirection(pos); EmitWorldEvent( "world-event-patrol-heli-despawn", "patrol_heli_despawn", "public", pos, $"Patrol Helicopter left the map towards the {direction}.", includeDirection: true ); } private void OnEntitySpawned(CargoShip ship) { if (!ingestReady) { return; } timer.Once( 3f, () => { if (ship == null || ship.IsDestroyed) { return; } var pos = ship.transform.position; var direction = GetCompassDirection(pos); EmitWorldEvent( "world-event-cargo-ship-spawn", "cargo_ship_spawn", "public", pos, $"Cargo Ship inbound from the {direction}.", includeDirection: true ); } ); } private void OnCargoShipHarborApproach(CargoShip ship) { if (!ingestReady || ship == null || ship.IsDestroyed) { return; } var harborLocation = GetHarborLocation(ship); if (string.IsNullOrEmpty(harborLocation)) { return; } EmitWorldEvent( "world-event-cargo-ship-harbor-approach", "cargo_ship_harbor_approach", "public", ship.transform.position, $"Cargo Ship approaching harbor at {harborLocation}." ); } private void OnCargoShipHarborArrived(CargoShip ship) { if (!ingestReady || ship == null || ship.IsDestroyed) { return; } var harborLocation = GetHarborLocation(ship); if (string.IsNullOrEmpty(harborLocation)) { return; } EmitWorldEvent( "world-event-cargo-ship-harbor-arrived", "cargo_ship_harbor_arrived", "public", ship.transform.position, $"Cargo Ship docked at harbor {harborLocation}." ); } private void OnCargoShipHarborLeave(CargoShip ship) { if (!ingestReady || ship == null || ship.IsDestroyed) { return; } var harborLocation = GetHarborLocation(ship); if (string.IsNullOrEmpty(harborLocation)) { return; } EmitWorldEvent( "world-event-cargo-ship-harbor-leave", "cargo_ship_harbor_leave", "public", ship.transform.position, $"Cargo Ship leaving harbor at {harborLocation}." ); } private void OnEntitySpawned(CH47HelicopterAIController ch47) { if (!ingestReady) { return; } timer.Once( 2f, () => { if (ch47 == null || ch47.IsDestroyed) { return; } if (ch47.ShouldLand()) { var target = ch47.GetMoveTarget(); var location = GetLocationString(target, null, false); EmitWorldEvent( "world-event-ch47-crate-delivery", "ch47_crate_delivery", "public", target, $"Chinook delivering crate to {location}." ); } else { var pos = ch47.transform.position; var direction = GetCompassDirection(pos); EmitWorldEvent( "world-event-ch47-flyby", "ch47_flyby", "public", pos, $"Chinook inbound from the {direction}.", includeDirection: true ); } } ); } private void OnBradleyApcInitialize(BradleyAPC apc) { if (!ingestReady) { return; } NextTick(() => { if (apc == null || apc.IsDestroyed) { return; } var pos = apc.transform.position; var location = GetLocationString(pos, null, false); EmitWorldEvent( "world-event-bradley-spawn", "bradley_spawn", "public", pos, $"Bradley APC spawned at {location}." ); }); } private void OnEntityDeath(BradleyAPC apc, HitInfo info) { if (!ingestReady || apc == null) { return; } var pos = apc.transform.position; var location = GetLocationString(pos, null, false); var actor = info?.InitiatorPlayer?.userID; EmitWorldEvent( "world-event-bradley-destroyed", "bradley_destroyed", "public", pos, $"Bradley APC destroyed at {location}.", actorSteamId: actor ); } private void OnEntitySpawned(HackableLockedCrate crate) { if (!ingestReady) { return; } NextTick(() => { if (crate == null || crate.IsDestroyed) { return; } if (IsAtCargoShip(crate) || IsAtOilRig(crate.transform.position) || IsAtLargeRig(crate.transform.position)) { return; } var pos = crate.transform.position; var location = GetLocationString(pos, crate, false); EmitWorldEvent( "world-event-hackable-crate-spawn", "hackable_crate_spawn", "public", pos, $"Chinook dropped a crate at {location}.", entity: crate ); }); } private void CanHackCrate(BasePlayer player, HackableLockedCrate crate) { if (!ingestReady) { return; } NextTick(() => { if (player == null || crate == null || crate.IsDestroyed || !crate.IsBeingHacked()) { return; } if (IsAtCargoShip(crate) || IsAtOilRig(crate.transform.position) || IsAtLargeRig(crate.transform.position)) { return; } var pos = crate.transform.position; var location = GetLocationString(pos, crate, false); EmitWorldEvent( "world-event-crate-hack-started", "crate_hack_started", "public", pos, $"A player is hacking a crate at {location}.", entity: crate, actorSteamId: player.userID ); }); } private void OnExcavatorResourceSet(ExcavatorArm arm, string resourceName, BasePlayer player) { if (!ingestReady || arm == null || arm.IsOn()) { return; } NextTick(() => { if (player == null || arm == null || arm.IsDestroyed || !arm.IsOn()) { return; } var pos = arm.transform.position; var location = GetLocationString(pos, null, false); EmitWorldEvent( "world-event-excavator-activated", "excavator_activated", "public", pos, $"Excavator activated at {location}.", actorSteamId: player.userID, enrichMetadata: meta => meta["resource"] = resourceName ?? string.Empty ); }); } private void OnExcavatorSuppliesRequested(ExcavatorSignalComputer computer, BasePlayer player, CargoPlane plane) { if (!ingestReady) { return; } NextTick(() => { if (player == null || plane == null) { return; } EnsureWorldEventsCleanupTimer(); _trackedSupplyDrops.Add( new SupplyDropTracker { PlayerName = player.displayName, PlayerId = player.userID, Plane = plane, CreatedTime = UnityEngine.Time.time, } ); var pos = player.transform.position; EmitWorldEvent( "world-event-excavator-supply-requested", "excavator_supply_requested", "staff", pos, broadcastMessage: null, actorSteamId: player.userID, enrichMetadata: meta => meta["player_called"] = true ); }); } private void OnEntitySpawned(TravellingVendor vendor) { if (!ingestReady) { return; } NextTick(() => { if (vendor == null || vendor.IsDestroyed) { return; } var pos = vendor.transform.position; var location = GetLocationString(pos, null, false); EmitWorldEvent( "world-event-travelling-vendor-spawn", "travelling_vendor_spawn", "public", pos, $"Travelling Vendor spawned near {location}." ); }); } private void OnQuarryEnabled(MiningQuarry quarry) { GameplayOnMiningQuarryEnabled(quarry); if (!ingestReady) { return; } NextTick(() => { if (quarry == null || quarry.IsDestroyed) { return; } var quarryType = GetQuarryType(quarry); var pos = quarry.transform.position; var location = GetLocationString(pos, null, false); var activator = FindNearestPlayer(pos, 5f); EmitWorldEvent( "world-event-quarry-enabled", "quarry_enabled", "public", pos, $"{quarryType} activated at {location}.", actorSteamId: activator?.userID, enrichMetadata: meta => meta["quarry_type"] = quarryType ); }); } private ItemContainer.CanAcceptResult? CanAcceptItem(ItemContainer container, Item item, int targetPos) { if (!ingestReady || item?.info?.shortname != "diesel_barrel") { return null; } var entity = container?.entityOwner; if (entity == null) { return null; } string entityType = null; Vector3 locationPos = entity.transform.position; ulong entityId = entity.net.ID.Value; if (entity.ShortPrefabName == "engine") { entityType = "Excavator"; } else if (entity.ShortPrefabName == "fuelstorage") { var quarry = entity.GetComponentInParent(); if (quarry == null) { return null; } entityType = GetQuarryType(quarry); locationPos = quarry.transform.position; entityId = quarry.net.ID.Value; } else { return null; } var player = item.GetOwnerPlayer() ?? FindNearestPlayer(entity.transform.position, 10f); if (player == null) { return null; } var now = UnityEngine.Time.realtimeSinceStartup; if (_lastDieselAdd != null && _lastDieselAdd.PlayerId == player.userID && _lastDieselAdd.EntityId == entityId && _lastDieselAdd.Amount == item.amount && now - _lastDieselAdd.Timestamp < 1f) { return null; } _lastDieselAdd = new DieselAddTracker { PlayerId = player.userID, EntityId = entityId, Amount = item.amount, Timestamp = now, }; EmitWorldEvent( "world-event-diesel-added", "diesel_added", "staff", locationPos, broadcastMessage: null, actorSteamId: player.userID, enrichMetadata: meta => { meta["entity_type"] = entityType; meta["amount"] = item.amount; meta["entity_id"] = entityId; } ); return null; } // Widened from (BasePlayer, SupplySignal) so raid explosives (C4/satchel/beancan/grenade) are // captured too — Oxide allows only one method per hook, so we branch rather than overload. // Supply signals keep their exact prior behaviour. private void OnExplosiveThrown(BasePlayer player, BaseEntity entity) { if (entity is SupplySignal signal) { TrackSupplySignal(player, signal); return; } if (!ingestReady || player == null || entity == null || !IsPlausibleSteamId(player.userID)) { return; } var shortPrefab = entity.ShortPrefabName ?? string.Empty; if (shortPrefab.IndexOf("smoke", StringComparison.OrdinalIgnoreCase) >= 0 || shortPrefab.IndexOf("flash", StringComparison.OrdinalIgnoreCase) >= 0) { return; // smoke / flashbang are not raid tools } EmitExplosiveUsed(player, entity, shortPrefab); } private void OnExplosiveDropped(BasePlayer player, SupplySignal signal) { TrackSupplySignal(player, signal); } private void TrackSupplySignal(BasePlayer player, SupplySignal signal) { if (!ingestReady || player == null || signal == null) { return; } NextTick(() => { EnsureWorldEventsCleanupTimer(); _trackedSupplyDrops.Add( new SupplyDropTracker { PlayerName = player.displayName, PlayerId = player.userID, Signal = signal, CreatedTime = UnityEngine.Time.time, } ); }); } private void OnCargoPlaneSignaled(CargoPlane plane, SupplySignal signal) { if (plane == null || signal == null) { return; } var tracker = FindSupplyTrackerBySignal(signal); if (tracker != null) { tracker.Plane = plane; } } private void OnAirdrop(CargoPlane plane, Vector3 dest) { if (!ingestReady) { return; } timer.Once( 2f, () => { if (plane == null) { return; } var tracker = FindSupplyTrackerByPlane(plane); var pos = plane.transform.position; var direction = GetCompassDirection(pos); if (tracker != null) { EmitWorldEvent( "world-event-cargo-plane-player", "cargo_plane_inbound", "staff", pos, broadcastMessage: null, actorSteamId: tracker.PlayerId, includeDirection: true, enrichMetadata: meta => { meta["player_called"] = true; AppendEntityChain(meta, tracker); } ); } else { EmitWorldEvent( "world-event-cargo-plane-random", "cargo_plane_inbound", "public", pos, $"Cargo Plane inbound from the {direction}.", includeDirection: true ); } } ); } private void OnSupplyDropDropped(SupplyDrop drop, CargoPlane plane) { if (!ingestReady) { return; } NextTick(() => HandleSupplyDropDropped(drop, plane)); } private void OnEntitySpawned(SupplyDrop drop) { NextTick(() => HandleSupplyDropDropped(drop, null)); } private void HandleSupplyDropDropped(SupplyDrop drop, CargoPlane plane) { if (!ingestReady || drop == null) { return; } var dropId = drop.net.ID.Value; if (_processedSupplyDropIds.Contains(dropId)) { return; } _processedSupplyDropIds.Add(dropId); SupplyDropTracker tracker = null; if (plane != null) { tracker = FindSupplyTrackerByPlane(plane); if (tracker != null) { tracker.Drop = drop; } } tracker ??= FindSupplyTrackerByDrop(drop); var pos = drop.transform.position; var location = GetLocationString(pos, null, false); if (tracker != null) { EmitWorldEvent( "world-event-supply-drop-player", "supply_drop_falling", "staff", pos, broadcastMessage: null, actorSteamId: tracker.PlayerId, enrichMetadata: meta => { meta["player_called"] = true; AppendEntityChain(meta, tracker); } ); } else { EmitWorldEvent( "world-event-supply-drop-random", "supply_drop_falling", "public", pos, $"Supply Drop is falling at {location}." ); } } private void OnSupplyDropLanded(SupplyDrop drop) { if (drop == null) { return; } _landedSupplyDropIds.Add(drop.net.ID.Value); } private void OnEntityKill(SupplyDrop drop) { if (drop == null) { return; } var tracker = FindSupplyTrackerByDrop(drop); if (tracker != null) { _trackedSupplyDrops.Remove(tracker); } _processedSupplyDropIds.Remove(drop.net.ID.Value); _landedSupplyDropIds.Remove(drop.net.ID.Value); } private void OnEntityKill(SupplySignal signal) { if (signal == null) { return; } var tracker = FindSupplyTrackerBySignal(signal); if (tracker != null) { _trackedSupplyDrops.Remove(tracker); } } private static void AppendEntityChain(Dictionary meta, SupplyDropTracker tracker) { var chain = new List(); if (tracker.Signal != null && !tracker.Signal.IsDestroyed) { chain.Add(tracker.Signal.net.ID.Value); } if (tracker.Plane != null && !tracker.Plane.IsDestroyed) { chain.Add(tracker.Plane.net.ID.Value); } if (tracker.Drop != null && !tracker.Drop.IsDestroyed) { chain.Add(tracker.Drop.net.ID.Value); } if (chain.Count > 0) { meta["entity_id_chain"] = chain; } } private static BasePlayer FindNearestPlayer(Vector3 position, float maxDistance) { BasePlayer closest = null; var closestDistance = maxDistance; foreach (var player in BasePlayer.activePlayerList) { if (player == null || !player.IsConnected) { continue; } var distance = Vector3.Distance(player.transform.position, position); if (distance < closestDistance) { closest = player; closestDistance = distance; } } return closest; } private string GetHarborLocation(CargoShip ship) { if (ship.harborIndex == -1 || CargoShip.harbors.Count <= ship.harborIndex) { return null; } var harbor = CargoShip.harbors[ship.harborIndex]; return GetLocationString(harbor.harborTransform.position, null, false); } private static string GetQuarryType(MiningQuarry quarry) { switch (quarry?.ShortPrefabName) { case "mining_quarry": case "mining.quarry": return "Stone Quarry"; case "mining_quarry_sulfur": case "mining.quarry.sulfur": return "Sulfur Quarry"; case "mining_quarry_hqm": case "mining.quarry.hqm": return "HQM Quarry"; default: return "Mining Quarry"; } } #endregion #region Puzzles (card swipes + resets, v0.12.0) /// /// Full-scene scan interval. This is the plugin's only FindObjectsOfType outside the /// serverEntities walk, and it cannot join that walk: TriggerRadiation is a /// plain Unity component, not a BaseNetworkable, so it never appears in /// serverEntities. 30s rather than the 10s the investigation ran at — a reset holds /// for about five minutes, so the announcement does not need better resolution than this, /// and the interval is the only lever on the cost. /// private const float PuzzleScanIntervalSeconds = 30f; /// /// Per-monument silence after an announcement. The zones hold ~5 minutes; a scan that /// briefly loses and refinds them must not announce the same reset twice. /// private const float PuzzleAnnounceCooldownSeconds = 600f; /// /// The cargo ship carries five graduated radiation triggers that switch on and off as it /// sails. They are real zones and nothing to do with a puzzle. /// private static readonly HashSet PuzzleIgnoredOwners = new HashSet( StringComparer.OrdinalIgnoreCase ) { "cargoship", "cargoshiptest", }; private static readonly string[] KeycardAccessLevelCandidates = { "accessLevel", "AccessLevel", "accessLevelRequired", "requiredAccessLevel", }; private sealed class PuzzleZone { public string GroupKey; public string MonumentName; public Vector3 Position; } private Timer _puzzleScanTimer; private readonly Dictionary _puzzleZones = new Dictionary(); private readonly Dictionary _puzzleAnnouncedRealtime = new Dictionary(); private bool _puzzleBaselineDone; private PuzzleNumericMember _keycardAccessLevel; private void StartPuzzleWatch() { _keycardAccessLevel = PuzzleNumericMember.Resolve( typeof(Keycard), KeycardAccessLevelCandidates, "access" ); VerbosePuts($"Cerebrust puzzles: keycard tier member = {_keycardAccessLevel.Label}"); _puzzleScanTimer?.Destroy(); _puzzleScanTimer = timer.Every(PuzzleScanIntervalSeconds, PuzzleScanTick); } private void StopPuzzleWatch() { _puzzleScanTimer?.Destroy(); _puzzleScanTimer = null; _puzzleZones.Clear(); _puzzleAnnouncedRealtime.Clear(); _puzzleBaselineDone = false; } /// /// Fires on every swipe attempt, including a wrong-tier card. Returns null so the reader is /// never interfered with. Position comes from the reader rather than the player, so the /// swipe is attributed to the room it happened in. /// private object OnCardSwipe(CardReader instance, Keycard keycard, BasePlayer player) { try { if (!ingestReady || player == null) { return null; } var position = instance != null ? instance.transform.position : player.transform.position; var monument = FindNearestMonument(position); // Assigned up front: `&&` short-circuits, so `out tier` is not definitely assigned // when the left side is false. var cardTier = 0; var hasTier = _keycardAccessLevel != null && _keycardAccessLevel.TryRead(keycard, out cardTier); EmitWorldEvent( "world-event-card-swipe", "card_swipe", // Staff-only, and no broadcast: a swipe is telemetry, not an announcement. "staff", position, null, actorSteamId: player.userID, enrichMetadata: meta => { if (hasTier) { meta["card_tier"] = cardTier; } if (monument != null && !string.IsNullOrEmpty(monument.DisplayName)) { meta["monument_name"] = monument.DisplayName; } } ); } catch (Exception ex) { PrintWarning($"Cerebrust OnCardSwipe error: {ex.Message}"); } return null; } /// /// Detects a puzzle reset as a change in the *set* of live radiation zones. /// /// There is deliberately no threshold. Observed maxima across seven runs at six monuments /// were 1, 10 and 80 — the amount jumps 0 to 1 instantly at both ends, so no threshold can /// tell a reset from nothing. What is reliable is that two zones parented to the monument's /// puzzle entity appear, hold for about five minutes, and vanish; /// FindObjectsOfType only returns components on active GameObjects, which is what /// makes appear/vanish visible at all. /// /// Only the appear edge is announced: the radiation switching on *is* the reset. /// private void PuzzleScanTick() { if (!ingestReady) { return; } try { var found = UnityEngine.Object.FindObjectsOfType(); var current = new Dictionary(); foreach (var zone in found) { if (zone == null) { continue; } var owner = PuzzleZoneOwner(zone); if (string.IsNullOrEmpty(owner) || PuzzleIgnoredOwners.Contains(owner)) { continue; } var position = zone.transform.position; var monument = FindNearestMonument(position); // One puzzle activating is two zones, so report the group, not each zone. // Falling back to the grid square stops every unplaceable zone on the map from // being filed under whichever monument happens to be least far away. current[zone.GetInstanceID()] = new PuzzleZone { GroupKey = (monument != null ? monument.Shortname : GetGridPosition(position)) + "|" + owner, MonumentName = monument != null ? monument.DisplayName : null, Position = position, }; } // Everything hot at load is the world's normal state, not something we witnessed. // Without this, a reload announces every mid-reset monument at once. if (!_puzzleBaselineDone) { _puzzleBaselineDone = true; ReplacePuzzleZones(current); return; } var appeared = new Dictionary(); foreach (var entry in current) { if (_puzzleZones.ContainsKey(entry.Key)) { continue; } if (!appeared.ContainsKey(entry.Value.GroupKey)) { appeared[entry.Value.GroupKey] = entry.Value; } } // Advance the tracked set every tick, announced or not — otherwise a zone that // appears and persists is re-reported on every subsequent scan. ReplacePuzzleZones(current); if (appeared.Count == 0) { return; } var now = UnityEngine.Time.realtimeSinceStartup; foreach (var group in appeared) { float lastAnnounced; if ( _puzzleAnnouncedRealtime.TryGetValue(group.Key, out lastAnnounced) && now - lastAnnounced < PuzzleAnnounceCooldownSeconds ) { continue; } _puzzleAnnouncedRealtime[group.Key] = now; EmitPuzzleReset(group.Value); } } catch (Exception ex) { PrintWarning($"Cerebrust puzzle scan error: {ex.Message}"); } } private void ReplacePuzzleZones(Dictionary current) { _puzzleZones.Clear(); foreach (var entry in current) { _puzzleZones[entry.Key] = entry.Value; } } /// /// The message names the monument and never a card colour. The signal is monument-wide — /// a monument swiped on two tiers 54 seconds apart produced one event, not two — so a /// colour here would be a claim the detection cannot support. /// private void EmitPuzzleReset(PuzzleZone zone) { var location = GetLocationString(zone.Position); EmitWorldEvent( "world-event-puzzle-reset", "puzzle_reset", "public", zone.Position, $"The puzzle at {location} has reset.", enrichMetadata: meta => { if (!string.IsNullOrEmpty(zone.MonumentName)) { meta["monument_name"] = zone.MonumentName; } } ); } /// /// What the zone is attached to. This is the field that identified the signal in the first /// place: the owner is always the monument's puzzle entity (generator.static, or /// nuclear_missile_silo at Missile Silo). /// private static string PuzzleZoneOwner(Component component) { try { var entity = component.GetComponentInParent(); if (entity != null) { return entity.ShortPrefabName ?? entity.name; } var root = component.transform.root; var name = root != null ? root.name : component.name; if (string.IsNullOrEmpty(name)) { return null; } var slash = name.LastIndexOf('/'); if (slash >= 0 && slash < name.Length - 1) { name = name.Substring(slash + 1); } return name.Replace(".prefab", string.Empty); } catch (Exception) { return null; } } /// /// Reads one numeric field or property by name, resolved once at boot. /// /// Reflection rather than keycard.accessLevel because naming a Facepunch field is a /// guess, and a wrong symbol does not fail this feature — it fails the whole plugin's /// compile, taking all telemetry with it. Same reasoning as deriving the upkeep period /// instead of naming a convar. Once a live boot logs the resolved name (verbose logging), /// this can collapse to a direct read. /// private sealed class PuzzleNumericMember { private readonly System.Reflection.FieldInfo _field; private readonly System.Reflection.PropertyInfo _property; public readonly string Label; private PuzzleNumericMember( System.Reflection.FieldInfo field, System.Reflection.PropertyInfo property, string label ) { _field = field; _property = property; Label = label; } public static PuzzleNumericMember Resolve(Type type, string[] candidates, string fuzzy) { const System.Reflection.BindingFlags Flags = System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic; try { foreach (var name in candidates) { var field = type.GetField(name, Flags); if (field != null && IsNumeric(field.FieldType)) { return new PuzzleNumericMember(field, null, type.Name + "." + name); } var property = type.GetProperty(name, Flags); if (property != null && property.CanRead && IsNumeric(property.PropertyType)) { return new PuzzleNumericMember(null, property, type.Name + "." + name); } } foreach (var field in type.GetFields(Flags)) { if ( IsNumeric(field.FieldType) && field.Name.IndexOf(fuzzy, StringComparison.OrdinalIgnoreCase) >= 0 ) { return new PuzzleNumericMember( field, null, type.Name + "." + field.Name + " (fuzzy)" ); } } } catch (Exception) { // fall through to the unresolved instance } return new PuzzleNumericMember(null, null, type.Name + "."); } public bool TryRead(object instance, out int value) { value = 0; if (instance == null) { return false; } try { object raw = null; if (_field != null) { raw = _field.GetValue(instance); } else if (_property != null) { raw = _property.GetValue(instance, null); } if (raw == null) { return false; } value = Convert.ToInt32(raw, CultureInfo.InvariantCulture); return true; } catch (Exception) { return false; } } private static bool IsNumeric(Type type) { return type == typeof(int) || type == typeof(short) || type == typeof(byte) || type == typeof(long) || type == typeof(float) || type == typeof(double); } } #endregion #region Gameplay features (Thistle port) #region Gameplay feature fields private bool _autoFuelInitialized; private readonly HashSet _autoFuelCompatibleOvens = new HashSet(); private bool _blueprintShareInitialized; private readonly Dictionary> _blueprintShareTargetCache = new Dictionary>(); private readonly Dictionary _blueprintShareTargetCacheTimers = new Dictionary(); private const float BlueprintShareTargetCacheDuration = 10f; private static Cerebrust _gameplayHost; private static float _furnaceBoostMultiplier = 1f; private Timer _furnaceBoostRecoveryTimer; private readonly Dictionary _furnaceOvenCookCounts = new Dictionary(); private readonly Dictionary _gatherNodesModifiers = new Dictionary(); private readonly Dictionary _gatherPickupModifiers = new Dictionary(); private readonly Dictionary _gatherQuarryModifiers = new Dictionary(); // item.shortname -> configured max stack size; rebuilt alongside the modifier caches and // read on the OnMaxStackable hot path. Only applied while the "gather" feature is enabled. private readonly Dictionary _gatherStackSizes = new Dictionary(); // item.shortname -> vanilla ItemDefinition.stackable, captured the first time we overwrite a // definition so the mutation can be reverted on toggle-off / unload. OnMaxStackable alone is // not honoured by systems that read ItemDefinition.stackable directly (e.g. industrial // conveyors), so the "gather" feature also mutates the shared definitions while enabled. private readonly Dictionary _gatherStackVanilla = new Dictionary(); private const float ExcavatorResourceTickRate = 3f; private const float ExcavatorTimeForFullResources = 120f; private const float ExcavatorBeltSpeedMax = 0.1f; private const float MiningQuarryResourceTickRate = 5f; private const int StackRecyclingScrapItemId = -932201673; private const float StackRecyclingClassicEfficiency = 0.5f; private const string SortButtonUiPanelName = "CerebrustSortButton"; private const float SortButtonWidth = 79f; private const float SortButtonDefaultOffsetX = 476.5f; private const float SortButtonBaseYOffset = 113.5f; private const float SortButtonYOffsetPerRow = 62f; private const int SortButtonMaxRows = 8; private static readonly Dictionary SortButtonPanelYOffsets = new Dictionary { ["dropboxcontents"] = SortButtonBaseYOffset + SortButtonYOffsetPerRow * 2, ["furnace"] = 277f, ["generic"] = SortButtonBaseYOffset + SortButtonYOffsetPerRow * 6, ["genericsmall"] = SortButtonBaseYOffset + SortButtonYOffsetPerRow, ["largefurnace"] = 395f, ["toolcupboard"] = 595f, ["vendingmachine.storage"] = SortButtonBaseYOffset + SortButtonYOffsetPerRow * 5, }; private static readonly Dictionary SortButtonPanelHeights = new Dictionary { ["animal-storage"] = 21f, ["dropboxcontents"] = 21f, ["furnace"] = 21f, ["largefurnace"] = 21f, ["toolcupboard"] = 21.5f, ["vendingmachine.storage"] = 21f, }; private static readonly string[] SortButtonAdditionalPrefabs = { "assets/content/vehicles/boats/rhib/subents/rhib_storage.prefab", "assets/content/vehicles/boats/rowboat/subents/rowboat_storage.prefab", "assets/content/vehicles/horse/ridablehorse.prefab", "assets/content/vehicles/modularcar/subents/modular_car_1mod_storage.prefab", "assets/content/vehicles/modularcar/subents/modular_car_camper_storage.prefab", "assets/content/vehicles/snowmobiles/subents/snowmobileitemstorage.prefab", "assets/content/vehicles/submarine/subents/submarineitemstorage.prefab", "assets/prefabs/deployable/hot air balloon/subents/hab_storage.prefab", "assets/prefabs/misc/halloween/coffin/coffinstorage.prefab", "assets/prefabs/misc/decor_dlc/storagebarrel/storage_barrel_b.prefab", "assets/prefabs/misc/decor_dlc/storagebarrel/storage_barrel_c.prefab", }; private readonly HashSet _sortButtonSupportedPrefabs = new HashSet(); private readonly HashSet _sortButtonUiViewers = new HashSet(); private int[] _sortButtonCategoryToSortIndex; private bool _sortButtonInitialized; private bool _turretAuthInitialized; private readonly Dictionary _turretAuthPendingClears = new Dictionary(); private int _turretAuthClearGraceSeconds = 30; private bool _samAuthInitialized; // Targeting-alert throttle: ":" -> realtimeSinceStartup of last DM sent. // Keyed per base (not per weapon) so a base bristling with turrets sends one alert, not ten. // Fixed window (not a plugin setting) — enable/disable + any tuning is gated server-side. private const float TargetingAlertCooldownSeconds = 300f; private readonly Dictionary _targetingAlertCooldowns = new Dictionary(); #endregion #region Gameplay feature lifecycle private bool IsGameplayFeatureEnabled(string categorySlug) { var cache = _gameplayEnabledCache; if (cache == null) { // Lazily build the flag dict (also memoizes the parse) on first use / after invalidation. ParsePluginSettingsCache(); cache = _gameplayEnabledCache; } return cache != null && cache.TryGetValue(categorySlug, out var enabled) && enabled; } private void InitializeGameplayFeatures() { _gameplayHost = this; RefreshGatherModifierCaches(); RefreshStackSizeCache(); _turretAuthClearGraceSeconds = Mathf.Clamp( ReadPluginSettingInt("turret_auth.clear_grace_seconds", 30), 5, 120 ); if (IsGameplayFeatureEnabled("auto_fuel")) { InitializeAutoFuel(); } if (IsGameplayFeatureEnabled("blueprint_share")) { _blueprintShareInitialized = true; } if (IsGameplayFeatureEnabled("furnace_boost")) { EnableFurnaceBoost(); } if (IsGameplayFeatureEnabled("gather")) { InitializeGather(); } if (IsGameplayFeatureEnabled("stack_sizes")) { ApplyStackSizeDefinitions(); } if (IsGameplayFeatureEnabled("sort_button")) { InitializeSortButton(); } if (IsGameplayFeatureEnabled("turret_auth")) { InitializeTurretAuth(); } if (IsGameplayFeatureEnabled("sam_auth")) { _samAuthInitialized = true; } } private void UnloadGameplayFeatures() { UnloadBlueprintShare(); DisableFurnaceBoost(); UnloadSortButton(); UnloadTurretAuth(); RestoreStackSizeDefinitions(); _autoFuelInitialized = false; _autoFuelCompatibleOvens.Clear(); _blueprintShareInitialized = false; _samAuthInitialized = false; _turretAuthInitialized = false; _gameplayHost = null; } private void MaybeRefreshGameplayFeaturesOnSettingsMerge() { RefreshGatherModifierCaches(); RefreshStackSizeCache(); _turretAuthClearGraceSeconds = Mathf.Clamp( ReadPluginSettingInt("turret_auth.clear_grace_seconds", 30), 5, 120 ); // Every feature below must toggle live: flipping .enabled in the dashboard applies on // the next heartbeat with no o.reload. Each transition mirrors what InitializeGameplayFeatures // (boot) / UnloadGameplayFeatures (unload) do, so a feature that was OFF at boot still comes up // when enabled later — the hot-path hooks gate on .enabled AND _Initialized, and // that flag is only ever set here or at boot. (ticket 0010) // auto_fuel — reversible init is just the compatible-oven scan. if (IsGameplayFeatureEnabled("auto_fuel")) { if (!_autoFuelInitialized) { InitializeAutoFuel(); } } else if (_autoFuelInitialized) { _autoFuelInitialized = false; _autoFuelCompatibleOvens.Clear(); } // blueprint_share — flag-gated; teardown clears the target cache + its timers. if (IsGameplayFeatureEnabled("blueprint_share")) { _blueprintShareInitialized = true; } else if (_blueprintShareInitialized) { UnloadBlueprintShare(); _blueprintShareInitialized = false; } if (IsGameplayFeatureEnabled("furnace_boost")) { if (_furnaceBoostRecoveryTimer == null) { // OFF -> ON transition: full enable attaches controllers to existing ovens now, // instead of waiting up to 60s for the recovery timer to sweep them in. EnableFurnaceBoost(); } else { // Already running: just pick up a changed speed multiplier. var mult = ReadPluginSettingInt("furnace_boost.speed_multiplier", 1); _furnaceBoostMultiplier = Mathf.Clamp(mult, 1, 4); } } else { DisableFurnaceBoost(); } // gather — multipliers are read straight from the caches, so only the excavator/quarry // tick reconfiguration needs a transition. Stack sizes are no longer part of this. if (IsGameplayFeatureEnabled("gather")) { foreach (var excavator in UnityEngine.Object.FindObjectsOfType()) { ConfigureGameplayExcavator(excavator); } } // stack_sizes — its own feature since the gather split, so raising stacks no longer // requires multiplying gather rates. RefreshStackSizeCache (above) has already rebuilt // _gatherStackSizes, so ON re-applies any changed sizes as well as a fresh enable. // OFF must restore, or the mutated ItemDefinition.stackable leaks past the toggle. if (IsGameplayFeatureEnabled("stack_sizes")) { ApplyStackSizeDefinitions(); } else { RestoreStackSizeDefinitions(); } // sort_button — InitializeSortButton/UnloadSortButton own the _sortButtonInitialized flag. if (IsGameplayFeatureEnabled("sort_button")) { if (!_sortButtonInitialized) { InitializeSortButton(); } } else if (_sortButtonInitialized) { UnloadSortButton(); } // turret_auth — InitializeTurretAuth syncs on enable; re-sync live when already up (e.g. a // changed clear_grace_seconds); UnloadTurretAuth tears down on disable. if (IsGameplayFeatureEnabled("turret_auth")) { if (!_turretAuthInitialized) { InitializeTurretAuth(); } else { SyncAllGameplayTurrets(); } } else if (_turretAuthInitialized) { UnloadTurretAuth(); } // sam_auth — flag-gated, no extra setup/teardown. if (IsGameplayFeatureEnabled("sam_auth")) { _samAuthInitialized = true; } else if (_samAuthInitialized) { _samAuthInitialized = false; } } #endregion #region auto_fuel private sealed class AutoFuelOvenSlot { public Item Item; public int? Position; public int Index; public int DeltaAmount; } private void InitializeAutoFuel() { _autoFuelCompatibleOvens.Clear(); foreach (var prefab in GameManifest.Current.entities) { var gameObj = GameManager.server.FindPrefab(prefab); if (gameObj == null) { continue; } var oven = gameObj.GetComponent(); if (oven != null && oven.allowByproductCreation) { _autoFuelCompatibleOvens.Add(oven.ShortPrefabName); } } _autoFuelInitialized = true; VerbosePuts($"CerebRUST auto_fuel: {_autoFuelCompatibleOvens.Count} compatible oven types"); } private object CanMoveItem( Item item, PlayerInventory inventory, ItemContainerId targetContainerId, int targetSlotIndex, int splitAmount ) { if (!IsGameplayFeatureEnabled("auto_fuel") || !_autoFuelInitialized || item == null || inventory == null) { return null; } var player = inventory.GetComponent(); if (player == null) { return null; } var oven = inventory.loot.entitySource as BaseOven; if (oven == null || !IsAutoFuelOvenCompatible(oven)) { return null; } var targetContainer = inventory.FindContainer(targetContainerId); if (targetContainer != null && !(targetContainer?.entityOwner is BaseOven)) { return null; } var container = oven.inventory; var originalContainer = item.GetRootContainer(); if (container == null || originalContainer == null || originalContainer?.entityOwner is BaseOven) { return null; } var allowedSlots = oven.GetAllowedSlots(item); if (allowedSlots == null) { return null; } for (var i = allowedSlots.Value.Min; i <= allowedSlots.Value.Max; i++) { var slot = oven.inventory.GetSlot(i); if (slot != null && slot.info.shortname != item.info.shortname) { return null; } } var cookable = item.info.GetComponent(); if (cookable == null || oven.IsOutputItem(item)) { return null; } if (cookable.lowTemp > oven.cookingTemperature || cookable.highTemp < oven.cookingTemperature) { return null; } var totalSlots = oven.inputSlots; if (MoveAutoFuelSplitItem(item, oven, totalSlots, splitAmount)) { AutoAddFuel(inventory, oven); return true; } return null; } private bool IsAutoFuelOvenCompatible(BaseOven oven) { return oven != null && oven.allowByproductCreation && _autoFuelCompatibleOvens.Contains(oven.ShortPrefabName); } private bool MoveAutoFuelSplitItem(Item item, BaseOven oven, int totalSlots, int splitAmount) { var container = oven.inventory; var numOreSlots = Math.Max(1, totalSlots); var totalMoved = 0; var itemAmount = Math.Min(item.amount, splitAmount); // Count existing ore only within the input-slot range this method redistributes // (matching the range FindAutoFuelMatchingSlotIndex scans below). Summing the whole // container instead would over-count same-info ore that the redistribution loop does // not subtract from, creating phantom ore — an exact 2X dupe on ovens (the electric // furnace) whose slot layout puts matching ore outside [_inputSlotIndex, +inputSlots). var inputSlotsMin = oven._inputSlotIndex; var inputSlotsMax = oven._inputSlotIndex + oven.inputSlots; var existingAmount = 0; for (var i = inputSlotsMin; i < inputSlotsMax; i++) { var slotItem = container.GetSlot(i); if (slotItem != null && slotItem.info == item.info) { existingAmount += slotItem.amount; } } var totalAmount = Math.Min(itemAmount + existingAmount, item.info.stackable * numOreSlots); var totalStackSize = Math.Min(totalAmount / numOreSlots, item.info.stackable); var remaining = totalAmount - (totalAmount / numOreSlots * numOreSlots); var usedSlots = new List(); var ovenSlots = new List(); for (var i = 0; i < numOreSlots; ++i) { var slot = FindAutoFuelMatchingSlotIndex(oven, container, out var existingItem, item.info, usedSlots); if (slot == -1) { break; } usedSlots.Add(slot); var currentAmount = existingItem?.amount ?? 0; var targetAmount = totalStackSize + (i < remaining ? 1 : 0); var deltaAmount = targetAmount - currentAmount; if (currentAmount + deltaAmount <= 0) { continue; } ovenSlots.Add( new AutoFuelOvenSlot { Position = existingItem?.position, Index = slot, Item = existingItem, DeltaAmount = deltaAmount, } ); } foreach (var slot in ovenSlots) { if (slot.Item == null) { var newItem = ItemManager.Create(item.info, slot.DeltaAmount, item.skin); slot.Item = newItem; newItem.MoveToContainer(container, slot.Position ?? slot.Index); } else { slot.Item.amount += slot.DeltaAmount; } totalMoved += slot.DeltaAmount; } container.MarkDirty(); if (totalMoved >= item.amount) { item.Remove(); item.GetRootContainer()?.MarkDirty(); return true; } if (totalMoved > 0) { item.amount -= totalMoved; item.GetRootContainer()?.MarkDirty(); return true; } return false; } private int FindAutoFuelMatchingSlotIndex( BaseOven oven, ItemContainer container, out Item existingItem, ItemDefinition itemType, List usedSlots ) { existingItem = null; var firstEmptySlot = -1; var inputSlotsMin = oven._inputSlotIndex; var inputSlotsMax = oven._inputSlotIndex + oven.inputSlots; var matchingItems = new Dictionary(); for (var i = inputSlotsMin; i < inputSlotsMax; ++i) { if (usedSlots.Contains(i)) { continue; } var slotItem = container.GetSlot(i); if (slotItem == null) { if (firstEmptySlot == -1) { firstEmptySlot = i; } } else if (slotItem.info == itemType) { matchingItems.Add(i, slotItem); if (firstEmptySlot == -1) { existingItem = slotItem; firstEmptySlot = i; } } } if (matchingItems.Count > 0) { var largestStack = matchingItems.OrderByDescending(kv => kv.Value.amount).First(); existingItem = largestStack.Value; return existingItem.position; } return firstEmptySlot; } private void AutoAddFuel(PlayerInventory playerInventory, BaseOven oven) { // Fuel-less ovens (the electric furnace runs on electricity) have no fuelType/fuel slots. // Every line below dereferences oven.fuelType, so bail out first — otherwise the NRE // unwinds out of CanMoveItem *after* the ore split already ran but *before* it returns // true, so the vanilla move is never cancelled and the deposited ore is duplicated. if (oven.fuelType == null || oven.fuelSlots <= 0) { return; } var neededFuel = CalculateAutoFuelNeeded(oven); var currentFuel = oven.inventory.GetAmount(oven.fuelType.itemid, false); var toAdd = (int)Math.Ceiling(neededFuel) - currentFuel; if (toAdd <= 0) { return; } var playerFuel = Pool.Get>(); try { playerInventory.FindItemsByItemID(playerFuel, oven.fuelType.itemid); if (playerFuel.Count == 0) { return; } var fuelSlotIndex = 0; var maxFuelPerSlot = oven.fuelType.stackable; var maxTotalFuel = maxFuelPerSlot * oven.fuelSlots; foreach (var fuelItem in playerFuel) { while (fuelSlotIndex < oven.fuelSlots) { var existingFuel = oven.inventory.GetSlot(fuelSlotIndex); if (existingFuel == null || existingFuel.amount < maxFuelPerSlot) { break; } fuelSlotIndex++; } if (fuelSlotIndex >= oven.fuelSlots) { break; } var currentTotal = oven.inventory.GetAmount(oven.fuelType.itemid, false); if (currentTotal >= maxTotalFuel) { break; } var spaceAvailable = maxTotalFuel - currentTotal; var toTake = Math.Min(Math.Min(toAdd, fuelItem.amount), spaceAvailable); if (toTake <= 0) { break; } toAdd -= toTake; if (toTake >= fuelItem.amount) { fuelItem.MoveToContainer(oven.inventory, fuelSlotIndex); } else { var splitItem = fuelItem.SplitItem(toTake); if (!splitItem.MoveToContainer(oven.inventory, fuelSlotIndex)) { splitItem.MoveToContainer(playerInventory.containerMain); break; } } if (toAdd <= 0) { break; } } } finally { Pool.Free(ref playerFuel); } } private float CalculateAutoFuelNeeded(BaseOven oven) { var totalSmeltTime = 0f; for (var i = oven._inputSlotIndex; i < oven._inputSlotIndex + oven.inputSlots; i++) { var inputItem = oven.inventory.GetSlot(i); if (inputItem == null) { continue; } var cookable = inputItem.info.GetComponent(); if (cookable == null) { continue; } if (oven.cookingTemperature >= cookable.lowTemp && oven.cookingTemperature <= cookable.highTemp) { totalSmeltTime += cookable.cookTime * inputItem.amount; } } if (totalSmeltTime <= 0) { return 0f; } var eta = totalSmeltTime / oven.GetSmeltingSpeed(); var burnable = oven.fuelType?.GetComponent(); if (burnable == null || burnable.fuelAmount <= 0) { return 0f; } var fuelUnits = burnable.fuelAmount; return (float)Math.Ceiling(eta * (oven.cookingTemperature / 200.0f) / fuelUnits); } #endregion #region blueprint_share private sealed class BlueprintShareUnlockTask { public ulong TargetId; public List Blueprints; } private static class BlueprintShareListPool { private static readonly Stack> Pool = new Stack>(); public static List Get() { return Pool.Count > 0 ? Pool.Pop() : new List(); } public static void Free(List list) { list.Clear(); Pool.Push(list); } } private void UnloadBlueprintShare() { foreach (var cacheTimer in _blueprintShareTargetCacheTimers.Values) { cacheTimer?.Destroy(); } _blueprintShareTargetCacheTimers.Clear(); _blueprintShareTargetCache.Clear(); } private void OnItemAction(Item item, string action, BasePlayer player) { if (!IsGameplayFeatureEnabled("blueprint_share") || !_blueprintShareInitialized) { return; } if (player == null || item == null || action != "study" || item.blueprintTargetDef == null) { return; } if (TryShareBlueprintWithTeam(item.blueprintTargetDef, player)) { item.Remove(); } } private void OnTechTreeNodeUnlocked( Workbench workbench, TechTreeData.NodeInstance node, BasePlayer player, PooledList items ) { if (!IsGameplayFeatureEnabled("blueprint_share") || !_blueprintShareInitialized) { return; } if (player == null || items == null) { return; } foreach (var itemDef in items) { if (itemDef == null) { continue; } TryShareBlueprintWithTeam(itemDef, player); } } private void GameplayOnTeamAcceptInvite(RelationshipManager.PlayerTeam team, BasePlayer joiningPlayer) { if (!IsGameplayFeatureEnabled("blueprint_share") || !_blueprintShareInitialized) { return; } if (team == null || joiningPlayer == null) { return; } timer.Once( 1f, () => { if (team == null || joiningPlayer == null) { return; } var teamMemberIds = team.members; if (teamMemberIds == null || teamMemberIds.Count == 0) { return; } var teamMembers = FindBlueprintSharePlayersByIds(teamMemberIds, joiningPlayer.userID); if (teamMembers.Count == 0) { return; } foreach (var member in teamMembers) { if (member == null || member == joiningPlayer) { continue; } ShareAllBlueprintsWithPlayer(joiningPlayer, member); ShareAllBlueprintsWithPlayer(member, joiningPlayer); } } ); } private bool TryShareBlueprintWithTeam(ItemDefinition item, BasePlayer player) { if (item == null || player == null) { return false; } var playerId = player.userID; if (!BlueprintShareInTeam(playerId) || !BlueprintShareSomeoneWillLearn(playerId, item)) { return false; } var targetIds = GetBlueprintShareCachedTargets(playerId); if (targetIds.Count == 0) { return false; } ShareBlueprintWithTargets(player, targetIds, item); return true; } private void ShareBlueprintWithTargets(BasePlayer sharer, List targetIds, ItemDefinition item) { var blueprintId = item.itemid; var sharedCount = 0; var tasks = new List(); foreach (var targetId in targetIds) { if (targetId == sharer.userID) { continue; } var task = new BlueprintShareUnlockTask { TargetId = targetId, Blueprints = BlueprintShareListPool.Get(), }; foreach (var blueprint in item.Blueprint.additionalUnlocks) { QueueBlueprintShareUnlock(targetId, blueprint.itemid, task.Blueprints); } QueueBlueprintShareUnlock(targetId, blueprintId, task.Blueprints); if (task.Blueprints.Count > 0) { tasks.Add(task); sharedCount++; } else { BlueprintShareListPool.Free(task.Blueprints); } } foreach (var task in tasks) { ProcessQueuedBlueprintShareUnlocks(task.TargetId, task.Blueprints); var target = BasePlayer.FindByID(task.TargetId) ?? BasePlayer.FindSleeping(task.TargetId); if (target != null) { SendToPlayer( target, $"{sharer.displayName} shared the {item.displayName.translated} blueprint with you." ); } BlueprintShareListPool.Free(task.Blueprints); } if (sharedCount > 0) { SendToPlayer( sharer, $"You shared the {item.displayName.translated} blueprint with {sharedCount} team member(s)." ); } } private void ShareAllBlueprintsWithPlayer(BasePlayer sharer, BasePlayer target) { if (sharer == null || target == null) { return; } if (!BlueprintShareSameTeam(sharer.userID, target.userID)) { return; } var filteredBlueprints = sharer.PersistantPlayerInfo.unlockedItems; if (filteredBlueprints.Count == 0) { return; } var queue = BlueprintShareListPool.Get(); foreach (var blueprintId in filteredBlueprints) { QueueBlueprintShareUnlock(target.userID, blueprintId, queue); } var unlocked = ProcessQueuedBlueprintShareUnlocks(target.userID, queue); BlueprintShareListPool.Free(queue); if (unlocked > 0) { SendToPlayer( sharer, $"You shared {unlocked} blueprint(s) with {target.displayName}." ); SendToPlayer( target, $"{sharer.displayName} shared {unlocked} blueprint(s) with you." ); } } private bool QueueBlueprintShareUnlock(ulong playerId, int blueprintId, List unlockQueue) { var playerInfo = ServerMgr.Instance.persistance.GetPlayerInfo(playerId); if (playerInfo?.unlockedItems == null || playerInfo.unlockedItems.Contains(blueprintId)) { return false; } unlockQueue.Add(blueprintId); return true; } private int ProcessQueuedBlueprintShareUnlocks(ulong playerId, List unlockQueue) { if (unlockQueue.Count == 0) { return 0; } var persistance = ServerMgr.Instance.persistance; if (persistance == null) { return 0; } var playerInfo = persistance.GetPlayerInfo(playerId); if (playerInfo == null) { return 0; } playerInfo.unlockedItems.AddRange(unlockQueue); persistance.SetPlayerInfo(playerId, playerInfo); var player = BasePlayer.FindByID(playerId) ?? BasePlayer.FindSleeping(playerId); if (player != null) { foreach (var blueprint in unlockQueue) { if (!player.PersistantPlayerInfo.unlockedItems.Contains(blueprint)) { player.PersistantPlayerInfo.unlockedItems.Add(blueprint); } player.ClientRPC(RpcTarget.Player("UnlockedBlueprint", player), blueprint); } player.stats.Add("blueprint_studied", unlockQueue.Count); player.SendNetworkUpdateImmediate(); PlayBlueprintShareSound(player); } return unlockQueue.Count; } private bool BlueprintShareSomeoneWillLearn(ulong playerId, ItemDefinition item) { var targetIds = GetBlueprintShareCachedTargets(playerId); if (targetIds.Count == 0) { return false; } foreach (var targetId in targetIds) { if (BlueprintSharePlayerWouldLearn(targetId, item.itemid)) { return true; } foreach (var blueprintItem in item.Blueprint.additionalUnlocks) { if (BlueprintSharePlayerWouldLearn(targetId, blueprintItem.itemid)) { return true; } } } return false; } private bool BlueprintSharePlayerWouldLearn(ulong playerId, int blueprintId) { var playerInfo = ServerMgr.Instance.persistance.GetPlayerInfo(playerId); return playerInfo?.unlockedItems != null && !playerInfo.unlockedItems.Contains(blueprintId); } private bool BlueprintShareInTeam(ulong playerId) { var playersTeam = RelationshipManager.ServerInstance.FindPlayersTeam(playerId); return playersTeam != null && playersTeam.members.Count > 1; } private List GetBlueprintShareCachedTargets(ulong playerId) { if (_blueprintShareTargetCache.TryGetValue(playerId, out var cached)) { return cached; } var targetIds = new List(); if (BlueprintShareInTeam(playerId)) { var teamMembers = RelationshipManager.ServerInstance.FindPlayersTeam(playerId)?.members; if (teamMembers != null) { targetIds.AddRange(teamMembers.Where(id => id != playerId)); } } _blueprintShareTargetCache[playerId] = targetIds; if (_blueprintShareTargetCacheTimers.TryGetValue(playerId, out var oldTimer)) { oldTimer.Destroy(); } _blueprintShareTargetCacheTimers[playerId] = timer.Once( BlueprintShareTargetCacheDuration, () => { _blueprintShareTargetCache.Remove(playerId); _blueprintShareTargetCacheTimers.Remove(playerId); } ); return targetIds; } private bool BlueprintShareSameTeam(ulong playerId, ulong targetId) { var playerTeam = RelationshipManager.ServerInstance.FindPlayersTeam(playerId); var targetTeam = RelationshipManager.ServerInstance.FindPlayersTeam(targetId); return playerTeam != null && playerTeam == targetTeam; } private void PlayBlueprintShareSound(BasePlayer player) { if (player == null) { return; } var soundEffect = new Effect( "assets/prefabs/deployable/research table/effects/research-success.prefab", player.transform.position, Vector3.zero ); EffectNetwork.Send(soundEffect, player.net.connection); } private List FindBlueprintSharePlayersByIds(IEnumerable ids, ulong excludeId = 0) { return ids .Where(id => id != excludeId) .Select(id => BasePlayer.FindByID(id) ?? BasePlayer.FindSleeping(id)) .Where(player => player != null) .Distinct() .ToList(); } #endregion #region furnace_boost private void EnableFurnaceBoost() { var mult = ReadPluginSettingInt("furnace_boost.speed_multiplier", 1); _furnaceBoostMultiplier = Mathf.Clamp(mult, 1, 4); ProcessExistingFurnaceBoostOvens(); EnableFurnaceBoostRecoveryTimer(); VerbosePuts($"CerebRUST furnace_boost: {_furnaceBoostMultiplier}x"); } private void DisableFurnaceBoost() { _furnaceBoostRecoveryTimer?.Destroy(); _furnaceBoostRecoveryTimer = null; _furnaceOvenCookCounts.Clear(); foreach (var oven in BaseNetworkable.serverEntities.OfType().ToArray()) { if (oven == null || oven.IsDestroyed) { continue; } var controller = oven.GetComponent(); if (controller == null) { continue; } if (oven.IsOn()) { controller.StopCooking(); oven.StartCooking(); } UnityEngine.Object.Destroy(controller); } } private void EnableFurnaceBoostRecoveryTimer() { _furnaceBoostRecoveryTimer?.Destroy(); _furnaceBoostRecoveryTimer = timer.Every( 60f, () => { if (!IsGameplayFeatureEnabled("furnace_boost")) { return; } var activeOvens = BaseNetworkable.serverEntities .OfType() .Where(o => o != null && !o.IsDestroyed && o.IsOn() && !(o is BaseFuelLightSource)) .ToArray(); var restarted = 0; foreach (var oven in activeOvens) { var netId = oven.net?.ID.Value ?? 0; var controller = oven.GetComponent(); var hasController = controller != null; var isCookingActive = hasController && controller.IsCookingActive(); var hadRecentTicks = _furnaceOvenCookCounts.TryGetValue((uint)netId, out var tickCount) && tickCount > 0; if (hasController && isCookingActive && hadRecentTicks) { continue; } if (!hasController) { GameplayOnEntitySpawned(oven); controller = oven.GetComponent(); } if (controller == null) { continue; } var fuel = controller.FindBurnablePublic(); if (fuel == null && !oven.CanRunWithNoFuel) { oven.SetFlag(BaseEntity.Flags.On, false); continue; } controller.StopCooking(); oven.StopCooking(); oven.CancelInvoke("Cook"); controller.StartCooking(); restarted++; } _furnaceOvenCookCounts.Clear(); if (restarted > 0) { VerbosePuts($"CerebRUST furnace_boost auto-recovery: restarted {restarted}/{activeOvens.Length}"); } } ); } private void ProcessExistingFurnaceBoostOvens() { var ovens = BaseNetworkable.serverEntities.OfType().ToArray(); var initialCount = 0; foreach (var oven in ovens) { if (oven != null && !oven.IsDestroyed && oven.GetComponent() == null) { GameplayOnEntitySpawned(oven); initialCount++; } } VerbosePuts($"CerebRUST furnace_boost stage 1: {initialCount} ovens"); timer.Once( 2f, () => { if (!IsGameplayFeatureEnabled("furnace_boost")) { return; } var lateCount = 0; foreach (var oven in BaseNetworkable.serverEntities.OfType()) { if (oven != null && !oven.IsDestroyed && oven.GetComponent() == null) { GameplayOnEntitySpawned(oven); lateCount++; } } if (lateCount > 0) { VerbosePuts($"CerebRUST furnace_boost stage 2: {lateCount} ovens"); } } ); timer.Once(5f, () => RestartActiveFurnaceBoostOvens("stage 3 (5s)")); timer.Once(15f, () => RestartActiveFurnaceBoostOvens("stage 4 (15s)")); timer.Once(30f, () => RestartActiveFurnaceBoostOvens("stage 5 (30s)")); } private int RestartActiveFurnaceBoostOvens(string stageName) { if (!IsGameplayFeatureEnabled("furnace_boost")) { return 0; } var restartedCount = 0; foreach (var oven in BaseNetworkable.serverEntities.OfType()) { if (oven == null || oven.IsDestroyed || oven is BaseFuelLightSource) { continue; } var controller = oven.GetComponent(); if (controller == null) { GameplayOnEntitySpawned(oven); controller = oven.GetComponent(); } if (!oven.IsOn()) { continue; } if (controller != null && controller.IsCookingActive()) { continue; } if (controller == null) { continue; } oven.StopCooking(); oven.CancelInvoke("Cook"); var fuel = controller.FindBurnablePublic(); if (fuel == null && !oven.CanRunWithNoFuel) { oven.SetFlag(BaseEntity.Flags.On, false); continue; } controller.StartCooking(); restartedCount++; } VerbosePuts($"CerebRUST furnace_boost {stageName}: restarted {restartedCount}"); return restartedCount; } private void GameplayOnEntitySpawned(BaseNetworkable entity) { if (!IsGameplayFeatureEnabled("furnace_boost")) { return; } var oven = entity as BaseOven; if (oven == null || oven is BaseFuelLightSource || oven.GetComponent() != null) { return; } oven.gameObject.AddComponent(); } private void GameplayOnEntityKill(BaseNetworkable entity) { if (!IsGameplayFeatureEnabled("furnace_boost")) { return; } var oven = entity as BaseOven; if (oven == null || oven.IsDestroyed || oven is BaseFuelLightSource) { return; } var controller = oven.GetComponent(); if (controller == null) { return; } controller.StopCooking(); UnityEngine.Object.Destroy(controller); } private object OnOvenToggle(BaseOven oven, BasePlayer player) { if (!IsGameplayFeatureEnabled("furnace_boost") || oven is BaseFuelLightSource) { return null; } if (oven.needsBuildingPrivilegeToUse && player != null && !player.CanBuild()) { return null; } var controller = oven.GetComponent(); if (controller == null) { GameplayOnEntitySpawned(oven); controller = oven.GetComponent(); if (controller == null) { return null; } } if (oven.IsOn()) { controller.StopCooking(); } else { controller.StartCooking(); } return false; } private object OnOvenStart(BaseOven oven) { if (!IsGameplayFeatureEnabled("furnace_boost") || oven is BaseFuelLightSource) { return null; } var controller = oven.GetComponent(); if (controller == null) { return null; } controller.StartCooking(); return false; } private class FurnaceController : FacepunchBehaviour { private BaseOven oven; private readonly List itemsToCook = new List(); private bool isCooking; private void Awake() { oven = GetComponent(); } public void StartCooking() { var fuel = FindBurnable(); if (!oven.CanRunWithNoFuel && fuel == null) { return; } oven.CancelInvoke("Cook"); oven.StopCooking(); if (isCooking) { CancelInvoke(Cook); isCooking = false; } oven.inventory.temperature = oven.cookingTemperature; oven.UpdateAttachmentTemperature(); InvokeRepeating(Cook, 0.5f, 0.5f); oven.SetFlag(BaseEntity.Flags.On, true); isCooking = true; } public void StopCooking() { CancelInvoke(Cook); oven.StopCooking(); isCooking = false; } public bool IsCookingActive() { return isCooking; } public Item FindBurnablePublic() { return FindBurnable(); } // furnace_boost Cook runs every 0.5s per lit oven and touches Facepunch oven internals // (_inputSlotIndex, ItemModBurnable, inventory layout) that Rust updates break monthly. // Oxide would otherwise log a full stack trace per throwing oven twice a second — a log // storm across hundreds of furnaces. Catch + throttle to one warning/minute (ticket 0013). private static float _lastCookErrorLogRealtime = float.NegativeInfinity; private static int _cookErrorsSinceLog; private void Cook() { try { CookInner(); } catch (Exception ex) { _cookErrorsSinceLog++; var now = UnityEngine.Time.realtimeSinceStartup; if (now - _lastCookErrorLogRealtime >= 60f) { Interface.Oxide.LogWarning( "[CerebRUST] furnace_boost Cook error x{0} in the last minute " + "(a Rust update may have changed oven internals; disable furnace_boost " + "if it persists): {1}", _cookErrorsSinceLog, ex.Message ); _cookErrorsSinceLog = 0; _lastCookErrorLogRealtime = now; } } } private void CookInner() { var netId = oven.net?.ID.Value ?? 0; if (oven.IsInvoking("Cook")) { oven.CancelInvoke("Cook"); } if (_gameplayHost != null) { _gameplayHost._furnaceOvenCookCounts[(uint)netId] = (_gameplayHost._furnaceOvenCookCounts.TryGetValue((uint)netId, out var existing) ? existing : 0) + 1; } var burnable = FindBurnable(); if (Interface.CallHook("OnOvenCook", oven, burnable) != null) { return; } if (burnable == null && !oven.CanRunWithNoFuel) { StopCooking(); return; } foreach (var item in oven.inventory.itemList) { if (item.position >= oven._inputSlotIndex && item.position < oven._inputSlotIndex + oven.inputSlots && !item.HasFlag(global::Item.Flag.Cooking)) { item.SetFlag(global::Item.Flag.Cooking, true); item.MarkDirty(); } } SmeltItems(); var fireMod = oven.GetSlot(BaseEntity.Slot.FireMod); if (fireMod != null) { fireMod.SendMessage("Cook", 0.5f, SendMessageOptions.DontRequireReceiver); } if (burnable != null) { var burnableComponent = burnable.info.GetComponent(); burnable.fuel -= 0.5f * (oven.cookingTemperature / 200f) * _furnaceBoostMultiplier; if (!burnable.HasFlag(global::Item.Flag.OnFire)) { burnable.SetFlag(global::Item.Flag.OnFire, true); burnable.MarkDirty(); } if (burnable.fuel <= 0f) { ConsumeFuel(burnable, burnableComponent); } } Interface.CallHook("OnOvenCooked", oven, burnable, fireMod); } private void ConsumeFuel(Item fuel, ItemModBurnable burnable) { if (Interface.CallHook("OnFuelConsume", oven, fuel, burnable) != null) { return; } if (oven.allowByproductCreation && burnable.byproductItem != null && UnityEngine.Random.Range(0f, 1f) > burnable.byproductChance) { var charcoal = ItemManager.Create(burnable.byproductItem, (int)burnable.byproductAmount); if (!charcoal.MoveToContainer(oven.inventory)) { StopCooking(); charcoal.Drop(oven.inventory.dropPosition, oven.inventory.dropVelocity); } } const int fuelToConsume = 1; if (fuel.amount <= fuelToConsume) { fuel.Remove(); return; } fuel.UseItem(fuelToConsume); fuel.fuel = burnable.fuelAmount; fuel.MarkDirty(); Interface.CallHook("OnFuelConsumed", oven, fuel, burnable); } private void SmeltItems() { itemsToCook.Clear(); foreach (var item in oven.inventory.itemList) { if (item.HasFlag(global::Item.Flag.Cooking)) { itemsToCook.Add(item); } } if (itemsToCook.Count == 0) { return; } foreach (var item in itemsToCook) { if (item == null || !item.IsValid()) { continue; } var cookable = item.info.GetComponent(); if (cookable == null) { continue; } var temperature = item.temperature; if (!cookable.CanBeCookedByAtTemperature(temperature) || item.cookTimeLeft < 0) { if (cookable.setCookingFlag && item.HasFlag(global::Item.Flag.Cooking)) { item.SetFlag(global::Item.Flag.Cooking, false); item.MarkDirty(); } continue; } if (cookable.setCookingFlag && !item.HasFlag(global::Item.Flag.Cooking)) { item.SetFlag(global::Item.Flag.Cooking, true); item.MarkDirty(); } item.cookTimeLeft -= 0.5f * oven.GetSmeltingSpeed() * _furnaceBoostMultiplier / itemsToCook.Count; if (item.cookTimeLeft > 0) { item.MarkDirty(); continue; } var cookTimeInverted = item.cookTimeLeft * -1; var amountConsumed = (int)(1 + Mathf.FloorToInt(cookTimeInverted / cookable.cookTime)); item.cookTimeLeft = cookable.cookTime - cookTimeInverted % cookable.cookTime; amountConsumed = Math.Min(amountConsumed, item.amount); var container = item.parent; if (item.amount > amountConsumed) { item.amount -= amountConsumed; item.MarkDirty(); } else { item.Remove(); } if (cookable.becomeOnCooked == null) { continue; } var outputAmount = (int)(cookable.amountOfBecome * amountConsumed); var output = ItemManager.Create(cookable.becomeOnCooked, outputAmount); if (output != null && !output.MoveToContainer(container)) { output.Drop(container.dropPosition, container.dropVelocity); StopCooking(); } } itemsToCook.Clear(); } private Item FindBurnable() { if (oven.inventory == null) { return null; } var burnable = Interface.Call("OnFindBurnable", oven); if (burnable != null) { return burnable; } foreach (var item in oven.inventory.itemList) { if (oven.IsBurnableItem(item)) { return item; } } return null; } } #endregion #region gather // Resource key -> the item SHORTNAMES it covers. Matching is on item.info.shortname, not on // displayName.english: display names are localisation- and rename-fragile, and one Facepunch // copy edit ("Stones" -> "Stone") would silently stop every stone multiplier working with no // error anywhere. Shortnames are the same identifiers the API and the stack list use. private static readonly Dictionary GatherResourceShortnames = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["wood"] = new[] { "wood" }, ["stone"] = new[] { "stones" }, ["metal"] = new[] { "metal.ore", "metal.fragments" }, ["hq_metal"] = new[] { "metal.refined", "hq.metal.ore" }, ["sulfur"] = new[] { "sulfur.ore", "sulfur" }, ["cloth"] = new[] { "cloth" }, }; // The (source, resource) pairs that actually exist. Rust yields no wood from quarries or // excavators, and cloth only from hemp pickup, so those cells have no setting on either // side. Reading all 18 combinations against a 15-key catalogue is what previously created // three phantom settings that always read 1.0 and could never be changed. private static readonly Dictionary GatherSourceResources = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["pickup"] = new[] { "wood", "stone", "metal", "hq_metal", "sulfur", "cloth" }, ["nodes"] = new[] { "wood", "stone", "metal", "hq_metal", "sulfur" }, ["quarry"] = new[] { "stone", "metal", "hq_metal", "sulfur" }, }; // Matches the API's bounds (settings_catalog.mods.gather). Clamped here too because a plugin // must not trust the wire: a hand-edited config or an older API could otherwise feed a 0 or // a negative multiplier straight into the gather maths. private const float GatherMultiplierMin = 0.5f; private const float GatherMultiplierMax = 5.0f; // Vanilla stack sizes, used when the API sends no stack list at all (an older API, or a // config written before the stack_sizes split). Keyed by shortname to match the new // stack_sizes.items shape rather than the old per-resource fan-out. private static readonly Dictionary StackSizeFallbacks = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["wood"] = 1000, ["stones"] = 1000, ["metal.ore"] = 1000, ["metal.fragments"] = 1000, ["hq.metal.ore"] = 100, ["metal.refined"] = 100, ["sulfur.ore"] = 1000, ["sulfur"] = 1000, ["cloth"] = 1000, ["scrap"] = 1000, ["charcoal"] = 1000, }; // Gather multipliers only. Stack sizes are their own feature now (RefreshStackSizeCache), // so an owner can raise stacks without also multiplying gather rates, and vice versa. private void RefreshGatherModifierCaches() { _gatherNodesModifiers.Clear(); _gatherPickupModifiers.Clear(); _gatherQuarryModifiers.Clear(); foreach (var pair in GatherSourceResources) { var source = pair.Key; var map = GatherModifierMapForSource(source); if (map == null) { continue; } foreach (var resource in pair.Value) { PopulateGatherModifierMap( map, resource, ReadPluginSettingDouble($"gather.{source}_{resource}", 1.0) ); } } } private Dictionary GatherModifierMapForSource(string source) { switch (source) { case "nodes": return _gatherNodesModifiers; case "pickup": return _gatherPickupModifiers; case "quarry": return _gatherQuarryModifiers; default: return null; } } // Rebuild the shortname -> max stack map from stack_sizes.items. Falls back to the legacy // gather.stack_ keys when the new list is absent, so a plugin updated ahead of the // API keeps working instead of silently reverting every stack to vanilla. private void RefreshStackSizeCache() { _gatherStackSizes.Clear(); var items = ReadPluginSettingItemStacks("stack_sizes.items"); if (items != null && items.Count > 0) { foreach (var pair in items) { _gatherStackSizes[pair.Key] = Math.Max(1, pair.Value); } return; } foreach (var pair in LegacyGatherStackShortnames) { var fallback = StackSizeFallbacks.TryGetValue(pair.Value[0], out var d) ? d : 1; var stack = Math.Max(1, ReadPluginSettingInt($"gather.stack_{pair.Key}", fallback)); foreach (var shortname in pair.Value) { _gatherStackSizes[shortname] = stack; } } } // Only read when stack_sizes.items is missing; see RefreshStackSizeCache. Remove once the // API stops echoing the legacy keys. private static readonly Dictionary LegacyGatherStackShortnames = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["wood"] = new[] { "wood" }, ["stone"] = new[] { "stones" }, ["metal"] = new[] { "metal.ore", "metal.fragments" }, ["hq_metal"] = new[] { "metal.refined", "hq.metal.ore" }, ["sulfur"] = new[] { "sulfur.ore", "sulfur" }, ["cloth"] = new[] { "cloth" }, ["scrap"] = new[] { "scrap" }, ["charcoal"] = new[] { "charcoal" }, }; private static void PopulateGatherModifierMap( Dictionary map, string resourceKey, double multiplier ) { if (!GatherResourceShortnames.TryGetValue(resourceKey, out var shortnames)) { return; } var mult = Mathf.Clamp((float)multiplier, GatherMultiplierMin, GatherMultiplierMax); foreach (var shortname in shortnames) { map[shortname] = mult; } } private void InitializeGather() { foreach (var excavator in UnityEngine.Object.FindObjectsOfType()) { ConfigureGameplayExcavator(excavator); } VerbosePuts("CerebRUST gather: rates active"); } // Overwrite the shared ItemDefinition.stackable for every configured gather resource so that // code paths which read the definition directly (industrial conveyors, some plugins, vanilla // clamps) honour the raised stack size — OnMaxStackable only covers Item.MaxStackable() // callers. The vanilla value is captured once per definition so RestoreStackSizeDefinitions // can revert it on toggle-off / unload. Re-applying is idempotent: the vanilla backup is only // taken the first time, so a later value change (e.g. 2000 -> 3000) does not corrupt it. private void ApplyStackSizeDefinitions() { foreach (var pair in _gatherStackSizes) { var definition = ItemManager.FindItemDefinition(pair.Key); if (definition == null) { continue; } if (!_gatherStackVanilla.ContainsKey(pair.Key)) { _gatherStackVanilla[pair.Key] = definition.stackable; } definition.stackable = Math.Max(1, pair.Value); } } // Revert every ItemDefinition.stackable we overwrote back to its captured vanilla value. // Called on gather toggle-off and on plugin unload so the shared definitions are never left // mutated once the feature is inactive. private void RestoreStackSizeDefinitions() { foreach (var pair in _gatherStackVanilla) { var definition = ItemManager.FindItemDefinition(pair.Key); if (definition != null) { definition.stackable = pair.Value; } } _gatherStackVanilla.Clear(); } private void ApplyGatherModifierToItem(Item item, string source) { if (!IsGameplayFeatureEnabled("gather") || item?.info == null) { return; } var map = GatherModifierMapForSource(source); if (map == null) { return; } if (!map.TryGetValue(item.info.shortname, out var modifier)) { return; } if (Math.Abs(modifier - 1f) < float.Epsilon) { return; } item.amount = Math.Max(1, (int)(item.amount * modifier)); } // Oxide hook: override an item's max stack size. Only overrides configured gather resources // while the "gather" feature is enabled; returns null otherwise so vanilla/other plugins win. private object OnMaxStackable(Item item) { if (!IsGameplayFeatureEnabled("stack_sizes") || item?.info == null) { return null; } if (_gatherStackSizes.TryGetValue(item.info.shortname, out var stack)) { return stack; } return null; } private void GameplayApplyDispenserGatherModifier(Item item) { if (!IsGameplayFeatureEnabled("gather") || item == null) { return; } ApplyGatherModifierToItem(item, "nodes"); } private void OnQuarryGather(MiningQuarry quarry, Item item) { if (!IsGameplayFeatureEnabled("gather")) { return; } ApplyGatherModifierToItem(item, "quarry"); } private void OnExcavatorGather(ExcavatorArm excavator, Item item) { if (!IsGameplayFeatureEnabled("gather")) { return; } ApplyGatherModifierToItem(item, "quarry"); } private void GameplayOnCollectiblePickup(CollectibleEntity collectible, BasePlayer player) { if (!IsGameplayFeatureEnabled("gather") || collectible?.itemList == null || player == null) { return; } foreach (var itemAmount in collectible.itemList) { if (itemAmount?.itemDef == null) { continue; } if (_gatherPickupModifiers.TryGetValue(itemAmount.itemDef.shortname, out var modifier)) { itemAmount.amount *= modifier; } } } private void GameplayOnMiningQuarryEnabled(MiningQuarry quarry) { if (!IsGameplayFeatureEnabled("gather") || quarry == null) { return; } quarry.CancelInvoke("ProcessResources"); quarry.InvokeRepeating( "ProcessResources", MiningQuarryResourceTickRate, MiningQuarryResourceTickRate ); } private void ConfigureGameplayExcavator(ExcavatorArm excavator) { if (!IsGameplayFeatureEnabled("gather") || excavator == null) { return; } excavator.CancelInvoke("ProcessResources"); excavator.InvokeRepeating( "ProcessResources", ExcavatorResourceTickRate, ExcavatorResourceTickRate ); excavator.beltSpeedMax = ExcavatorBeltSpeedMax; excavator.timeForFullResources = ExcavatorTimeForFullResources; } private static bool IsGameplayClothGrowable(GrowableEntity growable) { var prefab = growable.ShortPrefabName; return !string.IsNullOrEmpty(prefab) && prefab.IndexOf("cloth", StringComparison.OrdinalIgnoreCase) >= 0; } #endregion #region stack_recycling private object OnItemRecycle(Item item, Recycler recycler) { if (!IsGameplayFeatureEnabled("stack_recycling")) { return null; } if (item?.info?.Blueprint == null) { return null; } var recycleAmount = item.amount; if (recycleAmount <= 0) { return null; } if (Interface.CallHook("OnItemRecycleAmount", item, recycleAmount, recycler) is int overrideAmount) { recycleAmount = overrideAmount; if (recycleAmount <= 0) { return null; } } item.UseItem(recycleAmount); var recycleEfficiency = recycler.IsSafezoneRecycler() ? 0.4f : 0.6f; if (item.info.Blueprint.scrapFromRecycle > 0) { float scrapAmount = item.info.Blueprint.scrapFromRecycle * recycleAmount; if (item.MaxStackable() == 1 && item.hasCondition) { scrapAmount *= item.conditionNormalized; } scrapAmount *= recycleEfficiency / StackRecyclingClassicEfficiency; var scrapAmountInt = Mathf.FloorToInt(scrapAmount); if (scrapAmountInt >= 1) { var scrapItem = ItemManager.CreateByItemID(StackRecyclingScrapItemId, scrapAmountInt); if (scrapItem != null) { recycler.MoveItemToOutput(scrapItem); } } } foreach (var ingredient in item.info.Blueprint.ingredients) { if (ingredient.itemDef.itemid == StackRecyclingScrapItemId) { continue; } var ingredientAmount = ingredient.amount / item.info.Blueprint.amountToCreate; if (ingredientAmount <= 0) { continue; } var itemRecycleEfficiency = item.hasCondition ? Mathf.Clamp01( recycleEfficiency * Mathf.Clamp(item.conditionNormalized * item.maxConditionNormalized, 0.1f, 1f) ) : recycleEfficiency; var outputAmount = CalculateStackRecyclingOutputAmount( recycleAmount, ingredientAmount, itemRecycleEfficiency ); if (outputAmount <= 0) { continue; } var outputItem = ItemManager.Create(ingredient.itemDef, outputAmount); if (outputItem != null && !recycler.MoveItemToOutput(outputItem)) { recycler.StopRecycling(); break; } } if (!recycler.HasRecyclable()) { recycler.StopRecycling(); } return false; } private static int CalculateStackRecyclingOutputAmount( int recycleAmount, float ingredientAmount, float recycleEfficiency ) { if (ingredientAmount <= 1) { return CalculateStackRecyclingOutputAmountRandom(recycleAmount, ingredientAmount, recycleEfficiency); } var outputAmountDecimal = ingredientAmount * recycleAmount * recycleEfficiency; var outputAmountInt = (int)outputAmountDecimal; var remainder = outputAmountDecimal - outputAmountInt; if (remainder > 0 && UnityEngine.Random.Range(0f, 1f) <= remainder) { outputAmountInt++; } return outputAmountInt; } private static int CalculateStackRecyclingOutputAmountRandom( int recycleAmount, float ingredientAmount, float recycleEfficiency ) { var adjustedChance = ingredientAmount * recycleEfficiency; var outputAmount = 0; for (var i = 0; i < recycleAmount; i++) { if (UnityEngine.Random.Range(0f, 1f) <= adjustedChance) { outputAmount++; } } return outputAmount; } #endregion #region sort_button private void InitializeSortButton() { SetupSortButtonCategorySorting(); DiscoverSortButtonSupportedContainers(); _sortButtonInitialized = true; VerbosePuts($"CerebRUST sort_button: {_sortButtonSupportedPrefabs.Count} container types"); } private void UnloadSortButton() { foreach (var player in BasePlayer.activePlayerList) { DestroySortButtonUi(player); } _sortButtonSupportedPrefabs.Clear(); _sortButtonUiViewers.Clear(); _sortButtonInitialized = false; } private void OnLootEntity(BasePlayer player, BaseEntity entity) { if (!IsGameplayFeatureEnabled("sort_button") || !_sortButtonInitialized) { return; } if (player == null || entity == null || !_sortButtonSupportedPrefabs.Contains(entity.prefabID)) { return; } if (entity.OwnerID != 0 && entity.OwnerID != player.userID && !SortButtonIsOnSameTeam(player.userID, entity.OwnerID)) { return; } if (!SortButtonCanPlayerSort(player, entity)) { return; } NextTick( () => { if (player == null || entity == null || entity.IsDestroyed) { return; } if (player.inventory.loot.containers.Count != 1) { return; } var container = player.inventory.loot.containers[0]; if (entity is RidableHorse horse && container != horse.storageInventory) { return; } if (!SortButtonIsSortable(container)) { return; } var panelName = SortButtonGetPanelName(entity); if (!SortButtonTryGetYOffset(container, panelName, out var yOffset)) { return; } var height = SortButtonPanelHeights.TryGetValue(panelName, out var h) ? h : 23f; ShowSortButtonUi(player, SortButtonDefaultOffsetX, yOffset, height); } ); } private void OnPlayerLootEnd(PlayerLoot inventory) { if (!IsGameplayFeatureEnabled("sort_button")) { return; } if (inventory?.baseEntity != null) { DestroySortButtonUi(inventory.baseEntity); } } private void OnLootEntityEnd(BasePlayer player, BaseCombatEntity entity) { if (!IsGameplayFeatureEnabled("sort_button")) { return; } if (player != null) { DestroySortButtonUi(player); } } [ConsoleCommand("cerebrust.sort")] private void ConsoleCmdCerebrustSort(ConsoleSystem.Arg arg) { if (!IsGameplayFeatureEnabled("sort_button") || arg?.Connection == null) { return; } var player = arg.Connection.player as BasePlayer; if (player == null) { return; } var containers = player.inventory.loot.containers; if (containers.Count != 1) { return; } var entity = player.inventory.loot.entitySource; if (entity == null || !_sortButtonSupportedPrefabs.Contains(entity.prefabID)) { return; } if (!SortButtonCanPlayerSort(player, entity)) { return; } if (entity.OwnerID != 0 && entity.OwnerID != player.userID && !SortButtonIsOnSameTeam(player.userID, entity.OwnerID)) { return; } foreach (var container in containers) { if (SortButtonIsSortable(container)) { SortButtonSortContainer(container, player); } } } private void SetupSortButtonCategorySorting() { var categories = Enum.GetValues(typeof(ItemCategory)).Cast().ToList(); categories.Sort((a, b) => a.ToString().CompareTo(b.ToString())); _sortButtonCategoryToSortIndex = new int[categories.Count]; for (var i = 0; i < categories.Count; i++) { _sortButtonCategoryToSortIndex[(int)categories[i]] = i; } } private void SortButtonSortContainer(ItemContainer container, BasePlayer player) { var items = Pool.Get>(); var isTc = container.entityOwner is BuildingPrivlidge; try { for (var i = container.itemList.Count - 1; i >= 0; i--) { var item = container.itemList[i]; if (isTc && item.position >= 24) { continue; } item.RemoveFromContainer(); items.Add(item); } items.Sort( (a, b) => { var catA = _sortButtonCategoryToSortIndex[(int)a.info.category]; var catB = _sortButtonCategoryToSortIndex[(int)b.info.category]; var catCompare = catA.CompareTo(catB); if (catCompare != 0) { return catCompare; } var nameCompare = a.info.displayName.translated.CompareTo(b.info.displayName.translated); if (nameCompare != 0) { return nameCompare; } return a.amount.CompareTo(b.amount); } ); foreach (var item in items) { if (!item.MoveToContainer(container)) { player.GiveItem(item); } } } finally { Pool.FreeUnmanaged(ref items); } } private void DiscoverSortButtonSupportedContainers() { _sortButtonSupportedPrefabs.Clear(); foreach (var itemDef in ItemManager.itemList) { var deployable = itemDef.GetComponent(); if (deployable == null) { continue; } var entity = deployable.entityPrefab.GetEntity(); if (entity is not (BoxStorage or BuildingPrivlidge or Fridge)) { continue; } if (entity.PrefabName.Contains("unused")) { continue; } _sortButtonSupportedPrefabs.Add(entity.prefabID); } foreach (var path in SortButtonAdditionalPrefabs) { var prefab = GameManager.server.FindPrefab(path)?.GetComponent(); if (prefab != null) { _sortButtonSupportedPrefabs.Add(prefab.prefabID); } } } private static bool SortButtonIsSortable(ItemContainer container) { return !container.IsLocked() && !container.PlayerItemInputBlocked() && !container.HasFlag(ItemContainer.Flag.IsPlayer) && container.capacity > 1; } private static bool SortButtonCanPlayerSort(BasePlayer player, BaseEntity entity) { if (entity is DropBox dropBox) { return dropBox.PlayerBehind(player); } if (entity is VendingMachine vendingMachine) { return vendingMachine.PlayerBehind(player); } return true; } private static bool SortButtonIsOnSameTeam(ulong playerId, ulong targetId) { var team = RelationshipManager.ServerInstance.FindPlayersTeam(playerId); return team?.members.Contains(targetId) ?? false; } private static string SortButtonGetPanelName(BaseEntity entity) { return entity switch { Mailbox mailbox => mailbox.ownerPanel, StorageContainer sc => sc.panelName, RidableHorse horse => horse.storagePanelName, _ => "generic_resizable", }; } private static bool SortButtonTryGetYOffset(ItemContainer container, string panelName, out float yOffset) { if (panelName is "generic_resizable" or "animal-storage") { var numRows = Math.Min(1 + (container.capacity - 1) / 6, SortButtonMaxRows); yOffset = SortButtonBaseYOffset + SortButtonYOffsetPerRow * numRows; return true; } return SortButtonPanelYOffsets.TryGetValue(panelName, out yOffset); } private void ShowSortButtonUi(BasePlayer player, float offsetX, float offsetY, float height) { if (!_sortButtonUiViewers.Add(player.userID)) { return; } var elements = new CuiElementContainer(); elements.Add( new CuiPanel { Image = { Color = "0 0 0 0" }, RectTransform = { AnchorMin = "0.5 0", AnchorMax = "0.5 0", OffsetMin = $"{offsetX} {offsetY}", OffsetMax = $"{offsetX} {offsetY}", }, CursorEnabled = false, }, "Overlay", SortButtonUiPanelName ); elements.Add( new CuiButton { Button = { Color = "0.4156863 0.5921569 0.2352941 1", Command = "cerebrust.sort", }, Text = { Text = "Sort", FontSize = 12, Align = TextAnchor.MiddleCenter, Color = "1 1 1 1", }, RectTransform = { AnchorMin = "0 0", AnchorMax = "0 0", OffsetMin = "0 0", OffsetMax = $"{SortButtonWidth} {height}", }, }, SortButtonUiPanelName ); CuiHelper.AddUi(player, elements); } private void DestroySortButtonUi(BasePlayer player) { if (player == null || !_sortButtonUiViewers.Remove(player.userID)) { return; } CuiHelper.DestroyUi(player, SortButtonUiPanelName); } #endregion #region turret_auth private void InitializeTurretAuth() { _turretAuthInitialized = true; var synced = SyncAllGameplayTurrets(); VerbosePuts($"CerebRUST turret_auth: synced {synced} turrets"); } private void UnloadTurretAuth() { foreach (var pendingTimer in _turretAuthPendingClears.Values) { pendingTimer?.Destroy(); } _turretAuthPendingClears.Clear(); _turretAuthInitialized = false; } private void OnEntityBuilt(Planner plan, GameObject go) { if (!IsGameplayFeatureEnabled("turret_auth") || !_turretAuthInitialized) { return; } var turret = go.ToBaseEntity() as AutoTurret; if (turret == null) { return; } var privilege = turret.GetBuildingPrivilege(); if (privilege == null) { return; } if (ShouldSyncGameplayTurret(turret, privilege)) { SyncGameplayTurretAuth(turret, privilege); } } private void GameplayOnCupboardClearList(BuildingPrivlidge privilege, BasePlayer player) { if (!IsGameplayFeatureEnabled("turret_auth") || !_turretAuthInitialized || privilege == null || player == null) { return; } var playerId = player.userID; var tcOwnerId = privilege.OwnerID; if (playerId != tcOwnerId && !GameplayTurretAuthAreTeammates(playerId, tcOwnerId)) { return; } var buildingId = privilege.buildingID; if (_turretAuthPendingClears.TryGetValue(buildingId, out var existingTimer)) { existingTimer?.Destroy(); _turretAuthPendingClears.Remove(buildingId); } SendToPlayer( player, $"Turret authorization will be wiped in {_turretAuthClearGraceSeconds} seconds. Re-authorize on TC to rebuild access." ); _turretAuthPendingClears[buildingId] = timer.Once( _turretAuthClearGraceSeconds, () => { _turretAuthPendingClears.Remove(buildingId); if (privilege == null || privilege.IsDestroyed) { return; } ClearValidGameplayTurretsInBuilding(privilege); } ); } private object OnTurretTarget(AutoTurret turret, BaseCombatEntity entity) { // Discord targeting alert is independent of the turret_auth gameplay feature — it must // fire whether or not auth-syncing is on. It never changes the return value below. MaybeSendTargetingAlert("turret", turret, entity); if (!IsGameplayFeatureEnabled("turret_auth") || !_turretAuthInitialized || turret == null) { return null; } var player = entity as BasePlayer; if (player == null || player.IsNpc) { return player != null && player.IsNpc ? (object)false : null; } var privilege = turret.GetBuildingPrivilege(); if (privilege == null) { return null; } var playerId = player.userID; if (!ShouldSyncGameplayTurret(turret, privilege)) { return null; } if (privilege.IsAuthed(playerId) || privilege.OwnerID == playerId || turret.OwnerID == playerId) { AddPlayerToGameplayTurret(turret, player); return false; } return null; } private bool ShouldSyncGameplayTurret(AutoTurret turret, BuildingPrivlidge privilege) { var turretOwnerId = turret.OwnerID; var tcOwnerId = privilege.OwnerID; if (turretOwnerId == tcOwnerId) { return true; } if (privilege.IsAuthed(turretOwnerId)) { return true; } return GameplayTurretAuthAreTeammates(turretOwnerId, tcOwnerId); } private bool GameplayTurretAuthAreTeammates(ulong player1Id, ulong player2Id) { if (player1Id == 0 || player2Id == 0) { return false; } var player1 = BasePlayer.FindByID(player1Id) ?? BasePlayer.FindSleeping(player1Id); var player2 = BasePlayer.FindByID(player2Id) ?? BasePlayer.FindSleeping(player2Id); return player1 != null && player2 != null && player1.Team != null && player1.Team == player2.Team; } private int SyncAllGameplayTurrets() { var count = 0; foreach (var entity in BaseNetworkable.serverEntities) { var turret = entity as AutoTurret; if (turret == null || turret.IsDestroyed) { continue; } var privilege = turret.GetBuildingPrivilege(); if (privilege == null) { continue; } if (ShouldSyncGameplayTurret(turret, privilege)) { SyncGameplayTurretAuth(turret, privilege); count++; } } return count; } private int ClearValidGameplayTurretsInBuilding(BuildingPrivlidge privilege) { var count = 0; foreach (var turret in GetGameplayTurretsInBuilding(privilege)) { if (!ShouldSyncGameplayTurret(turret, privilege)) { continue; } turret.authorizedPlayers.Clear(); turret.target = null; turret.SendNetworkUpdate(); count++; } return count; } private void SyncGameplayTurretAuth(AutoTurret turret, BuildingPrivlidge privilege) { turret.authorizedPlayers.Clear(); foreach (var authedId in privilege.authorizedPlayers) { turret.authorizedPlayers.Add(authedId); } EnsureGameplayTurretPlayerAuthed(turret, privilege.OwnerID); EnsureGameplayTurretPlayerAuthed(turret, turret.OwnerID); turret.target = null; turret.SendNetworkUpdate(); } private static void EnsureGameplayTurretPlayerAuthed(AutoTurret turret, ulong playerId) { if (playerId != 0 && !turret.authorizedPlayers.Contains(playerId)) { turret.authorizedPlayers.Add(playerId); } } private static void AddPlayerToGameplayTurret(AutoTurret turret, BasePlayer player) { var playerId = player.userID; if (!turret.authorizedPlayers.Contains(playerId)) { turret.authorizedPlayers.Add(playerId); turret.target = null; turret.SendNetworkUpdate(); } } private static IEnumerable GetGameplayTurretsInBuilding(BuildingPrivlidge privilege) { var buildingId = privilege.buildingID; foreach (var entity in BaseNetworkable.serverEntities) { var turret = entity as AutoTurret; if (turret == null || turret.IsDestroyed) { continue; } var turretPrivilege = turret.GetBuildingPrivilege(); if (turretPrivilege != null && turretPrivilege.buildingID == buildingId) { yield return turret; } } } #endregion #region sam_auth private object OnSamSiteTarget(SamSite samSite, BaseCombatEntity target) { // Discord targeting alert is independent of the sam_auth gameplay feature — it must // fire whether or not SAM auth-ignoring is on. It never changes the return value below. MaybeSendTargetingAlert("sam", samSite, target); if (!IsGameplayFeatureEnabled("sam_auth") || !_samAuthInitialized || samSite == null || target == null) { return null; } if (samSite.staticRespawn) { return null; } if (target is not BaseVehicle) { return null; } var privilege = samSite.GetBuildingPrivilege(); if (privilege == null) { return null; } if (!GameplaySamIsOccupied(target)) { return true; } if (GameplaySamHasAuthedOccupant(target, privilege)) { return true; } return null; } /// /// The single shared vehicle-occupant walk: every player occupying — /// mounted players (across all mount points) plus child s. Callers apply /// their own per-occupant predicate (exists / authed-on-TC / in-allowlist). /// private static IEnumerable GameplayVehicleOccupants(BaseCombatEntity entity) { if (entity is BaseVehicle vehicle && vehicle.mountPoints != null) { foreach (var mountPoint in vehicle.mountPoints) { var player = mountPoint.mountable?.GetMounted(); if (player != null) { yield return player; } } } if (entity.children != null) { foreach (var child in entity.children) { if (child is BasePlayer player) { yield return player; } } } } private static bool GameplaySamIsOccupied(BaseCombatEntity entity) { foreach (var _ in GameplayVehicleOccupants(entity)) { return true; } return false; } private static bool GameplaySamHasAuthedOccupant(BaseCombatEntity entity, BuildingPrivlidge privilege) { foreach (var player in GameplayVehicleOccupants(entity)) { var playerId = player.userID; if (privilege.IsAuthed(playerId) || privilege.OwnerID == playerId) { return true; } } return false; } #endregion #region targeting_alerts /// /// Every Steam ID authorized on the tool cupboard connected to — /// the building-privilege owner plus its auth list, de-duplicated. Empty when the entity has /// no connected TC (static / monument turrets and SAM sites). Shared by the turret and SAM /// targeting-alert paths so "who owns this base" lives in exactly one place. /// private List GetAuthedPlayersOnConnectedTc(BaseEntity entity) { var result = new List(); var privilege = entity?.GetBuildingPrivilege(); if (privilege == null) { return result; } var ownerId = TryGetOwnerSteamId(privilege); if (ownerId.HasValue) { result.Add(ownerId.Value); } foreach (var authedId in CollectAuthorizedSteamIds(privilege)) { if (!result.Contains(authedId)) { result.Add(authedId); } } return result; } /// /// A turret / SAM has locked onto . If it is a hostile lock on a /// player-owned base, DM every TC-authed owner "your defence targeted someone" (never who). /// Always-on (independent of the turret_auth / sam_auth gameplay features); any enable/disable /// gating is applied server-side. A side-effect only — never alters targeting. /// private void MaybeSendTargetingAlert(string source, BaseEntity weapon, BaseCombatEntity target) { if (!ingestReady || weapon == null || target == null) { return; } var privilege = weapon.GetBuildingPrivilege(); if (privilege == null) { return; // static / monument defence: no owner to notify } // Cooldown FIRST (ticket 0013 P4): OnTurretTarget re-fires continuously during a raid, so // bail before the second GetBuildingPrivilege + list allocation (GetAuthedPlayersOnConnectedTc) // and the hostility scan whenever we're still inside the 300s window. This is a read-only // peek — the cooldown is *recorded* only when an alert is actually sent below, so a // suppressed (own-member / NPC / no-account) target never consumes the window. var tcEntityId = (long)privilege.net.ID.Value; if (TargetingAlertCooldownActive(source, tcEntityId)) { return; } var authedPlayers = GetAuthedPlayersOnConnectedTc(weapon); if (authedPlayers.Count == 0) { return; // nobody with an account to map to Discord } // Only a *hostile* lock is "sneaky sneaky": a base member tripping their own defence, // an NPC, an empty vehicle or a patrol heli must not spam the owners. if (!IsHostileTargetingAlertTarget(source, target, authedPlayers)) { return; } RecordTargetingAlertCooldown(source, tcEntityId); // Where + whose: a player is typically authed on several bases they did not build, so // "something targeted someone" is not actionable on its own. Grid comes from the // weapon, not the TC — a compound's turrets can sit a grid away from its cupboard. ulong? weaponOwnerId = IsPlausibleSteamId(weapon.OwnerID) ? weapon.OwnerID : (ulong?)null; var payload = new IngestTargetingAlertRequest { Token = config.IngestToken.Trim(), Source = source, TcEntityId = tcEntityId, AuthedSteamIds = authedPlayers, Grid = TryGetTargetingAlertGrid(weapon), OwnerSteamId = weaponOwnerId.HasValue ? SteamToLong(weaponOwnerId.Value) : (long?)null, OwnerDisplayName = weaponOwnerId.HasValue ? TryResolveDisplayName(weaponOwnerId.Value) : null, }; var url = $"{config.ApiBaseUrl.TrimEnd('/')}/api/v2/ingest/targeting-alert"; // queueIfPaused:false — a targeting alert is only useful live; a replayed one hours // later would DM a stale "you're being raided". Drop it if the endpoint is paused. PostIngestJson("targeting-alert", url, JsonConvert.SerializeObject(payload), queueIfPaused: false); } /// Grid label the defence sits in ("K12"), or null if the position can't be read. /// Cosmetic — never let it cost the alert. private string TryGetTargetingAlertGrid(BaseEntity weapon) { try { var weaponTransform = weapon != null ? weapon.transform : null; return weaponTransform != null ? GetGridPosition(weaponTransform.position) : null; } catch { return null; } } /// Best-effort display name from Oxide's player database (covers offline owners). /// Null when the server has never seen that account. private string TryResolveDisplayName(ulong steamId) { var name = covalence?.Players?.FindPlayerById(steamId.ToString())?.Name; return string.IsNullOrWhiteSpace(name) ? null : name.Trim(); } private bool IsHostileTargetingAlertTarget( string source, BaseCombatEntity target, List authedPlayers ) { if (source == "turret") { var player = target as BasePlayer; if (player == null || player.IsNpc) { return false; } return !authedPlayers.Contains(player.userID); } // sam: only a player-crewed vehicle whose crew are all outsiders. if (target is not BaseVehicle || !GameplaySamIsOccupied(target)) { return false; } return !TargetingAlertVehicleHasAuthedOccupant(target, authedPlayers); } private static bool TargetingAlertVehicleHasAuthedOccupant( BaseCombatEntity entity, List authedPlayers ) { foreach (var player in GameplayVehicleOccupants(entity)) { if (authedPlayers.Contains(player.userID)) { return true; } } return false; } /// Read-only: true if this source+TC is still inside its 300s alert cooldown. private bool TargetingAlertCooldownActive(string source, long tcEntityId) { var key = source + ":" + tcEntityId; return _targetingAlertCooldowns.TryGetValue(key, out var last) && UnityEngine.Time.realtimeSinceStartup - last < TargetingAlertCooldownSeconds; } /// Start the 300s cooldown — call only when an alert is actually sent. private void RecordTargetingAlertCooldown(string source, long tcEntityId) { _targetingAlertCooldowns[source + ":" + tcEntityId] = UnityEngine.Time.realtimeSinceStartup; } #endregion #endregion #region vip // --- VIP membership (payment tickets 0011 / 0017) ------------------------------------- // // CerebRUST is the source of *membership*; the owner chooses the *reward*. The seam is an // oxide permission group (default `cerebrust.vip`) whose members we keep exactly matching // the roster the API sends. The owner wires their own kits/homes/queue-skip plugin // permissions to that group — deliberately no reward code lives here. // // The API sends a **full roster**, never deltas, so this converges no matter what was // missed: a command that expired while the server was down, a plugin reload mid-push, or an // admin editing the group by hand in RCON. Applying it is add-missing + remove-absent. // // Oxide persists group membership itself, so the group survives a reload with no data file // of our own. Only the *cosmetic* settings (group name, tag) are persisted here, because the // chat hook needs them before the next roster push arrives. private const string VipDataFileName = "CerebrustVip"; private const string VipDefaultOxideGroup = "cerebrust.vip"; private const string VipDefaultTagText = "VIP"; private VipPersistedData _vipData; private sealed class VipPersistedData { public string OxideGroup { get; set; } = VipDefaultOxideGroup; // Off until an owner turns it on; the API sends the real value on every roster push. public bool TagEnabled { get; set; } = false; public string TagText { get; set; } = VipDefaultTagText; /// /// Colours a VIP's whole chat name, tag included — not the tag alone (v0.12.2). One /// colour over one string is the only shape that renders identically through /// chat.add rich text and through BroadcastTeamChat's separate colour /// parameter, which is what keeps VIPs looking the same in global chat, team chat and /// the Rust+ app. Defaults to the standard name colour, so "unset" means "looks like /// everyone else" rather than a colour an owner never chose. /// public string NameColor { get; set; } = CerebrustChatNameColorTag; } private void LoadVipData() { try { _vipData = Interface.Oxide.DataFileSystem.ReadObject(VipDataFileName); } catch (Exception ex) { PrintWarning($"CerebRUST vip data load failed: {ex.Message}"); } if (_vipData == null) { _vipData = new VipPersistedData(); } if (string.IsNullOrWhiteSpace(_vipData.OxideGroup)) { _vipData.OxideGroup = VipDefaultOxideGroup; } } private void SaveVipData() { if (_vipData != null) { Interface.Oxide.DataFileSystem.WriteObject(VipDataFileName, _vipData); } } private string VipOxideGroup => _vipData != null && !string.IsNullOrWhiteSpace(_vipData.OxideGroup) ? _vipData.OxideGroup : VipDefaultOxideGroup; /// True when the player is in the VIP oxide group. Oxide owns this state. private bool IsVip(ulong steamId) { if (!IsPlausibleSteamId(steamId)) { return false; } try { return permission.UserHasGroup(steamId.ToString(), VipOxideGroup); } catch (Exception) { // A permission lookup must never break the chat path. return false; } } /// /// The name as chat should show it: [VIP] Barry when the tag is on for this player, /// otherwise just Barry. **Plain text, never markup** — the same string is handed to /// chat.add (inside one colour span) and to BroadcastTeamChat (which passes it /// to the Rust+ app, where any rich text would arrive as literal /// <color=…> characters). Colour is 's job and /// covers the whole string, tag included. /// private string ChatDisplayNameFor(ulong steamId, string displayName) { if (_vipData == null || !_vipData.TagEnabled || !IsVip(steamId)) { return displayName; } var text = string.IsNullOrWhiteSpace(_vipData.TagText) ? VipDefaultTagText : _vipData.TagText; return $"[{text}] {displayName}"; } /// /// The colour a player's chat name is painted in — the VIP name colour for a VIP, otherwise /// the uniform that keeps admins off Rust's green. /// Always carries the leading # so it drops straight into either renderer. /// private string ChatNameColorTagFor(ulong steamId) { if (_vipData == null || !IsVip(steamId) || string.IsNullOrWhiteSpace(_vipData.NameColor)) { return CerebrustChatNameColorTag; } var color = _vipData.NameColor.Trim(); // A stored value without the '#' would silently produce ``, which Rust // renders as nothing at all rather than as an error. return color.StartsWith("#") ? color : "#" + color; } /// /// Apply a full VIP roster: create the group if needed, then add/remove so its membership /// matches exactly. Idempotent — re-applying changes nothing. /// private int ApplyVipRoster(HashSet steamIds, string oxideGroup) { var group = string.IsNullOrWhiteSpace(oxideGroup) ? VipDefaultOxideGroup : oxideGroup.Trim(); // A renamed group leaves the old one in place on purpose: it may carry the owner's own // permission grants, and quietly deleting a permission group is not ours to do. if (!permission.GroupExists(group)) { permission.CreateGroup(group, "CerebRUST VIP", 0); } var wanted = new HashSet(); foreach (var steamId in steamIds) { wanted.Add(steamId.ToString()); } var changed = 0; foreach (var userId in wanted) { if (!permission.UserHasGroup(userId, group)) { permission.AddUserGroup(userId, group); changed++; } } var existing = permission.GetUsersInGroup(group) ?? new string[0]; foreach (var entry in existing) { var userId = ExtractUserIdFromPermissionEntry(entry); if (string.IsNullOrEmpty(userId) || wanted.Contains(userId)) { continue; } permission.RemoveUserGroup(userId, group); changed++; } if (_vipData != null && !string.Equals(_vipData.OxideGroup, group, StringComparison.Ordinal)) { _vipData.OxideGroup = group; SaveVipData(); } return changed; } /// /// Oxide's GetUsersInGroup yields "76561198...(Nickname)" — the id with the /// last-seen nickname appended in brackets, and no separating space. Taking the leading run /// of digits is what makes the remove pass match the add pass: comparing the raw entries /// against bare ids never matches, so every roster push would strip the entire group and /// re-add it. Reading the leading digits (rather than splitting on a delimiter) also /// survives Oxide changing the decoration, and a nickname containing brackets or spaces. /// private static string ExtractUserIdFromPermissionEntry(string entry) { if (string.IsNullOrWhiteSpace(entry)) { return null; } var trimmed = entry.Trim(); var end = 0; while (end < trimmed.Length && char.IsDigit(trimmed[end])) { end++; } return end > 0 ? trimmed.Substring(0, end) : null; } /// /// Handle an AddUpkeep command payload: top a cupboard up by whatever fits, capped at /// the API's max_minutes (and this plugin's own ceiling regardless — a plugin does not /// trust the wire). Returns null on success, or the reason to ack failed. /// /// /// The API sent its own estimate alongside this command, computed from a snapshot up to five /// minutes old. That estimate is advice; this is the truth. The two are allowed to disagree, /// which is why reports what actually landed. /// private string HandleAddUpkeep(JObject payload, out Dictionary result) { result = null; if (payload == null) { return "payload is required"; } var entityToken = payload["tc_entity_id"]; ulong entityId = 0; if ( entityToken == null || entityToken.Type == JTokenType.Null || !ulong.TryParse(entityToken.ToString(), out entityId) || entityId == 0 ) { return "payload.tc_entity_id is required"; } var maxMinutes = AddUpkeepMaxMinutesCeiling; var maxToken = payload["max_minutes"]; if (maxToken != null && maxToken.Type != JTokenType.Null) { int requested; if (int.TryParse(maxToken.ToString(), out requested)) { maxMinutes = Math.Max(0, Math.Min(requested, AddUpkeepMaxMinutesCeiling)); } } var tc = FindCupboardByEntityId(entityId); if (tc == null) { return $"tool cupboard {entityId} no longer exists"; } var sample = SampleCupboardInventory(tc); if (sample == null) { return $"tool cupboard {entityId} has no readable inventory"; } if (sample.UpkeepCost.Count == 0) { return "this cupboard has no upkeep cost — nothing to add"; } var minutes = AddableUpkeepMinutes(sample, maxMinutes); if (minutes <= 0) { result = new Dictionary { ["added_minutes"] = 0, ["free_slots"] = sample.FreeSlots, }; return "the cupboard is too full to add any upkeep"; } var needed = UpkeepItemsForMinutes(sample, minutes); var added = new Dictionary(); var beforeMinutes = GetCupboardUpkeepMinutes(tc); foreach (var pair in needed) { var placed = InsertIntoCupboard(tc, pair.Key, pair.Value); if (placed > 0) { added[pair.Key] = placed; } } // Re-read rather than trust the projection: the game is the authority on what the // cupboard now holds, and the dashboard is about to be told this number. var afterMinutes = GetCupboardUpkeepMinutes(tc); var items = new List>(); foreach (var pair in added) { items.Add( new Dictionary { ["shortname"] = pair.Key, ["amount"] = pair.Value, } ); } result = new Dictionary { ["added_minutes"] = minutes, ["remaining_upkeep_minutes_before"] = beforeMinutes, ["remaining_upkeep_minutes_after"] = afterMinutes, ["items"] = items, }; VerbosePuts( $"CerebRUST AddUpkeep tc={entityId} minutes={minutes} " + $"upkeep {beforeMinutes} -> {afterMinutes} min" ); PostCupboardContents(tc, "cupboard-contents"); return null; } /// /// Locate a cupboard by its network id. Walks serverEntities rather than using the /// keyed lookup so this stays on the same API surface the world snapshot already compiles /// against; it runs once per operator click, not on any hot path. /// private static BuildingPrivlidge FindCupboardByEntityId(ulong entityId) { foreach (var entity in BaseNetworkable.serverEntities) { if (entity == null || entity.IsDestroyed || entity.net == null) { continue; } if (entity.net.ID.Value != entityId) { continue; } return entity as BuildingPrivlidge; } return null; } /// /// Put of a resource into a cupboard, filling stacks that already /// exist before taking a fresh slot. Returns how much actually landed — a partial insert is /// reported honestly rather than rolled back, because the items are already in the world. /// private int InsertIntoCupboard(BuildingPrivlidge tc, string shortname, int amount) { if (amount <= 0) { return 0; } var definition = ItemManager.FindItemDefinition(shortname); if (definition == null) { PrintWarning($"[CerebRUST] AddUpkeep: unknown item shortname '{shortname}'"); return 0; } var stack = Math.Max(1, definition.stackable); var remaining = amount; var itemList = tc.inventory?.itemList; if (itemList != null) { foreach (var existing in itemList) { if (remaining <= 0) { break; } if (existing?.info == null || existing.info.shortname != shortname) { continue; } var room = stack - existing.amount; if (room <= 0) { continue; } var take = Math.Min(room, remaining); existing.amount += take; existing.MarkDirty(); remaining -= take; } } while (remaining > 0) { var chunk = Math.Min(remaining, stack); var created = ItemManager.Create(definition, chunk, 0UL); if (created == null) { break; } if (!created.MoveToContainer(tc.inventory)) { // Out of slots despite the packing maths — drop the item rather than leak it into // the world at the cupboard's feet. created.Remove(); break; } remaining -= chunk; } tc.inventory?.MarkDirty(); return amount - remaining; } /// /// Handle a SyncVipMembers command payload. Returns null on success, or the reason /// the command should be acked failed. /// private string HandleSyncVipMembers(JObject payload) { if (payload == null) { return "payload is required"; } var idsToken = payload["steam_ids"]; if (idsToken == null || idsToken.Type != JTokenType.Array) { return "payload.steam_ids must be an array"; } var steamIds = new HashSet(); foreach (var entry in (JArray)idsToken) { if (entry == null || entry.Type == JTokenType.Null) { continue; } // Sent as strings because a SteamID64 exceeds 2^53; parse defensively either way. ulong parsed; if (!ulong.TryParse(entry.ToString(), out parsed) || !IsPlausibleSteamId(parsed)) { return $"payload.steam_ids contains an invalid SteamID64: {entry}"; } steamIds.Add(parsed); } var groupToken = payload["oxide_group"]; var group = groupToken?.Type == JTokenType.String ? groupToken.Value() : groupToken?.ToString(); if (_vipData == null) { LoadVipData(); } var tag = payload["tag"] as JObject; if (tag != null && _vipData != null) { var enabledToken = tag["enabled"]; if (enabledToken != null && enabledToken.Type == JTokenType.Boolean) { _vipData.TagEnabled = enabledToken.Value(); } var textToken = tag["text"]; if (textToken != null && textToken.Type == JTokenType.String) { _vipData.TagText = textToken.Value(); } // `name_color` since v0.12.2; `color` is the same value under the old name, sent by // the API so a server still running an older plugin keeps working. Prefer the new // key so a future API that drops the legacy one changes nothing here. var colorToken = tag["name_color"] ?? tag["color"]; if (colorToken != null && colorToken.Type == JTokenType.String) { _vipData.NameColor = colorToken.Value(); } SaveVipData(); } var changed = ApplyVipRoster(steamIds, group); VerbosePuts( $"CerebRUST vip: roster applied members={steamIds.Count} changed={changed} group={VipOxideGroup}" ); return null; } #endregion #region Moderation verbs — mute + warn (v0.18.0) // Rust has no native mute, so unlike a ban there is no second writer: nothing in game can // create one, and CerebRUST is unambiguously the source of truth. That is what makes the // absolute-reconcile shape available here, and it is strictly better than a pair of // per-player commands — it cannot deliver a mute and an unmute out of order, and it heals // a lost data file on the next push. Same shape as SyncVipMembers and SyncServerAdmins. // // **Every entry carries its own expiry, and this plugin honours it without the API.** That // is not redundancy: it means a ten-minute mute ends in ten minutes even if the API is // unreachable, the command queue is backed up, or the server has been offline. The API's // sweep is the reconcile, not the mechanism. // // Unlike the VIP group, oxide persists nothing for us here, so the muted set is written to // our own data file — the ticket's "survive a reload" requirement, and the reason the // roster push is a heal rather than the only source. private const string MuteDataFileName = "CerebrustMutes"; /// Told to a muted player when they try to speak. Never says who muted them. private const string MutedNoticeRich = "You are muted"; private MutePersistedData _muteData; private sealed class MuteEntry { /// Shown to the muted player, so a mute is never a mystery to its subject. public string Reason { get; set; } /// /// UTC, ISO-8601, or null for "until somebody lifts it". Stored as the string the API /// sent rather than a parsed DateTime: a data file that round-trips through /// Newtonsoft's local-time handling is a data file whose mutes end an hour early twice /// a year, and the parse is cheap enough to do on each check. /// public string ExpiresAt { get; set; } } private sealed class MutePersistedData { public Dictionary Muted { get; set; } = new Dictionary(); } private void LoadMuteData() { try { _muteData = Interface.Oxide.DataFileSystem.ReadObject(MuteDataFileName); } catch (Exception ex) { PrintWarning($"CerebRUST mute data load failed: {ex.Message}"); } if (_muteData == null) { _muteData = new MutePersistedData(); } if (_muteData.Muted == null) { _muteData.Muted = new Dictionary(); } } private void SaveMuteData() { if (_muteData != null) { Interface.Oxide.DataFileSystem.WriteObject(MuteDataFileName, _muteData); } } /// /// Parse an ISO-8601 UTC stamp the API sent, or null when it is absent or unreadable. /// /// /// AdjustToUniversal matters: without it a "Z"-suffixed string comes back as local /// time on a box that is not on UTC, and every timed mute ends at the wrong moment — in /// whichever direction the host happens to be offset. An unparseable value is treated as /// "no expiry" rather than "expired", because dropping a mute because we could not read a /// date is a moderation failure and keeping it is merely an inconvenience somebody can fix. /// private static DateTime? ParseUtcStamp(string raw) { if (string.IsNullOrWhiteSpace(raw)) { return null; } DateTime parsed; if ( DateTime.TryParse( raw, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out parsed ) ) { return parsed; } return null; } /// /// Is this player muted right now? Expires the entry locally when its time has passed. /// /// /// The local expiry is what makes a timed mute honest when the API cannot be reached. It /// removes the entry and persists, so the set on disk never accumulates dead rows and a /// reload does not resurrect a mute that has already run out. /// private bool IsMuted(ulong steamId, out MuteEntry entry) { entry = null; if (_muteData == null || _muteData.Muted == null || !IsPlausibleSteamId(steamId)) { return false; } MuteEntry found; if (!_muteData.Muted.TryGetValue(steamId, out found) || found == null) { return false; } var expires = ParseUtcStamp(found.ExpiresAt); if (expires.HasValue && expires.Value <= DateTime.UtcNow) { _muteData.Muted.Remove(steamId); SaveMuteData(); return false; } entry = found; return true; } /// Tell a muted player why their message went nowhere. /// /// Sent every time they try to speak rather than once, because a player who does not know /// they are muted concludes the server is broken and leaves — which is a worse outcome for /// the owner than the person being mildly annoyed. The line goes only to them. /// private void NotifyMuted(BasePlayer player, MuteEntry entry) { if (player == null) { return; } var suffix = string.Empty; if (entry != null && !string.IsNullOrWhiteSpace(entry.Reason)) { suffix = $" — {entry.Reason.Trim()}"; } var expires = entry != null ? ParseUtcStamp(entry.ExpiresAt) : null; if (expires.HasValue) { var remaining = expires.Value - DateTime.UtcNow; if (remaining.TotalSeconds > 0) { suffix += $" (ends in {FormatMuteRemaining(remaining)})"; } } try { // Through SendToPlayer, not an ad-hoc chat.add: the repo's branding rule is that // every line representing the product carries the same [CR] prefix and avatar, and // a notice that looks unlike the rest of CerebRUST reads as a different plugin. SendToPlayer(player, $"{MutedNoticeRich}{suffix}"); } catch (Exception) { // Telling somebody they are muted must never be the thing that throws in the chat // path — the mute itself has already been applied by the caller returning true. } } private static string FormatMuteRemaining(TimeSpan remaining) { if (remaining.TotalHours >= 1) { var hours = (int)Math.Round(remaining.TotalHours); return hours == 1 ? "an hour" : $"{hours} hours"; } var minutes = Math.Max(1, (int)Math.Round(remaining.TotalMinutes)); return minutes == 1 ? "a minute" : $"{minutes} minutes"; } /// /// Handle a SyncMutedPlayers command: make the muted set match CerebRUST's exactly. /// Returns null on success, or the reason to ack failed. /// /// /// Absolute, like every other roster this plugin takes. Anybody not in the payload /// is unmuted, which is what makes lifting a mute in the dashboard actually work rather /// than leaving somebody silenced until the next reload. /// private string HandleSyncMutedPlayers(JObject payload) { if (payload == null) { return "payload is required"; } var array = payload["muted"] as JArray; if (array == null) { return "payload.muted must be an array"; } var next = new Dictionary(); foreach (var token in array) { var obj = token as JObject; if (obj == null) { continue; } var steamToken = obj["steam_id"]; ulong steamId = 0; if (steamToken != null && steamToken.Type != JTokenType.Null) { if (steamToken.Type == JTokenType.Integer) { steamId = steamToken.Value(); } else { ulong.TryParse(steamToken.ToString(), out steamId); } } if (!IsPlausibleSteamId(steamId)) { // One malformed entry must not discard the whole roster: the rest of the set is // still the truth, and refusing it would leave everybody unmuted. continue; } var reasonToken = obj["reason"]; var expiresToken = obj["expires_at"]; next[steamId] = new MuteEntry { Reason = reasonToken != null && reasonToken.Type == JTokenType.String ? reasonToken.Value() : null, ExpiresAt = expiresToken != null && expiresToken.Type == JTokenType.String ? expiresToken.Value() : null, }; } if (_muteData == null) { _muteData = new MutePersistedData(); } _muteData.Muted = next; SaveMuteData(); VerbosePuts($"CerebRUST mutes: roster applied count={next.Count}"); return null; } /// /// Handle a WarnPlayer command: deliver a warning and report whether it landed. /// Returns null on success, or the reason to ack failed. /// /// /// An offline player is a failure, not a success — and that is the one place this /// deliberately differs from KickPlayer, where "already gone" satisfies the goal. A /// warning is a *delivered message*: recording it as delivered when nobody read it would /// put a line in the player's moderation history saying they were told something they were /// never told, which is exactly the record an admin would later rely on. /// private string HandleWarnPlayer(JObject payload) { if (payload == null) { return "payload is required"; } var steamToken = payload["steam_id"]; ulong steamId = 0; if (steamToken != null && steamToken.Type != JTokenType.Null) { if (steamToken.Type == JTokenType.Integer) { steamId = steamToken.Value(); } else { ulong.TryParse(steamToken.ToString(), out steamId); } } if (!IsPlausibleSteamId(steamId)) { return "payload.steam_id is required"; } var messageToken = payload["message"]; var message = messageToken != null && messageToken.Type == JTokenType.String ? messageToken.Value() : messageToken?.ToString(); if (string.IsNullOrWhiteSpace(message)) { return "payload.message is required"; } var target = BasePlayer.FindByID(steamId); if (target == null || !target.IsConnected) { return "player is not online"; } try { SendToPlayer(target, $"{WarningSpeakerRich}: {message.Trim()}"); } catch (Exception ex) { return ex.Message; } return null; } /// Prefix on a delivered warning. Distinct from ADMIN so a warning reads as one. private static readonly string WarningSpeakerRich = "WARNING"; #endregion #region Chat commands (Thistle port) private const string TestGeneratorScrapShortname = "scrap"; private const string TestGeneratorItemShortname = "electric.generator.small"; // Always available (no toggle): report the online player count. // Always available (no toggle): tell a player whether they are a VIP here, and what that // gets them. Reads the oxide group rather than asking the API — the group is the same truth // the rewards key off, so this answers "do my perks work" rather than "does a row exist". [ChatCommand("vip")] private void CmdVip(BasePlayer player, string command, string[] args) { if (player == null) { return; } if (!IsVip(player.userID)) { SendToPlayer(player, "You are not a VIP on this server."); return; } var tagText = _vipData != null && !string.IsNullOrWhiteSpace(_vipData.TagText) ? _vipData.TagText : VipDefaultTagText; SendToPlayer( player, _vipData != null && _vipData.TagEnabled ? $"You are a VIP on this server — your chat tag is [{tagText}]." : "You are a VIP on this server." ); } [ChatCommand("pop")] private void CmdPop(BasePlayer player, string command, string[] args) { if (player == null) { return; } var count = BasePlayer.activePlayerList?.Count ?? 0; var message = count == 1 ? $"There is {count} player online." : $"There are {count} players online."; SendToPlayer(player, message); } // Always available (no toggle): report the current in-game time (24h). [ChatCommand("time")] private void CmdTime(BasePlayer player, string command, string[] args) { if (player == null) { return; } var sky = TOD_Sky.Instance; if (sky == null) { SendToPlayer(player, "Current in-game time: unknown."); return; } var hourOfDay = (float)sky.Cycle.Hour; var hours = Mathf.FloorToInt(hourOfDay) % 24; var minutes = Mathf.FloorToInt((hourOfDay - Mathf.Floor(hourOfDay)) * 60f) % 60; SendToPlayer(player, $"Current in-game time: {hours:00}:{minutes:00}."); } // Gated by "takelead.enabled": let a team member claim leadership (e.g. leader offline). [ChatCommand("takelead")] private void CmdTakeLead(BasePlayer player, string command, string[] args) { if (player == null || !ReadPluginSettingBool("takelead.enabled", false)) { return; } var teamId = player.currentTeam; if (teamId == 0) { SendToPlayer(player, "You are not in a team."); return; } var team = RelationshipManager.ServerInstance?.FindTeam(teamId); if (team == null) { SendToPlayer(player, "Could not find your team."); return; } if (team.teamLeader == (ulong)player.userID) { SendToPlayer(player, "You are already the team leader."); return; } team.SetTeamLeader((ulong)player.userID); if (team.members != null) { foreach (var memberId in team.members) { var member = BasePlayer.FindByID(memberId); if (member != null && member.IsConnected) { SendToPlayer(member, $"{player.displayName} has taken team leadership."); } } } VerbosePuts($"CerebRUST takelead: {player.displayName} ({player.UserIDString}) took leadership of team {teamId}"); } // Gated by "testgenerator.enabled": buy a small generator for "testgenerator.scrap_cost" scrap. [ChatCommand("testgenerator")] private void CmdTestGenerator(BasePlayer player, string command, string[] args) { if (player == null || !ReadPluginSettingBool("testgenerator.enabled", false)) { return; } var scrapCost = Math.Max(1, ReadPluginSettingInt("testgenerator.scrap_cost", 20000)); var scrapDef = ItemManager.FindItemDefinition(TestGeneratorScrapShortname); var generatorDef = ItemManager.FindItemDefinition(TestGeneratorItemShortname); if (scrapDef == null || generatorDef == null) { SendToPlayer(player, "This command is temporarily unavailable."); return; } var scrapAmount = player.inventory.GetAmount(scrapDef.itemid); if (scrapAmount < scrapCost) { SendToPlayer( player, $"Insufficient scrap! You need {scrapCost:N0} scrap to purchase a Test Generator (you have {scrapAmount:N0})." ); return; } var taken = player.inventory.Take(null, scrapDef.itemid, scrapCost); if (taken < scrapCost) { // Partial take should not happen after the amount check, but refund defensively. if (taken > 0) { player.GiveItem(ItemManager.CreateByName(TestGeneratorScrapShortname, taken)); } SendToPlayer(player, "Failed to complete transaction. Please try again."); return; } var generator = ItemManager.CreateByName(TestGeneratorItemShortname, 1); if (generator == null) { player.GiveItem(ItemManager.CreateByName(TestGeneratorScrapShortname, scrapCost)); SendToPlayer(player, "Failed to create Test Generator. Please try again."); PrintError("CerebRUST testgenerator: failed to create Test Generator item."); return; } player.GiveItem(generator); SendToPlayer(player, "Purchase successful! You received a Test Generator."); if (ReadPluginSettingBool("testgenerator.broadcast", true)) { BroadcastToServer( $"{player.displayName} just purchased a Test Generator for {scrapCost:N0} scrap!" ); } VerbosePuts($"CerebRUST testgenerator: {player.displayName} ({player.UserIDString}) purchased for {scrapCost} scrap"); } #endregion } }