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 ShortPrefabName — wall, 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})");
}
}
///