C#:
using System;
using System.Collections.Generic;
using UnityEngine;
namespace Oxide.Plugins {
[Info("SmartSmelt", "SeesAll", "1.2.4")]
[Description("Preset-based accelerated smelting with instant sync, adaptive scaling, and smart fuel pull.")]
public class SmartSmelt : RustPlugin {
#region Configuration
private const string ItemWood = "wood";
private const string ItemCharcoal = "charcoal";
private const string ItemLowGradeFuel = "lowgradefuel";
private const string ItemMetalFragments = "metal.fragments";
private const string ItemSulfur = "sulfur";
private const string ItemMetalRefined = "metal.refined";
private const string ItemCrudeOil = "crude.oil";
private const string ItemMetalOre = "metal.ore";
private const string ItemSulfurOre = "sulfur.ore";
private const string ItemHqMetalOre = "hq.metal.ore";
private static readonly string[] DefaultOvenWhitelist = {
"furnace", "furnace.large", "refinery", "electric.furnace"
}
;
private Configuration _config;
private readonly List<string> _cachedWhitelistFragmentsLower = new List<string>();
private bool _cachedWhitelistAllowsAnyRefinery;
private ItemDefinition _charcoalDefinition;
private ItemDefinition _woodDefinition;
private ItemDefinition _lowGradeFuelDefinition;
private ItemDefinition _metalFragmentsDefinition;
private ItemDefinition _sulfurDefinition;
private ItemDefinition _metalRefinedDefinition;
private ItemDefinition _crudeOilDefinition;
private ItemDefinition _metalOreDefinition;
private ItemDefinition _sulfurOreDefinition;
private ItemDefinition _hqMetalOreDefinition;
private class Configuration {
public bool Enabled = true;
public int ConfigVersion = 3;
public string Preset = "10x";
public string PresetOptions = "2x, 3x, 5x, 10x, 25x, 50x, 100x, 1000x, instant";
public bool AutoTuneEnabled = true;
public int AveragePopulation = 100;
public string AutoTuneBias = "Balanced";
public bool AutoTuneWriteToConfig = false;
public bool EnableOreSplitting = true;
public bool EnableMixingTableScaling = true;
public List<string> OvenWhitelist = new List<string>(DefaultOvenWhitelist);
public bool ForceStartCookingOnToggle = true;
public bool VerboseTrackingLogs = false;
public bool VerboseCycleLogs = false;
public bool AdaptiveScaling = true;
public int AdaptiveMinOvensPerTick = 100;
public int AdaptiveMaxOvensPerTick = 800;
public bool DynamicTickInterval = true;
public float DynamicMinGlobalLoopInterval = 0.10f;
public float DynamicMaxGlobalLoopInterval = 0.25f;
public int DynamicLowOvenCount = 100;
public int DynamicHighOvenCount = 1200;
public float FixedGlobalLoopInterval = 0.25f;
public bool AutoPullFuelFromPlayer = true;
public float AutoPullFuelBufferPercent = 0f;
public bool ReducedWoodCostEnabled = false;
public float WoodCostScale = 0.5f;
public bool EnableElectricFurnaceNativeScaling = true;
public float ElectricFurnaceThroughputScale = 2.0f;
public float ElectricFurnaceCycleSpeedScale = 0.5f;
public bool ProduceCharcoalFromFuel = true;
public float CharcoalPerWood = 0.75f;
public string CharcoalOverflowMode = "Skip";
public bool Debug = false;
}
protected override void LoadDefaultConfig() {
_config = new Configuration();
RebuildRuntimeCaches();
SaveConfig();
}
protected override void LoadConfig() {
base.LoadConfig();
bool shouldSaveConfig = false;
if (MigrateConfigSchema()) {
base.LoadConfig();
}
try {
_config = Config.ReadObject<Configuration>() ?? new Configuration();
}
catch {
PrintWarning("Config was invalid; generating a new one.");
_config = new Configuration();
shouldSaveConfig = true;
}
if (NormalizeConfig()) shouldSaveConfig = true;
if (shouldSaveConfig) SaveConfig();
RebuildRuntimeCaches();
RefreshEffectiveScheduling(allowWriteToConfig : true);
}
protected override void SaveConfig() => Config.WriteObject(_config, true);
protected override void LoadDefaultMessages() {
lang.RegisterMessages(new Dictionary<string, string> {
["NoPermission"]
= "<color=#ff6b6b>SmartSmelt:</color> You don't have permission to use this command.", ["DebugTitle"] = "<color=#9be7ff>SmartSmelt Debug</color> v{0}", ["DebugLine1"] = "Enabled: {0} | Preset: {1} | Multiplier: {2}x", ["DebugLine2"] = "Tracked ovens: {0} | Ovens/tick cap: {1} (Adaptive: {2})", ["DebugLine3"] = "Global loop interval: {0:0.000}s | Desired: {1:0.000}s (Dynamic: {2})", ["ConsoleDebug"] = "SmartSmelt Debug v{0} | Enabled={1} Preset={2} Mult={3}x | Tracked={4} Cap={5} | Interval={6:0.000}s Desired={7:0.000}s", ["InfoLine1"] = "SmartSmelt v{0} | Preset={1} | Enabled={2} | OreSplitting={3} | AutoPullFuel={4} (Buffer={5:0.###}%) | WoodCost={6:0.##}x", ["InfoLine2"] = "AutoTune={0} (AvgPop={1}, Bias={2}, WriteToConfig={3})", ["InfoLine3"] = "TrackedOvens={0} | PerTickCap={1} (Adaptive={2}) | LoopInterval={3:0.000}s (Desired={4:0.000}s, Dynamic={5})", ["ConsoleInfo"] = "SmartSmelt v{0} | Preset={1} | Enabled={2} | AutoPullFuel={3} (Buffer={4:0.###}%) | WoodCost={11:0.##}x\nTrackedOvens={5} | PerTickCap={6} (Adaptive={7}) | LoopInterval={8:0.000}s (Desired={9:0.000}s, Dynamic={10})"
}
, this);
}
private string Msg(string key, string userId = null) => lang.GetMessage(key, this, userId);
private bool MigrateConfigSchema() {
try {
var raw = Config.ReadObject<Dictionary<string, object>>();
if (raw == null) return false;
int version = 0;
if (raw.TryGetValue("ConfigVersion", out var vObj)) {
try {
version = Convert.ToInt32(vObj);
}
catch {
version = 0;
}
}
bool changed = false;
if (version < 1) {
if (raw.Remove("AutoPullFuelTargetAmount")) changed = true;
if (raw.Remove("AutoPullFuelMaxPullPerInteraction")) changed = true;
if (raw.Remove("AutoPullFuelExactNeeded")) changed = true;
raw["ConfigVersion"] = 1;
changed = true;
}
if (version < 2) {
// Version 1 used FuelUsageMultiplier. Preserve its effective behavior
// when translating to the clearer opt-in reduced-wood-cost settings.
if (raw.TryGetValue("FuelUsageMultiplier", out var legacyFuelMultiplier)) {
try {
float legacyScale = Mathf.Clamp(Convert.ToSingle(legacyFuelMultiplier), 0f, 1f);
if (!raw.ContainsKey("WoodCostScale")) raw["WoodCostScale"] = legacyScale;
if (!raw.ContainsKey("ReducedWoodCostEnabled")) raw["ReducedWoodCostEnabled"] = legacyScale < 0.999f;
}
catch {
PrintWarning("Could not read legacy FuelUsageMultiplier; using the new wood-cost defaults.");
}
raw.Remove("FuelUsageMultiplier");
}
raw["ConfigVersion"] = 2;
changed = true;
}
if (version < 3) {
if (!raw.ContainsKey("EnableMixingTableScaling")) raw["EnableMixingTableScaling"] = true;
raw["ConfigVersion"] = 3;
changed = true;
}
if (!raw.ContainsKey("ConfigVersion")) {
raw["ConfigVersion"] = 3;
changed = true;
}
if (!changed) return false;
Config.WriteObject(raw, true);
return true;
}
catch {
return false;
}
}
private void RebuildRuntimeCaches() {
_cachedWhitelistFragmentsLower.Clear();
_cachedWhitelistAllowsAnyRefinery = false;
if (_config?.OvenWhitelist != null) {
for (int i = 0; i < _config.OvenWhitelist.Count; i++) {
var fragment = (_config.OvenWhitelist[i] ?? string.Empty).Trim().ToLowerInvariant();
if (fragment.Length == 0) continue;
_cachedWhitelistFragmentsLower.Add(fragment);
if (fragment.Contains("refinery_small")) _cachedWhitelistAllowsAnyRefinery = true;
}
}
_charcoalDefinition = ItemManager.FindItemDefinition(ItemCharcoal);
_woodDefinition = ItemManager.FindItemDefinition(ItemWood);
_lowGradeFuelDefinition = ItemManager.FindItemDefinition(ItemLowGradeFuel);
_metalFragmentsDefinition = ItemManager.FindItemDefinition(ItemMetalFragments);
_sulfurDefinition = ItemManager.FindItemDefinition(ItemSulfur);
_metalRefinedDefinition = ItemManager.FindItemDefinition(ItemMetalRefined);
_crudeOilDefinition = ItemManager.FindItemDefinition(ItemCrudeOil);
_metalOreDefinition = ItemManager.FindItemDefinition(ItemMetalOre);
_sulfurOreDefinition = ItemManager.FindItemDefinition(ItemSulfurOre);
_hqMetalOreDefinition = ItemManager.FindItemDefinition(ItemHqMetalOre);
}
private ItemDefinition GetCachedItemDefinition(string shortname) {
if (string.IsNullOrEmpty(shortname)) return null;
switch (shortname) {
case ItemCharcoal : return _charcoalDefinition;
case ItemWood : return _woodDefinition;
case ItemLowGradeFuel : return _lowGradeFuelDefinition;
case ItemMetalFragments : return _metalFragmentsDefinition;
case ItemSulfur : return _sulfurDefinition;
case ItemMetalRefined : return _metalRefinedDefinition;
case ItemCrudeOil : return _crudeOilDefinition;
case ItemMetalOre : return _metalOreDefinition;
case ItemSulfurOre : return _sulfurOreDefinition;
case ItemHqMetalOre : return _hqMetalOreDefinition;
default : return ItemManager.FindItemDefinition(shortname);
}
}
private bool NormalizeConfig() {
bool changed = false;
if (_config == null) {
_config = new Configuration();
return true;
}
// Version 3 introduced mixing-table preset scaling; bumping the version forces a
// save so the new key appears without replacing existing administrator settings.
if (_config.ConfigVersion != 3) {
_config.ConfigVersion = 3;
changed = true;
}
if (string.IsNullOrWhiteSpace(_config.PresetOptions)) {
_config.PresetOptions = "2x, 3x, 5x, 10x, 25x, 50x, 100x, 1000x, instant";
changed = true;
}
if (_config.AutoPullFuelBufferPercent < 0f) {
_config.AutoPullFuelBufferPercent = 0f;
changed = true;
}
else if (_config.AutoPullFuelBufferPercent > 10f) {
_config.AutoPullFuelBufferPercent = 10f;
changed = true;
}
if (_config.WoodCostScale < 0f) {
_config.WoodCostScale = 0f;
changed = true;
}
else if (_config.WoodCostScale > 1f) {
_config.WoodCostScale = 1f;
changed = true;
}
var presetKey = ResolvePresetKey(_config.Preset);
if (!PresetDefinitions.ContainsKey(presetKey)) {
PrintWarning($"Unknown Preset '{_config.Preset}', defaulting to {DefaultPresetKey}.");
presetKey = DefaultPresetKey;
changed = true;
}
if (!string.Equals(_config.Preset, presetKey, StringComparison.Ordinal)) {
_config.Preset = presetKey;
changed = true;
}
if (_config.AveragePopulation < 0) {
_config.AveragePopulation = 0;
changed = true;
}
if (_config.AveragePopulation > 5000) {
_config.AveragePopulation = 5000;
changed = true;
}
var bias = (_config.AutoTuneBias ?? "Balanced").Trim();
if (bias.Length == 0) bias = "Balanced";
if (bias.Equals("balanced", StringComparison.OrdinalIgnoreCase)) bias = "Balanced";
else if (bias.Equals("performance", StringComparison.OrdinalIgnoreCase)) bias = "Performance";
else if (bias.Equals("responsiveness", StringComparison.OrdinalIgnoreCase)) bias = "Responsiveness";
else {
PrintWarning($"Unknown AutoTuneBias '{_config.AutoTuneBias}', defaulting to Balanced.");
bias = "Balanced";
changed = true;
}
if (!string.Equals(_config.AutoTuneBias, bias, StringComparison.Ordinal)) {
_config.AutoTuneBias = bias;
changed = true;
}
if (_config.OvenWhitelist == null) {
_config.OvenWhitelist = new List<string>(DefaultOvenWhitelist);
changed = true;
}
else {
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var cleaned = new List<string>();
foreach (var s in _config.OvenWhitelist) {
var v = (s ?? string.Empty).Trim();
if (v.Length == 0) continue;
if (seen.Add(v)) cleaned.Add(v);
}
if (cleaned.Count == 0) cleaned.AddRange(DefaultOvenWhitelist);
if (cleaned.Count != _config.OvenWhitelist.Count) changed = true;
_config.OvenWhitelist = cleaned;
}
if (_config.CharcoalPerWood < 0f) {
_config.CharcoalPerWood = 0f;
changed = true;
}
else if (_config.CharcoalPerWood > 2f) {
_config.CharcoalPerWood = 2f;
changed = true;
}
if (_config.ElectricFurnaceThroughputScale < 0.25f) {
_config.ElectricFurnaceThroughputScale = 0.25f;
changed = true;
}
else if (_config.ElectricFurnaceThroughputScale > 10f) {
_config.ElectricFurnaceThroughputScale = 10f;
changed = true;
}
if (_config.ElectricFurnaceCycleSpeedScale < 0.1f) {
_config.ElectricFurnaceCycleSpeedScale = 0.1f;
changed = true;
}
else if (_config.ElectricFurnaceCycleSpeedScale > 2f) {
_config.ElectricFurnaceCycleSpeedScale = 2f;
changed = true;
}
var com = (_config.CharcoalOverflowMode ?? "Skip").Trim();
if (com.Length == 0) com = "Skip";
com = com.Equals("Pause", StringComparison.OrdinalIgnoreCase) ? "Pause" : "Skip";
if (_config.CharcoalOverflowMode != com) {
_config.CharcoalOverflowMode = com;
changed = true;
}
return changed;
}
private bool _effAdaptiveScaling;
private int _effAdaptiveMinOvensPerTick;
private int _effAdaptiveMaxOvensPerTick;
private bool _effDynamicTickInterval;
private float _effDynamicMinGlobalLoopInterval;
private float _effDynamicMaxGlobalLoopInterval;
private int _effDynamicLowOvenCount;
private int _effDynamicHighOvenCount;
private float _effFixedGlobalLoopInterval;
private readonly Dictionary<ulong, ulong> _pendingFuelRecalcPlayers = new Dictionary<ulong, ulong>();
private readonly Dictionary<ulong, bool> _pendingAutomationToggleStates = new Dictionary<ulong, bool>();
private void RefreshEffectiveScheduling(bool allowWriteToConfig) {
if (_config == null) return;
_effAdaptiveScaling = _config.AdaptiveScaling;
_effAdaptiveMinOvensPerTick = _config.AdaptiveMinOvensPerTick;
_effAdaptiveMaxOvensPerTick = _config.AdaptiveMaxOvensPerTick;
_effDynamicTickInterval = _config.DynamicTickInterval;
_effDynamicMinGlobalLoopInterval = _config.DynamicMinGlobalLoopInterval;
_effDynamicMaxGlobalLoopInterval = _config.DynamicMaxGlobalLoopInterval;
_effDynamicLowOvenCount = _config.DynamicLowOvenCount;
_effDynamicHighOvenCount = _config.DynamicHighOvenCount;
_effFixedGlobalLoopInterval = _config.FixedGlobalLoopInterval;
if (!_config.AutoTuneEnabled) return;
int pop = Mathf.Clamp(_config.AveragePopulation, 0, 5000);
int[] anchors = {
10, 25, 50, 100, 200, 300, 400, 500
}
;
int anchor = anchors[0];
int bestDist = Math.Abs(pop - anchor);
for (int i = 1; i < anchors.Length; i++) {
int d = Math.Abs(pop - anchors[i]);
if (d < bestDist) {
bestDist = d;
anchor = anchors[i];
}
}
int baseMin;
int baseMax;
float baseMinInterval;
float baseMaxInterval;
int baseLowCount;
int baseHighCount;
float baseFixed;
switch (anchor) {
case 10 : baseMin = 300;
baseMax = 900;
baseMinInterval = 0.05f;
baseMaxInterval = 0.15f;
baseLowCount = 50;
baseHighCount = 300;
baseFixed = 0.15f;
break;
case 25 : baseMin = 250;
baseMax = 900;
baseMinInterval = 0.07f;
baseMaxInterval = 0.18f;
baseLowCount = 75;
baseHighCount = 400;
baseFixed = 0.18f;
break;
case 50 : baseMin = 200;
baseMax = 800;
baseMinInterval = 0.10f;
baseMaxInterval = 0.22f;
baseLowCount = 100;
baseHighCount = 600;
baseFixed = 0.22f;
break;
case 100 : baseMin = 160;
baseMax = 700;
baseMinInterval = 0.12f;
baseMaxInterval = 0.25f;
baseLowCount = 150;
baseHighCount = 900;
baseFixed = 0.25f;
break;
case 200 : baseMin = 140;
baseMax = 650;
baseMinInterval = 0.14f;
baseMaxInterval = 0.28f;
baseLowCount = 200;
baseHighCount = 1100;
baseFixed = 0.28f;
break;
case 300 : baseMin = 120;
baseMax = 600;
baseMinInterval = 0.15f;
baseMaxInterval = 0.32f;
baseLowCount = 250;
baseHighCount = 1300;
baseFixed = 0.32f;
break;
case 400 : baseMin = 110;
baseMax = 550;
baseMinInterval = 0.16f;
baseMaxInterval = 0.35f;
baseLowCount = 300;
baseHighCount = 1500;
baseFixed = 0.35f;
break;
default : baseMin = 100;
baseMax = 500;
baseMinInterval = 0.18f;
baseMaxInterval = 0.40f;
baseLowCount = 350;
baseHighCount = 1700;
baseFixed = 0.40f;
break;
}
float ovensFactor = 1.0f;
float intervalFactor = 1.0f;
switch (ResolvePresetKey(_config.Preset)) {
case "2x" : ovensFactor = 1.15f;
intervalFactor = 0.90f;
break;
case "3x" : ovensFactor = 1.10f;
intervalFactor = 0.92f;
break;
case "5x" : ovensFactor = 1.05f;
intervalFactor = 0.95f;
break;
case "10x" : ovensFactor = 1.00f;
intervalFactor = 1.00f;
break;
case "25x" : ovensFactor = 0.85f;
intervalFactor = 1.10f;
break;
case "50x" : ovensFactor = 0.75f;
intervalFactor = 1.20f;
break;
case "100x" : ovensFactor = 0.65f;
intervalFactor = 1.30f;
break;
case "1000x" : ovensFactor = 0.45f;
intervalFactor = 1.60f;
break;
case "instant" : ovensFactor = 0.35f;
intervalFactor = 1.80f;
break;
}
string bias = (_config.AutoTuneBias ?? "Balanced").Trim();
if (bias.Equals("Responsiveness", StringComparison.OrdinalIgnoreCase)) {
ovensFactor *= 1.10f;
intervalFactor *= 0.90f;
}
else if (bias.Equals("Performance", StringComparison.OrdinalIgnoreCase)) {
ovensFactor *= 0.90f;
intervalFactor *= 1.10f;
}
int tunedMin = Mathf.Clamp(Mathf.RoundToInt(baseMin * ovensFactor), 25, 5000);
int tunedMax = Mathf.Clamp(Mathf.RoundToInt(baseMax * ovensFactor), tunedMin, 10000);
float tunedMinInterval = Mathf.Clamp(baseMinInterval * intervalFactor, 0.03f, 2f);
float tunedMaxInterval = Mathf.Clamp(baseMaxInterval * intervalFactor, tunedMinInterval, 2f);
float tunedFixed = Mathf.Clamp(baseFixed * intervalFactor, 0.03f, 2f);
_effAdaptiveScaling = true;
_effAdaptiveMinOvensPerTick = tunedMin;
_effAdaptiveMaxOvensPerTick = tunedMax;
_effDynamicTickInterval = true;
_effDynamicMinGlobalLoopInterval = tunedMinInterval;
_effDynamicMaxGlobalLoopInterval = tunedMaxInterval;
_effDynamicLowOvenCount = baseLowCount;
_effDynamicHighOvenCount = baseHighCount;
_effFixedGlobalLoopInterval = tunedFixed;
if (!allowWriteToConfig || !_config.AutoTuneWriteToConfig) return;
bool changed = false;
if (_config.AdaptiveScaling != _effAdaptiveScaling) {
_config.AdaptiveScaling = _effAdaptiveScaling;
changed = true;
}
if (_config.AdaptiveMinOvensPerTick != _effAdaptiveMinOvensPerTick) {
_config.AdaptiveMinOvensPerTick = _effAdaptiveMinOvensPerTick;
changed = true;
}
if (_config.AdaptiveMaxOvensPerTick != _effAdaptiveMaxOvensPerTick) {
_config.AdaptiveMaxOvensPerTick = _effAdaptiveMaxOvensPerTick;
changed = true;
}
if (_config.DynamicTickInterval != _effDynamicTickInterval) {
_config.DynamicTickInterval = _effDynamicTickInterval;
changed = true;
}
if (!Mathf.Approximately(_config.DynamicMinGlobalLoopInterval, _effDynamicMinGlobalLoopInterval)) {
_config.DynamicMinGlobalLoopInterval = _effDynamicMinGlobalLoopInterval;
changed = true;
}
if (!Mathf.Approximately(_config.DynamicMaxGlobalLoopInterval, _effDynamicMaxGlobalLoopInterval)) {
_config.DynamicMaxGlobalLoopInterval = _effDynamicMaxGlobalLoopInterval;
changed = true;
}
if (_config.DynamicLowOvenCount != _effDynamicLowOvenCount) {
_config.DynamicLowOvenCount = _effDynamicLowOvenCount;
changed = true;
}
if (_config.DynamicHighOvenCount != _effDynamicHighOvenCount) {
_config.DynamicHighOvenCount = _effDynamicHighOvenCount;
changed = true;
}
if (!Mathf.Approximately(_config.FixedGlobalLoopInterval, _effFixedGlobalLoopInterval)) {
_config.FixedGlobalLoopInterval = _effFixedGlobalLoopInterval;
changed = true;
}
if (changed) SaveConfig();
RefreshCachedPreset();
}
private void QueueFuelRecalcNextTick(BaseOven oven, BasePlayer player) {
if (!_config.Enabled) return;
if (!_config.AutoPullFuelFromPlayer) return;
if (oven == null || oven.IsDestroyed) return;
if (player == null || !player.IsConnected) return;
ulong id = 0ul;
if (oven.net != null) id = oven.net.ID.Value;
if (id == 0ul) {
NextTick(() => {
if (oven == null || oven.IsDestroyed) return;
if (player == null || !player.IsConnected) return;
TryAutoPullFuel(oven, player);
}
);
return;
}
ulong playerId = player.userID;
if (_pendingFuelRecalcPlayers.TryGetValue(id, out var pendingPlayerId)) {
// Multiple requests from the same player still collapse into one scan. A
// different player makes ownership ambiguous, so skip automatic withdrawal.
if (pendingPlayerId != playerId) _pendingFuelRecalcPlayers[id] = 0ul;
return;
}
_pendingFuelRecalcPlayers[id] = playerId;
NextTick(() => {
if (!_pendingFuelRecalcPlayers.TryGetValue(id, out var resolvedPlayerId)) return;
_pendingFuelRecalcPlayers.Remove(id);
if (resolvedPlayerId == 0ul || resolvedPlayerId != playerId) return;
if (oven == null || oven.IsDestroyed) return;
if (player == null || !player.IsConnected) return;
TryAutoPullFuel(oven, player);
}
);
}
#endregion
#region Presets
private struct PresetTuning {
public float CycleSeconds;
public int MaxTotalConsumedPerCycle;
public int MaxConsumedPerStackPerCycle;
}
private struct PresetDefinition {
public PresetTuning Tuning;
public int Multiplier;
}
private const string DefaultPresetKey = "10x";
private const string InstantPresetKey = "instant";
private static readonly Dictionary<string, string> PresetAliases = new Dictionary<string, string>(StringComparer.Ordinal) {
["2"]
= "2x", ["3"] = "3x", ["5"] = "5x", ["10"] = "10x", ["25"] = "25x", ["50"] = "50x", ["100"] = "100x", ["1000"] = "1000x", ["inst"] = InstantPresetKey
}
;
private static readonly Dictionary<string, PresetDefinition> PresetDefinitions = new Dictionary<string, PresetDefinition>(StringComparer.Ordinal) {
["2x"]
= MakePreset(0.5f, 20, 10, 2), ["3x"] = MakePreset(0.5f, 30, 15, 3), ["5x"] = MakePreset(0.5f, 50, 25, 5), ["10x"] = MakePreset(0.5f, 100, 50, 10), ["25x"] = MakePreset(0.25f, 500, 250, 25), ["50x"] = MakePreset(0.2f, 1000, 500, 50), ["100x"] = MakePreset(0.1f, 2000, 1000, 100), ["1000x"] = MakePreset(0.05f, 20000, 10000, 1000), [InstantPresetKey] = MakePreset(0.1f, int.MaxValue, int.MaxValue, 1000000)
}
;
private static PresetDefinition MakePreset(float cycleSeconds, int maxTotalPerCycle, int maxPerStackPerCycle, int multiplier) {
return new PresetDefinition {
Tuning = new PresetTuning {
CycleSeconds = cycleSeconds, MaxTotalConsumedPerCycle = maxTotalPerCycle, MaxConsumedPerStackPerCycle = maxPerStackPerCycle
}
, Multiplier = multiplier
}
;
}
private static string ResolvePresetKey(string preset) {
var key = (preset ?? DefaultPresetKey).Trim().ToLowerInvariant();
if (key.Length == 0) key = DefaultPresetKey;
if (PresetAliases.TryGetValue(key, out var canonical)) key = canonical;
return key;
}
private PresetTuning GetPreset(string preset) {
return PresetDefinitions.TryGetValue(ResolvePresetKey(preset), out var def) ? def.Tuning : PresetDefinitions[DefaultPresetKey].Tuning;
}
private int GetMultiplier(string preset) {
return PresetDefinitions.TryGetValue(ResolvePresetKey(preset), out var def) ? def.Multiplier : PresetDefinitions[DefaultPresetKey].Multiplier;
}
private string _cachedPresetKey = null;
private PresetTuning _cachedPresetTuning;
private int _cachedPresetMultiplier = 10;
private bool _cachedIsInstantPreset = false;
private void RefreshCachedPreset() {
string key = ResolvePresetKey(_config?.Preset);
if (key == _cachedPresetKey) return;
_cachedPresetKey = key;
_cachedPresetTuning = GetPreset(key);
_cachedPresetMultiplier = GetMultiplier(key);
_cachedIsInstantPreset = string.Equals(key, InstantPresetKey, StringComparison.Ordinal);
}
private PresetTuning GetEffectivePresetForKind(PresetTuning basePreset, OvenKind kind) {
if (_config == null || kind != OvenKind.ElectricFurnace || !_config.EnableElectricFurnaceNativeScaling) return basePreset;
if (basePreset.MaxTotalConsumedPerCycle == int.MaxValue || basePreset.MaxConsumedPerStackPerCycle == int.MaxValue) return basePreset;
var tuned = basePreset;
tuned.CycleSeconds = Mathf.Clamp(basePreset.CycleSeconds * _config.ElectricFurnaceCycleSpeedScale, 0.02f, 2f);
long totalScaled = (long)Mathf.RoundToInt(basePreset.MaxTotalConsumedPerCycle * _config.ElectricFurnaceThroughputScale);
long stackScaled = (long)Mathf.RoundToInt(basePreset.MaxConsumedPerStackPerCycle * _config.ElectricFurnaceThroughputScale);
if (totalScaled < 1L) totalScaled = 1L;
if (stackScaled < 1L) stackScaled = 1L;
if (totalScaled > int.MaxValue) totalScaled = int.MaxValue;
if (stackScaled > int.MaxValue) stackScaled = int.MaxValue;
tuned.MaxTotalConsumedPerCycle = (int)totalScaled;
tuned.MaxConsumedPerStackPerCycle = (int)stackScaled;
return tuned;
}
#endregion
#region State
private readonly Dictionary<ulong, OvenTracker> _active = new Dictionary<ulong, OvenTracker>();
private readonly Dictionary<ulong, string> _cachedOvenPrefabNamesLower = new Dictionary<ulong, string>();
private readonly HashSet<Item> _customMovesInProgress = new HashSet<Item>();
private readonly HashSet<ulong> _pendingPostMoveRebalances = new HashSet<ulong>();
private readonly HashSet<ulong> _postMoveRebalancesInProgress = new HashSet<ulong>();
private int _activeVersion = 0;
private int _tmpTrackerIdsBuiltForVersion = -1;
private void MarkActiveChanged() {
_activeVersion++;
_tmpTrackerIdsBuiltForVersion = -1;
if (_activeVersion == int.MaxValue) _activeVersion = 0;
}
private Timer _globalTimer;
private float _currentGlobalLoopInterval = 0.25f;
private bool _rescheduleQueued;
private float _queuedRescheduleInterval = DefaultGlobalLoopInterval;
private readonly List<ulong> _tmpTrackerIds = new List<ulong>(256);
private readonly Stack<List<Item>> _itemListPool = new Stack<List<Item>>();
private const int DefaultMaxOvensPerGlobalTick = 200;
private const float DefaultGlobalLoopInterval = 0.25f;
private const float MinGlobalLoopInterval = 0.05f;
private const float MaxGlobalLoopInterval = 2f;
private const float TimerRescheduleThreshold = 0.05f;
private const float ForceStartCookingDelaySeconds = 0.2f;
private const int MaxOffCyclesBeforeEvict = 50;
private const int StartupScanEntitiesPerSlice = 500;
private const float StartupScanSliceDelaySeconds = 0.02f;
private int _globalCursor = 0;
private IEnumerator<BaseNetworkable> _startupScanEnumerator;
private int _startupScanTracked;
private int GetOvensPerGlobalTickCap(int trackedCount) {
if (!_effAdaptiveScaling) return DefaultMaxOvensPerGlobalTick;
int cap = trackedCount;
if (cap < _effAdaptiveMinOvensPerTick) cap = _effAdaptiveMinOvensPerTick;
if (cap > _effAdaptiveMaxOvensPerTick) cap = _effAdaptiveMaxOvensPerTick;
return cap;
}
private float ComputeGlobalLoopInterval(int trackedCount) {
if (_config == null) return DefaultGlobalLoopInterval;
if (!_effDynamicTickInterval) return Mathf.Clamp(_effFixedGlobalLoopInterval, MinGlobalLoopInterval, MaxGlobalLoopInterval);
float min = Mathf.Clamp(_effDynamicMinGlobalLoopInterval, MinGlobalLoopInterval, MaxGlobalLoopInterval);
float max = Mathf.Clamp(_effDynamicMaxGlobalLoopInterval, MinGlobalLoopInterval, MaxGlobalLoopInterval);
if (max < min) {
var t = min;
min = max;
max = t;
}
int low = Math.Max(0, _effDynamicLowOvenCount);
int high = Math.Max(low + 1, _effDynamicHighOvenCount);
float t01 = Mathf.InverseLerp(low, high, trackedCount);
return Mathf.Lerp(min, max, t01);
}
private void EnsureGlobalTimer(float interval) {
interval = Mathf.Clamp(interval, MinGlobalLoopInterval, MaxGlobalLoopInterval);
if (_globalTimer == null) {
_currentGlobalLoopInterval = interval;
_globalTimer = timer.Every(_currentGlobalLoopInterval, GlobalTick);
return;
}
if (Mathf.Abs(_currentGlobalLoopInterval - interval) < TimerRescheduleThreshold) return;
// Always remember the latest requested interval so a queued reschedule uses it
// instead of the value captured when the reschedule was first queued.
_queuedRescheduleInterval = interval;
if (_rescheduleQueued) return;
_rescheduleQueued = true;
timer.Once(0f, () => {
_rescheduleQueued = false;
_globalTimer?.Destroy();
_globalTimer = null;
_currentGlobalLoopInterval = Mathf.Clamp(_queuedRescheduleInterval, MinGlobalLoopInterval, MaxGlobalLoopInterval);
_globalTimer = timer.Every(_currentGlobalLoopInterval, GlobalTick);
}
);
}
private const string PermAdmin = "smartsmelt.admin";
private const string PermDebug = "smartsmelt.debug";
private class OvenTracker {
public BaseOven Oven;
public OvenKind Kind;
public bool GateOnWood;
public float NextTickAt;
public int Cycles;
public int OffCycles;
public float CharcoalRemainder;
public float FuelDebt;
public float LastBalanceTime;
public readonly List<Item> InputsBuffer = new List<Item>(8);
}
#endregion
#region Hooks
private void OnServerInitialized() {
// ItemManager is fully initialized at this point. Rebuild cached definitions so
// a very early config load cannot leave the runtime with unresolved item entries.
RebuildRuntimeCaches();
RefreshEffectiveScheduling(allowWriteToConfig : true);
_active.Clear();
_cachedOvenPrefabNamesLower.Clear();
_pendingFuelRecalcPlayers.Clear();
_pendingAutomationToggleStates.Clear();
_customMovesInProgress.Clear();
_pendingPostMoveRebalances.Clear();
_postMoveRebalancesInProgress.Clear();
MarkActiveChanged();
permission.RegisterPermission(PermAdmin, this);
permission.RegisterPermission(PermDebug, this);
_globalTimer?.Destroy();
_globalTimer = null;
EnsureGlobalTimer(ComputeGlobalLoopInterval(0));
timer.Once(1f, ScanAndTrackRunningOvens);
}
private void Unload() {
_globalTimer?.Destroy();
_globalTimer = null;
DisposeStartupScanEnumerator();
_active.Clear();
_cachedOvenPrefabNamesLower.Clear();
_pendingFuelRecalcPlayers.Clear();
_pendingAutomationToggleStates.Clear();
_customMovesInProgress.Clear();
_pendingPostMoveRebalances.Clear();
_postMoveRebalancesInProgress.Clear();
MarkActiveChanged();
}
private void OnItemAddedToContainer(ItemContainer container, Item item) {
if (_config == null || !_config.Enabled) return;
if (container == null || item?.info == null) return;
var oven = container.entityOwner as BaseOven;
if (oven == null || oven.IsDestroyed || container != oven.inventory) return;
if (!IsWhitelistedSmeltingOven(oven)) return;
var kind = GetKind(oven);
string shortname = item.info.shortname;
if (kind == OvenKind.SmallRefinery) {
if (shortname != ItemCrudeOil) return;
}
else if (!IsOreShortname(shortname)) {
return;
}
// Capture the item type that caused the hook. For mixed-ore furnaces this lets
// the fallback spread only the newly-added type into its own and empty slots,
// without disturbing ore types the player already arranged.
QueuePostMoveInputRebalance(oven, null, item.info, item.skin, kind == OvenKind.SmallRefinery || _config.EnableOreSplitting);
}
private void OnEntityKill(BaseNetworkable ent) {
var oven = ent as BaseOven;
if (oven == null) return;
StopTracking(oven);
}
private void OnOvenToggle(BaseOven oven, BasePlayer player) {
if (!_config.Enabled || oven == null) return;
if (!IsWhitelistedSmeltingOven(oven)) {
if (_config.Debug) Puts($"Ignoring oven (not whitelisted): {oven.ShortPrefabName}");
return;
}
NextTick(() => {
if (oven == null || oven.IsDestroyed) return;
if (oven.IsOn()) {
StartTracking(oven);
if (_config.ForceStartCookingOnToggle) {
timer.Once(ForceStartCookingDelaySeconds, () => {
if (oven == null || oven.IsDestroyed) return;
if (!oven.IsOn()) {
if (_config.Debug) Puts("Toggle ON but oven still OFF after 0.2s. Attempting StartCooking().");
TryStartCooking(oven);
}
}
);
}
}
else {
StopTracking(oven);
}
}
);
}
private void OnMixingTableToggle(MixingTable table, BasePlayer player) {
if (_config == null || !_config.Enabled || !_config.EnableMixingTableScaling) return;
if (table == null || table.IsDestroyed || table.IsOn()) return;
// Rust calculates the selected recipe and its full duration as the table starts.
// Apply the preset on the next tick so those vanilla values are ready first.
NextTick(() => {
if (table == null || table.IsDestroyed || !table.IsOn()) return;
RefreshCachedPreset();
float multiplier = Mathf.Max(1f, _cachedPresetMultiplier);
if (multiplier <= 1f || table.RemainingMixTime <= 0f) return;
table.RemainingMixTime /= multiplier;
table.TotalMixTime /= multiplier;
table.SendNetworkUpdateImmediate();
// Vanilla ticks once per second. Reschedule sub-second recipes so high and
// Instant presets do not wait for an unnecessary full vanilla tick.
if (table.RemainingMixTime < 1f) {
table.CancelInvoke(table.TickMix);
table.Invoke(table.TickMix, Mathf.Max(0.01f, table.RemainingMixTime));
}
if (_config.Debug) Puts($"Mixing table started at {ResolvePresetKey(_config.Preset)} ({multiplier:0}x); remaining time is {table.RemainingMixTime:0.###}s.");
}
);
}
private void OnEntityFlagsChanged(BaseEntity entity, BaseEntity.Flags oldFlags, BaseEntity.Flags newFlags) {
if (!_config.Enabled || entity == null) return;
bool oldOn = (oldFlags & BaseEntity.Flags.On) != 0;
bool newOn = (newFlags & BaseEntity.Flags.On) != 0;
if (oldOn == newOn) return;
HandleAutomationOvenToggle(entity, newOn);
}
private void HandleAutomationOvenToggle(BaseEntity entity, bool newOnState) {
var oven = entity as BaseOven;
if (oven == null) return;
if (!IsWhitelistedSmeltingOven(oven)) return;
ulong id = oven.net != null ? oven.net.ID.Value : 0ul;
if (id == 0ul) {
NextTick(() => {
if (oven == null || oven.IsDestroyed) return;
if (newOnState && oven.IsOn()) StartTracking(oven);
else if (!newOnState && !oven.IsOn()) StopTracking(oven);
}
);
return;
}
if (_pendingAutomationToggleStates.ContainsKey(id)) {
_pendingAutomationToggleStates[id] = newOnState;
return;
}
_pendingAutomationToggleStates[id] = newOnState;
NextTick(() => {
if (!_pendingAutomationToggleStates.TryGetValue(id, out var desiredState)) return;
_pendingAutomationToggleStates.Remove(id);
if (oven == null || oven.IsDestroyed) return;
if (desiredState && oven.IsOn()) StartTracking(oven);
else if (!desiredState && !oven.IsOn()) StopTracking(oven);
}
);
}
private void OnOvenCook(BaseOven oven) {
EnsureTrackedFromCook(oven);
}
private void EnsureTrackedFromCook(BaseOven oven) {
if (!_config.Enabled || oven == null || oven.IsDestroyed) return;
if (!oven.IsOn()) return;
if (!IsWhitelistedSmeltingOven(oven)) return;
StartTracking(oven);
}
#region Commands
[ChatCommand("ss.debug")]
private void CmdSmartSmeltDebug(BasePlayer player, string command, string[] args) {
if (player == null) return;
if (!permission.UserHasPermission(player.UserIDString, PermDebug)) {
player.ChatMessage(Msg("NoPermission", player.UserIDString));
return;
}
int tracked = _active?.Count ?? 0;
int cap = GetOvensPerGlobalTickCap(tracked);
float desiredInterval = ComputeGlobalLoopInterval(tracked);
string presetName = _config?.Preset ?? "unknown";
int mult = GetMultiplier(presetName);
player.ChatMessage(string.Format(Msg("DebugTitle", player.UserIDString), Version));
player.ChatMessage(string.Format(Msg("DebugLine1", player.UserIDString), _config?.Enabled ?? false, presetName, mult));
player.ChatMessage(string.Format(Msg("DebugLine2", player.UserIDString), tracked, cap, _effAdaptiveScaling));
player.ChatMessage(string.Format(Msg("DebugLine3", player.UserIDString), _currentGlobalLoopInterval, desiredInterval, _config?.DynamicTickInterval ?? false));
}
[ConsoleCommand("ss.debug")]
private void CCmdSmartSmeltDebug(ConsoleSystem.Arg arg) {
var player = arg.Player();
if (player != null && !permission.UserHasPermission(player.UserIDString, PermDebug)) {
arg.ReplyWith(Msg("NoPermission", player.UserIDString));
return;
}
int tracked = _active?.Count ?? 0;
int cap = GetOvensPerGlobalTickCap(tracked);
float desiredInterval = ComputeGlobalLoopInterval(tracked);
string presetName = _config?.Preset ?? "unknown";
int mult = GetMultiplier(presetName);
arg.ReplyWith(string.Format(Msg("ConsoleDebug"), Version, _config?.Enabled ?? false, presetName, mult, tracked, cap, _currentGlobalLoopInterval, desiredInterval));
}
[ChatCommand("ss.info")]
private void CmdSmartSmeltInfo(BasePlayer player, string command, string[] args) {
if (player == null) return;
if (!permission.UserHasPermission(player.UserIDString, PermDebug)) {
player.ChatMessage(Msg("NoPermission", player.UserIDString));
return;
}
var desiredInterval = ComputeGlobalLoopInterval(_active.Count);
var cap = GetOvensPerGlobalTickCap(_active.Count);
SendReply(player, string.Format(Msg("InfoLine1", player.UserIDString), Version, _config.Preset, _config.Enabled, _config.EnableOreSplitting, _config.AutoPullFuelFromPlayer, _config.AutoPullFuelBufferPercent, GetWoodCostScale()));
SendReply(player, string.Format(Msg("InfoLine2", player.UserIDString), _config.AutoTuneEnabled, _config.AveragePopulation, _config.AutoTuneBias, _config.AutoTuneWriteToConfig));
SendReply(player, string.Format(Msg("InfoLine3", player.UserIDString), _active.Count,
// ... [Code preview truncated due to length. Full source in attached file] ...