C#:
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using Oxide.Core.Plugins;
using Oxide.Game.Rust.Cui;
using UnityEngine;
namespace Oxide.Plugins
{
[Info("MonumentChoice", "RustFlash", "0.2.2")]
[Description("Per-monument PvE/PvP runtime override system for Rust PvE servers")]
public class MonumentChoice : RustPlugin
{
#region Fields
[PluginReference] Plugin MyNotify;
[PluginReference] Plugin ZoneManager;
private const string PermUse = "monumentchoice.use";
private const string PermBypass = "monumentchoice.bypass";
private const string UI_MAIN = "MonumentChoice_UI";
private Configuration config;
private readonly Dictionary<string, MonumentState> states = new Dictionary<string, MonumentState>();
private readonly Dictionary<string, MonumentData> monumentData = new Dictionary<string, MonumentData>();
private readonly Dictionary<ulong, string> playerMonument = new Dictionary<ulong, string>();
private readonly Dictionary<ulong, Dictionary<string, long>> cooldowns = new Dictionary<ulong, Dictionary<string, long>>();
private readonly Dictionary<ulong, Dictionary<string, long>> travelerCooldowns = new Dictionary<ulong, Dictionary<string, long>>();
private readonly Dictionary<MonumentInfo, string> monumentLookup = new Dictionary<MonumentInfo, string>();
private readonly HashSet<string> activeZones = new HashSet<string>();
private Timer positionTimer;
private int preventBuildingMask;
#endregion
#region Config
private class Configuration
{
[JsonProperty("DecisionCooldownMinutes")]
public int DecisionCooldownMinutes = 15;
[JsonProperty("TravelerCooldownMinutes")]
public int TravelerCooldownMinutes = 5;
[JsonProperty("UseMyNotify")]
public bool UseMyNotify = false;
[JsonProperty("ZoneManagerEnabled")]
public bool ZoneManagerEnabled = false;
[JsonProperty("ZoneRadiusPadding")]
public float ZoneRadiusPadding = 0f;
[JsonProperty("BlacklistedMonuments")]
public List<string> BlacklistedMonuments = new List<string>
{
"compound",
"bandit_town",
"apartments_complex_1",
"fishing_village_a",
"fishing_village_b",
"fishing_village_c",
"large_fishing_village",
"small_fishing_village",
"stables_a",
"stables_b",
"ranch",
"bandit_camp",
"gas_station_1",
"supermarket_1",
"lighthouse",
"mining_quarry_a",
"mining_quarry_b",
"mining_quarry_c",
"substation_a",
"substation_b",
"substation_c",
"swamp_a",
"swamp_b",
"swamp_c",
"ice_lake_1",
"ice_lake_2",
"ice_lake_3",
"ice_lake_4",
"power_sub_big_1",
"power_sub_big_2",
"power_sub_small_1",
"power_sub_small_2",
"ferryman",
"water_well_a",
"water_well_b",
"water_well_c",
"water_well_d",
"water_well_e",
"cave_small_easy",
"cave_small_medium",
"cave_small_hard",
"cave_medium_easy",
"cave_medium_medium",
"cave_medium_hard",
"cave_large_easy",
"cave_large_medium",
"cave_large_hard",
"cave_large_sewers_hard",
"excavator_1",
"nuclear_missile_silo",
"arctic_research_base_a",
"desert_military_base_a",
"desert_military_base_b",
"desert_military_base_c",
"desert_military_base_d"
};
}
protected override void LoadConfig()
{
base.LoadConfig();
try
{
config = Config.ReadObject<Configuration>();
if (config == null) LoadDefaultConfig();
}
catch { LoadDefaultConfig(); }
SaveConfig();
}
protected override void LoadDefaultConfig() => config = new Configuration();
protected override void SaveConfig() => Config.WriteObject(config);
#endregion
#region Data Models
private enum MonumentMode { Unclaimed, PvE, PvP }
private class MonumentState
{
public string MonumentId;
public string DisplayName;
public MonumentMode Mode = MonumentMode.Unclaimed;
public ulong OwnerId;
public string OwnerName;
}
private class MonumentData
{
public MonumentInfo Monument;
}
#endregion
#region Monument Display Names
private static readonly Dictionary<string, string> DisplayNames = new Dictionary<string, string>
{
["launch_site"] = "Launch Site",
["military_tunnel_1"] = "Military Tunnel",
["airfield_1"] = "Airfield",
["powerplant_1"] = "Power Plant",
["trainyard_1"] = "Train Yard",
["water_treatment_plant_1"] = "Water Treatment Plant",
["satellite_dish_1"] = "Satellite Dish",
["lighthouse"] = "Lighthouse",
["harbor_1"] = "Harbor",
["harbor_2"] = "Harbor",
["junkyard_1"] = "Junkyard",
["gas_station_1"] = "Gas Station",
["supermarket_1"] = "Supermarket",
["mining_quarry_a"] = "Sulfur Quarry",
["mining_quarry_b"] = "HQM Quarry",
["mining_quarry_c"] = "Stone Quarry",
["excavator_1"] = "Giant Excavator",
["radtown_small_3"] = "Sewer Branch",
["sphere_tank"] = "Dome",
["nuclear_missile_silo"] = "Missile Silo",
["oilrig_1"] = "Small Oil Rig",
["oilrig_2"] = "Large Oil Rig",
["substation_a"] = "Power Substation",
["substation_b"] = "Power Substation",
["substation_c"] = "Power Substation",
["arctic_research_base_a"] = "Arctic Research Base",
["desert_military_base_a"] = "Desert Military Base",
["desert_military_base_b"] = "Desert Military Base",
["desert_military_base_c"] = "Desert Military Base",
["desert_military_base_d"] = "Desert Military Base",
["ferryman"] = "Ferry Terminal",
["underwater_lab_a"] = "Underwater Lab",
["underwater_lab_b"] = "Underwater Lab",
["underwater_lab_c"] = "Underwater Lab",
["underwater_lab_d"] = "Underwater Lab",
};
#endregion
#region Init / Unload
private void Init()
{
permission.RegisterPermission(PermUse, this);
permission.RegisterPermission(PermBypass, this);
RegisterLanguages();
}
private void OnServerInitialized()
{
preventBuildingMask = LayerMask.GetMask("Prevent Building");
RegisterMonuments();
positionTimer = timer.Every(1f, CheckPlayerPositions);
}
private void Unload()
{
positionTimer?.Destroy();
RemoveAllPvPZones();
foreach (var player in BasePlayer.activePlayerList)
CuiHelper.DestroyUi(player, UI_MAIN);
}
#endregion
#region Monument Registration
private void RegisterMonuments()
{
if (TerrainMeta.Path?.Monuments == null)
{
PrintWarning("No monuments found on this map.");
return;
}
var idCounts = new Dictionary<string, int>();
foreach (var monument in TerrainMeta.Path.Monuments)
{
string baseId = ExtractMonumentId(monument.name);
if (string.IsNullOrEmpty(baseId)) continue;
if (IsBlacklisted(baseId)) continue;
idCounts.TryGetValue(baseId, out int count);
string monumentId = count == 0 ? baseId : $"{baseId}_{count}";
idCounts[baseId] = count + 1;
monumentData[monumentId] = new MonumentData
{
Monument = monument
};
monumentLookup[monument] = monumentId;
states[monumentId] = new MonumentState
{
MonumentId = monumentId,
DisplayName = ResolveDisplayName(baseId, count)
};
}
Puts($"MonumentChoice: {states.Count} monuments registered.");
}
private string ExtractMonumentId(string prefabPath)
{
if (string.IsNullOrEmpty(prefabPath)) return null;
var parts = prefabPath.Split('/');
return parts[parts.Length - 1].Replace(".prefab", "").Trim();
}
private string ResolveDisplayName(string baseId, int duplicateIndex)
{
string name = DisplayNames.TryGetValue(baseId, out string display)
? display
: System.Text.RegularExpressions.Regex.Replace(
baseId.Replace("_", " "), @"\s*\d+\s*$", "").Trim();
if (duplicateIndex > 0)
name += $" {(char)('A' + duplicateIndex)}";
return name;
}
private bool IsBlacklisted(string id)
{
foreach (var entry in config.BlacklistedMonuments)
if (string.Equals(id, entry, StringComparison.OrdinalIgnoreCase)) return true;
return false;
}
#endregion
#region Position Tracking (1-second timer)
private void CheckPlayerPositions()
{
foreach (var player in BasePlayer.activePlayerList)
{
if (player == null || !player.IsConnected) continue;
string newMonument = FindMonumentAt(player.transform.position);
playerMonument.TryGetValue(player.userID, out string oldMonument);
if (newMonument == oldMonument) continue;
if (oldMonument != null)
HandlePlayerExit(player, oldMonument);
if (newMonument != null)
HandlePlayerEnter(player, newMonument);
else
playerMonument.Remove(player.userID);
}
}
private string FindMonumentAt(Vector3 pos)
{
List<Collider> hits = Facepunch.Pool.Get<List<Collider>>();
Vis.Colliders(pos, 0.5f, hits, preventBuildingMask, QueryTriggerInteraction.Collide);
string found = null;
foreach (var col in hits)
{
var tag = col.GetComponentInParent<PreventBuildingMonumentTag>();
if (tag == null) continue;
var monument = tag.GetAttachedMonument();
if (monument != null && monumentLookup.TryGetValue(monument, out string id))
{
found = id;
break;
}
}
Facepunch.Pool.FreeUnmanaged<Collider>(ref hits);
if (found != null) return found;
foreach (var kvp in monumentData)
if (kvp.Value.Monument.IsInBounds(pos))
return kvp.Key;
return null;
}
#endregion
#region Enter / Exit Logic
private void HandlePlayerEnter(BasePlayer player, string monumentId)
{
playerMonument[player.userID] = monumentId;
if (!states.TryGetValue(monumentId, out var state)) return;
if (HasBypass(player)) return;
if (!permission.UserHasPermission(player.UserIDString, PermUse)) return;
if (state.Mode == MonumentMode.Unclaimed)
{
if (IsTravelerSuppressed(player.userID, monumentId))
return;
if (IsOnCooldown(player.userID, monumentId))
{
SendMessage(player, Lang("Cooldown", player.UserIDString));
return;
}
ShowSelectionUI(player, state);
}
else
{
SendModeInfo(player, state);
}
}
private void HandlePlayerExit(BasePlayer player, string monumentId)
{
playerMonument.Remove(player.userID);
CuiHelper.DestroyUi(player, UI_MAIN);
if (!states.TryGetValue(monumentId, out var state)) return;
if (state.OwnerId != player.userID) return;
string playerName = player.displayName;
string displayName = state.DisplayName;
BroadcastToMonument(monumentId, Lang("LeftZone", null, playerName, displayName));
BroadcastToMonument(monumentId, Lang("ZoneAvailable", null, displayName));
SendMessage(player, Lang("LeftZone", player.UserIDString, playerName, displayName));
SendMessage(player, Lang("ZoneAvailable", player.UserIDString, displayName));
SetCooldown(player.userID, monumentId);
ResetMonument(state);
}
private void ResetMonument(MonumentState state)
{
RemovePvPZone(state.MonumentId);
state.Mode = MonumentMode.Unclaimed;
state.OwnerId = 0;
state.OwnerName = string.Empty;
}
#endregion
#region Selection UI
private void ShowSelectionUI(BasePlayer player, MonumentState state)
{
CuiHelper.DestroyUi(player, UI_MAIN);
var container = new CuiElementContainer();
container.Add(new CuiPanel
{
Image = { Color = "0 0 0 0.88" },
RectTransform = { AnchorMin = "0.34 0.33", AnchorMax = "0.66 0.65" },
CursorEnabled = true
}, "Hud", UI_MAIN);
container.Add(new CuiLabel
{
Text = {
Text = state.DisplayName,
FontSize = 18,
Align = TextAnchor.MiddleCenter,
Color = "1 0.85 0.2 1"
},
RectTransform = { AnchorMin = "0.05 0.80", AnchorMax = "0.95 0.97" }
}, UI_MAIN);
container.Add(new CuiLabel
{
Text = {
Text = "Choose your mode:",
FontSize = 11,
Align = TextAnchor.MiddleCenter,
Color = "0.75 0.75 0.75 1"
},
RectTransform = { AnchorMin = "0.05 0.66", AnchorMax = "0.95 0.80" }
}, UI_MAIN);
container.Add(new CuiButton
{
Button = {
Command = $"monumentchoice.select pve {state.MonumentId}",
Color = "0.13 0.53 0.13 0.95"
},
RectTransform = { AnchorMin = "0.05 0.30", AnchorMax = "0.475 0.63" },
Text = {
Text = "PvE",
FontSize = 24,
Align = TextAnchor.MiddleCenter,
Color = "0.85 1 0.85 1"
}
}, UI_MAIN);
container.Add(new CuiButton
{
Button = {
Command = $"monumentchoice.select pvp {state.MonumentId}",
Color = "0.62 0.1 0.1 0.95"
},
RectTransform = { AnchorMin = "0.525 0.30", AnchorMax = "0.95 0.63" },
Text = {
Text = "PvP",
FontSize = 24,
Align = TextAnchor.MiddleCenter,
Color = "1 0.82 0.82 1"
}
}, UI_MAIN);
container.Add(new CuiButton
{
Button = {
Command = $"monumentchoice.skip {state.MonumentId}",
Color = "0.40 0.62 0.82 0.85"
},
RectTransform = { AnchorMin = "0.22 0.07", AnchorMax = "0.78 0.25" },
Text = {
Text = "I'm just a traveler",
FontSize = 12,
Align = TextAnchor.MiddleCenter,
Color = "0.92 0.97 1 1"
}
}, UI_MAIN);
CuiHelper.AddUi(player, container);
}
[ConsoleCommand("monumentchoice.select")]
private void CmdSelectMode(ConsoleSystem.Arg arg)
{
var player = arg.Player();
if (player == null) return;
if (!arg.HasArgs(2)) return;
string modeStr = arg.GetString(0).ToLower();
string monumentId = arg.GetString(1);
CuiHelper.DestroyUi(player, UI_MAIN);
if (!states.TryGetValue(monumentId, out var state)) return;
if (state.Mode != MonumentMode.Unclaimed) return;
if (!permission.UserHasPermission(player.UserIDString, PermUse)) return;
if (IsOnCooldown(player.userID, monumentId))
{
SendMessage(player, Lang("Cooldown", player.UserIDString));
return;
}
state.Mode = modeStr == "pvp" ? MonumentMode.PvP : MonumentMode.PvE;
state.OwnerId = player.userID;
state.OwnerName = player.displayName;
if (state.Mode == MonumentMode.PvP)
CreatePvPZone(monumentId, state.DisplayName);
string key = state.Mode == MonumentMode.PvP ? "SelectedPvP" : "SelectedPvE";
BroadcastToMonument(monumentId, Lang(key, null, player.displayName, state.DisplayName));
}
[ConsoleCommand("monumentchoice.skip")]
private void CmdSkipChoice(ConsoleSystem.Arg arg)
{
var player = arg.Player();
if (player == null) return;
if (!arg.HasArgs(1)) return;
string monumentId = arg.GetString(0);
CuiHelper.DestroyUi(player, UI_MAIN);
if (!states.TryGetValue(monumentId, out var state)) return;
if (state.Mode != MonumentMode.Unclaimed) return;
SetTravelerCooldown(player.userID, monumentId);
}
#endregion
#region Gameplay Hooks
private object CanEntityTakeDamage(BasePlayer victim, HitInfo info)
{
if (victim == null || info == null) return null;
var attacker = info.InitiatorPlayer;
if (attacker == null || attacker == victim) return null;
if (HasBypass(victim) || HasBypass(attacker)) return null;
if (!InSamePvPMonument(attacker.userID, victim.userID)) return null;
return true;
}
private object CanTeleport(BasePlayer player)
{
if (player == null) return null;
playerMonument.TryGetValue(player.userID, out string monumentId);
if (monumentId == null) return null;
if (!states.TryGetValue(monumentId, out var state)) return null;
if (state.Mode != MonumentMode.PvP) return null;
return false;
}
#endregion
#region Disconnect / Reconnect
private void OnPlayerDisconnected(BasePlayer player, string reason)
{
playerMonument.TryGetValue(player.userID, out string monumentId);
if (monumentId != null)
HandlePlayerExit(player, monumentId);
}
#endregion
#region Public API (callable by other plugins)
private bool IsPlayerInPvPZone(BasePlayer player)
{
if (player == null) return false;
playerMonument.TryGetValue(player.userID, out string monumentId);
if (monumentId == null) return false;
return states.TryGetValue(monumentId, out var state) && state.Mode == MonumentMode.PvP;
}
private string GetPlayerMonumentMode(BasePlayer player)
{
if (player == null) return "Unclaimed";
playerMonument.TryGetValue(player.userID, out string monumentId);
if (monumentId == null) return "Unclaimed";
if (!states.TryGetValue(monumentId, out var state)) return "Unclaimed";
return state.Mode.ToString();
}
#endregion
#region ZoneManager Integration
private void CreatePvPZone(string monumentId, string displayName)
{
if (!config.ZoneManagerEnabled || ZoneManager == null) return;
if (!monumentData.TryGetValue(monumentId, out var data) || data.Monument == null) return;
MonumentInfo monument = data.Monument;
Vector3 center = monument.transform.TransformPoint(monument.Bounds.center);
float radius = ZoneRadiusFor(monument);
string zoneId = ZoneId(monumentId);
string[] args = { "name", "PvP " + displayName, "radius", radius.ToString(System.Globalization.CultureInfo.InvariantCulture) };
ZoneManager.Call("CreateOrUpdateZone", zoneId, args, center);
activeZones.Add(zoneId);
}
private void RemovePvPZone(string monumentId)
{
if (ZoneManager == null) return;
string zoneId = ZoneId(monumentId);
if (!activeZones.Remove(zoneId)) return;
ZoneManager.Call("EraseZone", zoneId);
}
private void RemoveAllPvPZones()
{
if (ZoneManager == null)
{
activeZones.Clear();
return;
}
foreach (var zoneId in activeZones)
ZoneManager.Call("EraseZone", zoneId);
activeZones.Clear();
}
private float ZoneRadiusFor(MonumentInfo monument)
{
float ex = monument.Bounds.extents.x;
float ez = monument.Bounds.extents.z;
float radius = Mathf.Sqrt(ex * ex + ez * ez) + config.ZoneRadiusPadding;
return radius < 10f ? 10f : radius;
}
private string ZoneId(string monumentId) => "monumentchoice_" + monumentId;
#endregion
#region Helpers
private bool InSamePvPMonument(ulong playerAId, ulong playerBId)
{
playerMonument.TryGetValue(playerAId, out string monA);
playerMonument.TryGetValue(playerBId, out string monB);
if (monA == null || monA != monB) return false;
return states.TryGetValue(monA, out var state) && state.Mode == MonumentMode.PvP;
}
private bool HasBypass(BasePlayer player) =>
permission.UserHasPermission(player.UserIDString, PermBypass);
private bool IsOnCooldown(ulong playerId, string monumentId)
{
if (!cooldowns.TryGetValue(playerId, out var dict)) return false;
if (!dict.TryGetValue(monumentId, out long expiry)) return false;
return DateTimeOffset.UtcNow.ToUnixTimeSeconds() < expiry;
}
private void SetCooldown(ulong playerId, string monumentId)
{
if (!cooldowns.ContainsKey(playerId))
cooldowns[playerId] = new Dictionary<string, long>();
cooldowns[playerId][monumentId] =
DateTimeOffset.UtcNow.ToUnixTimeSeconds() + config.DecisionCooldownMinutes * 60L;
}
private bool IsTravelerSuppressed(ulong playerId, string monumentId)
{
if (!travelerCooldowns.TryGetValue(playerId, out var dict)) return false;
if (!dict.TryGetValue(monumentId, out long expiry)) return false;
return DateTimeOffset.UtcNow.ToUnixTimeSeconds() < expiry;
}
private void SetTravelerCooldown(ulong playerId, string monumentId)
{
if (!travelerCooldowns.ContainsKey(playerId))
travelerCooldowns[playerId] = new Dictionary<string, long>();
travelerCooldowns[playerId][monumentId] =
DateTimeOffset.UtcNow.ToUnixTimeSeconds() + config.TravelerCooldownMinutes * 60L;
}
private void BroadcastToMonument(string monumentId, string message)
{
foreach (var player in BasePlayer.activePlayerList)
{
playerMonument.TryGetValue(player.userID, out string mon);
if (mon == monumentId)
SendMessage(player, message);
}
}
private void SendMessage(BasePlayer player, string message)
{
if (config.UseMyNotify && MyNotify != null)
MyNotify.Call("SendNotify", player, message);
else
player.ChatMessage($"<color=#e8c840>[Monument Choice]</color> {message}");
}
private void SendModeInfo(BasePlayer player, MonumentState state)
{
string key = state.Mode == MonumentMode.PvP ? "ModePvP" : "ModePvE";
SendMessage(player, Lang(key, player.UserIDString));
}
#endregion
#region Lang
private void RegisterLanguages()
{
lang.RegisterMessages(new Dictionary<string, string>
{
["ModePvE"] = "This monument is currently <color=#55ffaa>PvE</color>.",
["ModePvP"] = "This monument is currently <color=#ff5555>PvP</color>.",
["SelectedPvE"] = "{0} selected <color=#55ffaa>PvE</color> for {1}.",
["SelectedPvP"] = "{0} selected <color=#ff5555>PvP</color> for {1}.",
["LeftZone"] = "{0} left {1}.",
["ZoneAvailable"] = "{0} is now available again.",
["Cooldown"] = "You must wait before choosing this monument again."
}, this, "en");
lang.RegisterMessages(new Dictionary<string, string>
{
["ModePvE"] = "In diesem Monument ist aktuell <color=#55ffaa>PvE</color> aktiv.",
["ModePvP"] = "In diesem Monument ist aktuell <color=#ff5555>PvP</color> aktiv.",
["SelectedPvE"] = "{0} hat <color=#55ffaa>PvE</color> für {1} aktiviert.",
["SelectedPvP"] = "{0} hat <color=#ff5555>PvP</color> für {1} aktiviert.",
["LeftZone"] = "{0} hat {1} verlassen.",
["ZoneAvailable"] = "{0} steht nun wieder zur Verfügung.",
["Cooldown"] = "Du musst warten, bevor du dieses Monument erneut auswählen darfst."
}, this, "de");
}
private string Lang(string key, string userId = null, params object[] args)
{
string msg = lang.GetMessage(key, this, userId);
return args.Length > 0 ? string.Format(msg, args) : msg;
}
#endregion
}
}