C#:
/*
* Dungeon Events Loot Editor
* Copyright (c) 2026 illknown. All rights reserved.
*
* FREE-TO-USE SERVER LICENCE
*
* Permission is granted to users who obtain this plugin from its official
* Codefling listing to install and use it on Rust servers they own or manage.
* Private modifications are permitted for use on those servers only, provided
* this copyright and licence notice remains complete and unchanged.
*
* You may not redistribute, re-upload, share, sell, sublicense, publish, or
* otherwise make the original or a modified copy of this plugin available to
* any third party. You may not remove or obscure the author attribution.
* Written permission from illknown is required for any other use.
*
* Official distribution:
* https://codefling.com/plugins/dungeon-events-loot-editor
*
* CHANGELOG
* v1.1.0
* - Completely redesigned the editor with a polished Rust-themed interface.
* - Added an original paginated item catalogue using live Rust definitions,
* native categories, compact icons, search, and selectable detail rows.
* - Removed automatic and manual tier-folder creation; only existing
* DungeonEvents tier folders are scanned.
* - Added a true/false option to fill every missing or empty tier loot profile
* from Default_Loot.json on first load without overwriting customised loot.
* - Added Rust shortname validation, safer paging, and clearer backup controls.
* - Changed search inputs to start genuinely empty with external field labels,
* so clicking a search bar never requires deleting instructional text.
* - Smoothed navigation by batching rapid UI actions into one next-frame
* redraw and keeping a persistent visual frame behind changing content.
* - Added live item icons and Rust display names to the current loot table for
* each tier and loot source, plus a selected-item preview in the editor.
* - Added the free-to-use server licence and permanent author attribution.
*
* This software is provided "as is", without warranty of any kind, express or
* implied. The author is not liable for any claim, damage, or other liability
* arising from its use.
*/
using System;
using System.IO;
using System.Linq;
using System.Collections.Generic;
using System.Globalization;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Oxide.Core;
using Oxide.Game.Rust.Cui;
using UnityEngine;
namespace Oxide.Plugins {
[Info("Dungeon Events Loot Editor", "illknown", "1.1.0")]
[Description("Polished in-game loot table editor for existing DungeonEvents tier data files.")]
public class DungeonEventsLootEditor : RustPlugin {
private const string PermUse = "dungeoneventslooteditor.admin";
private const string Ui = "DungeonEventsLootEditor.UI";
private const string Content = "DungeonEventsLootEditor.Content";
private const string ColorBackdrop = "0.025 0.023 0.021 0.985";
private const string ColorHeader = "0.055 0.050 0.046 0.995";
private const string ColorSurface = "0.075 0.070 0.066 0.990";
private const string ColorSurfaceRaised = "0.105 0.098 0.091 0.990";
private const string ColorSurfaceHover = "0.135 0.125 0.115 0.990";
private const string ColorAccent = "0.820 0.325 0.090 1.000";
private const string ColorAccentDark = "0.410 0.150 0.045 1.000";
private const string ColorSuccess = "0.235 0.455 0.245 1.000";
private const string ColorDanger = "0.600 0.125 0.105 1.000";
private const string ColorWarning = "0.470 0.245 0.075 1.000";
private const string ColorText = "0.965 0.945 0.920 1.000";
private const string ColorMuted = "0.675 0.640 0.600 1.000";
private PluginConfig _config;
private Timer _lockTimer;
private readonly Dictionary<ulong, UiState> _states = new Dictionary<ulong, UiState>();
private readonly Dictionary<ulong, EditBuffer> _buffers = new Dictionary<ulong, EditBuffer>();
private readonly Dictionary<ulong, Vector3> _lockedPositions = new Dictionary<ulong, Vector3>();
private readonly HashSet<ulong> _openPlayers = new HashSet<ulong>();
private readonly HashSet<ulong> _backupClearConfirm = new HashSet<ulong>();
private readonly HashSet<ulong> _queuedDraws = new HashSet<ulong>();
private static readonly string[] PickerCategories = {
"All", "Weapon", "Ammunition", "Resources", "Tool", "Attire", "Medical", "Food", "Traps", "Misc", "Items", "Component", "Construction", "Electrical", "Fun"
}
;
private class PluginConfig {
public string DataRootFolder = "DungeonEvents";
public string BackupFolder = "DungeonEventsLootEditor_Backups";
[JsonProperty("Fill empty tier profiles with Default_Loot.json on first load")]
public bool FillEmptyProfilesWithDefaultLoot = true;
[JsonProperty("Default loot seed completed")]
public bool DefaultLootSeedCompleted = false;
public bool ReloadDungeonEventsAfterSave = false;
public bool ShowReloadButton = false;
public bool BlockMovementWhileOpen = true;
public float MovementLockCheckInterval = 0.20f;
public float MovementLockDistance = 0.60f;
public int ItemsPerPage = 15;
public int PickerItemsPerPage = 10;
public int MaxBackupsPerLootFile = 30;
public List<string> LootFiles = new List<string> {
"lootbox.json", "npc_loot.json", "boss_loot.json"
}
;
[JsonProperty("Preferred tier order")]
public List<string> PreferredTierOrder = new List<string> {
"Easy", "Medium", "Hard", "Expert", "Nightmare", "Impossible"
}
;
}
private class UiState {
public string Tier = "";
public string File = "lootbox.json";
public string View = "Browse";
public int Page = 0;
public int Selected = -1;
public string Search = "";
public int PickerPage = 0;
public string PickerSearch = "";
public string PickerCategory = "All";
}
private class EditBuffer {
public string ShortName = "scrap";
public string Chance = "100";
public string Min = "1";
public string Max = "1";
public string SkinID = "0";
public string CustomName = "";
}
protected override void LoadDefaultConfig() {
_config = new PluginConfig();
SaveConfig();
}
protected override void LoadConfig() {
base.LoadConfig();
try {
_config = Config.ReadObject<PluginConfig>();
if (_config == null) throw new Exception("Config returned null");
NormalizeConfig();
SaveConfig();
}
catch {
PrintWarning("Config invalid. Creating a new default config.");
LoadDefaultConfig();
}
}
protected override void SaveConfig() {
Config.WriteObject(_config, true);
}
private void NormalizeConfig() {
if (_config.LootFiles == null || _config.LootFiles.Count == 0) _config.LootFiles = new List<string> {
"lootbox.json", "npc_loot.json", "boss_loot.json"
}
;
_config.LootFiles = _config.LootFiles .Where(x => !string.IsNullOrWhiteSpace(x)) .Select(CleanFile) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList();
if (_config.PreferredTierOrder == null || _config.PreferredTierOrder.Count == 0) _config.PreferredTierOrder = new List<string> {
"Easy", "Medium", "Hard", "Expert", "Nightmare", "Impossible"
}
;
_config.PreferredTierOrder = _config.PreferredTierOrder .Where(x => !string.IsNullOrWhiteSpace(x)) .Select(x => CleanName(CleanArg(x))) .Where(x => !string.IsNullOrEmpty(x)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList();
if (_config.ItemsPerPage < 8) _config.ItemsPerPage = 8;
if (_config.ItemsPerPage > 24) _config.ItemsPerPage = 24;
if (_config.PickerItemsPerPage < 8) _config.PickerItemsPerPage = 8;
if (_config.PickerItemsPerPage > 12) _config.PickerItemsPerPage = 12;
if (_config.MovementLockCheckInterval < 0.10f) _config.MovementLockCheckInterval = 0.10f;
if (_config.MovementLockCheckInterval > 1.00f) _config.MovementLockCheckInterval = 1.00f;
if (_config.MovementLockDistance < 0.15f) _config.MovementLockDistance = 0.15f;
if (_config.MovementLockDistance > 3.00f) _config.MovementLockDistance = 3.00f;
if (_config.MaxBackupsPerLootFile < 0) _config.MaxBackupsPerLootFile = 0;
}
private void Init() {
permission.RegisterPermission(PermUse, this);
_lockTimer = timer.Every(_config.MovementLockCheckInterval, LockOpenPlayers);
timer.Once(1f, TrySeedDefaultLootProfiles);
}
private void Unload() {
_lockTimer?.Destroy();
foreach (BasePlayer player in BasePlayer.activePlayerList) Destroy(player);
_states.Clear();
_buffers.Clear();
_lockedPositions.Clear();
_openPlayers.Clear();
_backupClearConfirm.Clear();
_queuedDraws.Clear();
}
private void OnPlayerDisconnected(BasePlayer player, string reason) {
if (player == null) return;
_states.Remove(player.userID);
_buffers.Remove(player.userID);
_lockedPositions.Remove(player.userID);
_openPlayers.Remove(player.userID);
_backupClearConfirm.Remove(player.userID);
_queuedDraws.Remove(player.userID);
}
private object OnPlayerInput(BasePlayer player, InputState input) {
if (!_config.BlockMovementWhileOpen || player == null || input == null) return null;
if (!_openPlayers.Contains(player.userID)) return null;
input.current.buttons = 0;
input.previous.buttons = 0;
return null;
}
private void LockOpenPlayers() {
if (!_config.BlockMovementWhileOpen || _openPlayers.Count == 0) return;
foreach (ulong userId in _openPlayers.ToArray()) {
BasePlayer player = BasePlayer.FindByID(userId);
if (player == null || !player.IsConnected) {
_openPlayers.Remove(userId);
_lockedPositions.Remove(userId);
continue;
}
Vector3 lockedPos;
if (!_lockedPositions.TryGetValue(userId, out lockedPos)) {
_lockedPositions[userId] = player.transform.position;
continue;
}
if (Vector3.Distance(player.transform.position, lockedPos) > _config.MovementLockDistance) player.Teleport(lockedPos);
}
}
private bool HasAccess(BasePlayer player) {
return player != null && (player.IsAdmin || permission.UserHasPermission(player.UserIDString, PermUse));
}
private string DataRootPath {
get {
return Path.Combine(Interface.Oxide.DataDirectory, _config.DataRootFolder);
}
}
private string BackupRootPath {
get {
return Path.Combine(Interface.Oxide.DataDirectory, _config.BackupFolder);
}
}
private string CleanArg(string value) {
if (string.IsNullOrWhiteSpace(value)) return "";
value = value.Trim();
while (value.Length >= 2 && ((value[0] == '"' && value[value.Length - 1] == '"') || (value[0] == '\'' && value[value.Length - 1] == '\''))) value = value.Substring(1, value.Length - 2).Trim();
value = value.Replace("\\\"", "\"");
value = value.Replace("\\'", "'");
return value.Trim();
}
private string CleanName(string value) {
value = CleanArg(value);
if (string.IsNullOrWhiteSpace(value)) return "";
foreach (char c in Path.GetInvalidFileNameChars()) value = value.Replace(c.ToString(), "");
return value.Trim();
}
private string CleanFile(string value) {
value = CleanName(value);
if (string.IsNullOrEmpty(value)) value = "lootbox.json";
if (!value.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) value += ".json";
return value;
}
private string Quote(string value) {
if (value == null) value = "";
return "\"" + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"";
}
private string TierPath(string tier) {
return Path.Combine(DataRootPath, CleanName(tier));
}
private string LootPath(string tier, string file) {
return Path.Combine(TierPath(tier), CleanFile(file));
}
private List<string> GetTiers() {
if (!Directory.Exists(DataRootPath)) return new List<string>();
List<string> tiers = Directory.GetDirectories(DataRootPath) .Select(Path.GetFileName) .Where(x => !string.IsNullOrWhiteSpace(x)) .ToList();
List<string> ordered = new List<string>();
foreach (string preferred in _config.PreferredTierOrder) {
string match = tiers.FirstOrDefault(x => x.Equals(preferred, StringComparison.OrdinalIgnoreCase));
if (match == null) continue;
ordered.Add(match);
tiers.Remove(match);
}
ordered.AddRange(tiers.OrderBy(x => x));
return ordered;
}
private UiState GetState(BasePlayer player) {
UiState state;
if (!_states.TryGetValue(player.userID, out state)) {
state = new UiState();
_states[player.userID] = state;
}
List<string> tiers = GetTiers();
if (tiers.Count == 0) state.Tier = "";
else if (string.IsNullOrWhiteSpace(state.Tier) || !tiers.Any(x => x.Equals(state.Tier, StringComparison.OrdinalIgnoreCase))) state.Tier = tiers[0];
state.File = CleanFile(state.File);
List<string> files = _config.LootFiles.Select(CleanFile).ToList();
if (files.Count > 0 && !files.Any(x => x.Equals(state.File, StringComparison.OrdinalIgnoreCase))) state.File = files[0];
state.View = NormalizeView(state.View);
if (string.IsNullOrWhiteSpace(state.PickerCategory) || !PickerCategories.Any(x => x.Equals(state.PickerCategory, StringComparison.OrdinalIgnoreCase))) state.PickerCategory = "All";
return state;
}
private EditBuffer GetBuffer(BasePlayer player) {
EditBuffer buffer;
if (!_buffers.TryGetValue(player.userID, out buffer)) {
buffer = NewBuffer();
_buffers[player.userID] = buffer;
}
return buffer;
}
private bool TierExists(UiState state) {
return state != null && !string.IsNullOrWhiteSpace(state.Tier) && Directory.Exists(TierPath(state.Tier));
}
private EditBuffer NewBuffer() {
return new EditBuffer {
ShortName = "scrap", Chance = "100", Min = "1", Max = "1", SkinID = "0", CustomName = ""
}
;
}
private EditBuffer BufferFromItem(JObject item) {
if (item == null) return NewBuffer();
return new EditBuffer {
ShortName = item["ShortName"]?.ToString() ?? "scrap", Chance = item["Inclusion Chance Percentage"]?.ToString() ?? "100", Min = item["Minimum Amount"]?.ToString() ?? "1", Max = item["Maximum Amount"]?.ToString() ?? "1", SkinID = item["SkinID"]?.ToString() ?? "0", CustomName = item["CustomName"]?.ToString() ?? ""
}
;
}
private JObject NewLootObject() {
return new JObject {
["Items"]
= new JArray()
}
;
}
private void TrySeedDefaultLootProfiles() {
if (!_config.FillEmptyProfilesWithDefaultLoot || _config.DefaultLootSeedCompleted) return;
if (!Directory.Exists(DataRootPath)) return;
string defaultPath = Path.Combine(DataRootPath, "Default_Loot.json");
if (!File.Exists(defaultPath)) {
PrintWarning("First-run loot seeding is waiting for DungeonEvents/Default_Loot.json.");
return;
}
JObject defaultLoot;
try {
JToken token = JToken.Parse(File.ReadAllText(defaultPath));
if (token is JObject) defaultLoot = (JObject)token;
else if (token is JArray) defaultLoot = new JObject {
["Items"]
= token
}
;
else throw new Exception("root must be a JSON object or array");
JArray defaultItems = defaultLoot["Items"] as JArray;
if (defaultItems == null || defaultItems.Count == 0) throw new Exception("the Items list is missing or empty");
}
catch (Exception ex) {
PrintWarning("Cannot seed tier loot from Default_Loot.json: " + ex.Message);
return;
}
List<string> tiers = GetTiers();
if (tiers.Count == 0) return;
int seededFiles = 0;
bool hadErrors = false;
foreach (string tier in tiers) {
foreach (string configuredFile in _config.LootFiles) {
string file = CleanFile(configuredFile);
string path = LootPath(tier, file);
bool shouldSeed;
if (!File.Exists(path)) {
shouldSeed = true;
}
else {
try {
string existingJson = File.ReadAllText(path);
if (string.IsNullOrWhiteSpace(existingJson)) {
shouldSeed = true;
}
else {
JToken existingToken = JToken.Parse(existingJson);
JObject existingObject = existingToken as JObject;
JArray existingItems = existingObject?["Items"] as JArray;
if (existingItems != null) shouldSeed = existingItems.Count == 0;
else if (existingToken is JArray && ((JArray)existingToken).Count == 0) shouldSeed = true;
else {
PrintWarning("Skipped first-run seed for invalid loot file: " + path);
hadErrors = true;
continue;
}
}
}
catch (Exception ex) {
PrintWarning("Skipped first-run seed for '" + path + "': " + ex.Message);
hadErrors = true;
continue;
}
}
if (!shouldSeed) continue;
try {
File.WriteAllText(path, defaultLoot.DeepClone().ToString(Formatting.Indented));
seededFiles++;
}
catch (Exception ex) {
PrintWarning("Failed to seed '" + path + "': " + ex.Message);
hadErrors = true;
}
}
}
if (hadErrors) {
PrintWarning("First-run loot seeding was not marked complete because one or more files could not be processed.");
return;
}
_config.DefaultLootSeedCompleted = true;
SaveConfig();
Puts("First-run default loot seed complete. Populated " + seededFiles + " empty tier loot file(s) across " + tiers.Count + " existing tier folder(s).");
}
private JObject LoadLoot(string tier, string file) {
string path = LootPath(tier, file);
if (!File.Exists(path)) return NewLootObject();
try {
JObject obj = JObject.Parse(File.ReadAllText(path));
if (!(obj["Items"] is JArray)) obj["Items"] = new JArray();
return obj;
}
catch (Exception ex) {
PrintWarning("Failed to read " + path + ": " + ex.Message);
return NewLootObject();
}
}
private JObject MakeItem(EditBuffer buffer) {
double chance;
int min;
int max;
ulong skin;
if (!double.TryParse(buffer.Chance, out chance)) chance = 0;
if (!int.TryParse(buffer.Min, out min)) min = 1;
if (!int.TryParse(buffer.Max, out max)) max = min;
if (!ulong.TryParse(buffer.SkinID, out skin)) skin = 0;
if (chance < 0) chance = 0;
if (chance > 100) chance = 100;
if (min < 0) min = 0;
if (max < min) max = min;
return new JObject {
["ShortName"]
= string.IsNullOrWhiteSpace(buffer.ShortName) ? "scrap" : buffer.ShortName.Trim(), ["Inclusion Chance Percentage"] = chance, ["Minimum Amount"] = min, ["Maximum Amount"] = max, ["CustomName"] = buffer.CustomName ?? "", ["SkinID"] = skin
}
;
}
private bool SaveLoot(string tier, string file, JObject obj) {
if (string.IsNullOrWhiteSpace(tier) || !Directory.Exists(TierPath(tier))) return false;
Backup(tier, file);
File.WriteAllText(LootPath(tier, file), obj.ToString(Formatting.Indented));
TrimBackups(tier, file);
if (_config.ReloadDungeonEventsAfterSave) Interface.Oxide.ReloadPlugin("DungeonEvents");
return true;
}
private void Backup(string tier, string file) {
string source = LootPath(tier, file);
if (!File.Exists(source)) return;
string folder = Path.Combine(BackupRootPath, CleanName(tier));
Directory.CreateDirectory(folder);
string backupName = Path.GetFileNameWithoutExtension(file) + "_" + DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss") + ".json";
File.Copy(source, Path.Combine(folder, backupName), true);
}
private void TrimBackups(string tier, string file) {
if (_config.MaxBackupsPerLootFile <= 0) return;
string folder = Path.Combine(BackupRootPath, CleanName(tier));
if (!Directory.Exists(folder)) return;
string prefix = Path.GetFileNameWithoutExtension(CleanFile(file)) + "_";
List<string> backups = Directory.GetFiles(folder, prefix + "*.json") .OrderByDescending(File.GetCreationTimeUtc) .ToList();
foreach (string oldBackup in backups.Skip(_config.MaxBackupsPerLootFile)) {
try {
File.Delete(oldBackup);
}
catch {
}
}
}
[ChatCommand("dloot")]
private void CmdLootEditor(BasePlayer player, string command, string[] args) {
if (!HasAccess(player)) {
SendReply(player, "No permission.");
return;
}
if (args.Length > 0 && args[0].Equals("close", StringComparison.OrdinalIgnoreCase)) {
Destroy(player);
return;
}
TrySeedDefaultLootProfiles();
Draw(player);
}
[ConsoleCommand("dlootui.close")]
private void UiClose(ConsoleSystem.Arg arg) {
BasePlayer player = arg.Player();
if (player != null) Destroy(player);
}
[ConsoleCommand("dlootui.view")]
private void UiView(ConsoleSystem.Arg arg) {
BasePlayer player = arg.Player();
if (!HasAccess(player)) return;
UiState state = GetState(player);
state.View = NormalizeView(CleanArg(arg.GetString(0, "Browse")));
Draw(player);
}
[ConsoleCommand("dlootui.tier")]
private void UiTier(ConsoleSystem.Arg arg) {
BasePlayer player = arg.Player();
if (!HasAccess(player)) return;
UiState state = GetState(player);
state.Tier = CleanName(arg.GetString(0, state.Tier));
state.Page = 0;
state.Selected = -1;
state.View = "Browse";
_buffers[player.userID] = NewBuffer();
Draw(player);
}
[ConsoleCommand("dlootui.file")]
private void UiFile(ConsoleSystem.Arg arg) {
BasePlayer player = arg.Player();
if (!HasAccess(player)) return;
UiState state = GetState(player);
state.File = CleanFile(arg.GetString(0, state.File));
state.Page = 0;
state.Selected = -1;
state.View = "Browse";
_buffers[player.userID] = NewBuffer();
Draw(player);
}
[ConsoleCommand("dlootui.page")]
private void UiPage(ConsoleSystem.Arg arg) {
BasePlayer player = arg.Player();
if (!HasAccess(player)) return;
UiState state = GetState(player);
state.Page = Mathf.Max(0, state.Page + arg.GetInt(0, 0));
state.Selected = -1;
Draw(player);
}
[ConsoleCommand("dlootui.select")]
private void UiSelect(ConsoleSystem.Arg arg) {
BasePlayer player = arg.Player();
if (!HasAccess(player)) return;
UiState state = GetState(player);
int index = arg.GetInt(0, -1);
JObject obj = LoadLoot(state.Tier, state.File);
JArray items = (JArray)obj["Items"];
if (index < 0 || index >= items.Count) return;
state.Selected = index;
state.View = "Browse";
_buffers[player.userID] = BufferFromItem(items[index] as JObject);
Draw(player);
}
[ConsoleCommand("dlootui.new")]
private void UiNew(ConsoleSystem.Arg arg) {
BasePlayer player = arg.Player();
if (!HasAccess(player)) return;
UiState state = GetState(player);
state.Selected = -1;
state.View = "Browse";
_buffers[player.userID] = NewBuffer();
Draw(player);
}
[ConsoleCommand("dlootui.set")]
private void UiSet(ConsoleSystem.Arg arg) {
BasePlayer player = arg.Player();
if (!HasAccess(player)) return;
string field = CleanArg(arg.GetString(0, ""));
string value = string.Join(" ", (arg.Args ?? new Facepunch.StringView[0]).Skip(1).Select(x => x.ToString()).ToArray()).Trim();
UiState state = GetState(player);
EditBuffer edit = GetBuffer(player);
switch (field.ToLower()) {
case "shortname" : edit.ShortName = CleanArg(value);
break;
case "chance" : edit.Chance = CleanArg(value);
break;
case "min" : edit.Min = CleanArg(value);
break;
case "max" : edit.Max = CleanArg(value);
break;
case "skin" : edit.SkinID = CleanArg(value);
break;
case "name" : edit.CustomName = value;
break;
case "search" : state.Search = value;
state.Page = 0;
state.Selected = -1;
break;
case "picksearch" : state.PickerSearch = value;
state.PickerPage = 0;
state.View = "Picker";
break;
}
Draw(player);
}
[ConsoleCommand("dlootui.quick")]
private void UiQuick(ConsoleSystem.Arg arg) {
BasePlayer player = arg.Player();
if (!HasAccess(player)) return;
string field = CleanArg(arg.GetString(0, ""));
string value = CleanArg(arg.GetString(1, ""));
EditBuffer edit = GetBuffer(player);
switch (field.ToLower()) {
case "chance" : edit.Chance = value;
break;
case "min" : edit.Min = value;
break;
case "max" : edit.Max = value;
break;
case "skin" : edit.SkinID = value;
break;
}
Draw(player);
}
[ConsoleCommand("dlootui.adjust")]
private void UiAdjust(ConsoleSystem.Arg arg) {
BasePlayer player = arg.Player();
if (!HasAccess(player)) return;
string field = CleanArg(arg.GetString(0, ""));
int amount = arg.GetInt(1, 0);
EditBuffer edit = GetBuffer(player);
if (field.Equals("chance", StringComparison.OrdinalIgnoreCase)) {
double chance;
if (!double.TryParse(edit.Chance, out chance)) chance = 0;
chance = Math.Max(0, Math.Min(100, chance + amount));
edit.Chance = chance.ToString("0.##");
}
else if (field.Equals("min", StringComparison.OrdinalIgnoreCase)) {
int min;
if (!int.TryParse(edit.Min, out min)) min = 1;
min = Math.Max(0, min + amount);
edit.Min = min.ToString();
int max;
if (!int.TryParse(edit.Max, out max)) max = min;
if (max < min) edit.Max = min.ToString();
}
else if (field.Equals("max", StringComparison.OrdinalIgnoreCase)) {
int max;
if (!int.TryParse(edit.Max, out max)) max = 1;
max = Math.Max(0, max + amount);
int min;
if (!int.TryParse(edit.Min, out min)) min = 0;
if (max < min) max = min;
edit.Max = max.ToString();
}
Draw(player);
}
[ConsoleCommand("dlootui.save")]
private void UiSave(ConsoleSystem.Arg arg) {
BasePlayer player = arg.Player();
if (!HasAccess(player)) return;
UiState state = GetState(player);
EditBuffer edit = GetBuffer(player);
if (!TierExists(state)) {
SendReply(player, "No existing DungeonEvents tier is selected.");
Draw(player);
return;
}
ItemDefinition definition = ItemManager.FindItemDefinition((edit.ShortName ?? "").Trim());
if (definition == null) {
SendReply(player, "Invalid Rust item shortname: " + (edit.ShortName ?? ""));
Draw(player);
return;
}
edit.ShortName = definition.shortname;
JObject obj = LoadLoot(state.Tier, state.File);
JArray items = (JArray)obj["Items"];
JObject item = MakeItem(edit);
if (state.Selected >= 0 && state.Selected < items.Count) items[state.Selected] = item;
else {
items.Add(item);
state.Selected = items.Count - 1;
}
if (!SaveLoot(state.Tier, state.File, obj)) {
SendReply(player, "Save cancelled because the selected tier folder no longer exists.");
Draw(player);
return;
}
state.View = "Browse";
SendReply(player, "Loot item saved.");
Draw(player);
}
[ConsoleCommand("dlootui.delete")]
private void UiDelete(ConsoleSystem.Arg arg) {
BasePlayer player = arg.Player();
if (!HasAccess(player)) return;
UiState state = GetState(player);
if (!TierExists(state)) {
SendReply(player, "No existing DungeonEvents tier is selected.");
Draw(player);
return;
}
if (state.Selected < 0) {
SendReply(player, "No item selected.");
return;
}
JObject obj = LoadLoot(state.Tier, state.File);
JArray items = (JArray)obj["Items"];
if (state.Selected >= items.Count) {
state.Selected = -1;
Draw(player);
return;
}
string name = items[state.Selected]["ShortName"]?.ToString() ?? "item";
items.RemoveAt(state.Selected);
state.Selected = -1;
_buffers[player.userID] = NewBuffer();
if (!SaveLoot(state.Tier, state.File, obj)) {
SendReply(player, "Delete cancelled because the selected tier folder no longer exists.");
Draw(player);
return;
}
SendReply(player, "Deleted " + name + ".");
Draw(player);
}
[ConsoleCommand("dlootui.duplicate")]
private void UiDuplicate(ConsoleSystem.Arg arg) {
BasePlayer player = arg.Player();
if (!HasAccess(player)) return;
UiState state = GetState(player);
if (!TierExists(state)) {
SendReply(player, "No existing DungeonEvents tier is selected.");
Draw(player);
return;
}
if (state.Selected < 0) return;
JObject obj = LoadLoot(state.Tier, state.File);
JArray items = (JArray)obj["Items"];
if (state.Selected >= items.Count) return;
items.Add(items[state.Selected].DeepClone());
state.Selected = items.Count - 1;
_buffers[player.userID] = BufferFromItem(items[state.Selected] as JObject);
if (!SaveLoot(state.Tier, state.File, obj)) {
SendReply(player, "Duplicate cancelled because the selected tier folder no longer exists.");
Draw(player);
return;
}
SendReply(player, "Duplicated selected loot item.");
Draw(player);
}
[ConsoleCommand("dlootui.clearsearch")]
private void UiClearSearch(ConsoleSystem.Arg arg) {
BasePlayer player = arg.Player();
if (!HasAccess(player)) return;
UiState state = GetState(player);
state.Search = "";
state.Page = 0;
state.Selected = -1;
Draw(player);
}
[ConsoleCommand("dlootui.pickcat")]
private void UiPickCategory(ConsoleSystem.Arg arg) {
BasePlayer player = arg.Player();
if (!HasAccess(player)) return;
UiState state = GetState(player);
string category = CleanArg(arg.GetString(0, "All"));
state.PickerCategory = PickerCategories.FirstOrDefault(x => x.Equals(category, StringComparison.OrdinalIgnoreCase)) ?? "All";
state.PickerPage = 0;
state.View = "Picker";
Draw(player);
}
[ConsoleCommand("dlootui.pickpage")]
private void UiPickPage(ConsoleSystem.Arg arg) {
BasePlayer player = arg.Player();
if (!HasAccess(player)) return;
UiState state = GetState(player);
state.PickerPage = Mathf.Max(0, state.PickerPage + arg.GetInt(0, 0));
state.View = "Picker";
Draw(player);
}
[ConsoleCommand("dlootui.pickclear")]
private void UiPickClear(ConsoleSystem.Arg arg) {
BasePlayer player = arg.Player();
if (!HasAccess(player)) return;
UiState state = GetState(player);
state.PickerSearch = "";
state.PickerPage = 0;
state.View = "Picker";
Draw(player);
}
[ConsoleCommand("dlootui.pickitem")]
private void UiPickItem(ConsoleSystem.Arg arg) {
BasePlayer player = arg.Player();
if (!HasAccess(player)) return;
string shortName = CleanArg(arg.GetString(0, ""));
if (string.IsNullOrWhiteSpace(shortName)) return;
UiState state = GetState(player);
EditBuffer edit = GetBuffer(player);
edit.ShortName = shortName.Trim();
state.View = "Browse";
Draw(player);
}
[ConsoleCommand("dlootui.reload")]
private void UiReload(ConsoleSystem.Arg arg) {
BasePlayer player = arg.Player();
if (!HasAccess(player)) return;
Interface.Oxide.ReloadPlugin("DungeonEvents");
SendReply(player, "Reload command sent for DungeonEvents.");
}
[ConsoleCommand("dlootui.clearbackups")]
private void UiClearBackups(ConsoleSystem.Arg arg) {
BasePlayer player = arg.Player();
if (!HasAccess(player)) return;
if (!_backupClearConfirm.Contains(player.userID)) {
_backupClearConfirm.Add(player.userID);
SendReply(player, "Click Clear Backups again to confirm.");
timer.Once(10f, () => {
if (player != null) _backupClearConfirm.Remove(player.userID);
}
);
Draw(player);
return;
}
_backupClearConfirm.Remove(player.userID);
int deleted = ClearBackups();
SendReply(player, "Deleted " + deleted + " backup file(s).");
Draw(player);
}
private int ClearBackups() {
if (!Directory.Exists(BackupRootPath)) return 0;
int deleted = 0;
foreach (string file in Directory.GetFiles(BackupRootPath, "*.json", SearchOption.AllDirectories)) {
try {
File.Delete(file);
deleted++;
}
catch (Exception ex) {
PrintWarning("Failed deleting backup '" + file + "': " + ex.Message);
}
}
foreach (string dir in Directory.GetDirectories(BackupRootPath, "*", SearchOption.AllDirectories).OrderByDescending(x => x.Length)) {
try {
if (!Directory.EnumerateFileSystemEntries(dir).Any()) Directory.Delete(dir);
}
catch {
}
}
return deleted;
}
private string NormalizeView(string view) {
view = CleanArg(view);
if (view.Equals("Picker", StringComparison.OrdinalIgnoreCase)) return "Picker";
return "Browse";
}
private List<KeyValuePair<int, JObject>> FilteredItems(UiState state, JArray items) {
string query = (state.Search ?? "").Trim().ToLower();
return items .Select((token, index) => new KeyValuePair<int, JObject>(index, token as JObject)) .Where(x => x.Value != null) .Where(x => {
if (string.IsNullOrEmpty(query)) return true;
string shortName = x.Value["ShortName"]?.ToString().ToLower() ?? "";
string custom = x.Value["CustomName"]?.ToString().ToLower() ?? "";
string skin = x.Value["SkinID"]?.ToString().ToLower() ?? "";
return shortName.Contains(query) || custom.Contains(query) || skin.Contains(query);
}
) .ToList();
}
private int CountBackups() {
try {
if (!Directory.Exists(BackupRootPath)) return 0;
return Directory.GetFiles(BackupRootPath, "*.json", SearchOption.AllDirectories).Length;
}
catch {
return 0;
}
}
private void Destroy(BasePlayer player) {
if (player == null) return;
CuiHelper.DestroyUi(player, Ui);
_openPlayers.Remove(player.userID);
_lockedPositions.Remove(player.userID);
_queuedDraws.Remove(player.userID);
}
private void Draw(BasePlayer player) {
if (player == null || !player.IsConnected) return;
ulong userId = player.userID;
if (!_queuedDraws.Add(userId)) return;
NextTick(() => {
if (!_queuedDraws.Remove(userId)) return;
if (player == null || !player.IsConnected) return;
DrawNow(player);
}
);
}
private void DrawNow(BasePlayer player) {
if (player == null) return;
bool wasOpen = _openPlayers.Contains(player.userID);
if (wasOpen) CuiHelper.DestroyUi(player, Content);
else CuiHelper.DestroyUi(player, Ui);
UiState state = GetState(player);
bool hasTier = TierExists(state);
JObject obj = hasTier ? LoadLoot(state.Tier, state.File) : NewLootObject();
JArray items = (JArray)obj["Items"];
List<KeyValuePair<int, JObject>> filtered = FilteredItems(state, items);
int per = Mathf.Max(1, _config.ItemsPerPage);
int maxPage = Mathf.Max(0, (int)Math.Ceiling(filtered.Count / (double)per) - 1);
state.Page = Mathf.Clamp(state.Page, 0, maxPage);
List<KeyValuePair<int, JObject>> pageItems = filtered.Skip(state.Page * per).Take(per).ToList();
CuiElementContainer c = new CuiElementContainer();
if (!wasOpen) {
c.Add(new CuiPanel {
Image = {
Color = ColorBackdrop
}
, RectTransform = {
AnchorMin = "0.035 0.055", AnchorMax = "0.965 0.945"
}
, CursorEnabled = true, KeyboardEnabled = true
}
, "Overlay", Ui);
DrawPersistentFrame(c);
}
c.Add(new CuiPanel {
Image = {
Color = "0 0 0 0"
}
, RectTransform = {
AnchorMin = "0 0", AnchorMax = "1 1"
}
, CursorEnabled = false
}
, Ui, Content);
Panel(c, Content, ColorAccent, "0 0.992", "1 1");
Header(c, state);
TierPanel(c, state);
if (!hasTier) {
NoTiersPage(c);
}
else {
FileTabs(c, state);
PageTabs(c, state);
if (state.View.Equals("Picker", StringComparison.OrdinalIgnoreCase)) ItemDatabasePage(c, player, state);
else BrowsePage(c, player, state, items, filtered.Count, maxPage, pageItems);
}
Footer(c, player);
CuiHelper.AddUi(player, c);
_openPlayers.Add(player.userID);
if (!_lockedPositions.ContainsKey(player.userID)) _lockedPositions[player.userID] = player.transform.position;
}
private void DrawPersistentFrame(CuiElementContainer c) {
Panel(c, Ui, ColorAccent, "0 0.992", "1 1");
Panel(c, Ui, ColorHeader, "0 0.900", "1 0.992");
Panel(c, Ui, ColorSurface, "0.020 0.105", "0.158 0.885");
Panel(c, Ui, ColorSurface, "0.175 0.105", "0.980 0.885");
Panel(c, Ui, ColorHeader, "0.175 0.025", "0.980 0.088");
}
private void Header(CuiElementContainer c, UiState state) {
Panel(c, Content, ColorHeader, "0 0.900", "1 0.992");
Panel(c, Content, ColorAccent, "0.020 0.920", "0.025 0.972");
Label(c, Content, "DUNGEON EVENTS", 24, "0.035 0.935", "0.390 0.978", TextAnchor.MiddleLeft);
MutedLabel(c, Content, "LOOT EDITOR • v1.1.0", 9, "0.036 0.912", "0.390 0.940", TextAnchor.MiddleLeft);
string location = TierExists(state) ? state.Tier.ToUpperInvariant() + " / " + FormatFileLabel(state.File) : "NO EXISTING TIERS FOUND";
Panel(c, Content, ColorSurfaceRaised, "0.585 0.925", "0.925 0.973");
MutedLabel(c, Content, "ACTIVE SOURCE", 8, "0.600 0.950", "0.705 0.969", TextAnchor.MiddleLeft);
Label(c, Content, location, 11, "0.600 0.928", "0.910 0.952", TextAnchor.MiddleLeft);
Button(c, Content, "X", "dlootui.close", "0.945 0.925", "0.980 0.973", ColorDanger, 14);
}
private void TierPanel(CuiElementContainer c, UiState state) {
List<string> tiers = GetTiers();
Panel(c, Content, ColorSurface, "0.020 0.105", "0.158 0.885");
Panel(c, Content, ColorAccent, "0.020 0.879", "0.158 0.885");
Label(c, Content, "DIFFICULTY TIERS", 12, "0.036 0.835", "0.142 0.870", TextAnchor.MiddleLeft);
MutedLabel(c, Content, tiers.Count + " FOLDER" + (tiers.Count == 1 ? "" : "S") + " DETECTED", 8, "0.036 0.808", "0.142 0.838", TextAnchor.MiddleLeft);
float y = 0.755f;
foreach (string tier in tiers.Take(12)) {
bool selected = tier.Equals(state.Tier, StringComparison.OrdinalIgnoreCase);
string min = "0.036 " + y.ToString("0.000", CultureInfo.InvariantCulture);
string max = "0.142 " + (y + 0.042f).ToString("0.000", CultureInfo.InvariantCulture);
Button(c, Content, " " + tier.ToUpperInvariant(), "dlootui.tier " + Quote(tier), min, max, selected ? ColorAccentDark : ColorSurfaceRaised, 10, TextAnchor.MiddleLeft);
if (selected) Panel(c, Content, ColorAccent, "0.036 " + y.ToString("0.000", CultureInfo.InvariantCulture), "0.041 " + (y + 0.042f).ToString("0.000", CultureInfo.InvariantCulture));
y -= 0.050f;
}
if (tiers.Count > 12) MutedLabel(c, Content, "+ " + (tiers.Count - 12) + " MORE FOLDERS", 8, "0.036 0.178", "0.142 0.202", TextAnchor.MiddleCenter);
Panel(c, Content, ColorHeader, "0.036 0.115", "0.142 0.172");
MutedLabel(c, Content, "EXISTING FOLDERS ONLY\nManaged by DungeonEvents", 8, "0.044 0.120", "0.134 0.167", TextAnchor.MiddleCenter);
}
private void FileTabs(CuiElementContainer c, UiState state) {
Panel(c, Content, ColorSurface, "0.175 0.818", "0.980 0.885");
MutedLabel(c, Content, "LOOT SOURCE", 8, "0.192 0.836", "0.295 0.869", TextAnchor.MiddleLeft);
List<string> files = _config.LootFiles.Select(CleanFile).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
float start = 0.300f;
float end = _config.ShowReloa
// ... [Code preview truncated due to length. Full source in attached file] ...