C#:
using System;
using System.Collections.Generic;
using System.Linq;
using Oxide.Core.Plugins;
using UnityEngine;
namespace Oxide.Plugins
{
[Info("SupplyLock", "Ghosty", "2.0.0")]
[Description("Locks supply drops to a player, their team, or clan members.")]
public class SupplyLock : RustPlugin
{
#region References
[PluginReference] private Plugin Clans, ClansReborn, InstantAirdrop;
#endregion
#region Config
private Configuration _config;
private class Configuration
{
public GeneralSettings General { get; set; } = new GeneralSettings();
public AccessSettings Access { get; set; } = new AccessSettings();
public InstantAirdropSettings InstantAirdropIntegration { get; set; } = new InstantAirdropSettings();
public MessageSettings Messages { get; set; } = new MessageSettings();
public bool DebugEnabled { get; set; } = false;
}
private class GeneralSettings
{
public float SignalMatchRadius { get; set; } = 50f;
public float SignalExpirySeconds { get; set; } = 300f;
public bool LockRandomAirdrops { get; set; } = false;
public float UnlockAfterSeconds { get; set; } = 0f;
}
private class AccessSettings
{
public bool EnableTeamAccess { get; set; } = true;
public bool EnableClanAccess { get; set; } = true;
}
private class InstantAirdropSettings
{
public bool Enabled { get; set; } = true;
public bool LockInstantDrops { get; set; } = true;
public float InstantDropUnlockSeconds { get; set; } = 0f;
public float InstantDropMatchRadius { get; set; } = 25f;
}
private class MessageSettings
{
public bool ShowLockMessage { get; set; } = true;
public string LockMessageFormat { get; set; } = "<size=16><color=#e74c3c>SupplyLock</color></size>\n<size=13>This supply drop belongs to <color=#3498db>{0}</color>.</size>";
public string UnlockedMessageFormat { get; set; } = "<size=16><color=#2ecc71>SupplyLock</color></size>\n<size=13>This supply drop is now unlocked!</size>";
public bool NotifyOwnerOnSpawn { get; set; } = true;
public string OwnerNotifyFormat { get; set; } = "<size=16><color=#2ecc71>SupplyLock</color></size>\n<size=13>Your supply drop has landed and is locked to you!</size>";
public string OwnerNotifyInstantFormat { get; set; } = "<size=16><color=#2ecc71>SupplyLock</color></size>\n<size=13>Your instant supply drop has spawned and is locked to you!</size>";
}
protected override void LoadDefaultConfig()
{
_config = new Configuration();
SaveConfig();
}
protected override void LoadConfig()
{
base.LoadConfig();
try
{
_config = Config.ReadObject<Configuration>();
if (_config == null)
LoadDefaultConfig();
}
catch
{
PrintWarning("Configuration file is corrupt, loading defaults...");
LoadDefaultConfig();
}
}
protected override void SaveConfig() => Config.WriteObject(_config);
#endregion
#region Data Structures
private class SupplySignalData
{
public ulong PlayerId;
public string PlayerName;
public Vector3 Position;
public float ThrownTime;
public NetworkableId SignalNetId;
public bool IsLanded;
public SupplySignalData(ulong playerId, string playerName, Vector3 position, NetworkableId signalNetId)
{
PlayerId = playerId;
PlayerName = playerName;
Position = position;
ThrownTime = Time.realtimeSinceStartup;
SignalNetId = signalNetId;
IsLanded = false;
}
public bool IsExpired(float expirySeconds) => Time.realtimeSinceStartup - ThrownTime > expirySeconds;
public float Age => Time.realtimeSinceStartup - ThrownTime;
}
private class SupplyDropData
{
public ulong OwnerId;
public string OwnerName;
public bool IsInstantDrop;
public SupplyDropData(ulong ownerId, string ownerName, bool isInstant = false)
{
OwnerId = ownerId;
OwnerName = ownerName;
IsInstantDrop = isInstant;
}
}
private readonly Dictionary<NetworkableId, SupplySignalData> _activeSignals = new Dictionary<NetworkableId, SupplySignalData>();
private readonly Dictionary<NetworkableId, SupplyDropData> _lockedDrops = new Dictionary<NetworkableId, SupplyDropData>();
private readonly Dictionary<NetworkableId, SupplySignal> _trackedSignalEntities = new Dictionary<NetworkableId, SupplySignal>();
private readonly Dictionary<NetworkableId, SupplyDrop> _pendingLandingNotifications = new Dictionary<NetworkableId, SupplyDrop>();
private readonly Dictionary<ulong, float> _lastNotificationTime = new Dictionary<ulong, float>();
private readonly Dictionary<ulong, int> _pendingNotificationCount = new Dictionary<ulong, int>();
private readonly HashSet<ulong> _pendingFlushTimers = new HashSet<ulong>();
private Timer _positionUpdateTimer;
private const float NOTIFICATION_COOLDOWN = 5f;
#endregion
#region Oxide Hooks
private void OnServerInitialized()
{
_positionUpdateTimer = timer.Every(0.5f, UpdateTrackedSignalPositions);
if (_config.InstantAirdropIntegration.Enabled && InstantAirdrop != null && InstantAirdrop.IsLoaded)
Puts("InstantAirdrop plugin detected - integration enabled");
if (Clans != null && Clans.IsLoaded)
Puts("Clans plugin detected - clan access enabled");
if (ClansReborn != null && ClansReborn.IsLoaded)
Puts("ClansReborn plugin detected - clan access enabled");
}
private void Unload()
{
_positionUpdateTimer?.Destroy();
_activeSignals.Clear();
_lockedDrops.Clear();
_trackedSignalEntities.Clear();
_pendingLandingNotifications.Clear();
_lastNotificationTime.Clear();
_pendingNotificationCount.Clear();
_pendingFlushTimers.Clear();
}
private void OnExplosiveThrown(BasePlayer player, BaseEntity entity, ThrownWeapon item)
{
if (player == null || !(entity is SupplySignal signal) || signal.net == null)
return;
var signalData = new SupplySignalData(player.userID, player.displayName, signal.transform.position, signal.net.ID);
_activeSignals[signal.net.ID] = signalData;
_trackedSignalEntities[signal.net.ID] = signal;
DebugLog($"Signal thrown by {player.displayName} at {signal.transform.position}");
NextTick(() =>
{
if (signal != null && !signal.IsDestroyed && _activeSignals.TryGetValue(signal.net.ID, out var data))
data.Position = signal.transform.position;
});
}
private void OnExplosiveDropped(BasePlayer player, BaseEntity entity, ThrownWeapon item)
=> OnExplosiveThrown(player, entity, item);
private void UpdateTrackedSignalPositions()
{
if (_trackedSignalEntities.Count == 0)
return;
var toRemove = new List<NetworkableId>();
foreach (var kvp in _trackedSignalEntities)
{
if (kvp.Value == null || kvp.Value.IsDestroyed || !_activeSignals.TryGetValue(kvp.Key, out var signalData))
{
toRemove.Add(kvp.Key);
continue;
}
Vector3 newPos = kvp.Value.transform.position;
if (Vector3.Distance(signalData.Position, newPos) > 0.1f)signalData.Position = newPos;
}
foreach (var id in toRemove)
_trackedSignalEntities.Remove(id);
}
private void OnSupplyDropLanded(SupplyDrop drop)
{
if (drop?.net == null || !_pendingLandingNotifications.Remove(drop.net.ID))
return;
DebugLog($"Drop landed [NetID: {drop.net.ID}]");
if (_lockedDrops.TryGetValue(drop.net.ID, out var dropData))
QueueLandingNotification(dropData.OwnerId, dropData.IsInstantDrop);
}
private void QueueLandingNotification(ulong playerId, bool isInstantDrop)
{
float currentTime = Time.realtimeSinceStartup;
if (_lastNotificationTime.TryGetValue(playerId, out float lastTime))
{
float timeSinceLast = currentTime - lastTime;
if (timeSinceLast < NOTIFICATION_COOLDOWN)
{
if (!_pendingNotificationCount.ContainsKey(playerId))
_pendingNotificationCount[playerId] = 0;
_pendingNotificationCount[playerId]++;
if (!_pendingFlushTimers.Contains(playerId))
{
_pendingFlushTimers.Add(playerId);
timer.Once(NOTIFICATION_COOLDOWN - timeSinceLast + 0.1f, () => FlushPendingNotifications(playerId));
}
return;
}
}
_pendingFlushTimers.Remove(playerId);
SendLandingNotification(playerId, isInstantDrop);
}
private void SendLandingNotification(ulong playerId, bool isInstantDrop, bool isFlush = false)
{
var player = BasePlayer.FindByID(playerId);
if (player == null || !player.IsConnected)
{
_pendingNotificationCount.Remove(playerId);
_pendingFlushTimers.Remove(playerId);
return;
}
_pendingNotificationCount.TryGetValue(playerId, out int pendingCount);
_pendingNotificationCount.Remove(playerId);
int totalDrops = isFlush ? pendingCount : pendingCount + 1;
string message = totalDrops > 1
? $"<size=16><color=#2ecc71>SupplyLock</color></size>\n<size=13>{totalDrops} supply drops have landed and are locked to you!</size>"
: isInstantDrop ? _config.Messages.OwnerNotifyInstantFormat : _config.Messages.OwnerNotifyFormat;
player.ChatMessage(message);
_lastNotificationTime[playerId] = Time.realtimeSinceStartup;
}
private void FlushPendingNotifications(ulong playerId)
{
_pendingFlushTimers.Remove(playerId);
if (_pendingNotificationCount.TryGetValue(playerId, out int count) && count > 0)
SendLandingNotification(playerId, false, true);
}
private void OnEntityKill(SupplySignal signal)
{
if (signal?.net == null)
return;
_trackedSignalEntities.Remove(signal.net.ID);
if (_activeSignals.TryGetValue(signal.net.ID, out var signalData))
{
signalData.Position = signal.transform.position;
signalData.IsLanded = true;
}
}
private void OnEntitySpawned(SupplyDrop drop)
{
if (drop?.net?.ID == null)
return;
Vector3 spawnPosition = drop.transform.position;
DebugLog($"Drop spawned [NetID: {drop.net.ID}] at {spawnPosition}");
NextTick(() => ProcessSupplyDrop(drop, spawnPosition));
}
private void ProcessSupplyDrop(SupplyDrop drop, Vector3 spawnPosition)
{
if (drop == null || drop.IsDestroyed)
return;
CleanupExpiredSignals();
bool isInstantDrop = IsInstantAirdropDrop(drop);
if (isInstantDrop && _config.InstantAirdropIntegration.Enabled && !_config.InstantAirdropIntegration.LockInstantDrops)
return;
float matchRadius = isInstantDrop && _config.InstantAirdropIntegration.Enabled
? _config.InstantAirdropIntegration.InstantDropMatchRadius
: _config.General.SignalMatchRadius;
var matchedSignal = FindMatchingSignal(spawnPosition, matchRadius);
if (matchedSignal == null && _activeSignals.Count > 0)
matchedSignal = TryMatchFromNearbySignalEntities(spawnPosition, matchRadius);
if (matchedSignal != null)
{
var owner = BasePlayer.FindByID(matchedSignal.PlayerId);
string ownerName = owner?.displayName ?? matchedSignal.PlayerName ?? "Unknown";
_lockedDrops[drop.net.ID] = new SupplyDropData(matchedSignal.PlayerId, ownerName, isInstantDrop);
_activeSignals.Remove(matchedSignal.SignalNetId);
_trackedSignalEntities.Remove(matchedSignal.SignalNetId);
DebugLog($"Drop matched to {ownerName}");
if (_config.Messages.NotifyOwnerOnSpawn)
{
if (isInstantDrop)
QueueLandingNotification(matchedSignal.PlayerId, true);
else
_pendingLandingNotifications[drop.net.ID] = drop;
}
float unlockTime = isInstantDrop && _config.InstantAirdropIntegration.Enabled
? _config.InstantAirdropIntegration.InstantDropUnlockSeconds
: _config.General.UnlockAfterSeconds;
if (unlockTime > 0)
timer.Once(unlockTime, () => UnlockDrop(drop));
}
else if (_config.General.LockRandomAirdrops)
{
DebugLog("No matching signal found, random airdrop locking not implemented");
}
}
private void OnEntityKill(SupplyDrop drop)
{
if (drop?.net?.ID == null)
return;
_pendingLandingNotifications.Remove(drop.net.ID);
_lockedDrops.Remove(drop.net.ID);
}
private object CanLootEntity(BasePlayer player, SupplyDrop drop)
{
if (player == null || drop?.net?.ID == null)
return null;
if (!_lockedDrops.TryGetValue(drop.net.ID, out var dropData))
return null;
if (HasAccess(player, dropData.OwnerId))
return null;
if (_config.Messages.ShowLockMessage)
player.ChatMessage(string.Format(_config.Messages.LockMessageFormat, dropData.OwnerName));
return false;
}
#endregion
#region Main
private void DebugLog(string message)
{
if (_config.DebugEnabled)
Puts($"[DEBUG] {message}");
}
private bool IsInstantAirdropDrop(SupplyDrop drop)
{
if (!_config.InstantAirdropIntegration.Enabled || InstantAirdrop == null || !InstantAirdrop.IsLoaded)
return false;
try { return InstantAirdrop.Call<bool>("IsInstantAirdrop", drop); }
catch { return false; }
}
private SupplySignalData FindMatchingSignal(Vector3 dropPosition, float matchRadius)
{
var candidates = new List<(SupplySignalData Signal, float Distance)>();
foreach (var kvp in _activeSignals)
{
var signal = kvp.Value;
if (signal.IsExpired(_config.General.SignalExpirySeconds))
continue;
float horizontalDist = Vector3.Distance(
new Vector3(signal.Position.x, 0, signal.Position.z),
new Vector3(dropPosition.x, 0, dropPosition.z)
);
if (horizontalDist <= matchRadius)
candidates.Add((signal, horizontalDist));
}
if (candidates.Count == 0)
return null;
if (candidates.Count == 1)
return candidates[0].Signal;
var landedCandidates = candidates.Where(c => c.Signal.IsLanded).ToList();
if (landedCandidates.Count == 1)
return landedCandidates[0].Signal;
var workingCandidates = landedCandidates.Count > 0 ? landedCandidates : candidates;
var sorted = workingCandidates.OrderBy(c => c.Distance).ToList();
if (sorted.Count >= 2 && sorted[1].Distance - sorted[0].Distance > 10f)
return sorted[0].Signal;
return workingCandidates.OrderByDescending(c => c.Signal.Age).ThenBy(c => c.Signal.SignalNetId.Value).First().Signal;
}
private SupplySignalData TryMatchFromNearbySignalEntities(Vector3 dropPosition, float searchRadius)
{
var nearbySignals = new List<SupplySignal>();
Vis.Entities(dropPosition, searchRadius * 1.5f, nearbySignals);
foreach (var signalEntity in nearbySignals)
{
if (signalEntity?.net == null)
continue;
if (_activeSignals.TryGetValue(signalEntity.net.ID, out var signalData))
{
Vector3 actualPos = signalEntity.transform.position;
float distToActual = Vector3.Distance(
new Vector3(actualPos.x, 0, actualPos.z),
new Vector3(dropPosition.x, 0, dropPosition.z)
);
if (distToActual <= searchRadius)
{
signalData.Position = actualPos;
signalData.IsLanded = true;
return signalData;
}
}
}
return null;
}
private void CleanupExpiredSignals()
{
var expired = _activeSignals.Where(kvp => kvp.Value.IsExpired(_config.General.SignalExpirySeconds)).Select(kvp => kvp.Key).ToList();
foreach (var signalId in expired)
_activeSignals.Remove(signalId);
}
private bool HasAccess(BasePlayer player, ulong ownerId)
{
if (player.userID == ownerId)
return true;
if (_config.Access.EnableTeamAccess && IsTeammate(player.userID, ownerId))
return true;
if (_config.Access.EnableClanAccess && IsClanMember(player.userID, ownerId))
return true;
return false;
}
private bool IsTeammate(ulong playerId, ulong ownerId)
{
var ownerTeam = RelationshipManager.ServerInstance?.FindPlayersTeam(ownerId);
return ownerTeam != null && ownerTeam.members.Contains(playerId);
}
private bool IsClanMember(ulong playerId, ulong ownerId)
{
if (Clans != null && Clans.IsLoaded)
{
try
{
var playerClan = Clans.Call<string>("GetClanOf", playerId);
var ownerClan = Clans.Call<string>("GetClanOf", ownerId);
if (!string.IsNullOrEmpty(playerClan) && playerClan == ownerClan)
return true;
}
catch { }
}
if (ClansReborn != null && ClansReborn.IsLoaded)
{
try
{
var playerClan = ClansReborn.Call<string>("GetClanOf", playerId);
var ownerClan = ClansReborn.Call<string>("GetClanOf", ownerId);
if (!string.IsNullOrEmpty(playerClan) && playerClan == ownerClan)
return true;
}
catch { }
}
return false;
}
private void UnlockDrop(SupplyDrop drop)
{
if (drop == null || drop.IsDestroyed)
return;
if (!_lockedDrops.TryGetValue(drop.net.ID, out var dropData))
return;
_lockedDrops.Remove(drop.net.ID);
var owner = BasePlayer.FindByID(dropData.OwnerId);
if (owner != null && owner.IsConnected)
owner.ChatMessage(_config.Messages.UnlockedMessageFormat);
}
#endregion
#region Commands
[ConsoleCommand("supplylock.debug")]
private void CmdDebugToggle(ConsoleSystem.Arg arg)
{
if (!arg.IsAdmin)
return;
_config.DebugEnabled = !_config.DebugEnabled;
SaveConfig();
arg.ReplyWith($"SupplyLock debug mode: {(_config.DebugEnabled ? "ENABLED" : "DISABLED")}");
}
[ConsoleCommand("supplylock.status")]
private void CmdStatus(ConsoleSystem.Arg arg)
{
if (!arg.IsAdmin)
return;
arg.ReplyWith($"SupplyLock Status:\n" +
$" Active signals: {_activeSignals.Count}\n" +
$" Locked drops: {_lockedDrops.Count}\n" +
$" Debug mode: {(_config.DebugEnabled ? "ON" : "OFF")}\n" +
$" InstantAirdrop: {(InstantAirdrop != null && InstantAirdrop.IsLoaded ? "Detected" : "Not found")}\n" +
$" Clans: {(Clans != null && Clans.IsLoaded ? "Detected" : "Not found")}\n" +
$" ClansReborn: {(ClansReborn != null && ClansReborn.IsLoaded ? "Detected" : "Not found")}");
}
#endregion
#region API
private bool IsDropLocked(SupplyDrop drop)
=> drop?.net?.ID != null && _lockedDrops.ContainsKey(drop.net.ID);
private ulong GetDropOwner(SupplyDrop drop)
{
if (drop?.net?.ID != null && _lockedDrops.TryGetValue(drop.net.ID, out var data))
return data.OwnerId;
return 0;
}
private bool CanPlayerLoot(BasePlayer player, SupplyDrop drop)
{
if (player == null || drop?.net?.ID == null || !_lockedDrops.TryGetValue(drop.net.ID, out var data))
return true;
return HasAccess(player, data.OwnerId);
}
private void ForceUnlock(SupplyDrop drop)
{
if (drop?.net?.ID != null)
_lockedDrops.Remove(drop.net.ID);
}
private void ForceLock(SupplyDrop drop, ulong ownerId, string ownerName = null)
{
if (drop?.net?.ID == null)
return;
ownerName ??= BasePlayer.FindByID(ownerId)?.displayName ?? "Unknown";
_lockedDrops[drop.net.ID] = new SupplyDropData(ownerId, ownerName);
}
#endregion
}
}