C#:
#nullable enable
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 Newtonsoft.Json.Linq;
using static ConsoleSystem;
using Pool = Facepunch.Pool;
using Random = System.Random;
using UnityEngine.Networking;
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.1.9")]
[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
/// <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 Probability")]
public double BlueprintProbability = 0.11;
[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 = false;
[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;
}
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;
}
/// <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>
/// <param name="wipeDayBypass"></param>
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.Any();
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 List<string> {
"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"
}
);
// If does contain, skip
partialNames.AddRange(new List<string> {
"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) ? true : false);
}
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+\}");
DataSystem.LoadBlacklist();
DataSystem.LoadLootTables();
DataSystem.LoadLootGroups();
}
private void OnServerInitialized() {
CheckWatchedPrefabs();
// Called so OnNewSave can trigger flag
ItemManager.Initialize();
BuildWeaponInfoCache();
permission.RegisterPermission(ADMIN_PERM, this);
InitLootSystem();
}
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;
// 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 class LootyResponse {
[JsonProperty("LootTable")]
public Dictionary<string, PrefabLoot> LootTables = new();
[JsonProperty("Loot Groups")]
public Dictionary<string, LootProfile>? LootGroups = new();
public LootyResponse() {
}
}
// 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.Any()) // 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.Any()) {
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 restoreMode = 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";
bool sendPlayer = msgPlayer is not null;
string notifyMessage = string.Format(restoreMode ? "Restoring backup of {0}" : "Attempting to create backup of datafile {0}", $"{filename}.json");
if (sendPlayer) _instance?.SendMessage(msgPlayer, notifyMessage);
else Log(notifyMessage);
// Rename specified file to *.bak before regenerating a file in place of it
string path = Path.Combine(Interface.Oxide.DataFileSystem.Directory, $"{nameof(BetterLoot)}/{filename}.json");
string bakPath = $"{path}.bak";
string existSearchPath = !restoreMode ? bakPath : path;
if (File.Exists(existSearchPath)) {
File.Delete(existSearchPath);
}
else if (restoreMode) {
if (sendPlayer) _instance?.SendMessage(msgPlayer, NoRestoreFileFound);
else Log(NoRestoreFileFound);
return;
}
else {
if (sendPlayer) _instance?.SendMessage(msgPlayer, NoMainFileFound);
else Log(NoMainFileFound);
return;
}
// Main file does not exist, cannot push to backup
File.Move(restoreMode ? bakPath : path, existSearchPath);
if (sendPlayer) _instance?.SendMessage(msgPlayer, FileRestored);
else Log(FileRestored);
if (restoreMode) _instance.InitLootSystem(true);
}
#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 Loot Classes
/// <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 {
[JsonProperty("Is Prefab Enabled?")]
public bool Enabled;
[JsonProperty("Loot Profiles", ObjectCreationHandling = ObjectCreationHandling.Replace)]
public List<LootProfileImport> LootProfiles;
[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("Max Blueprints")]
public int MaxBPs;
internal ItemProperties() {
}
#region v4.0.6 Configuration Migration
[JsonProperty("Scrap Amount", NullValueHandling = NullValueHandling.Ignore)]
private int? LegacyScrap {
get;
set;
}
[OnDeserialized]
private void OnDeserialized(StreamingContext _) {
if (LegacyScrap.HasValue) {
if (MaxScrap == 0) {
MaxScrap = LegacyScrap.Value;
MinScrap = LegacyScrap.Value;
}
}
LegacyScrap = null;
}
[OnSerializing]
private void OnSerializing(StreamingContext _) => LegacyScrap = null;
// force null, no omit
#endregion
}
private int GetTotalItemCount() {
int count = 0;
count += UngroupedItems.Count();
if (lootGroups is not null) foreach (LootProfileImport import in LootProfiles) if (lootGroups.LootGroups.TryGetValue(import.LootProfileName, out LootProfile? profile) && profile is not null) count += profile.ItemList.Count;
return count;
}
}
/// <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 {
[JsonProperty("Enabled?")]
public bool Enabled = true;
[JsonProperty("Guaranteed Items")]
public Dictionary<string, LootEntrySettings> GuaranteedItems = new Dictionary<string, LootEntrySettings>();
[JsonProperty("Item List")]
public Dictionary<string, LootRNG> ItemList;
[JsonIgnore]
private List<double> _culminativeProbabilities = new List<double>();
[JsonIgnore]
public bool DoProbabilitiesExist => _culminativeProbabilities.Any();
public LootProfile() => ItemList = new Dictionary<string, LootRNG>();
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
public void UpdateProbabilities() {
double _culminative = 0;
foreach(var item in ItemList.Values) {
_culminative += item.Probability;
_culminativeProbabilities.Add(_culminative);
}
}
/// <summary>
/// Get a random item from this loot group based off of items probabilities
/// </summary>
public (Item?, List<Item>?) GetItem() {
List<Item> bonusItems = null;
double randomSelect = RNG.NextDouble() * 1e2;
int itemIndex = _culminativeProbabilities.BinarySearch(randomSelect);
if (itemIndex < 0) itemIndex = ~itemIndex;
// No item found
if (itemIndex >= ItemList.Count) return (null, null);
var entry = ItemList.ElementAt(itemIndex);
entry.Value.Amount.CreateBonusItems(ref bonusItems);
// Select Amount
int amount = GetRNG(entry.Value.Amount.Min, entry.Value.Amount.Max);
// Get Custom Properties
ulong skinId = entry.Value.Amount.SkinId;
string? customName = entry.Value.Amount.DisplayName;
// Create Item
Item item = ItemManager.CreateByPartialName(UniqueTagREGEX.Replace(entry.Key, string.Empty), amount);
if (!string.IsNullOrWhiteSpace(customName)) item.name = customName;
item.skin = skinId;
entry.Value.Amount.ApplyAttachments(item);
entry.Value.Amount.ApplyAmmo(item);
item.MarkDirty();
if (item is null) Log($"ERROR: item \"{entry.Key}\" could not be created! System returned null entry!");
return (item, 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("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 ApplyAttachments(Item item) {
// No mods to apply
if (!(ItemEntryModifications?.AttachmentSettings?.itemMods.Any() ?? false)) 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(x => x.info.shortname.Equals("weapon.mod.extendedmags")) ?? false) ammoAmount = (int)Math.Ceiling(ammoAmount * 1.25);
}
else if (GetRNG(0, 100) <= ammoSettings.Probability) {
ammoDef = ItemManager.FindDefinitionByPartialName(ammoSettings.AmmoItemShortname);
}
// No ammo defined
if (ammoDef is null) return;
// Type check of how should apply
var heldEntity = item.GetHeldEntity();
if (heldEntity is BaseProjectile bp) {
var magazine = bp.primaryMagazine.ammoType = ammoDef;
bp.primaryMagazine.contents = ammoAmount;
bp.SendNetworkUpdateImmediate();
}
else if (heldEntity is FlameThrower ft) {
// Vanilla rust spawns this with full fuel
// By default should only be lowgrade fuel but leave the definition open for allowing custom fuel types
ft.fuelType = ammoDef;
ft.ammo = ammoAmount;
ft.SendNetworkUpdateImmediate();
}
else if (heldEntity is LiquidWeapon lw) {
if (lw.GetContents() is Item _liquid) {
_liquid.amount = ammoAmount;
_liquid.MarkDirty();
}
else {
Item liquid = ItemManager.Create(ammoDef, ammoAmount);
liquid.MoveToContainer(item.contents);
item.MarkDirty();
}
}
}
}
public class LootEntryDurability {
[JsonProperty("Minimum Durability")]
public int MinDurability = 100;
[JsonProperty("Maximum Durability")]
public int MaxDurability = 100;
}
#region Attachment System
/*
Added v4.1.1
*/
public class ItemEntrySettings {
// Should add deserialzier value to set min / max bounds (min <= max ALWAYS)
public class AmmoSettings // default state
{
[JsonIgnore]
public bool CanHoldMultipleAmmoUnits = true;
// Should be set to false when its a single shot weapon (no magazine. Also declares to not serialize the ItemMods list)
[JsonProperty("Ammo Item Shortname")]
public string AmmoItemShortname = string.Empty;
// Item shortname of ammo item
[JsonIgnore]
public int MaxAmmo;
// Maximum ammo the item can take.
// These should only be added in config if the weapon can hold more than 1 unit of ammunition. (e.g harpoon gun vs ak)
[JsonProperty("Minimum Amount")]
public int Min;
[JsonProperty("Maximum Amount")]
public int Max;
// These should only exist if the weapon can only hold 1 unit of ammo
[JsonProperty("Spawn Probability")]
public double Probability;
// conditional serialization
public bool ShouldSerializeMin() => CanHoldMultipleAmmoUnits;
public bool ShouldSerializeMax() => CanHoldMultipleAmmoUnits;
public bool ShouldSerializeProbability() => !CanHoldMultipleAmmoUnits;
}
// A weapon always needs ammo
[JsonProperty("Ammunition Settings")]
public AmmoSettings AmmunitionSettings = new AmmoSettings();
#region Attachment Related
#region Internal
[JsonIgnore]
public int maxMods;
// Maximum amount of mods the item can have.
[JsonIgnore]
private List<double> _culminativeProbabilities = new List<double>();
#endregion
public class ItemModEntry {
[JsonProperty("Spawn Probability (0%-100%)")]
// Culminative probabilty with other items in list. => Implement loot groups like system
public double Probability;
[JsonProperty("Durability", NullValueHandling=NullValueHandling.Ignore)]
public LootEntryDurability? Durability;
}
public class ItemModSettings {
[JsonProperty("Minimum Mod Amount")]
public int minModAmount = 1;
[JsonProperty("Maximum Mod Amount")]
public int maxModAmount = 1;
[JsonProperty("Available Attachments")]
public Dictionary<string, ItemModEntry> itemMods = new Dictionary<string, ItemModEntry>();
}
// Should only be on weapons that have slots for weapon mods
[JsonProperty("Weapon Attachments", NullValueHandling = NullValueHandling.Ignore)]
public ItemModSettings? AttachmentSettings;
// Item mods / weapon attachments. Null by default. Item shortname: {probabilty, {durability}}
public void BalanceItemModProbabilities() {
if (!(AttachmentSettings?.itemMods?.Any() ?? false) || maxMods is 0) return;
AttachmentSettings.maxModAmount = Math.Clamp(AttachmentSettings.maxModAmount, 1, maxMods);
double GetSum() => AttachmentSettings.itemMods.Sum(x => x.Value.Probability);
double Round(double x) => Math.Round(x, 2);
const double target = 100;
double sum = GetSum();
// Initial sum
if (sum > target) // Only balance if greater than target
{
if (_config.Loot.EnableBonusItemsAutoBalanceLogging) Log($"- Bonus items probability sum ({sum}) > 100. Balancing list!");
double _ratio = target / sum;
string largestKey = AttachmentSettings.itemMods.Keys.First();
double largestValue = AttachmentSettings.itemMods[largestKey].Probability;
foreach(var item in AttachmentSettings.itemMods) {
// ... [Code preview truncated due to length. Full source in attached file] ...