Иконка ресурса

FREE AutoVendingBuyout : 1.9.3 1.9.3

Нет прав для скачивания
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();
            }
        }
    }
}

Автор
15FPS
Скачиваний
0
Просмотры
1
Первый выпуск
Обновление
Оценка
0.00 звёзд 0 оценок
Сверху