C#:
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using Oxide.Core;
using UnityEngine;
namespace Oxide.Plugins {
[Info("AutoVendingBuyout", "Crage", "1.9.3")]
[Description("Automatically buys random player vending machine listings based on configurable chances.")]
public class AutoVendingBuyout : RustPlugin {
private PluginConfig _config;
private Timer _buyoutTimer;
private Dictionary<int, int> _allowedCurrencyMaxPrice = new Dictionary<int, int>();
private Dictionary<int, float> _itemValueByItemId = new Dictionary<int, float>();
private float _maxItemValueCoefficient;
private class PluginConfig {
[JsonProperty("Интервал автовыкупа (секунды).")]
public float BuyoutIntervalSeconds = 1200f;
[JsonProperty("Режим правил выкупа: true = взаимоисключающие, false = независимые. Влияет на блок Rules.")]
public bool UseMutuallyExclusiveRules = true;
[JsonProperty("Правила шанса по количеству лотов за тик. Для Chance percent диапазон: 0..100, для Listings to buy: >= 0. При UseMutuallyExclusiveRules=true сумма шансов рекомендуется <=100.", ObjectCreationHandling = ObjectCreationHandling.Replace)]
public List<BuyoutRule> Rules = new List<BuyoutRule> {
new BuyoutRule {
ListingsToBuy = 0, ChancePercent = 84f
}
, new BuyoutRule {
ListingsToBuy = 1, ChancePercent = 10f
}
, new BuyoutRule {
ListingsToBuy = 2, ChancePercent = 5f
}
, new BuyoutRule {
ListingsToBuy = 3, ChancePercent = 1f
}
}
;
[JsonProperty("Игнорировать кастомные NPC-автоматы из NPCVendingManager.")]
public bool IgnoreNpcVendingManagerCustomMachines = true;
[JsonProperty("Включить фильтр по разрешенной валюте и максимальной цене. Если false, блок AllowedCurrencies не используется.")]
public bool EnforceAllowedCurrencies = true;
[JsonProperty("Список разрешенной валюты покупки. Работает только при EnforceAllowedCurrencies=true. Поля: Item short name (shortname предмета), Max price purchase per transaction (целое >=1).", ObjectCreationHandling = ObjectCreationHandling.Replace)]
public List<AllowedCurrencyResource> AllowedCurrencies;
[JsonProperty("Включить фильтр по коэффициенту ценности предмета (value): MaxPriceToValueRatio. Диапазон: true/false. Таблица value может строиться и при false, если включен скейл лимита валюты по ценности.")]
public bool UseItemValueCoefficientFilter = false;
[JsonProperty("Максимальное отношение цены к ценности: (price per one item / value). 0 = отключено. Работает при UseItemValueCoefficientFilter=true.")]
public float MaxPriceToValueRatio = 0f;
[JsonProperty("Масштабировать лимит цены валюты по ценности: scaledMaxExact = configuredMax * (value/maxValue), effectiveMaxRounded = Round(scaledMaxExact) в диапазоне 1..configuredMax. Работает при EnforceAllowedCurrencies=true и наличии таблицы value (строится если UseItemValueCoefficientFilter=true ИЛИ включен этот скейл).")]
public bool UseValueScaledMaxPurchasePrice = true;
[JsonProperty("Автоматически считать шансы дропа из живых LootSpawn-таблиц контейнеров. Работает если нужна таблица value (UseItemValueCoefficientFilter=true ИЛИ UseValueScaledMaxPurchasePrice=true). Если false, используются ItemDropChances.")]
public bool AutoBuildDropChancesFromLootTables = true;
[JsonProperty("Белый список контейнеров для автосчета шансов по ShortPrefabName. Работает только при AutoBuildDropChancesFromLootTables=true. Пусто = все контейнеры.", ObjectCreationHandling = ObjectCreationHandling.Replace)]
public List<string> LootContainerShortPrefabNameWhitelist;
[JsonProperty("Дебаг: топ-N автосчитанных шансов/value в лог. 0 = выключено. Работает при AutoBuildDropChancesFromLootTables=true и построении таблицы value.")]
public int DebugLogTopAutoDropChances = 0;
[JsonProperty("Дебаг: лог отклоненных лотов по ценовым фильтрам (причина, price, effectiveMax, value, maxValue).")]
public bool DebugLogRejectedByValueScaledPrice = false;
[JsonProperty("Ручная таблица шансов дропа для расчета value. Работает если нужна таблица value и AutoBuildDropChancesFromLootTables=false. Поля: Item short name, Drop chance percent (0..100).", ObjectCreationHandling = ObjectCreationHandling.Replace)]
public List<ItemDropChanceEntry> ItemDropChances;
[JsonProperty("Логировать каждый успешный автовыкуп.")]
public bool LogSuccessfulBuyouts = true;
[JsonProperty("Логировать каждый неуспешный автовыкуп с причиной.")]
public bool LogUnsuccessfulBuyouts = false;
}
private class AllowedCurrencyResource {
[JsonProperty("Item short name (shortname валюты, например scrap)")]
public string ShortName;
[JsonProperty("Max price purchase per transaction (целое >= 1)")]
public int MaxPricePerPurchase = int.MaxValue;
}
private class ItemDropChanceEntry {
[JsonProperty("Item short name (shortname продаваемого предмета)")]
public string ShortName;
[JsonProperty("Drop chance percent (0..100): чем меньше шанс, тем выше value")]
public float DropChancePercent;
}
private class BuyoutRule {
[JsonProperty("Listings to buy (кол-во лотов за тик, целое >= 0)")]
public int ListingsToBuy;
[JsonProperty("Chance percent (0..100)")]
public float ChancePercent;
}
private class ListingRef {
public VendingMachine Machine;
public int OrderIndex;
public ProtoBuf.VendingMachine.SellOrder Order;
}
private enum PurchaseFailReason {
None, BlueprintListingNotSupported, InvalidAmounts, CurrencyNotAllowed, PriceLimitExceeded, TooExpensiveByValue, OutOfStock, InternalError
}
private readonly struct PurchaseResult {
public readonly bool Success;
public readonly PurchaseFailReason FailReason;
public PurchaseResult(bool success, PurchaseFailReason failReason) {
Success = success;
FailReason = failReason;
}
}
/// <summary>
/// Example fragment written into new or repaired config JSON (EnforceAllowedCurrencies stays false until you enable it).
/// </summary>
private static List<AllowedCurrencyResource> CreateExampleAllowedCurrencies() {
return new List<AllowedCurrencyResource> {
new AllowedCurrencyResource {
ShortName = "fat.animal", MaxPricePerPurchase = 500
}
, new AllowedCurrencyResource {
ShortName = "bone.fragments", MaxPricePerPurchase = 2000
}
, new AllowedCurrencyResource {
ShortName = "charcoal", MaxPricePerPurchase = 15000
}
, new AllowedCurrencyResource {
ShortName = "cloth", MaxPricePerPurchase = 3000
}
, new AllowedCurrencyResource {
ShortName = "crude.oil", MaxPricePerPurchase = 500
}
, new AllowedCurrencyResource {
ShortName = "diesel_barrel", MaxPricePerPurchase = 30
}
, new AllowedCurrencyResource {
ShortName = "gunpowder", MaxPricePerPurchase = 1500
}
, new AllowedCurrencyResource {
ShortName = "hq.metal.ore", MaxPricePerPurchase = 2000
}
, new AllowedCurrencyResource {
ShortName = "leather", MaxPricePerPurchase = 1000
}
, new AllowedCurrencyResource {
ShortName = "lowgradefuel", MaxPricePerPurchase = 500
}
, new AllowedCurrencyResource {
ShortName = "metal.fragments", MaxPricePerPurchase = 3000
}
, new AllowedCurrencyResource {
ShortName = "metal.ore", MaxPricePerPurchase = 10000
}
, new AllowedCurrencyResource {
ShortName = "metal.refined", MaxPricePerPurchase = 100
}
, new AllowedCurrencyResource {
ShortName = "rope", MaxPricePerPurchase = 100
}
, new AllowedCurrencyResource {
ShortName = "scrap", MaxPricePerPurchase = 1000
}
, new AllowedCurrencyResource {
ShortName = "stones", MaxPricePerPurchase = 10000
}
, new AllowedCurrencyResource {
ShortName = "sulfur", MaxPricePerPurchase = 3000
}
, new AllowedCurrencyResource {
ShortName = "sulfur.ore", MaxPricePerPurchase = 8000
}
, new AllowedCurrencyResource {
ShortName = "techparts", MaxPricePerPurchase = 50
}
, new AllowedCurrencyResource {
ShortName = "wood", MaxPricePerPurchase = 20000
}
}
;
}
private static List<ItemDropChanceEntry> CreateExampleItemDropChances() {
return new List<ItemDropChanceEntry> {
new ItemDropChanceEntry {
ShortName = "rifle.ak", DropChancePercent = 0.5f
}
, new ItemDropChanceEntry {
ShortName = "metal.facemask", DropChancePercent = 1.5f
}
, new ItemDropChanceEntry {
ShortName = "roadsign.jacket", DropChancePercent = 2.5f
}
}
;
}
private static List<string> CreateExampleLootContainerWhitelist() {
return new List<string> {
// Common radtown / world loot containers (ShortPrefabName). Includes both underscore and legacy hyphen barrel names.
"crate_basic", "crate_normal", "crate_normal_2", "crate_military", "crate_elite", "crate_tools", "crate_food", "crate_food_2", "crate_medical", "crate_mine", "crate_vehicle_parts", "crate_minecart", "crate_underwater_basic", "crate_underwater_advanced", "loot_barrel_1", "loot_barrel_2", "loot-barrel-1", "loot-barrel-2", "barrel_oil", "crate_hackable", "crate_hackable_oilrig", "crate_heli", "crate_apc", "crate_supplydrop"
}
;
}
protected override void LoadDefaultConfig() {
_config = new PluginConfig();
_config.AllowedCurrencies = CreateExampleAllowedCurrencies();
_config.ItemDropChances = CreateExampleItemDropChances();
_config.LootContainerShortPrefabNameWhitelist = CreateExampleLootContainerWhitelist();
SaveConfig();
}
protected override void LoadConfig() {
base.LoadConfig();
try {
_config = Config.ReadObject<PluginConfig>();
if (_config == null) {
throw new Exception("Config is null");
}
}
catch {
PrintWarning("Config is invalid, generating a new one with defaults.");
LoadDefaultConfig();
}
ValidateConfig();
}
protected override void SaveConfig() => Config.WriteObject(_config, true);
private void Init() {
LoadConfig();
}
private void OnServerInitialized() {
RebuildAllowedCurrencyMap();
RebuildItemValueMap();
StartTimer();
Puts($"Auto buyout started. Interval: {_config.BuyoutIntervalSeconds:0.##} sec.");
}
private void Unload() {
_buyoutTimer?.Destroy();
}
private void StartTimer() {
_buyoutTimer?.Destroy();
_buyoutTimer = timer.Every(_config.BuyoutIntervalSeconds, TryRunBuyout);
}
private void TryRunBuyout() {
int countToBuy = DetermineListingCountByChance();
if (countToBuy <= 0) {
if (_config.LogUnsuccessfulBuyouts) {
Puts("Auto-buyout skipped: selected 0 listings to buy by chance rules.");
}
return;
}
List<ListingRef> availableListings = CollectEligibleListings();
if (availableListings.Count == 0) {
if (_config.LogUnsuccessfulBuyouts) {
Puts($"Auto-buyout failed: no eligible listings found (requested {countToBuy}). " + $"Diagnostics: {BuildRejectionDiagnosticsSummary()}" );
}
return;
}
Shuffle(availableListings);
int target = Mathf.Min(countToBuy, availableListings.Count);
int successCount = 0;
for (int i = 0; i < availableListings.Count && successCount < target; i++) {
PurchaseResult result = TryExecuteSinglePurchase(availableListings[i]);
if (result.Success) {
successCount++;
}
}
if (successCount > 0) {
Puts($"Auto-buyout completed: {successCount} listing(s) purchased.");
}
else {
if (_config.LogUnsuccessfulBuyouts) {
Puts($"Auto-buyout failed: 0 listing(s) purchased out of requested {target}. " + $"Diagnostics: {BuildRejectionDiagnosticsSummary()}" );
}
}
}
private string BuildRejectionDiagnosticsSummary() {
Dictionary<PurchaseFailReason, int> counts = new Dictionary<PurchaseFailReason, int>();
Dictionary<PurchaseFailReason, string> samples = new Dictionary<PurchaseFailReason, string>();
int scannedOrders = 0;
foreach (VendingMachine machine in BaseNetworkable.serverEntities.OfType<VendingMachine>()) {
if (machine == null || machine.IsDestroyed) {
continue;
}
if (machine.IsNpc) {
continue;
}
if (ShouldIgnoreMachine(machine)) {
continue;
}
if (machine.sellOrders?.sellOrders == null) {
continue;
}
for (int i = 0; i < machine.sellOrders.sellOrders.Count; i++) {
ProtoBuf.VendingMachine.SellOrder order = machine.sellOrders.sellOrders[i];
if (order == null) {
continue;
}
scannedOrders++;
PurchaseFailReason reason = TryGetPurchaseFailReason(machine, order, emitDebugLogs : false);
if (reason == PurchaseFailReason.None) {
continue;
}
if (!counts.TryAdd(reason, 1)) {
counts[reason]++;
}
if (!samples.ContainsKey(reason)) {
samples[reason] = FormatRejectionSample(machine, order, reason);
}
}
}
if (scannedOrders == 0) {
return "no player vending orders found on the server";
}
if (counts.Count == 0) {
return $"scannedOrders={scannedOrders}, all orders passed filters (unexpected vs eligible list empty)";
}
IEnumerable<string> parts = counts .OrderByDescending(x => x.Value) .Select(x => $"{GetOwnerFailReasonText(x.Key)}={x.Value}");
IEnumerable<string> sampleParts = counts .OrderByDescending(x => x.Value) .Select(x => {
samples.TryGetValue(x.Key, out string sample);
return string.IsNullOrWhiteSpace(sample) ? null : $"{GetOwnerFailReasonText(x.Key)}: {sample}";
}
) .Where(x => x != null);
string sampleText = sampleParts.Any() ? $", samples: {string.Join(" | ", sampleParts)}" : string.Empty;
return $"scannedOrders={scannedOrders}, rejections: {string.Join(", ", parts)}{sampleText}";
}
private string FormatRejectionSample(VendingMachine machine, ProtoBuf.VendingMachine.SellOrder order, PurchaseFailReason reason) {
string sell = ItemManager.FindItemDefinition(order.itemToSellID)?.shortname ?? order.itemToSellID.ToString();
string cur = ItemManager.FindItemDefinition(order.currencyID)?.shortname ?? order.currencyID.ToString();
if (reason == PurchaseFailReason.PriceLimitExceeded && _config.EnforceAllowedCurrencies) {
if (_allowedCurrencyMaxPrice.TryGetValue(order.currencyID, out int configuredMax)) {
int effective = GetEffectiveMaxCurrencyPrice(order, configuredMax);
float scaledExact = GetExactScaledMaxCurrencyPrice(order, configuredMax);
float itemValue = _itemValueByItemId.TryGetValue(order.itemToSellID, out float v) ? v : 0f;
bool hasValue = _itemValueByItemId.ContainsKey(order.itemToSellID);
float? normalized = TryGetNormalizedValueCoefficient(order);
string normalizedText = normalized.HasValue ? $"normalizedValue={normalized.Value:0.######}, " : "normalizedValue=n/a, ";
return $"sell={sell} x{order.itemToSellAmount}, price={order.currencyAmountPerItem} {cur}, configuredMax={configuredMax}, effectiveMaxRounded={effective}, scaledMaxExact={scaledExact:0.####}, {normalizedText}" + $"hasValue={hasValue}, value={itemValue:0.####}, maxValue={_maxItemValueCoefficient:0.####}";
}
}
return $"sell={sell} x{order.itemToSellAmount}, price={order.currencyAmountPerItem} {cur}, machine={machine.ShortPrefabName}";
}
private int DetermineListingCountByChance() {
return _config.UseMutuallyExclusiveRules ? DetermineMutuallyExclusiveCount() : DetermineIndependentCount();
}
private int DetermineMutuallyExclusiveCount() {
float roll = UnityEngine.Random.Range(0f, 100f);
float cumulative = 0f;
foreach (BuyoutRule rule in _config.Rules.OrderBy(r => r.ListingsToBuy)) {
cumulative += rule.ChancePercent;
if (roll < cumulative) {
return rule.ListingsToBuy;
}
}
// No rule selected this tick.
return 0;
}
private int DetermineIndependentCount() {
int selectedCount = 0;
foreach (BuyoutRule rule in _config.Rules.OrderBy(r => r.ListingsToBuy)) {
if (Roll(rule.ChancePercent)) {
selectedCount = Math.Max(selectedCount, rule.ListingsToBuy);
}
}
return selectedCount;
}
private List<ListingRef> CollectEligibleListings() {
List<ListingRef> result = new List<ListingRef>();
List<VendingMachine> machines = BaseNetworkable.serverEntities.OfType<VendingMachine>().ToList();
foreach (VendingMachine machine in machines) {
if (machine == null || machine.IsDestroyed) {
continue;
}
if (machine.IsNpc) {
continue;
}
if (ShouldIgnoreMachine(machine)) {
continue;
}
if (machine.sellOrders?.sellOrders == null) {
continue;
}
for (int i = 0; i < machine.sellOrders.sellOrders.Count; i++) {
ProtoBuf.VendingMachine.SellOrder order = machine.sellOrders.sellOrders[i];
if (order == null) {
continue;
}
if (!IsOrderEligible(machine, order)) {
continue;
}
result.Add(new ListingRef {
Machine = machine, OrderIndex = i, Order = order
}
);
}
}
return result;
}
private bool ShouldIgnoreMachine(VendingMachine machine) {
if (!_config.IgnoreNpcVendingManagerCustomMachines) {
return false;
}
// NPCVendingManager marks managed machines with this component.
if (machine.GetComponent("VendimgResotrer") != null) {
return true;
}
NPCVendingMachine npcMachine = machine as NPCVendingMachine;
if (npcMachine == null) {
return false;
}
// Extra safeguard for plugin-managed world NPC shops.
return npcMachine.OwnerID == 0;
}
private bool IsOrderEligible(VendingMachine machine, ProtoBuf.VendingMachine.SellOrder order) {
if (order.currencyIsBP || order.itemToSellIsBP) {
return false;
}
if (order.itemToSellAmount <= 0 || order.currencyAmountPerItem <= 0) {
return false;
}
if (!IsPaymentCurrencyAllowed(order)) {
return false;
}
if (!IsPriceValidByItemValue(order)) {
return false;
}
int stockAmount = machine.inventory.GetAmount(order.itemToSellID, false);
return stockAmount >= order.itemToSellAmount;
}
private bool IsPaymentCurrencyAllowed(ProtoBuf.VendingMachine.SellOrder order) {
return TryGetPaymentCurrencyBlockReason(order, emitDebugLogs : true) == PurchaseFailReason.None;
}
private PurchaseFailReason TryGetPaymentCurrencyBlockReason(ProtoBuf.VendingMachine.SellOrder order, bool emitDebugLogs) {
if (!_config.EnforceAllowedCurrencies) {
return PurchaseFailReason.None;
}
if (order.currencyIsBP) {
if (emitDebugLogs) {
LogRejectedByValueScaledPrice(order, "currency blueprint is not allowed");
}
return PurchaseFailReason.CurrencyNotAllowed;
}
if (_allowedCurrencyMaxPrice.Count == 0) {
if (emitDebugLogs) {
LogRejectedByValueScaledPrice(order, "allowed currency list is empty");
}
return PurchaseFailReason.CurrencyNotAllowed;
}
if (!_allowedCurrencyMaxPrice.TryGetValue(order.currencyID, out int maxPrice)) {
if (emitDebugLogs) {
LogRejectedByValueScaledPrice(order, "currency is not in allowed list");
}
return PurchaseFailReason.CurrencyNotAllowed;
}
int effectiveMaxPrice = GetEffectiveMaxCurrencyPrice(order, maxPrice);
if (order.currencyAmountPerItem > effectiveMaxPrice) {
float itemValue = _itemValueByItemId.TryGetValue(order.itemToSellID, out float v) ? v : 0f;
if (emitDebugLogs) {
LogRejectedByValueScaledPrice(order, $"price exceeds allowed max", maxPrice, effectiveMaxPrice, itemValue, _maxItemValueCoefficient, GetExactScaledMaxCurrencyPrice(order, maxPrice), TryGetNormalizedValueCoefficient(order) );
}
return PurchaseFailReason.PriceLimitExceeded;
}
return PurchaseFailReason.None;
}
private int GetEffectiveMaxCurrencyPrice(ProtoBuf.VendingMachine.SellOrder order, int configuredMaxPrice) {
if (!_config.UseValueScaledMaxPurchasePrice) {
return configuredMaxPrice;
}
// If value table is empty/unavailable, do not zero-out purchases: fall back to configured max.
if (_maxItemValueCoefficient <= 0f || _itemValueByItemId.Count == 0) {
return configuredMaxPrice;
}
// If this sell item has no known value coefficient, scaling cannot be applied safely.
// Falling back to configured max avoids "everything exceeds limit" behavior.
if (!_itemValueByItemId.TryGetValue(order.itemToSellID, out float itemValueCoefficient) || itemValueCoefficient <= 0f) {
return configuredMaxPrice;
}
float normalizedValue = Mathf.Clamp01(itemValueCoefficient / _maxItemValueCoefficient);
float scaledPrice = configuredMaxPrice * normalizedValue;
int rounded = Mathf.RoundToInt(scaledPrice);
return Mathf.Clamp(rounded, 1, configuredMaxPrice);
}
private float GetExactScaledMaxCurrencyPrice(ProtoBuf.VendingMachine.SellOrder order, int configuredMaxPrice) {
if (!_config.UseValueScaledMaxPurchasePrice) {
return configuredMaxPrice;
}
if (_maxItemValueCoefficient <= 0f || _itemValueByItemId.Count == 0) {
return configuredMaxPrice;
}
if (!_itemValueByItemId.TryGetValue(order.itemToSellID, out float itemValueCoefficient) || itemValueCoefficient <= 0f) {
return configuredMaxPrice;
}
float normalizedValue = Mathf.Clamp01(itemValueCoefficient / _maxItemValueCoefficient);
return configuredMaxPrice * normalizedValue;
}
private float? TryGetNormalizedValueCoefficient(ProtoBuf.VendingMachine.SellOrder order) {
if (_maxItemValueCoefficient <= 0f || _itemValueByItemId.Count == 0) {
return null;
}
if (!_itemValueByItemId.TryGetValue(order.itemToSellID, out float itemValueCoefficient) || itemValueCoefficient <= 0f) {
return null;
}
return Mathf.Clamp01(itemValueCoefficient / _maxItemValueCoefficient);
}
private void LogRejectedByValueScaledPrice(ProtoBuf.VendingMachine.SellOrder order, string reason, int configuredMaxPrice = -1, int effectiveMaxPrice = -1, float itemValue = -1f, float maxValue = -1f, float scaledMaxExact = -1f, float? normalizedValue = null) {
if (!_config.DebugLogRejectedByValueScaledPrice) {
return;
}
ItemDefinition currencyDef = ItemManager.FindItemDefinition(order.currencyID);
ItemDefinition sellDef = ItemManager.FindItemDefinition(order.itemToSellID);
string currencyName = currencyDef?.shortname ?? order.currencyID.ToString();
string sellName = sellDef?.shortname ?? order.itemToSellID.ToString();
string normalizedText = normalizedValue.HasValue ? $"normalizedValue={normalizedValue.Value:0.######}, " : "normalizedValue=n/a, ";
string details = configuredMaxPrice >= 0 ? (scaledMaxExact >= 0f ? $"configuredMax={configuredMaxPrice}, effectiveMaxRounded={effectiveMaxPrice}, scaledMaxExact={scaledMaxExact:0.####}, {normalizedText}itemValue={itemValue:0.####}, maxValue={maxValue:0.####}" : $"configuredMax={configuredMaxPrice}, effectiveMaxRounded={effectiveMaxPrice}, {normalizedText}itemValue={itemValue:0.####}, maxValue={maxValue:0.####}" ) : "configuredMax=n/a, effectiveMax=n/a, itemValue=n/a, maxValue=n/a";
Puts($"Rejected listing [{reason}]: sell={sellName} x{order.itemToSellAmount}, " + $"price={order.currencyAmountPerItem} {currencyName}, {details}" );
}
private bool IsPriceValidByItemValue(ProtoBuf.VendingMachine.SellOrder order) {
return TryGetValueRatioBlockReason(order) == PurchaseFailReason.None;
}
private PurchaseFailReason TryGetValueRatioBlockReason(ProtoBuf.VendingMachine.SellOrder order) {
if (!_config.UseItemValueCoefficientFilter || _config.MaxPriceToValueRatio <= 0f) {
return PurchaseFailReason.None;
}
if (!_itemValueByItemId.TryGetValue(order.itemToSellID, out float valueCoefficient)) {
return PurchaseFailReason.TooExpensiveByValue;
}
if (valueCoefficient <= 0f || order.itemToSellAmount <= 0) {
return PurchaseFailReason.TooExpensiveByValue;
}
float pricePerOneSoldItem = (float)order.currencyAmountPerItem / order.itemToSellAmount;
float ratio = pricePerOneSoldItem / valueCoefficient;
return ratio <= _config.MaxPriceToValueRatio ? PurchaseFailReason.None : PurchaseFailReason.TooExpensiveByValue;
}
private PurchaseFailReason TryGetPurchaseFailReason(VendingMachine machine, ProtoBuf.VendingMachine.SellOrder order, bool emitDebugLogs = true) {
if (order.currencyIsBP || order.itemToSellIsBP) {
return PurchaseFailReason.BlueprintListingNotSupported;
}
if (order.itemToSellAmount <= 0 || order.currencyAmountPerItem <= 0) {
return PurchaseFailReason.InvalidAmounts;
}
PurchaseFailReason currencyReason = TryGetPaymentCurrencyBlockReason(order, emitDebugLogs);
if (currencyReason != PurchaseFailReason.None) {
return currencyReason;
}
PurchaseFailReason valueReason = TryGetValueRatioBlockReason(order);
if (valueReason != PurchaseFailReason.None) {
return valueReason;
}
int stockAmount = machine.inventory.GetAmount(order.itemToSellID, false);
return stockAmount >= order.itemToSellAmount ? PurchaseFailReason.None : PurchaseFailReason.OutOfStock;
}
private string GetOwnerFailReasonText(PurchaseFailReason reason) {
switch (reason) {
case PurchaseFailReason.CurrencyNotAllowed : return "за этот ресурс продавать нельзя";
case PurchaseFailReason.PriceLimitExceeded : return "превышение лимита стоимости";
case PurchaseFailReason.TooExpensiveByValue : return "дорого";
case PurchaseFailReason.OutOfStock : return "нет товара на складе автомата";
case PurchaseFailReason.BlueprintListingNotSupported : return "чертежи/чертежная валюта не поддерживаются";
case PurchaseFailReason.InvalidAmounts : return "некорректные количества в ордере";
case PurchaseFailReason.InternalError : return "внутренняя ошибка при выкупе";
default : return "неизвестная причина";
}
}
private void RebuildAllowedCurrencyMap() {
_allowedCurrencyMaxPrice = new Dictionary<int, int>();
if (_config.AllowedCurrencies == null || !_config.EnforceAllowedCurrencies) {
return;
}
foreach (AllowedCurrencyResource entry in _config.AllowedCurrencies) {
if (entry == null || string.IsNullOrWhiteSpace(entry.ShortName)) {
continue;
}
ItemDefinition def = ItemManager.FindItemDefinition(entry.ShortName.Trim());
if (def == null) {
PrintWarning($"AllowedCurrencies: unknown item short name '{entry.ShortName}'.");
continue;
}
int maxPrice = Mathf.Max(1, entry.MaxPricePerPurchase);
if (_allowedCurrencyMaxPrice.TryGetValue(def.itemid, out int existing)) {
_allowedCurrencyMaxPrice[def.itemid] = Math.Max(existing, maxPrice);
}
else {
_allowedCurrencyMaxPrice[def.itemid] = maxPrice;
}
}
}
private void RebuildItemValueMap() {
_itemValueByItemId = new Dictionary<int, float>();
_maxItemValueCoefficient = 0f;
bool needsValueTable = _config.UseItemValueCoefficientFilter || _config.UseValueScaledMaxPurchasePrice;
if (!needsValueTable) {
return;
}
Dictionary<int, float> itemChancesPercent = _config.AutoBuildDropChancesFromLootTables ? BuildItemDropChancesFromLootTables() : BuildItemDropChancesFromConfig();
if (_config.AutoBuildDropChancesFromLootTables && _config.DebugLogTopAutoDropChances > 0) {
PrintTopAutoDropChances(itemChancesPercent, _config.DebugLogTopAutoDropChances);
}
foreach (KeyValuePair<int, float> pair in itemChancesPercent) {
float chance = Mathf.Clamp(pair.Value, 0.0001f, 100f);
float valueCoefficient = 100f / chance;
_itemValueByItemId[pair.Key] = valueCoefficient;
if (valueCoefficient > _maxItemValueCoefficient) {
_maxItemValueCoefficient = valueCoefficient;
}
}
}
private Dictionary<int, float> BuildItemDropChancesFromConfig() {
Dictionary<int, float> result = new Dictionary<int, float>();
if (_config.ItemDropChances == null) {
return result;
}
foreach (ItemDropChanceEntry entry in _config.ItemDropChances) {
if (entry == null || string.IsNullOrWhiteSpace(entry.ShortName)) {
continue;
}
ItemDefinition def = ItemManager.FindItemDefinition(entry.ShortName.Trim());
if (def == null) {
PrintWarning($"ItemDropChances: unknown item short name '{entry.ShortName}'.");
continue;
}
result[def.itemid] = Mathf.Clamp(entry.DropChancePercent, 0.0001f, 100f);
}
return result;
}
private Dictionary<int, float> BuildItemDropChancesFromLootTables() {
Dictionary<int, float> weightedChanceSum = new Dictionary<int, float>();
HashSet<uint> processedPrefabIds = new HashSet<uint>();
float totalWeight = 0f;
foreach (LootContainer container in BaseNetworkable.serverEntities.OfType<LootContainer>()) {
if (container == null || container.IsDestroyed) {
continue;
}
if (!IsContainerAllowedForChanceBuilder(container)) {
continue;
}
if (!processedPrefabIds.Add(container.prefabID)) {
continue;
}
Dictionary<int, float> singleSource = BuildSingleContainerChanceMap(container);
if (singleSource.Count == 0) {
continue;
}
totalWeight += 1f;
foreach (KeyValuePair<int, float> pair in singleSource) {
if (weightedChanceSum.TryGetValue(pair.Key, out float current)) {
weightedChanceSum[pair.Key] = current + pair.Value;
}
else {
weightedChanceSum[pair.Key] = pair.Value;
}
}
}
if (totalWeight <= 0f) {
return new Dictionary<int, float>();
}
Dictionary<int, float> result = new Dictionary<int, float>();
foreach (KeyValuePair<int, float> pair in weightedChanceSum) {
float averagedChance01 = pair.Value / totalWeight;
result[pair.Key] = Mathf.Clamp(averagedChance01 * 100f, 0.0001f, 100f);
}
return result;
}
private bool IsContainerAllowedForChanceBuilder(LootContainer container) {
if (_config.LootContainerShortPrefabNameWhitelist == null || _config.LootContainerShortPrefabNameWhitelist.Count == 0) {
return true;
}
string shortName = container.ShortPrefabName;
if (string.IsNullOrWhiteSpace(shortName)) {
return false;
}
return _config.LootContainerShortPrefabNameWhitelist.Any(x => string.Equals(x, shortName, StringComparison.OrdinalIgnoreCase));
}
private void PrintTopAutoDropChances(Dictionary<int, float> chancePercentByItemId, int topCount) {
if (chancePercentByItemId == null || chancePercentByItemId.Count == 0 || topCount <= 0) {
return;
}
int max = Mathf.Min(topCount, chancePercentByItemId.Count);
List<string> rows = chancePercentByItemId .OrderByDescending(x => x.Value) .Take(max) .Select(x => {
ItemDefinition def = ItemManager.FindItemDefinition(x.Key);
string shortName = def?.shortname ?? x.Key.ToString();
float chance = Mathf.Clamp(x.Value, 0.0001f, 100f);
float valueCoefficient = 100f / chance;
return $"{shortName}={chance:0.####}% (value={valueCoefficient:0.####})";
}
) .ToList();
Puts($"Auto drop-chance/value top {max}: {string.Join(", ", rows)}");
}
private Dictionary<int, float> BuildSingleContainerChanceMap(LootContainer container) {
Dictionary<int, float> result = new Dictionary<int, float>();
LootFill lootFill = container.GetComponent<LootFill>();
if (lootFill != null) {
if (lootFill.LootSpawnSlots != null && lootFill.LootSpawnSlots.Length > 0) {
foreach (LootContainer.LootSpawnSlot slot in lootFill.LootSpawnSlots) {
if (slot.definition == null) {
continue;
}
AddLootSpawnItems(result, slot.definition, Mathf.Clamp01(slot.probability));
}
}
else if (lootFill.LootDefinition != null) {
AddLootSpawnItems(result, lootFill.LootDefinition, 1f);
}
}
else {
if (container.LootSpawnSlots != null && container.LootSpawnSlots.Length > 0) {
foreach (LootContainer.LootSpawnSlot slot in container.LootSpawnSlots) {
if (slot.definition == null) {
continue;
}
AddLootSpawnItems(result, slot.definition, Mathf.Clamp01(slot.probability));
}
}
else if (container.lootDefinition != null) {
AddLootSpawnItems(result, container.lootDefinition, 1f);
}
}
if (container.scrapAmount > 0) {
AddItemChance(result, -932201673, 1f);
}
return result;
}
private void AddLootSpawnItems(Dictionary<int, float> map, LootSpawn def, float chanceMultiplier) {
if (def == null || chanceMultiplier <= 0f) {
return;
}
if (def.subSpawn != null && def.subSpawn.Length > 0) {
float totalWeight = def.subSpawn.Sum(x => x.weight);
if (totalWeight <= 0f) {
return;
}
foreach (var entry in def.subSpawn) {
if (entry.category == null || entry.weight <= 0f) {
continue;
}
float branchChance = chanceMultiplier * (entry.weight / totalWeight);
AddLootSpawnItems(map, entry.category, branchChance);
}
return;
}
if (def.items == null) {
return;
}
int itemCount = def.items.Count(x => x != null);
if (itemCount <= 0) {
return;
}
float itemChance = chanceMultiplier / itemCount;
foreach (ItemAmount itemAmount in def.items) {
if (itemAmount == null) {
continue;
}
AddItemChance(map, itemAmount.itemid, itemChance);
}
}
private void AddItemChance(Dictionary<int, float> map, int itemId, float chance01) {
if (chance01 <= 0f) {
return;
}
if (map.TryGetValue(itemId, out float existing)) {
map[itemId] = existing + chance01;
}
else {
map[itemId] = chance01;
}
}
private PurchaseResult TryExecuteSinglePurchase(ListingRef listing) {
VendingMachine machine = listing.Machine;
if (machine == null || machine.IsDestroyed || machine.inventory == null) {
return new PurchaseResult(false, PurchaseFailReason.InternalError);
}
ProtoBuf.VendingMachine.SellOrder order = listing.Order;
PurchaseFailReason failReason = TryGetPurchaseFailReason(machine, order, emitDebugLogs : true);
if (failReason != PurchaseFailReason.None) {
return new PurchaseResult(false, failReason);
}
int amountToTake = order.itemToSellAmount;
if (!TakeItems(machine.inventory, order.itemToSellID, amountToTake)) {
return new PurchaseResult(false, PurchaseFailReason.OutOfStock);
}
int currencyAmount = order.currencyAmountPerItem;
Item payment = ItemManager.CreateByItemID(order.currencyID, currencyAmount);
if (payment == null) {
return new PurchaseResult(false, PurchaseFailReason.InternalError);
}
if (!payment.MoveToContainer(machine.inventory)) {
payment.Drop(machine.transform.position + Vector3.up, Vector3.zero);
}
machine.SendNetworkUpdateImmediate();
if (_config.LogSuccessfulBuyouts) {
Puts($"Bought listing from machine #{machine.net?.ID}: sold {amountToTake} of item {order.itemToSellID}, paid {currencyAmount} of item {order.currencyID}.");
}
return new PurchaseResult(true, PurchaseFailReason.None);
}
private bool TakeItems(ItemContainer container, int itemId, int amount) {
int leftToTake = amount;
List<Item> matchingItems = container.itemList.Where(i => i.info.itemid == itemId).ToList();
foreach (Item item in matchingItems) {
if (leftToTake <= 0) {
break;
}
int take = Math.Min(item.amount, leftToTake);
item.UseItem(take);
leftToTake -= take;
}
return leftToTake <= 0;
}
private bool Roll(float chancePercent) {
if (chancePercent <= 0f) {
return false;
}
if (chancePercent >= 100f) {
return true;
}
return UnityEngine.Random.Range(0f, 100f) < chancePercent;
}
private void Shuffle<T>(IList<T> list) {
for (int i = list.Count - 1; i > 0; i--) {
int j = UnityEngine.Random.Range(0, i + 1);
T temp = list[i];
list[i] = list[j];
list[j] = temp;
}
}
private void ValidateConfig() {
bool configChanged = false;
if (_config.BuyoutIntervalSeconds < 10f) {
_config.BuyoutIntervalSeconds = 10f;
configChanged = true;
}
if (_config.Rules == null || _config.Rules.Count == 0) {
_config.Rules = new List<BuyoutRule> {
new BuyoutRule {
ListingsToBuy = 0, ChancePercent = 84f
}
, new BuyoutRule {
ListingsToBuy = 1, ChancePercent = 10f
}
, new BuyoutRule {
ListingsToBuy = 2, ChancePercent = 5f
}
, new BuyoutRule {
ListingsToBuy = 3, ChancePercent = 1f
}
}
;
configChanged = true;
}
_config.Rules = _config.Rules .Where(r => r != null && r.ListingsToBuy >= 0) .GroupBy(r => r.ListingsToBuy) .Select(g => new BuyoutRule {
ListingsToBuy = g.Key, ChancePercent = Mathf.Clamp(g.Max(x => x.ChancePercent), 0f, 100f)
}
) .OrderBy(r => r.ListingsToBuy) .ToList();
if (_config.UseMutuallyExclusiveRules) {
float totalChance = _config.Rules.Sum(r => r.ChancePercent);
if (totalChance > 100f) {
// Keep relative probabilities but enforce valid total range.
float scale = 100f / totalChance;
foreach (BuyoutRule rule in _config.Rules) {
rule.ChancePercent = (float)Math.Round(rule.ChancePercent * scale, 3);
}
configChanged = true;
}
}
if (_config.AllowedCurrencies == null) {
_config.AllowedCurrencies = CreateExampleAllowedCurrencies();
configChanged = true;
}
if (_config.ItemDropChances == null) {
_config.ItemDropChances = CreateExampleItemDropChances();
configChanged = true;
}
if (_config.LootContainerShortPrefabNameWhitelist == null) {
_config.LootContainerShortPrefabNameWhitelist = CreateExampleLootContainerWhitelist();
configChanged = true;
}
_config.LootContainerShortPrefabNameWhitelist = _config.LootContainerShortPrefabNameWhitelist .Where(x => !string.IsNullOrWhiteSpace(x)) .Select(x => x.Trim()) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList();
_config.MaxPriceToValueRatio = Mathf.Max(0f, _config.MaxPriceToValueRatio);
if (_config.EnforceAllowedCurrencies && _config.AllowedCurrencies.Count == 0) {
PrintWarning("EnforceAllowedCurrencies is true but AllowedCurrencies is empty: no listings will match until you add entries.");
}
RebuildAllowedCurrencyMap();
RebuildItemValueMap();
if (configChanged) {
SaveConfig();
}
}
}
}