C#:
using System;
using System.IO;
using Oxide.Core;
using System.Data;
using System.Linq;
using UnityEngine;
using Rust.Ai.Gen2;
using Newtonsoft.Json;
using Facepunch.Extend;
using Oxide.Core.Plugins;
using System.Collections;
using System.Globalization;
using Newtonsoft.Json.Linq;
using static ConsoleSystem;
using Pool = Facepunch.Pool;
using UnityEngine.Networking;
using Random = System.Random;
using System.Collections.Generic;
using System.Runtime.Serialization;
using System.Text.RegularExpressions;
using Oxide.Plugins.BetterLootExtensions;
namespace Oxide.Plugins {
[Info("BetterLoot", "MagicServices.co // TGWA", "4.2.5")]
[Description("A light loot container modification system with rarity support | Previously maintained and updated by Khan & Tryhard")]
public class BetterLoot : RustPlugin {
#region Fields
[PluginReference]
Plugin? CustomLootSpawns;
// Static Instance
private static BetterLoot? _instance;
private static PluginConfig? _config;
// System States
private bool Changed = true;
private static bool NewConfigGenerated;
private static bool NewSave;
private static bool Initialized;
private static Random? RNG;
private static Regex? UniqueTagREGEX;
// Data Instances
private Dictionary<string, List<string>[]> Items = new Dictionary<string, List<string>[]>();
// Cached Item Data for each container
private Dictionary<string, List<string>[]> Blueprints = new Dictionary<string, List<string>[]>();
// Cached Blueprint Data for each container
private Dictionary<string, int[]> ItemWeights = new Dictionary<string, int[]>();
// Item weights for each container
private Dictionary<string, int[]> BlueprintWeights = new Dictionary<string, int[]>();
// Blueprint weights for each container
private Dictionary<string, int> TotalItemWeights = new Dictionary<string, int>();
// Total sum of item weights for each container
private Dictionary<string, int> TotalBlueprintWeights = new Dictionary<string, int>();
// Total sum of blueprint weights for each container
#region Info Caching
private Dictionary<string, WI_Cache>? WeaponInfoCache;
// Item info for building table
private Dictionary<string, ItemSlot>? WeaponModInfoCache;
// Weapon mod shortname to enum mapping.
private List<string>? DurabilityItems;
// Items that need the durability property
private static ItemDefinition? BlueprintBaseDef;
private List<string> ItemManagerItemNamesList = new List<string>();
/// <summary>
/// Info for each weapon type item
/// Contains info for building config data for if a item is a weapon (it will be in this dict) as well as how many mods and how much ammo it is allowed to have.
/// </summary>
private sealed class WI_Cache {
public bool IsLiquidWeapon {
get;
}
// is a watergun.
public int MaxMods {
get;
}
// if > 0 can have mods + max mods property.
public int MaxAmmo {
get;
}
// vanilla max ammo.
public ItemSlot ModTypes {
get;
}
// The types of mods that can be applied to this weapon.
public WI_Cache(bool isLiquidWeapon, int maxMods, int maxAmmo, ItemSlot modTypes) {
//IsLiquidWeapon = isLiquidWeapon;
MaxMods = maxMods;
MaxAmmo = maxAmmo;
ModTypes = modTypes;
}
}
#endregion
#endregion
#region Instance Constants
private const double BASE_ITEM_RARITY = 2;
private const string ADMIN_PERM = "betterloot.admin";
#endregion
#region Lang
private string BLLang(string key, string? id = null) => lang.GetMessage(key, this, id);
private string BLLang(string key, string? id, params object[] args) => string.Format(BLLang(key, id), args);
protected override void LoadDefaultMessages() {
lang.RegisterMessages(new Dictionary<string, string> {
{
"initialized", "Plugin not enabled"
}
, {
"perm", "You are not authorized to use this command"
}
, {
"syntax", "Usage: /blacklist [additem|deleteitem] \"ITEMNAME\""
}
, {
"none", "There are no blacklisted items"
}
, {
"blocked", "Blacklisted items: {0}"
}
, {
"notvalid", "Not a valid item: {0}"
}
, {
"blockedpass", "The item '{0}' is now blacklisted"
}
, {
"blockedtrue", "The item '{0}' is already blacklisted}"
}
, {
"unblacklisted", "The item '{0}' has been unblacklisted"
}
, {
"blockedfalse", "The item '{0}' is not blacklisted"
}
, {
"lootycmdformat", "Usage: /looty \"looty-id\""
}
, // Blank code provided.
{
"lootynotfound", "The requested table id was not found. Please ensure youve got the right code."
}
// 404 Looty api
}
, this);
//en
}
#endregion
#region Config
private class PluginConfig : SerializableConfiguration {
[JsonProperty("Chat Configuration")]
public ChatConfiguration ChatConfig = new ChatConfiguration();
[JsonProperty("General Configuration")]
public GenericConfiguration Generic = new GenericConfiguration();
[JsonProperty("Loot Configuration")]
public LootConfiguration Loot = new LootConfiguration();
[JsonProperty("Loot Groups Configuration")]
public LootGroupsConfiguration LootGroupsConfig = new LootGroupsConfiguration();
}
private class GenericConfiguration {
[JsonProperty("Blueprint Weight (0.0 = min bias, 1.0 = max bias, 0.5 = balanced)")]
public double BlueprintWeight = 0.11;
#region v4.2.1 Configuration Migration
[JsonProperty("Blueprint Probability")]
private double? LegacyBlueprintProbability {
set => LegacyMigrate_BlueprintProbability(value);
}
private void LegacyMigrate_BlueprintProbability(double? value) {
if (value.HasValue) BlueprintWeight = value.Value;
}
#endregion
[JsonProperty("Log Updates On Load")]
public bool ListUpdatesOnLoad = true;
[JsonProperty("Remove Stacked Containers")]
public bool RemoveStackedContainers = true;
[JsonProperty("Only update prefab list on wipe day")]
public bool OnlyUpdatePrefabListOnWipe = false;
// Slightly faster start time. (Doesnt check to regenerate prefab watch list from game manifest every startup).
[JsonProperty("Auto enable new prefabs found on wipe")]
public bool AutoEnableNewContainers = true;
[JsonProperty("Watched Container Prefabs (true = monitor container loot, false = disabled)")]
public Dictionary<string, bool> WatchedPrefabs = new();
#region v4.1.7 Configuration Migration
[JsonProperty("Watched Prefabs")]
private HashSet<string>? LegacyWatchedPrefabs {
set => LegacyMerge_WatchedPrefabs(value);
}
private void LegacyMerge_WatchedPrefabs(IEnumerable<string>? values) {
if (values == null) return;
foreach (string prefab in values) if (!string.IsNullOrWhiteSpace(prefab)) WatchedPrefabs.TryAdd(prefab, true);
}
#endregion
}
private class LootConfiguration {
[JsonProperty("Enable Hammer Hit Loot Cycle")]
public bool EnableHammerLootCycle = false;
[JsonProperty("Hammer Loot Cycle Time")]
public double HammerLootCycleTime = 3.0;
[JsonProperty("Loot Multiplier")]
public int LootMultiplier = 1;
[JsonProperty("Scrap Multipler")]
public int ScrapMultiplier = 1;
[JsonProperty("Allow duplicate items")]
public bool AllowDuplicateItems = true;
[JsonProperty("Enable logging for item attachments auto balancing operations")]
public bool EnableBonusItemsAutoBalanceLogging = false;
[JsonProperty("Always allow duplicate items from bonus items list (if set, will override 'Allow duplicate items option')")]
public bool AllowBonusItemsDuplicateItems = true;
[JsonProperty("Enable Blueprint Conversion")]
public bool EnableBlueprintConversion = true;
[JsonProperty("Allow Duplicate Blueprints")]
public bool AllowDuplicateBlueprints = false;
[JsonProperty("Enable Loot Pool Locking System")]
public bool EnableLootPoolLocking = true;
}
private class ChatConfiguration {
[JsonProperty("Chat Message Prefix")]
public string Prefix = $"[<color=#00ff00>{nameof(BetterLoot)}</color>]";
[JsonProperty("Chat Message Icon SteamID (0 = None)")]
public ulong MessageIcon = 0;
}
private class LootGroupsConfiguration {
[JsonProperty("Enable creation of example loot group on load?")]
public bool EnableExampleGroupCreation = true;
[JsonProperty("Enable auto profile probability balancing?")]
public bool EnableProbabilityBalancing = true;
[JsonProperty("Always allow duplicate items from loot groups (if true overrides 'Allow duplicate items option')")]
public bool AllowLootGroupDuplicateItems = true;
[JsonProperty("Allowed probablity difference to select neighbour during duplicate item resolution.")]
public double AllowedDuplicateNudgeDifference = 10;
}
/// <summary>
/// As of v4.1.7 this system runs everytime to check for new prefabs as well as maintain the integrity of the current prefab list that is available so it can be enabled / disabled easily at any time.
/// This behaviour can optionally be disabled in the config for it to only run on wipe day (to update for any new prefab types after a update)
/// </summary>
private void CheckWatchedPrefabs() {
/* Watched Prefabs Auto-Population */
if (_config.Generic.OnlyUpdatePrefabListOnWipe && !NewSave) // If it is a new wipe new found prefabs will be auto enabled.
return;
NewConfigGenerated = _config.Generic.WatchedPrefabs.Count == 0;
if (NewConfigGenerated) Log("Checking for missing viable loot containers in prefab watch list. (Currently disabled containers will stay disabled).");
// Name filtering
List<string> negativePartialNames = Pool.Get<List<string>>();
List<string> partialNames = Pool.Get<List<string>>();
// If does not contain, skip
negativePartialNames.AddRange(new[] {
"resource/loot", "misc/supply drop/supply_drop", "/npc/m2bradley/bradley_crate", "/npc/patrol helicopter/heli_crate", "/deployable/chinooklockedcrate/chinooklocked", "/deployable/chinooklockedcrate/codelocked", "prefabs/radtown", "props/roadsigns", "humannpc/scientist", "humannpc/tunneldweller", "humannpc/underwaterdweller", "ptboat.deepsea", "rhib.deepsea", "cache/food"
}
);
// If does contain, skip
partialNames.AddRange(new[] {
"radtown/ore", "static", "/spawners", "radtown/desk", "radtown/loot_component_test", "chinooklockedcrate/chinooklockedcrate", // Specific crate with no spawnable
"water_puddles_border_fix" // Weird container prefab from radtown update??
}
);
// Adding default values
foreach (GameManifest.PrefabProperties category in GameManifest.Current.prefabProperties) {
string name = category.name;
if (!negativePartialNames.ContainsPartial(name) || partialNames.ContainsPartial(name)) continue;
// Add false by default, may have been disabled by user if was not in list from previous version. If is a new wipe and auto-enable is set, containers will be added as enabled prefabs.
_config.Generic.WatchedPrefabs.TryAdd(name, NewConfigGenerated || (NewSave && _config.Generic.AutoEnableNewContainers));
}
if (NewConfigGenerated) {
Log("Updated configuration with manifest values.");
AttemptSendLootyLink();
}
Pool.FreeUnmanaged(ref negativePartialNames);
Pool.FreeUnmanaged(ref partialNames);
}
protected override void LoadDefaultConfig() => _config = new PluginConfig();
protected override void LoadConfig() {
base.LoadConfig();
try {
_config = Config.ReadObject<PluginConfig>();
if (MaybeUpdateConfig(_config)) {
Log("Configuration appears to be outdated; updating and saving Better Loot");
SaveConfig();
}
Log("Loaded configuration!");
}
catch (Exception ex) {
Log("Failed to load Better Loot config file (is the config file corrupt?) (" + ex.Message + ")");
}
}
protected override void SaveConfig() {
Log($"Configuration changes saved to {nameof(BetterLoot)}.json");
Config.WriteObject(_config, true);
}
#region Configuration Updater
internal class SerializableConfiguration {
public string ToJson() => JsonConvert.SerializeObject(this);
public Dictionary<string, object> ToDictionary() => JsonHelper.Deserialize(ToJson()) as Dictionary<string, object>;
}
private static class JsonHelper {
public static object Deserialize(string json) => ToObject(JToken.Parse(json));
private static object ToObject(JToken token) {
switch (token.Type) {
case JTokenType.Object : return token.Children<JProperty>().ToDictionary(prop => prop.Name, prop => ToObject(prop.Value));
case JTokenType.Array : return token.Select(ToObject).ToList();
default : return ((JValue)token).Value;
}
}
}
private bool MaybeUpdateConfig(SerializableConfiguration config) {
var currentWithDefaults = config.ToDictionary();
var currentRaw = Config.ToDictionary(x => x.Key, x => x.Value);
return MaybeUpdateConfigDict(currentWithDefaults, currentRaw);
}
private bool MaybeUpdateConfigDict(Dictionary<string, object> currentWithDefaults, Dictionary<string, object> currentRaw) {
bool changed = false;
foreach (var key in currentWithDefaults.Keys) {
if (currentRaw.TryGetValue(key, out object? currentRawValue) && currentRawValue is not null) {
var defaultDictValue = currentWithDefaults[key] as Dictionary<string, object>;
var currentDictValue = currentRawValue as Dictionary<string, object>;
if (defaultDictValue != null) {
if (currentDictValue == null) {
currentRaw[key] = currentWithDefaults[key];
changed = true;
}
else if (MaybeUpdateConfigDict(defaultDictValue, currentDictValue)) changed = true;
}
}
else {
currentRaw[key] = currentWithDefaults[key];
changed = true;
}
}
return changed;
}
#endregion
#endregion
#region Oxide Loaded / Unload / Server Load / New Save
private void Loaded() {
_instance = this;
RNG = new Random();
UniqueTagREGEX = new Regex(@"\{\d+\}", RegexOptions.Compiled);
DataSystem.LoadBlacklist();
DataSystem.LoadLootTables();
DataSystem.LoadLootGroups();
}
private void OnServerInitialized() {
try {
CheckWatchedPrefabs();
// Called so OnNewSave can trigger flag
ItemManager.Initialize();
BlueprintBaseDef = ItemManager.FindItemDefinition("blueprintbase");
BuildWeaponInfoCache();
permission.RegisterPermission(ADMIN_PERM, this);
InitLootSystem();
}
catch (Exception ex) {
Puts($"Error initializing plugin. Please ensure you configuration and data files are corrent and that you have used the looty editor to confirm. EX: \n{ex.Message}");
Server.Command($"o.unload {Name}");
}
}
private void InitLootSystem(bool newData = false) {
// Ensure empty, is called when looty tables are loaded.
if (newData) {
Items.Clear();
Blueprints.Clear();
ItemWeights.Clear();
BlueprintWeights.Clear();
TotalItemWeights.Clear();
TotalBlueprintWeights.Clear();
BuildWeaponInfoCache();
}
// Load container data
LoadAllContainers();
Pool.FreeUnmanaged(ref WeaponInfoCache);
Pool.FreeUnmanaged(ref WeaponModInfoCache);
Pool.FreeUnmanaged(ref DurabilityItems);
UpdateInternals(_config.Generic.ListUpdatesOnLoad);
}
private void Unload() {
// Static variable instances
UniqueTagREGEX = null;
storedBlacklist = null;
lootTables = null;
lootGroups = null;
RNG = null;
BlueprintBaseDef = null;
// Reset Static Flags
NewConfigGenerated = false;
NewSave = false;
Initialized = false;
// Static BetterLoot instance
_instance = null;
_config = null;
foreach (HammerHitLootCycle hhlc in UnityEngine.Object.FindObjectsByType<HammerHitLootCycle>(FindObjectsInactive.Include, FindObjectsSortMode.None).Where(i => i is not null)) UnityEngine.Object.Destroy(hhlc);
}
// Set flag so new prefabs can be autoenabled
private void OnNewSave(string _) => NewSave = true;
#endregion
#region DataFile
private static LootTableData? lootTables = null;
private static StoredBlacklist? storedBlacklist = null;
private static LootGroupsData? lootGroups = null;
// Looty API Schema
private sealed record LootyResponse {
public LootyTarget looty = new ();
[JsonProperty("LootTable")]
public Dictionary<string, PrefabLoot> LootTables = new();
[JsonProperty("Loot Groups")]
public Dictionary<string, LootProfile>? LootGroups = new();
public LootyResponse() {
}
public sealed record LootyTarget {
public string target;
public string id;
}
}
// LootTables.json structure
private class LootTableData {
public Dictionary<string, PrefabLoot> LootTables = new Dictionary<string, PrefabLoot>();
public LootTableData() {
}
}
// Blacklist.json structure
private class StoredBlacklist {
public HashSet<string> ItemList = new HashSet<string>();
public StoredBlacklist() {
}
}
private class LootGroupsData {
[JsonProperty("Loot Groups", ObjectCreationHandling = ObjectCreationHandling.Replace)]
public Dictionary<string, LootProfile> LootGroups = new Dictionary<string, LootProfile> {
["example_group"]
= new LootProfile(new Dictionary<string, LootProfile.LootRNG> {
["lmg.m249"]
= new LootProfile.LootRNG(10, new LootEntry(1, 2))
}
, false)
}
;
public LootGroupsData() {
}
public static void ValidateGroups(LootGroupsData? Data) {
ItemManager.Initialize();
if (ItemManager.itemDictionaryByName is null) {
Log("Error: Failed to initialize ItemDictionary. Unloading");
_instance.Server.Command($"o.unload BetterLoot");
return;
}
if (Data is null || Data.LootGroups is null) {
Log($"Error: Invalid data was provided to the {nameof(LootGroupsData)} validator!");
return;
}
// Attempt to create an example group in the LootGroups file
TryCreateExampleGroup();
foreach ((string profileName, LootProfile? profileData) in Data.LootGroups) {
Log($"Validating LootGroup: \"{profileName}\"");
// NRE Data Check
if (profileData.ItemList is null) {
Log("- Error: Profile item list is null. Skipping...");
continue;
}
// Ensure items are valid
List<string> invalidItemPrefabs = Pool.Get<List<string>>();
invalidItemPrefabs.AddRange(profileData.ItemList.Keys.Where(key => !ItemManager.itemDictionaryByName.ContainsKey(UniqueTagREGEX.Replace(key.ToLower(), string.Empty))));
if (profileData.ItemList.RemoveAll(invalidItemPrefabs.Contains) is int removeCount && removeCount > 0) Log($"Error - Removing {removeCount} invalid entries. Please check item names that were not found in the games item dictionary: ({string.Join(", ", invalidItemPrefabs)})");
Pool.FreeUnmanaged(ref invalidItemPrefabs);
string extraReason = string.Empty;
if (profileData.ItemList.Count > 0) // Ensure we still have items
{
// Balance loot percentages
if (_config.LootGroupsConfig.EnableProbabilityBalancing) {
double GetSum() => profileData.ItemList.Sum(x => x.Value.Probability);
double Round(double x) => Math.Round(x, 2);
const double target = 100;
double sum = GetSum();
if (Math.Abs(target - sum) > 1e-3) {
Log($"- Profile probability sum ({sum}) != 100. Balancing profile!");
double _ratio = target / sum;
// Set first key as largest by default for empty string edgecase
string largestKey = profileData.ItemList.Keys.First();
double largestValue = profileData.ItemList[largestKey].Probability;
foreach (var item in profileData.ItemList) {
double probability = item.Value.Probability;
if (probability > largestValue) {
largestValue = probability;
largestKey = item.Key;
}
item.Value.Probability = Round(probability * _ratio);
}
var largestEntry = profileData.ItemList[largestKey];
largestEntry.Probability = Round(largestEntry.Probability - Round(target - GetSum()));
}
}
else {
extraReason = "Probability balance skipped.";
}
}
else {
extraReason = "No remaining / valid items in profile, skipped.";
}
Log($"Profile \"{profileName}\" validation complete. {extraReason}");
}
}
public static void TryCreateExampleGroup() {
if (!_config.LootGroupsConfig.EnableExampleGroupCreation) return;
// Create a default group in the first item of the loot table for reference if none exists
var firstLootTable = lootTables.LootTables.FirstOrDefault();
if (!firstLootTable.IsDefault() && firstLootTable.Value.LootProfiles.Count == 0) {
firstLootTable.Value.LootProfiles.Add(new PrefabLoot.LootProfileImport("example_group", 30, false));
Log($"Added LootGroup Import example to \"{firstLootTable.Key}\"");
DataSystem.SaveLootTables();
}
}
}
private static class DataSystem {
#region Public Methods
#region Blacklist
private const string BL_FN = "Blacklist";
public static void LoadBlacklist() => LoadFile(BL_FN, (blacklistData) => CheckNull(ref blacklistData, ref storedBlacklist, blacklistData?.ItemList), ref storedBlacklist);
public static void SaveBlacklist() => SaveFile(BL_FN, (blacklistData) => CheckNull(ref blacklistData, ref storedBlacklist, blacklistData?.ItemList), ref storedBlacklist);
#endregion
#region Loot Tables
private const string LT_FN = "LootTables";
public static void LoadLootTables() => LoadFile(LT_FN, (tableData) => CheckNull(ref tableData, ref lootTables, tableData?.LootTables), ref lootTables);
public static void SaveLootTables() => SaveFile(LT_FN, (tableData) => CheckNull(ref tableData, ref lootTables, tableData?.LootTables), ref lootTables);
#endregion
#region Loot Groups
private const string LG_FN = "LootGroups";
public static void LoadLootGroups() => LoadFile(LG_FN, (groupsData) => CheckNull(ref groupsData, ref lootGroups), ref lootGroups, LootGroupsData.ValidateGroups);
public static void SaveLootGroups() => SaveFile(LG_FN, (groupsData) => CheckNull(ref groupsData, ref lootGroups), ref lootGroups);
#endregion
#endregion
#region DataFile Error Backup
public static void BakDataFile(string filename, bool isRestoring = false, BasePlayer? msgPlayer = null) {
// Move these to lang
const string NoRestoreFileFound = "No backup file to restore.";
const string NoMainFileFound = "No file found to move to backup, skipping file.";
const string FileRestored = "Backup restored.";
const string BackupTaken = "File backed up to bak file.";
bool sendPlayer = msgPlayer is not null;
string notifyMessage = string.Format(isRestoring ? "Restoring backup of {0}" : "Attempting to create backup of datafile {0}", $"{filename}.json");
void Respond(string message) {
if (sendPlayer) _instance?.SendMessage(msgPlayer, message);
else Log(message);
}
Respond(notifyMessage);
// Rename specified file to *.bak before regenerating a file in place of it
string dataFilePath = Path.Combine(Interface.Oxide.DataFileSystem.Directory, $"{nameof(BetterLoot)}/{filename}.json");
string bakFilePath = $"{dataFilePath}.bak";
if (!isRestoring) {
if (!File.Exists(dataFilePath)) {
Respond(NoMainFileFound);
return;
}
if (File.Exists(bakFilePath)) File.Delete(bakFilePath);
File.Copy(dataFilePath, bakFilePath);
}
else {
if (!File.Exists(bakFilePath)) {
Respond(NoRestoreFileFound);
return;
}
if (File.Exists(dataFilePath)) File.Delete(dataFilePath);
File.Copy(bakFilePath, dataFilePath);
}
if (isRestoring) {
Respond(FileRestored);
_instance.InitLootSystem(true);
}
else {
Respond(BackupTaken);
}
}
#endregion
#region Save / Load Methods
/// <summary>
/// Load a data from a file within the plugin data directory.
/// </summary>
/// <typeparam name="T">The structure of the data being read from the file.</typeparam>
/// <param name="fileName">The name of the file within the plugin data directory</param>
/// <param name="validator">A custom data validator. Is nullable.</param>
/// <param name="loadVar">The variable where the loaded data should be stored.</param>
private static void LoadFile<T>(string fileName, Func<T, T>? validator, ref T loadVar, Action<T>? postLoadMethod = null) where T : class? {
// If no validator was provided, set to check if instance is null, if it is create new instance.
if (validator is null) validator = (data) => data ?? Activator.CreateInstance<T>();
try {
loadVar = validator(Interface.Oxide.DataFileSystem.ReadObject<T>($"{nameof(BetterLoot)}\\{fileName}"));
Log($"Loaded file \"{fileName}\" datafile successfully!");
}
catch (Exception e) {
Log($"ERROR: There was an issue loading your \"{fileName}.json\" datafile, a new one has been created.\n{e.Message}");
BakDataFile(fileName);
loadVar = Activator.CreateInstance<T>();
}
postLoadMethod?.Invoke(loadVar);
SaveFile(fileName, validator, ref loadVar);
}
/// <summary>
/// Save plugin data to the plugin data directory.
/// </summary>
/// <typeparam name="T">The type of the datafile structure</typeparam>
/// <param name="fileName">The name of the datafile within the plugin data directory.</param>
/// <param name="validator">A custom data validator. Is nullable.</param>
/// <param name="saveVar">The variable where this data is currently stored.</param>
private static void SaveFile<T>(string fileName, Func<T, T>? validator, ref T saveVar) where T : class? {
if (validator is null) validator = (data) => data ?? Activator.CreateInstance<T>();
Interface.Oxide.DataFileSystem.WriteObject($"{nameof(BetterLoot)}\\{fileName}", validator(saveVar));
Log($"Saved {fileName}.json");
}
#endregion
#region Data Validator
/// <summary>
/// Checks if the provided datafile's data is null, if so create a new instance and optionally write it to file.
/// </summary>
/// <typeparam name="T">The type of the data structure that is being checked</typeparam>
/// <param name="obj">The local instance of the data to check</param>
/// <param name="target">The global instance of where the data is held</param>
/// <param name="additional">Additional objects to check if null aside from arguement 'obj'</param>
/// <returns>>Non null type of provided object type</returns>
private static T CheckNull<T>(ref T? obj, ref T? target, params object?[] additional) where T : class? {
if (obj is null || additional.Any(x => x is null)) {
target = Activator.CreateInstance<T>();
obj = target;
}
return obj;
}
#endregion
}
private static void AttemptSendLootyLink() {
Log("--------------------------------------------------------------------------");
Log("Use the Looty Editor to easily edit and create loot tables for BetterLoot!");
Log("Find it here -> https://looty.cc/betterloot-v4");
Log("--------------------------------------------------------------------------");
}
#endregion
#region Culminative Probabilities Class
public class ProbalisticRNG {
[JsonIgnore]
private List<double> _culminativeProbabilities = new List<double>();
[JsonIgnore]
public bool DoProbabilitiesExist => _culminativeProbabilities.Count > 0;
public void UpdateProbabilities(IEnumerable<double> probabilities) {
double _culminative = 0;
foreach (int item in probabilities) {
_culminative += item;
_culminativeProbabilities.Add(_culminative);
}
}
public int GetRandomIndex() {
double randomSelect = RNG.NextDouble() * 1e2;
int elementIndex = _culminativeProbabilities.BinarySearch(randomSelect);
if (elementIndex < 0) elementIndex = ~elementIndex;
return elementIndex;
}
}
#endregion
#region Loot Classes
public record ItemConvertInfo(Item Item, bool CanBeBp);
/// <summary>
/// Prefab Loot system will be contained in a list. This is the new loot class for loot containers that will
/// allow the import of custom loot groups allowing for RNG on groups as well as individual items
/// </summary>
private class PrefabLoot : ProbalisticRNG {
[JsonProperty("Is Prefab Enabled?")]
public bool Enabled;
[JsonProperty("Loot Profiles", ObjectCreationHandling = ObjectCreationHandling.Replace)]
public List<LootProfileImport> LootProfiles;
[JsonProperty("Enable Loot Pool Locking")]
public bool LootPoolLocking;
[JsonProperty("Select ungrouped items ignoring rarity bias")]
public bool IgnoreRarityBias;
[JsonProperty("Guaranteed Items")]
public Dictionary<string, LootEntrySettings> GuaranteedItems = new Dictionary<string, LootEntrySettings>();
[JsonProperty("Ungrouped Items")]
public Dictionary<string, LootEntry> UngroupedItems;
[JsonProperty("Item Settings")]
public ItemProperties ItemSettings;
public PrefabLoot() {
LootProfiles = new List<LootProfileImport>();
UngroupedItems = new Dictionary<string, LootEntry>();
ItemSettings = new ItemProperties();
}
internal class LootProfileImport {
[JsonProperty("Group Enabled?")]
public bool Enabled = true;
[JsonProperty("Loot Profile Name")]
public string LootProfileName = string.Empty;
[JsonProperty("Loot Profile Probability (1% - 100%)")]
public double LootProfileProbability;
internal LootProfileImport() {
}
internal LootProfileImport(string LootProfileName, double LootProfileProbability, bool Enabled = true) {
this.LootProfileName = LootProfileName;
this.LootProfileProbability = LootProfileProbability;
this.Enabled = Enabled;
}
}
internal class ItemProperties {
[JsonProperty("Minimum Amount of Items")]
public int ItemsMin;
[JsonProperty("Maximum Amount of Items")]
public int ItemsMax;
[JsonProperty("Minimum Scrap Amount")]
public int MinScrap;
[JsonProperty("Maximum Scrap Amount")]
public int MaxScrap;
[JsonProperty("Minimum Blueprints")]
public int MinBlueprints = 0;
[JsonProperty("Maximum Blueprints")]
public int MaxBlueprints = 0;
[JsonProperty("Bonus Items Contribute to Item Count")]
public bool bonusItemsAddCount;
[JsonProperty("Guaranteed Items Contribute to Item Count")]
public bool guaranteedItemsAddCount;
internal ItemProperties() {
}
#region v4.0.6 / v4.2.1 Configuration Migration
[JsonProperty("Scrap Amount", NullValueHandling = NullValueHandling.Ignore)]
private int? LegacyScrap {
get;
set;
}
[JsonProperty("Max Blueprints", NullValueHandling = NullValueHandling.Ignore)]
private int? LegacyMaxBPs {
get;
set;
}
[OnDeserialized]
private void OnDeserialized(StreamingContext _) {
if (LegacyScrap.HasValue) {
if (MaxScrap == 0) {
MaxScrap = LegacyScrap.Value;
MinScrap = LegacyScrap.Value;
}
}
if (LegacyMaxBPs.HasValue && MaxBlueprints == 0) {
MaxBlueprints = LegacyMaxBPs.Value;
}
LegacyScrap = null;
LegacyMaxBPs = null;
}
[OnSerializing]
private void OnSerializing(StreamingContext _) => LegacyScrap = null;
// force null, no omit
#endregion
}
#region Random Profile Selector
[JsonIgnore]
private List<int> _enabledProfiles = new ();
// Map position to index
/// <summary>
/// Implemented binary search to select random loot import profile quickly. Returns null if should select from ungrouped items.
/// </summary>
/// <param name="tableReference">for reference to use in error message if there is a problem with the selection or config</param>
/// <returns></returns>
public LootProfile? GetRandomProfile(string? tableReference) {
if (LootProfiles is null) return null;
if (!DoProbabilitiesExist) {
// Updates and uses probalistic probability based off of only enabled profiles
List<LootProfileImport> _enabledProfiles = new List<LootProfileImport>();
for (int i = 0; i < LootProfiles.Count; i++) {
var _profile = LootProfiles[i];
if (_profile.Enabled) {
_enabledProfiles.Add(_profile);
this._enabledProfiles.Add(i);
}
}
UpdateProbabilities(_enabledProfiles.Select(x => x.LootProfileProbability));
}
int randomProfileIndex = GetRandomIndex();
if (randomProfileIndex >= _enabledProfiles.Count) return null;
var importProfile = LootProfiles[_enabledProfiles[randomProfileIndex]];
if (!lootGroups.LootGroups.TryGetValue(importProfile.LootProfileName, out LootProfile? profile)) {
Log($"WARNING: prefab \"{tableReference}\" requested a loot group import with name \"{importProfile.LootProfileName}\". Group does not exist or is disabled in the LootGroups.json!");
return null;
}
// No Warning
if (!profile.Enabled) return null;
return profile;
}
#endregion
}
/// <summary>
/// LootProfile for containing all items that will be part of a certain profile
/// Will be referenced by the specified profile name that the user creates within the LootGroups.json
/// </summary>
public class LootProfile : ProbalisticRNG {
[JsonProperty("Enabled?")]
public bool Enabled = true;
[JsonProperty("Guaranteed Items")]
public Dictionary<string, LootEntrySettings> GuaranteedItems = new ();
[JsonProperty("Item List")]
public Dictionary<string, LootRNG> ItemList;
public LootProfile(Dictionary<string, LootRNG> ItemList, bool Enabled = true) {
this.ItemList = ItemList;
this.Enabled = Enabled;
}
public class LootRNG {
[JsonProperty("Item Probability (1-100)")]
public double Probability;
[JsonProperty("Item Amount")]
public LootEntry Amount;
public LootRNG(double Probability, LootEntry Amount) {
this.Probability = Probability;
this.Amount = Amount;
}
}
#region Probalistic Selector Methods
/// <summary>
/// Get a random item from this loot group based off of items probabilities
/// </summary>
public (ItemConvertInfo?, List<ItemConvertInfo>?) GetItem(HashSet<string> currentItemEntries) {
int itemIndex = GetRandomIndex();
// No item found, out of index
if (itemIndex >= ItemList.Count) return (null, null);
var bonusItems = new List<ItemConvertInfo>();
var entry = ItemList.ElementAt(itemIndex);
if (!entry.Value.Amount.allowDuplicates && currentItemEntries.Contains(entry.Key)) {
// Only item in the list and it's a duplicate, else all hope is lost :(
if (ItemList.Count == 1) return (null, null);
// Prefer the larger-index neighbour, fall back to smaller
if (itemIndex < ItemList.Count - 1) itemIndex++;
else if (itemIndex > 0 && (entry.Value.Probability - ItemList.ElementAt(itemIndex - 1).Value.Probability) <= _config.LootGroupsConfig.AllowedDuplicateNudgeDifference) itemIndex--;
else return (null, null);
// Set entry to the entry of the nudged index
entry = ItemList.ElementAt(itemIndex);
}
entry.Value.Amount.CreateBonusItems(ref bonusItems);
// Create Item
string sanitizedName = UniqueTagREGEX.Replace(entry.Key, string.Empty);
Item item = ItemManager.CreateByPartialName(sanitizedName, GetRNG(entry.Value.Amount.Min, entry.Value.Amount.Max), entry.Value.Amount.SkinId);
// Apply custom properties
entry.Value.Amount.ApplyAllProperties(item);
if (item is null) Log($"ERROR: item \"{entry.Key}\" could not be created! System returned null entry!");
// Add for future duplicate checking.
currentItemEntries.Add(entry.Key);
return (new ItemConvertInfo(item, entry.Value.Amount.CanConvertToBlueprint ?? false), bonusItems);
}
#endregion
}
#region Loot Entry
public class LootEntrySettings {
[JsonProperty("Skin ID (0 = default)")]
public ulong SkinId = 0;
[JsonProperty("Display Name (empty = none)")]
public string? DisplayName = string.Empty;
[JsonProperty("Item Minimum")]
public int Min;
[JsonProperty("Item Maximum")]
public int Max;
[JsonProperty("Can Convert To Blueprint", NullValueHandling = NullValueHandling.Ignore)]
public bool? CanConvertToBlueprint;
[JsonProperty("Item Durability", NullValueHandling = NullValueHandling.Ignore)]
public LootEntryDurability? DurabilitySettings;
// By default will not exist in item entries. System will add it during table scan.
[JsonProperty("Item Properties", NullValueHandling = NullValueHandling.Ignore)]
public ItemEntrySettings? ItemEntryModifications;
public void ApplyAllProperties(Item item) {
ApplyAmmo(item);
ApplyAttachments(item);
if (!string.IsNullOrWhiteSpace(DisplayName)) item.name = DisplayName;
if (DurabilitySettings is {
}
) item.ChangeConditionPercentage(GetRNG(DurabilitySettings.MinDurability, DurabilitySettings.MaxDurability));
item.MarkDirty();
}
public void ApplyAttachments(Item item) {
// No mods to apply
if (!(ItemEntryModifications?.AttachmentSettings?.itemMods?.Count > 0)) return;
// Recursively apply
int total = Math.Clamp(GetRNG(ItemEntryModifications.AttachmentSettings.minModAmount, ItemEntryModifications.AttachmentSettings.maxModAmount), 1, ItemEntryModifications.maxMods);
for (int i = 0; i < total; i++) {
for (int r = 0; r < 5; r++) {
Item? itemMod = ItemEntryModifications?.GetRandomItemMod();
if (itemMod is null) // No item selected! This is ok.
break;
// Attempt to regenerate if there is a duplicate
if (itemMod.MoveToContainer(item.contents)) break;
}
}
}
public void ApplyAmmo(Item item) {
if (ItemEntryModifications?.AmmunitionSettings is not ItemEntrySettings.AmmoSettings ammoSettings || string.IsNullOrWhiteSpace(ammoSettings.AmmoItemShortname)) return;
// if extended mag is applied, add 25% to calculated ammo amount
ItemDefinition? ammoDef = null;
int ammoAmount = 1;
bool applyExtraAmmo = item.contents?.itemList.Any(x => x.info.shortname.Equals("weapon.mod.extendedmags")) ?? false;
if (ammoSettings.CanHoldMultipleAmmoUnits) {
ammoDef = ItemManager.FindDefinitionByPartialName(ammoSettings.AmmoItemShortname);
ammoAmount = Math.Clamp(GetRNG(ammoSettings.Min, ammoSettings.Max), 0, ammoSettings.MaxAmmo);
if (item.contents?.itemList.Any(
// ... [Code preview truncated due to length. Full source in attached file] ...