C#:
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using UnityEngine;
namespace Oxide.Plugins {
[Info("Vanilla Loot Boost", "Kirar / Codex", "1.1.0")]
[Description("A small loot boost using each container's own vanilla loot definitions.")]
public class VanillaLootBoost : RustPlugin {
private Configuration config;
private const int BonusSlotLimit = 36;
private readonly Dictionary<LootContainer, ItemContainer> pending = new Dictionary<LootContainer, ItemContainer>();
private readonly Dictionary<string, LootContainer> vanillaSources = new Dictionary<string, LootContainer>();
private readonly HashSet<string> warnedPrefabs = new HashSet<string>();
private int boostedContainers;
private int addedStacks;
private int componentBonuses;
private int otherBonuses;
private class Configuration {
[JsonProperty("Enabled")]
public bool Enabled = true;
[JsonProperty("Minimum extra item rolls per container")]
public int MinimumExtras = 1;
[JsonProperty("Maximum extra item rolls per container")]
public int MaximumExtras = 2;
[JsonProperty("Bonus component category chance (percent)")]
public double ComponentChance = 95.0;
[JsonProperty("Existing scrap amount multiplier")]
public double ScrapMultiplier = 2.0;
[JsonProperty("Other existing stackable item amount multiplier")]
public double StackMultiplier = 1.5;
[JsonProperty("Excluded container short prefab names")]
public List<string> Excluded = new List<string>();
}
protected override void LoadDefaultConfig() => config = new Configuration();
protected override void LoadConfig() {
base.LoadConfig();
try {
config = Config.ReadObject<Configuration>() ?? new Configuration();
}
catch (Exception ex) {
throw new Exception("VanillaLootBoost config is invalid. Repair its JSON; the file has not been overwritten.", ex);
}
config.MinimumExtras = Math.Max(0, Math.Min(5, config.MinimumExtras));
config.MaximumExtras = Math.Max(config.MinimumExtras, Math.Min(5, config.MaximumExtras));
config.ComponentChance = double.IsNaN(config.ComponentChance) || double.IsInfinity(config.ComponentChance) ? 95 : Math.Max(0, Math.Min(100, config.ComponentChance));
config.ScrapMultiplier = ValidateMultiplier(config.ScrapMultiplier, 2);
config.StackMultiplier = ValidateMultiplier(config.StackMultiplier, 1.5);
config.Excluded = (config.Excluded ?? new List<string>()).Where(s => !string.IsNullOrWhiteSpace(s)) .Select(s => s.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
SaveConfig();
}
protected override void SaveConfig() => Config.WriteObject(config, true);
private static double ValidateMultiplier(double value, double fallback) {
return double.IsNaN(value) || double.IsInfinity(value) ? fallback : Math.Max(1, Math.Min(5, value));
}
private void OnServerInitialized() {
Puts(config.Enabled ? $"Enabled: {config.MinimumExtras}-{config.MaximumExtras} bonus rolls ({config.ComponentChance}% components), scrap x{config.ScrapMultiplier}, other stacks x{config.StackMultiplier}. Applies to newly generated loot; existing loot waits for its normal refresh." : "Disabled in configuration.");
}
// This hook runs before PopulateLoot. Do not return a value or replace the vanilla population.
private void OnLootSpawn(LootContainer container) {
if (!Eligible(container) || pending.ContainsKey(container)) return;
pending[container] = container.inventory;
NextTick(() => FlushPending(container));
}
// Flush queued generation before a player can remove an item or break a freshly spawned barrel.
private void OnLootEntity(BasePlayer player, BaseEntity entity) => FlushPending(entity as LootContainer);
private void OnEntityTakeDamage(LootContainer container, HitInfo info) => FlushPending(container);
private bool Eligible(LootContainer container) {
return config.Enabled && container != null && !container.IsDestroyed && container.OwnerID == 0 && container.inventory != null && !config.Excluded.Contains(container.ShortPrefabName, StringComparer.OrdinalIgnoreCase);
}
private void FlushPending(LootContainer container) {
if (ReferenceEquals(container, null)) return;
ItemContainer inventory;
if (!pending.TryGetValue(container, out inventory)) return;
// Claim before generation/moves: callbacks cannot boost the same inventory twice.
pending.Remove(container);
if (!Eligible(container) || container.inventory != inventory || inventory.itemList.Count == 0) return;
try {
Boost(container, inventory);
}
catch (Exception ex) {
PrintError($"Could not finish boosting {container.ShortPrefabName}: {ex.Message}. Original loot was not cleared; no retry for this generation.");
}
}
private LootContainer VanillaSource(LootContainer container) {
LootContainer source;
if (vanillaSources.TryGetValue(container.PrefabName, out source)) return source;
var prefab = GameManager.server.FindPrefab(container.PrefabName);
source = prefab == null ? null : prefab.GetComponent<LootContainer>();
vanillaSources[container.PrefabName] = source;
return source;
}
private static bool HasLootDefinition(LootContainer source) {
return source != null && (source.lootDefinition != null || (source.LootSpawnSlots != null && source.LootSpawnSlots.Any(slot => slot.definition != null)));
}
private void Boost(LootContainer container, ItemContainer inventory) {
var source = VanillaSource(container);
if (!HasLootDefinition(source)) {
if (warnedPrefabs.Add(container.PrefabName)) PrintWarning($"Skipping {container.ShortPrefabName}: no vanilla prefab loot definition. No generic replacement table will be used.");
return;
}
// Only the original stacks receive a quantity boost. Bonus items retain their rolled amounts.
foreach (var item in inventory.itemList.ToArray()) {
if (item == null || item.info == null || item.IsBlueprint() || item.info.stackable <= 1) continue;
double multiplier = item.info.shortname == "scrap" ? config.ScrapMultiplier : config.StackMultiplier;
int amount = BoostedAmount(item.amount, multiplier, item.MaxStackable());
if (amount == item.amount) continue;
item.amount = amount;
item.MarkDirty();
}
int requested = UnityEngine.Random.Range(config.MinimumExtras, config.MaximumExtras + 1);
if (requested > 0) AddVanillaExtras(source, inventory, requested);
inventory.MarkDirty();
container.SendNetworkUpdate();
boostedContainers++;
}
private static int BoostedAmount(int original, double multiplier, int stackLimit) {
if (original <= 0 || stackLimit <= original) return original;
double target = Math.Ceiling(original * multiplier);
return (int)Math.Max(original, Math.Min(stackLimit, target));
}
private void AddVanillaExtras(LootContainer source, ItemContainer destination, int requested) {
// Generate a scratch roll using Rust's own FillLoot (including slot probabilities,
// nested weights, blueprint handling and current era restrictions). Never mutate the prefab.
var scratch = new ItemContainer();
scratch.ServerInitialize(null, BonusSlotLimit);
scratch.GiveUID();
try {
for (int bonus = 0; bonus < requested; bonus++) {
// Pick the category ONCE per requested bonus. Rerolling this choice after a
// miss would make rare weapon draws much more common than the configured 5%.
bool wantComponent = IsComponentRoll(UnityEngine.Random.Range(0, 10000), config.ComponentChance);
bool inserted = false;
// Bound the work, including on food/medical-only tables. A missed component
// roll is skipped; it never falls back to a weapon or a foreign component.
for (int attempt = 0; attempt < 8 && !inserted; attempt++) {
scratch.Clear();
LootContainer.FillLoot(scratch, source.lootDefinition, source.maxDefinitionsToSpawn, source.LootSpawnSlots ?? new LootContainer.LootSpawnSlot[0]);
var candidates = scratch.itemList.Where(item => item != null && item.info != null && item.amount > 0 && IsComponent(item) == wantComponent).ToList();
while (candidates.Count > 0 && !inserted) {
int index = UnityEngine.Random.Range(0, candidates.Count);
Item candidate = candidates[index];
candidates.RemoveAt(index);
bool fitsExistingStack = destination.itemList.Any(item => item != null && item.CanStack(candidate) && candidate.amount <= item.MaxStackable() - item.amount);
if (!fitsExistingStack && destination.itemList.Count >= destination.capacity && destination.capacity < BonusSlotLimit) destination.capacity++;
if (!candidate.MoveToContainer(destination)) continue;
inserted = true;
addedStacks++;
if (wantComponent) componentBonuses++;
else otherBonuses++;
}
}
}
}
finally {
// Moved items belong to the destination. Destroy only unused scratch loot.
scratch.Clear();
scratch.Kill();
}
}
private static bool IsComponentRoll(int roll, double chance) => roll < chance * 100;
private static bool IsComponent(Item item) => !item.IsBlueprint() && item.info.category == ItemCategory.Component;
private void OnEntityKill(LootContainer container) => pending.Remove(container);
private void Unload() {
pending.Clear();
vanillaSources.Clear();
warnedPrefabs.Clear();
}
[ConsoleCommand("vanillalootboost.status")]
private void StatusCommand(ConsoleSystem.Arg arg) {
if (arg.Connection != null && (arg.Player() == null || !arg.Player().IsAdmin)) return;
arg.ReplyWith($"VanillaLootBoost: enabled={config.Enabled}, component chance={config.ComponentChance}%, containers boosted={boostedContainers}, bonus rolls inserted={addedStacks} (components={componentBonuses}, other={otherBonuses}), pending={pending.Count}. Counts reset on reload; boosts apply on loot generation, not opening.");
}
}
}