C#:
using System;
using System.Collections.Generic;
using System.Globalization;
using Newtonsoft.Json;
using Oxide.Core.Plugins;
using UnityEngine;
namespace Oxide.Plugins
{
[Info("QuickStack", "Arkto", "1.1.3")]
[Description("Quickly moves matching inventory items into storage containers.")]
public class QuickStack : RustPlugin
{
private const string PermUse = "quickstack.use";
private const string UiName = "QuickStack.UI";
private const string UiParent = "Hud.Menu";
private ConfigData _config;
private class ConfigData
{
[JsonProperty("Inventory")]
public InventoryConfig Inventory = new InventoryConfig();
[JsonProperty("Containers")]
public ContainerConfig Containers = new ContainerConfig();
[JsonProperty("UI")]
public UiConfig UI = new UiConfig();
[JsonProperty("Notifications")]
public NotificationConfig Notifications = new NotificationConfig();
[JsonProperty("Quick Command")]
public CommandConfig Command = new CommandConfig();
}
private class UiConfig
{
[JsonProperty("Enabled")] public bool Enabled = true;
[JsonProperty("Button Text")] public string Text = "STACK";
[JsonProperty("Button Color")] public string Color = "#333D30F2";
[JsonProperty("Text Color")] public string TextColor = "#E6E6DB";
[JsonProperty("Position X")] public float PositionX = 120f;
[JsonProperty("Position Y")] public float PositionY = 355f;
[JsonProperty("Width")] public float Width = 60f;
[JsonProperty("Height")] public float Height = 20f;
// Kept only for automatic migration from <= 1.1.5 configs.
[JsonProperty("Offset Min", NullValueHandling = NullValueHandling.Ignore)] public string LegacyOffsetMin;
[JsonProperty("Offset Max", NullValueHandling = NullValueHandling.Ignore)] public string LegacyOffsetMax;
}
private class CommandConfig
{
[JsonProperty("Enabled")] public bool Enabled = true;
[JsonProperty("Command")] public string Name = "qs";
[JsonProperty("Raycast Distance")] public float Distance = 3f;
}
private class InventoryConfig
{
[JsonProperty("Use Main Inventory")] public bool Main = true;
[JsonProperty("Use Belt")] public bool Belt = false;
[JsonProperty("Use Wear")] public bool Wear = false;
}
private class ContainerConfig
{
[JsonProperty("Storage Containers")] public bool Storage = true;
[JsonProperty("Tool Cupboards")] public bool Cupboards = true;
[JsonProperty("Furnaces")] public bool Furnaces = false;
[JsonProperty("Recyclers")] public bool Recyclers = false;
[JsonProperty("Vending Machines")] public bool Vending = false;
[JsonProperty("Allow Prefabs", ObjectCreationHandling = ObjectCreationHandling.Replace)] public List<string> Allow = new List<string>();
[JsonProperty("Deny Prefabs", ObjectCreationHandling = ObjectCreationHandling.Replace)] public List<string> Deny = new List<string>();
}
private class NotificationConfig
{
[JsonProperty("Enabled")] public bool Enabled = true;
[JsonProperty("Show Total Types")] public bool ShowTypes = true;
[JsonProperty("Show Item Breakdown")] public bool ShowItems = true;
[JsonProperty("Max Item Lines")] public int MaxLines = 8;
}
protected override void LoadDefaultConfig()
{
_config = new ConfigData();
SaveConfig();
}
protected override void LoadConfig()
{
base.LoadConfig();
try
{
_config = Config.ReadObject<ConfigData>() ?? new ConfigData();
}
catch
{
PrintWarning("Invalid configuration. Creating a new one.");
_config = new ConfigData();
}
ValidateConfig();
SaveConfig();
}
protected override void SaveConfig() => Config.WriteObject(_config, true);
private void ValidateConfig()
{
if (_config.Inventory == null) _config.Inventory = new InventoryConfig();
if (_config.Containers == null) _config.Containers = new ContainerConfig();
if (_config.UI == null) _config.UI = new UiConfig();
if (_config.Notifications == null) _config.Notifications = new NotificationConfig();
if (_config.Command == null) _config.Command = new CommandConfig();
if (_config.Containers.Allow == null) _config.Containers.Allow = new List<string>();
if (_config.Containers.Deny == null) _config.Containers.Deny = new List<string>();
MigrateUiOffsets();
_config.UI.Width = Mathf.Max(1f, _config.UI.Width);
_config.UI.Height = Mathf.Max(1f, _config.UI.Height);
_config.Notifications.MaxLines = Mathf.Max(0, _config.Notifications.MaxLines);
_config.Command.Distance = Mathf.Max(0.5f, _config.Command.Distance);
}
private void MigrateUiOffsets()
{
if (string.IsNullOrWhiteSpace(_config.UI.LegacyOffsetMin) ||
string.IsNullOrWhiteSpace(_config.UI.LegacyOffsetMax))
return;
bool oldOverlayDefault = _config.UI.LegacyOffsetMin == "120 -57" &&
_config.UI.LegacyOffsetMax == "180 -37";
bool oldInventoryDefault = _config.UI.LegacyOffsetMin == "120 -20" &&
_config.UI.LegacyOffsetMax == "180 0";
if (oldOverlayDefault || oldInventoryDefault)
{
// Known defaults from older layouts need the new Hud.Menu coordinate space.
_config.UI.PositionX = 120f;
_config.UI.PositionY = 345f;
_config.UI.Width = 60f;
_config.UI.Height = 20f;
}
else
{
float minX, minY, maxX, maxY;
if (TryParseOffset(_config.UI.LegacyOffsetMin, out minX, out minY) &&
TryParseOffset(_config.UI.LegacyOffsetMax, out maxX, out maxY))
{
// Preserve custom 1.1.5 placement while separating position from size.
_config.UI.PositionX = minX;
_config.UI.PositionY = minY;
_config.UI.Width = Mathf.Max(1f, maxX - minX);
_config.UI.Height = Mathf.Max(1f, maxY - minY);
}
}
// Remove legacy fields from the next saved config.
_config.UI.LegacyOffsetMin = null;
_config.UI.LegacyOffsetMax = null;
}
private bool TryParseOffset(string value, out float x, out float y)
{
x = 0f;
y = 0f;
if (string.IsNullOrWhiteSpace(value))
return false;
var parts = value.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
return parts.Length == 2 &&
float.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out x) &&
float.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out y);
}
private void Init()
{
permission.RegisterPermission(PermUse, this);
RegisterMessages();
if (_config.Command.Enabled && !string.IsNullOrWhiteSpace(_config.Command.Name))
{
cmd.AddConsoleCommand(_config.Command.Name, this, nameof(CommandQuickStack));
cmd.AddChatCommand(_config.Command.Name, this, nameof(ChatQuickStack));
}
}
private void Unload()
{
foreach (var player in BasePlayer.activePlayerList)
DestroyUi(player);
}
private void RegisterMessages()
{
lang.RegisterMessages(new Dictionary<string, string>
{
["NoPermission"] = "You don't have permission to use QuickStack.",
["NoContainer"] = "Look at or open a supported container.",
["NothingToStack"] = "Nothing to stack.",
["StoredTypes"] = "QuickStack: stored {0} different item{1}.",
["MoreTypes"] = "+{0} more item type{1}"
}, this);
}
private string Lang(string key, BasePlayer player)
{
return lang.GetMessage(key, this, player?.UserIDString);
}
private void OnLootEntity(BasePlayer player, BaseEntity entity)
{
if (!_config.UI.Enabled || !CanUse(player) || !IsAllowed(entity))
return;
NextTick(() =>
{
if (player == null || !player.IsConnected || player.inventory?.loot?.entitySource != entity)
return;
ShowUi(player);
});
}
private void OnLootEntityEnd(BasePlayer player, BaseCombatEntity entity) => DestroyUi(player);
[ConsoleCommand("quickstack.ui")]
private void UiQuickStack(ConsoleSystem.Arg arg)
{
var player = arg.Player();
if (!CanUse(player))
return;
var entity = player.inventory?.loot?.entitySource;
if (entity == null || !IsAllowed(entity))
return;
QuickStackItems(player, GetInventory(entity), true);
}
private void CommandQuickStack(ConsoleSystem.Arg arg)
{
var player = arg.Player();
if (player != null)
RunCommand(player);
}
private void ChatQuickStack(BasePlayer player, string command, string[] args) => RunCommand(player);
private void RunCommand(BasePlayer player)
{
if (!CanUse(player))
{
SendReply(player, Lang("NoPermission", player));
return;
}
var entity = player.inventory?.loot?.entitySource ?? LookEntity(player);
if (entity == null || !IsAllowed(entity))
{
SendReply(player, Lang("NoContainer", player));
return;
}
if (QuickStackItems(player, GetInventory(entity), true) == 0)
SendReply(player, Lang("NothingToStack", player));
}
private int QuickStackItems(BasePlayer player, ItemContainer target, bool notify)
{
if (player == null || target == null || target.itemList.Count == 0)
return 0;
// Snapshot before moving anything: only item types already in the target qualify.
var allowed = new HashSet<int>();
for (int i = 0; i < target.itemList.Count; i++)
{
var targetItem = target.itemList[i];
if (targetItem?.info != null)
allowed.Add(targetItem.info.itemid);
}
var moved = new Dictionary<int, int>();
foreach (var source in SourceContainers(player))
{
foreach (var item in source.itemList.ToArray())
{
if (item?.info == null || !allowed.Contains(item.info.itemid))
continue;
int itemId = item.info.itemid;
int before = item.amount;
item.MoveToContainer(target, -1, true);
int remaining = item.parent == source ? item.amount : 0;
int amount = before - remaining;
if (amount <= 0)
continue;
int current;
moved.TryGetValue(itemId, out current);
moved[itemId] = current + amount;
}
}
int total = 0;
foreach (var entry in moved)
total += entry.Value;
if (total == 0)
return 0;
target.MarkDirty();
player.inventory.ServerUpdate(0f);
if (notify && _config.Notifications.Enabled)
Notify(player, moved);
return total;
}
private IEnumerable<ItemContainer> SourceContainers(BasePlayer player)
{
if (_config.Inventory.Main && player.inventory.containerMain != null)
yield return player.inventory.containerMain;
if (_config.Inventory.Belt && player.inventory.containerBelt != null)
yield return player.inventory.containerBelt;
if (_config.Inventory.Wear && player.inventory.containerWear != null)
yield return player.inventory.containerWear;
}
private void Notify(BasePlayer player, Dictionary<int, int> moved)
{
var lines = new List<string>();
if (_config.Notifications.ShowTypes)
lines.Add(string.Format(Lang("StoredTypes", player), moved.Count, moved.Count == 1 ? string.Empty : "s"));
if (_config.Notifications.ShowItems)
{
int max = Mathf.Max(0, _config.Notifications.MaxLines);
var entries = new List<KeyValuePair<int, int>>(moved);
entries.Sort((a, b) => b.Value.CompareTo(a.Value));
int shown = Math.Min(entries.Count, max);
for (int i = 0; i < shown; i++)
{
var entry = entries[i];
var def = ItemManager.FindItemDefinition(entry.Key);
string name = def?.displayName?.english ?? def?.shortname ?? entry.Key.ToString();
lines.Add($"{name}: +{entry.Value:N0}");
}
int hidden = moved.Count - shown;
if (hidden > 0)
lines.Add(string.Format(Lang("MoreTypes", player), hidden, hidden == 1 ? string.Empty : "s"));
}
if (lines.Count > 0)
SendReply(player, string.Join("\n", lines));
}
private void ShowUi(BasePlayer player)
{
DestroyUi(player);
var ui = new Oxide.Game.Rust.Cui.CuiElementContainer();
ui.Add(new Oxide.Game.Rust.Cui.CuiButton
{
Button =
{
Command = "quickstack.ui",
Color = CuiColor(_config.UI.Color, "0.20 0.24 0.19 0.95")
},
RectTransform =
{
// Rust's inventory is laid out from the lower center of Hud.Menu.
// Position is the button's lower-left offset from that anchor; size is configured separately.
AnchorMin = "0.5 0.0",
AnchorMax = "0.5 0.0",
OffsetMin = FormatOffset(_config.UI.PositionX, _config.UI.PositionY),
OffsetMax = FormatOffset(_config.UI.PositionX + _config.UI.Width, _config.UI.PositionY + _config.UI.Height)
},
Text =
{
Text = _config.UI.Text,
FontSize = 12,
Align = TextAnchor.MiddleCenter,
Color = CuiColor(_config.UI.TextColor, "0.90 0.90 0.86 1")
}
}, UiParent, UiName);
Oxide.Game.Rust.Cui.CuiHelper.AddUi(player, ui);
}
private void DestroyUi(BasePlayer player)
{
if (player != null)
Oxide.Game.Rust.Cui.CuiHelper.DestroyUi(player, UiName);
}
private bool CanUse(BasePlayer player) =>
player != null && permission.UserHasPermission(player.UserIDString, PermUse);
private ItemContainer GetInventory(BaseEntity entity)
{
if (entity is StorageContainer storage) return storage.inventory;
if (entity is BuildingPrivlidge cupboard) return cupboard.inventory;
if (entity is Recycler recycler) return recycler.inventory;
if (entity is VendingMachine vending) return vending.inventory;
if (entity is BaseOven oven) return oven.inventory;
return null;
}
private bool IsAllowed(BaseEntity entity)
{
if (entity == null || GetInventory(entity) == null)
return false;
string prefab = entity.ShortPrefabName ?? string.Empty;
if (Matches(prefab, _config.Containers.Deny)) return false;
if (Matches(prefab, _config.Containers.Allow)) return true;
if (entity is BuildingPrivlidge) return _config.Containers.Cupboards;
if (entity is Recycler) return _config.Containers.Recyclers;
if (entity is VendingMachine) return _config.Containers.Vending;
if (entity is BaseOven) return _config.Containers.Furnaces;
return entity is StorageContainer && _config.Containers.Storage;
}
private bool Matches(string prefab, List<string> list)
{
if (list == null)
return false;
for (int i = 0; i < list.Count; i++)
{
string value = list[i];
if (!string.IsNullOrWhiteSpace(value) &&
prefab.IndexOf(value.Trim(), StringComparison.OrdinalIgnoreCase) >= 0)
return true;
}
return false;
}
private BaseEntity LookEntity(BasePlayer player)
{
RaycastHit hit;
var ray = new Ray(player.eyes.position, player.eyes.HeadForward());
int mask = LayerMask.GetMask("Construction", "Deployed", "Default");
return Physics.Raycast(ray, out hit, Mathf.Max(0.5f, _config.Command.Distance), mask,
QueryTriggerInteraction.Ignore) ? hit.GetEntity() : null;
}
private string FormatOffset(float x, float y)
{
return string.Format(CultureInfo.InvariantCulture, "{0:0.###} {1:0.###}", x, y);
}
private string CuiColor(string value, string fallback)
{
if (string.IsNullOrWhiteSpace(value))
return fallback;
string input = value.Trim();
// Keep native Rust CUI RGBA untouched.
var parts = input.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 4)
{
bool valid = true;
float number;
for (int i = 0; i < parts.Length; i++)
{
if (float.TryParse(parts[i], NumberStyles.Float, CultureInfo.InvariantCulture, out number))
continue;
valid = false;
break;
}
if (valid)
return input;
}
// Convert #RRGGBB / #RRGGBBAA to normalized CUI RGBA.
string hex = input.TrimStart('#');
if (hex.Length != 6 && hex.Length != 8)
return fallback;
uint rgba;
if (!uint.TryParse(hex, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out rgba))
return fallback;
byte r, g, b, a;
if (hex.Length == 6)
{
r = (byte)(rgba >> 16);
g = (byte)(rgba >> 8);
b = (byte)rgba;
a = 255;
}
else
{
r = (byte)(rgba >> 24);
g = (byte)(rgba >> 16);
b = (byte)(rgba >> 8);
a = (byte)rgba;
}
return string.Format(CultureInfo.InvariantCulture, "{0:0.###} {1:0.###} {2:0.###} {3:0.###}",
r / 255f, g / 255f, b / 255f, a / 255f);
}
[HookMethod("API_QuickStack")]
private object API_QuickStack(BasePlayer player, BaseEntity entity)
{
if (!CanUse(player) || !IsAllowed(entity))
return 0;
return QuickStackItems(player, GetInventory(entity), false);
}
}
}