From 22519ba0700f451dc80fbbb496e695ae6d74b878 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 28 Jun 2026 19:29:40 +0200 Subject: [PATCH 1/9] feat(itemdata): add Smelter/SmeltConversion schema (v4 members, bundle unchanged) --- .../Data/ItemDataset.cs | 29 +++++++++++++++++-- .../DatasetValidatorTests.cs | 10 +++++-- .../RustPlusBot.ItemData.Generator/Program.cs | 7 +++-- 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/RustPlusBot.Features.ItemData/Data/ItemDataset.cs b/src/RustPlusBot.Features.ItemData/Data/ItemDataset.cs index 1eea174d..19b59996 100644 --- a/src/RustPlusBot.Features.ItemData/Data/ItemDataset.cs +++ b/src/RustPlusBot.Features.ItemData/Data/ItemDataset.cs @@ -5,11 +5,13 @@ namespace RustPlusBot.Features.ItemData.Data; /// Per-section provenance dates. /// Every known item, one record each. /// Every raid target (item/building-block/vehicle) and its explosive cost. +/// Every known smelter and the conversions it performs. public sealed record ItemDataset( int SchemaVersion, DatasetSources Sources, IReadOnlyList Items, - IReadOnlyList RaidTargets); + IReadOnlyList RaidTargets, + IReadOnlyList Smelters); /// When each section of the dataset was last sourced, for "data as of" display. /// Names/ids/stack source date. @@ -19,6 +21,7 @@ public sealed record ItemDataset( /// Decay data source date. /// Upkeep data source date. /// Durability/raid-cost data source date. +/// Smelting data source date. public sealed record DatasetSources( DateOnly NamesAsOf, DateOnly RecycleAsOf, @@ -26,7 +29,8 @@ public sealed record DatasetSources( DateOnly ResearchAsOf, DateOnly DecayAsOf, DateOnly UpkeepAsOf, - DateOnly DurabilityAsOf); + DateOnly DurabilityAsOf, + DateOnly SmeltingAsOf); /// One item, with all calculator data inlined (null where not applicable). /// The Rust item id. @@ -135,3 +139,24 @@ public sealed record RaidCost( double? TimeSeconds, int? Sulfur, int? Fuel); + +/// One smelter (furnace, oven, refinery, cooker) and the conversions it performs. +/// The smelter's Rust item id, as a string. +/// The display name (e.g. "Furnace", "Camp Fire"). +/// The input → output conversions, in source order (always non-empty). +public sealed record Smelter(string Key, string Name, IReadOnlyList Conversions); + +/// One input → output conversion within a smelter. +/// The smeltable input item id (resolves via the item spine). +/// The produced item id (resolves via the item spine). +/// Units produced per smelt. +/// The probability (0..1] of producing the output. +/// Wood (fuel) consumed per smelt; 0 means electric / no fuel. +/// Seconds per smelt. +public sealed record SmeltConversion( + int InputId, + int OutputId, + int OutputQuantity, + double OutputProbability, + double WoodQuantity, + double TimeSeconds); diff --git a/tests/RustPlusBot.ItemData.Generator.Tests/DatasetValidatorTests.cs b/tests/RustPlusBot.ItemData.Generator.Tests/DatasetValidatorTests.cs index 834e913f..59a3fdb3 100644 --- a/tests/RustPlusBot.ItemData.Generator.Tests/DatasetValidatorTests.cs +++ b/tests/RustPlusBot.ItemData.Generator.Tests/DatasetValidatorTests.cs @@ -8,16 +8,17 @@ public sealed class DatasetValidatorTests { private static ItemDataset Good() => new(1, new DatasetSources(new(2026, 4, 8), new(2024, 9, 7), new(2024, 9, 7), new(2024, 9, 7), new(2024, 9, 7), - new(2024, 9, 7), new(2024, 9, 7)), + new(2024, 9, 7), new(2024, 9, 7), new(2023, 11, 5)), [ new ItemRecord(1, "AK-47", 1, 3600, new RecycleYield([new YieldEntry(2, 4, 1.0)]), null, null, null, null), new ItemRecord(2, "Metal Fragments", 1000, null, null, null, null, null, null), ], + [], []); private static ItemDataset WithRaid(params RaidTarget[] raid) => - new(3, Good().Sources, Good().Items, raid); + new(3, Good().Sources, Good().Items, raid, []); /// A dataset with all referential constraints satisfied should produce no errors. [Fact] @@ -44,6 +45,7 @@ public void UnresolvableYieldId_isError() new ItemRecord(1, "AK-47", 1, null, new RecycleYield([new YieldEntry(99999, 4, 1.0)]), null, null, null, null), ], + [], []); var errors = DatasetValidator.Validate(bad, new ValidationOptions(MinItemCount: 1)); Assert.Contains(errors, e => e.Contains("99999", StringComparison.Ordinal)); @@ -58,6 +60,7 @@ public void UnresolvableCraftIngredientId_isError() new ItemRecord(1, "AK-47", 1, null, null, new CraftRecipe([new Ingredient(88888, 100)], 30.0, 3), null, null, null), ], + [], []); var errors = DatasetValidator.Validate(bad, new ValidationOptions(MinItemCount: 1)); Assert.Contains(errors, e => e.Contains("88888", StringComparison.Ordinal)); @@ -72,6 +75,7 @@ public void UnresolvableUpkeepId_isError() new ItemRecord(1, "Wooden Door", 1, null, null, null, null, null, new UpkeepCost([new UpkeepEntry(77777, 8, 25)])), ], + [], []); var errors = DatasetValidator.Validate(bad, new ValidationOptions(MinItemCount: 1)); Assert.Contains(errors, e => e.Contains("77777", StringComparison.Ordinal)); @@ -86,6 +90,7 @@ public void UpkeepMinGreaterThanMax_isError() new ItemRecord(1, "Wooden Door", 1, null, null, null, null, null, new UpkeepCost([new UpkeepEntry(1, 25, 8)])), ], + [], []); var errors = DatasetValidator.Validate(bad, new ValidationOptions(MinItemCount: 1)); Assert.Contains(errors, e => e.Contains("upkeep", StringComparison.OrdinalIgnoreCase)); @@ -100,6 +105,7 @@ public void NegativeDecay_isError() new ItemRecord(1, "Stone Barricade", 1, null, null, null, null, new DecayInfo(-900, null, null, null, 100), null), ], + [], []); var errors = DatasetValidator.Validate(bad, new ValidationOptions(MinItemCount: 1)); Assert.Contains(errors, e => e.Contains("decay", StringComparison.OrdinalIgnoreCase)); diff --git a/tools/RustPlusBot.ItemData.Generator/Program.cs b/tools/RustPlusBot.ItemData.Generator/Program.cs index cc5b8bd1..10df31f8 100644 --- a/tools/RustPlusBot.ItemData.Generator/Program.cs +++ b/tools/RustPlusBot.ItemData.Generator/Program.cs @@ -15,6 +15,7 @@ internal static class Program private static readonly DateOnly DecayAsOf = new(2024, 9, 7); private static readonly DateOnly UpkeepAsOf = new(2024, 9, 7); private static readonly DateOnly DurabilityAsOf = new(2024, 9, 7); + private static readonly DateOnly SmeltingAsOf = new(2023, 11, 5); private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { @@ -115,9 +116,11 @@ internal static int Main(string[] args) var dataset = new ItemDataset( 3, - new DatasetSources(NamesAsOf, RecycleAsOf, CraftAsOf, ResearchAsOf, DecayAsOf, UpkeepAsOf, DurabilityAsOf), + new DatasetSources(NamesAsOf, RecycleAsOf, CraftAsOf, ResearchAsOf, DecayAsOf, UpkeepAsOf, DurabilityAsOf, + SmeltingAsOf), items, - raidTargets); + raidTargets, + []); var validationOptions = new ValidationOptions(MinItemCount: minItems, MinRaidTargetCount: 300); var errors = DatasetValidator.Validate(dataset, validationOptions); From b12e733458fde233e25e329fa843a50533250298 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 28 Jun 2026 19:34:06 +0200 Subject: [PATCH 2/9] feat(itemdata): offline smelting source (per-smelter conversion transform) Co-Authored-By: Claude Sonnet 4.6 --- .../OfflineSmeltingSourceTests.cs | 63 ++++++++++++++ .../Sources/ISmeltingSource.cs | 12 +++ .../Sources/OfflineSmeltingSource.cs | 83 +++++++++++++++++++ 3 files changed, 158 insertions(+) create mode 100644 tests/RustPlusBot.ItemData.Generator.Tests/OfflineSmeltingSourceTests.cs create mode 100644 tools/RustPlusBot.ItemData.Generator/Sources/ISmeltingSource.cs create mode 100644 tools/RustPlusBot.ItemData.Generator/Sources/OfflineSmeltingSource.cs diff --git a/tests/RustPlusBot.ItemData.Generator.Tests/OfflineSmeltingSourceTests.cs b/tests/RustPlusBot.ItemData.Generator.Tests/OfflineSmeltingSourceTests.cs new file mode 100644 index 00000000..cf01af7a --- /dev/null +++ b/tests/RustPlusBot.ItemData.Generator.Tests/OfflineSmeltingSourceTests.cs @@ -0,0 +1,63 @@ +using RustPlusBot.Features.ItemData.Data; +using RustPlusBot.ItemData.Generator.Sources; + +namespace RustPlusBot.ItemData.Generator.Tests; + +public sealed class OfflineSmeltingSourceTests +{ + private static OfflineSmeltingSource SourceWith(string json) + { + var path = Path.GetTempFileName(); + File.WriteAllText(path, json); + return new OfflineSmeltingSource(path); + } + + [Fact] + public void LoadSmelters_ProjectsConversions_ResolvingSmelterNameAndIds() + { + const string json = """ + { + "100": [ + {"fromId":"10","woodQuantity":1.67,"toId":"20","toQuantity":1,"toProbability":1,"time":3.33,"timeString":"3.33 sec"}, + {"fromId":"11","woodQuantity":1,"toId":"21","toQuantity":1,"toProbability":0.75,"time":2,"timeString":"2 sec"} + ] + } + """; + var names = new Dictionary + { + [100] = "Furnace", [10] = "Metal Ore", [20] = "Metal Fragments", [11] = "Wood", [21] = "Charcoal", + }; + + var smelters = SourceWith(json).LoadSmelters(names); + + var furnace = Assert.Single(smelters); + Assert.Equal("Furnace", furnace.Name); + Assert.Equal("100", furnace.Key); + Assert.Equal(2, furnace.Conversions.Count); + var first = furnace.Conversions[0]; + Assert.Equal(10, first.InputId); + Assert.Equal(20, first.OutputId); + Assert.Equal(1, first.OutputQuantity); + Assert.Equal(1.67, first.WoodQuantity); + Assert.Equal(3.33, first.TimeSeconds); + Assert.Equal(0.75, furnace.Conversions[1].OutputProbability); + } + + [Fact] + public void LoadSmelters_DropsSmelterWithUnknownId() + { + const string json = + """{"999":[{"fromId":"10","woodQuantity":1,"toId":"20","toQuantity":1,"toProbability":1,"time":2}]}"""; + var names = new Dictionary { [10] = "Metal Ore", [20] = "Metal Fragments" }; + Assert.Empty(SourceWith(json).LoadSmelters(names)); + } + + [Fact] + public void LoadSmelters_DropsConversionWithUnknownInputOrOutput() + { + const string json = + """{"100":[{"fromId":"10","woodQuantity":1,"toId":"999","toQuantity":1,"toProbability":1,"time":2}]}"""; + var names = new Dictionary { [100] = "Furnace", [10] = "Metal Ore" }; + Assert.Empty(SourceWith(json).LoadSmelters(names)); // smelter dropped: no valid conversions remain + } +} diff --git a/tools/RustPlusBot.ItemData.Generator/Sources/ISmeltingSource.cs b/tools/RustPlusBot.ItemData.Generator/Sources/ISmeltingSource.cs new file mode 100644 index 00000000..a87e6950 --- /dev/null +++ b/tools/RustPlusBot.ItemData.Generator/Sources/ISmeltingSource.cs @@ -0,0 +1,12 @@ +using RustPlusBot.Features.ItemData.Data; + +namespace RustPlusBot.ItemData.Generator.Sources; + +/// Provides per-smelter smelting/cooking conversion data from RustLabs. +internal interface ISmeltingSource +{ + /// Loads smelters, resolving smelter and input/output names via . + /// Item id → display name, for the smelter id and each conversion's ids. + /// Every smelter with at least one resolvable conversion. + IReadOnlyList LoadSmelters(IReadOnlyDictionary names); +} diff --git a/tools/RustPlusBot.ItemData.Generator/Sources/OfflineSmeltingSource.cs b/tools/RustPlusBot.ItemData.Generator/Sources/OfflineSmeltingSource.cs new file mode 100644 index 00000000..8f5955d5 --- /dev/null +++ b/tools/RustPlusBot.ItemData.Generator/Sources/OfflineSmeltingSource.cs @@ -0,0 +1,83 @@ +using System.Globalization; +using System.Text.Json; +using RustPlusBot.Features.ItemData.Data; + +namespace RustPlusBot.ItemData.Generator.Sources; + +/// Reads smelting data from the offline RustLabs JSON file, keyed by smelter id. +/// Path to the rustlabsSmeltingData.json file. +internal sealed class OfflineSmeltingSource(string SmeltingFilePath) : ISmeltingSource +{ + /// + public IReadOnlyList LoadSmelters(IReadOnlyDictionary names) + { + ArgumentNullException.ThrowIfNull(names); + + using var stream = File.OpenRead(SmeltingFilePath); + using var doc = JsonDocument.Parse(stream); + var root = doc.RootElement; + + var smelters = new List(); + foreach (var prop in root.EnumerateObject()) + { + if (!int.TryParse(prop.Name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var smelterId) || + !names.TryGetValue(smelterId, out var smelterName)) + { + continue; // unknown smelter id — drop + } + + var conversions = ReadConversions(prop.Value, names); + if (conversions.Count > 0) + { + smelters.Add(new Smelter(prop.Name, smelterName, conversions)); + } + } + + return smelters; + } + + private static List ReadConversions(JsonElement rows, IReadOnlyDictionary names) + { + var conversions = new List(); + foreach (var row in rows.EnumerateArray()) + { + if (ReadId(row, "fromId") is not { } inputId || !names.ContainsKey(inputId) || + ReadId(row, "toId") is not { } outputId || !names.ContainsKey(outputId)) + { + continue; // orphan id with no item name — drop + } + + conversions.Add(new SmeltConversion( + inputId, + outputId, + ReadInt(row, "toQuantity") ?? 1, + ReadDouble(row, "toProbability") ?? 1, + ReadDouble(row, "woodQuantity") ?? 0, + ReadDouble(row, "time") ?? 0)); + } + + return conversions; + } + + private static int? ReadId(JsonElement el, string name) + { + if (!el.TryGetProperty(name, out var p)) + { + return null; + } + + return p.ValueKind switch + { + JsonValueKind.String when int.TryParse(p.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, + out var v) => v, + JsonValueKind.Number => p.GetInt32(), + _ => null, + }; + } + + private static int? ReadInt(JsonElement el, string name) => + el.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.Number ? p.GetInt32() : null; + + private static double? ReadDouble(JsonElement el, string name) => + el.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.Number ? p.GetDouble() : null; +} From 28b472d866e0e1fa6fe8ecb788e21980ff5f0a0f Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 28 Jun 2026 19:38:11 +0200 Subject: [PATCH 3/9] feat(itemdata): validate smelter conversions (ids, time, probability, floor) Co-Authored-By: Claude Sonnet 4.6 --- .../DatasetValidatorTests.cs | 37 ++++++++++++++ .../Validation/DatasetValidator.cs | 51 ++++++++++++++++++- 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/tests/RustPlusBot.ItemData.Generator.Tests/DatasetValidatorTests.cs b/tests/RustPlusBot.ItemData.Generator.Tests/DatasetValidatorTests.cs index 59a3fdb3..7a04a044 100644 --- a/tests/RustPlusBot.ItemData.Generator.Tests/DatasetValidatorTests.cs +++ b/tests/RustPlusBot.ItemData.Generator.Tests/DatasetValidatorTests.cs @@ -20,6 +20,9 @@ public sealed class DatasetValidatorTests private static ItemDataset WithRaid(params RaidTarget[] raid) => new(3, Good().Sources, Good().Items, raid, []); + private static ItemDataset WithSmelters(params Smelter[] smelters) => + new(4, Good().Sources, Good().Items, [], smelters); + /// A dataset with all referential constraints satisfied should produce no errors. [Fact] public void Good_dataset_hasNoErrors() @@ -138,4 +141,38 @@ public void TooFewRaidTargets_isError() var errors = DatasetValidator.Validate(Good(), new ValidationOptions(MinItemCount: 1, MinRaidTargetCount: 300)); Assert.Contains(errors, e => e.Contains("raid target count", StringComparison.OrdinalIgnoreCase)); } + + [Fact] + public void SmelterConversion_UnknownInputId_isError() + { + var bad = WithSmelters(new Smelter("100", "Furnace", + [new SmeltConversion(424242, 2, 1, 1, 1, 3)])); + var errors = DatasetValidator.Validate(bad, new ValidationOptions(MinItemCount: 1)); + Assert.Contains(errors, e => e.Contains("424242", StringComparison.Ordinal)); + } + + [Fact] + public void SmelterConversion_NonPositiveTime_isError() + { + var bad = WithSmelters(new Smelter("100", "Furnace", + [new SmeltConversion(1, 2, 1, 1, 1, 0)])); + var errors = DatasetValidator.Validate(bad, new ValidationOptions(MinItemCount: 1)); + Assert.Contains(errors, e => e.Contains("time", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void SmelterConversion_ProbabilityOutOfRange_isError() + { + var bad = WithSmelters(new Smelter("100", "Furnace", + [new SmeltConversion(1, 2, 1, 1.5, 1, 3)])); + var errors = DatasetValidator.Validate(bad, new ValidationOptions(MinItemCount: 1)); + Assert.Contains(errors, e => e.Contains("probability", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void TooFewSmelters_isError() + { + var errors = DatasetValidator.Validate(Good(), new ValidationOptions(MinItemCount: 1, MinSmelterCount: 8)); + Assert.Contains(errors, e => e.Contains("smelter count", StringComparison.OrdinalIgnoreCase)); + } } diff --git a/tools/RustPlusBot.ItemData.Generator/Validation/DatasetValidator.cs b/tools/RustPlusBot.ItemData.Generator/Validation/DatasetValidator.cs index 0c83b0f8..266d6209 100644 --- a/tools/RustPlusBot.ItemData.Generator/Validation/DatasetValidator.cs +++ b/tools/RustPlusBot.ItemData.Generator/Validation/DatasetValidator.cs @@ -5,7 +5,8 @@ namespace RustPlusBot.ItemData.Generator.Validation; /// Options that control dataset validation thresholds. /// The minimum number of items the dataset must contain. /// The minimum number of raid targets the dataset must contain. -internal sealed record ValidationOptions(int MinItemCount, int MinRaidTargetCount = 0); +/// The minimum number of smelters the dataset must contain. +internal sealed record ValidationOptions(int MinItemCount, int MinRaidTargetCount = 0, int MinSmelterCount = 0); /// Validates an for structural integrity. internal static class DatasetValidator @@ -97,6 +98,54 @@ public static IReadOnlyList Validate(ItemDataset dataset, ValidationOpti } } + var smelters = dataset.Smelters ?? []; + if (smelters.Count < options.MinSmelterCount) + { + errors.Add($"smelter count {smelters.Count} below minimum {options.MinSmelterCount}"); + } + + foreach (var smelter in smelters) + { + if (smelter.Conversions.Count == 0) + { + errors.Add($"smelter {smelter.Name}: has no conversions"); + } + + foreach (var c in smelter.Conversions) + { + if (!ids.Contains(c.InputId)) + { + errors.Add($"smelter {smelter.Name}: conversion references unknown input id {c.InputId}"); + } + + if (!ids.Contains(c.OutputId)) + { + errors.Add($"smelter {smelter.Name}: conversion references unknown output id {c.OutputId}"); + } + + if (c.OutputQuantity <= 0) + { + errors.Add($"smelter {smelter.Name}: non-positive output quantity {c.OutputQuantity}"); + } + + if (c.TimeSeconds <= 0) + { + errors.Add($"smelter {smelter.Name}: non-positive time {c.TimeSeconds}"); + } + + if (c.WoodQuantity < 0) + { + errors.Add($"smelter {smelter.Name}: negative wood quantity {c.WoodQuantity}"); + } + + if (c.OutputProbability is <= 0 or > 1) + { + errors.Add( + $"smelter {smelter.Name}: output probability {c.OutputProbability} out of range (0,1]"); + } + } + } + return errors; } } From 38462a2a4dd59ab46ed0cf88a091d61178e404ae Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 28 Jun 2026 19:42:15 +0200 Subject: [PATCH 4/9] feat(itemdata): emit Smelters table, bump schema v3->v4, regenerate bundle --- .../Data/item-data.json | 1259 ++++++++++++++++- .../EmbeddedItemDatabase.cs | 2 +- .../RustPlusBot.ItemData.Generator/Program.cs | 12 +- 3 files changed, 1267 insertions(+), 6 deletions(-) diff --git a/src/RustPlusBot.Features.ItemData/Data/item-data.json b/src/RustPlusBot.Features.ItemData/Data/item-data.json index 760ce9f8..c6450353 100644 --- a/src/RustPlusBot.Features.ItemData/Data/item-data.json +++ b/src/RustPlusBot.Features.ItemData/Data/item-data.json @@ -1,5 +1,5 @@ { - "schemaVersion": 3, + "schemaVersion": 4, "sources": { "namesAsOf": "2026-04-08", "recycleAsOf": "2024-09-07", @@ -7,7 +7,8 @@ "researchAsOf": "2024-09-07", "decayAsOf": "2024-09-07", "upkeepAsOf": "2024-09-07", - "durabilityAsOf": "2024-09-07" + "durabilityAsOf": "2024-09-07", + "smeltingAsOf": "2023-11-05" }, "items": [ { @@ -79505,5 +79506,1259 @@ } ] } + ], + "smelters": [ + { + "key": "553887414", + "name": "Skull Fire Pit", + "conversions": [ + { + "inputId": 989925924, + "outputId": 1668129151, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1, + "timeSeconds": 10 + }, + { + "inputId": -1440987069, + "outputId": -1848736516, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": 1422530437, + "outputId": -1509851560, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": -1130350864, + "outputId": -1162759543, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1.3, + "timeSeconds": 13 + }, + { + "inputId": -1709878924, + "outputId": 1536610005, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": -1520560807, + "outputId": 1873897110, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": -395377963, + "outputId": 813023040, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": 621915341, + "outputId": -242084766, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": 1668129151, + "outputId": -1938052175, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 3, + "timeSeconds": 30 + }, + { + "inputId": -1848736516, + "outputId": 1973684065, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 3, + "timeSeconds": 30 + }, + { + "inputId": -1509851560, + "outputId": -78533081, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 3, + "timeSeconds": 30 + }, + { + "inputId": -1162759543, + "outputId": 1917703890, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 3, + "timeSeconds": 30 + }, + { + "inputId": 1536610005, + "outputId": -682687162, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 3, + "timeSeconds": 30 + }, + { + "inputId": 1873897110, + "outputId": -989755543, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": 813023040, + "outputId": 1827479659, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 3, + "timeSeconds": 30 + }, + { + "inputId": 1391703481, + "outputId": -1938052175, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 6, + "timeSeconds": 60 + }, + { + "inputId": -242084766, + "outputId": 1391703481, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 3, + "timeSeconds": 30 + }, + { + "inputId": 1655979682, + "outputId": 69511070, + "outputQuantity": 15, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": -1557377697, + "outputId": 69511070, + "outputQuantity": 10, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": -151838493, + "outputId": -1938052175, + "outputQuantity": 1, + "outputProbability": 0.75, + "woodQuantity": 1, + "timeSeconds": 10 + } + ] + }, + { + "key": "1099314009", + "name": "Barbeque", + "conversions": [ + { + "inputId": 989925924, + "outputId": 1668129151, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 0.25, + "timeSeconds": 2.5 + }, + { + "inputId": -1440987069, + "outputId": -1848736516, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 0.38, + "timeSeconds": 3.75 + }, + { + "inputId": 1422530437, + "outputId": -1509851560, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 0.38, + "timeSeconds": 3.75 + }, + { + "inputId": -1130350864, + "outputId": -1162759543, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 0.33, + "timeSeconds": 3.25 + }, + { + "inputId": -1709878924, + "outputId": 1536610005, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 0.38, + "timeSeconds": 3.75 + }, + { + "inputId": -1520560807, + "outputId": 1873897110, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 0.38, + "timeSeconds": 3.75 + }, + { + "inputId": -395377963, + "outputId": 813023040, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 0.38, + "timeSeconds": 3.75 + }, + { + "inputId": 621915341, + "outputId": -242084766, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 0.38, + "timeSeconds": 3.75 + }, + { + "inputId": 1668129151, + "outputId": -1938052175, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 0.75, + "timeSeconds": 7.5 + }, + { + "inputId": -1848736516, + "outputId": 1973684065, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 0.75, + "timeSeconds": 7.5 + }, + { + "inputId": -1509851560, + "outputId": -78533081, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 0.75, + "timeSeconds": 7.5 + }, + { + "inputId": -1162759543, + "outputId": 1917703890, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 0.75, + "timeSeconds": 7.5 + }, + { + "inputId": 1536610005, + "outputId": -682687162, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 0.75, + "timeSeconds": 7.5 + }, + { + "inputId": 1873897110, + "outputId": -989755543, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 0.38, + "timeSeconds": 3.75 + }, + { + "inputId": 813023040, + "outputId": 1827479659, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 0.75, + "timeSeconds": 7.5 + }, + { + "inputId": 1391703481, + "outputId": -1938052175, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": -242084766, + "outputId": 1391703481, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 0.75, + "timeSeconds": 7.5 + }, + { + "inputId": 1655979682, + "outputId": 69511070, + "outputQuantity": 15, + "outputProbability": 1, + "woodQuantity": 0.38, + "timeSeconds": 3.75 + }, + { + "inputId": -1557377697, + "outputId": 69511070, + "outputQuantity": 10, + "outputProbability": 1, + "woodQuantity": 0.38, + "timeSeconds": 3.75 + }, + { + "inputId": -151838493, + "outputId": -1938052175, + "outputQuantity": 1, + "outputProbability": 0.75, + "woodQuantity": 1, + "timeSeconds": 10 + } + ] + }, + { + "key": "1242522330", + "name": "Cursed Cauldron", + "conversions": [ + { + "inputId": 989925924, + "outputId": 1668129151, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1, + "timeSeconds": 10 + }, + { + "inputId": -1440987069, + "outputId": -1848736516, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": 1422530437, + "outputId": -1509851560, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": -1130350864, + "outputId": -1162759543, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1.3, + "timeSeconds": 13 + }, + { + "inputId": -1709878924, + "outputId": 1536610005, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": -1520560807, + "outputId": 1873897110, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": -395377963, + "outputId": 813023040, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": 621915341, + "outputId": -242084766, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": 1668129151, + "outputId": -1938052175, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 3, + "timeSeconds": 30 + }, + { + "inputId": -1848736516, + "outputId": 1973684065, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 3, + "timeSeconds": 30 + }, + { + "inputId": -1509851560, + "outputId": -78533081, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 3, + "timeSeconds": 30 + }, + { + "inputId": -1162759543, + "outputId": 1917703890, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 3, + "timeSeconds": 30 + }, + { + "inputId": 1536610005, + "outputId": -682687162, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 3, + "timeSeconds": 30 + }, + { + "inputId": 1873897110, + "outputId": -989755543, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": 813023040, + "outputId": 1827479659, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 3, + "timeSeconds": 30 + }, + { + "inputId": 1391703481, + "outputId": -1938052175, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 6, + "timeSeconds": 60 + }, + { + "inputId": -242084766, + "outputId": 1391703481, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 3, + "timeSeconds": 30 + }, + { + "inputId": 1655979682, + "outputId": 69511070, + "outputQuantity": 15, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": -1557377697, + "outputId": 69511070, + "outputQuantity": 10, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": -151838493, + "outputId": -1938052175, + "outputQuantity": 1, + "outputProbability": 0.75, + "woodQuantity": 1, + "timeSeconds": 10 + } + ] + }, + { + "key": "1946219319", + "name": "Camp Fire", + "conversions": [ + { + "inputId": 989925924, + "outputId": 1668129151, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1, + "timeSeconds": 10 + }, + { + "inputId": -1440987069, + "outputId": -1848736516, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": 1422530437, + "outputId": -1509851560, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": -1130350864, + "outputId": -1162759543, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1.3, + "timeSeconds": 13 + }, + { + "inputId": -1709878924, + "outputId": 1536610005, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": -1520560807, + "outputId": 1873897110, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": -395377963, + "outputId": 813023040, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": 621915341, + "outputId": -242084766, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": 1668129151, + "outputId": -1938052175, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 3, + "timeSeconds": 30 + }, + { + "inputId": -1848736516, + "outputId": 1973684065, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 3, + "timeSeconds": 30 + }, + { + "inputId": -1509851560, + "outputId": -78533081, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 3, + "timeSeconds": 30 + }, + { + "inputId": -1162759543, + "outputId": 1917703890, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 3, + "timeSeconds": 30 + }, + { + "inputId": 1536610005, + "outputId": -682687162, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 3, + "timeSeconds": 30 + }, + { + "inputId": 1873897110, + "outputId": -989755543, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": 813023040, + "outputId": 1827479659, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 3, + "timeSeconds": 30 + }, + { + "inputId": 1391703481, + "outputId": -1938052175, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 6, + "timeSeconds": 60 + }, + { + "inputId": -242084766, + "outputId": 1391703481, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 3, + "timeSeconds": 30 + }, + { + "inputId": 1655979682, + "outputId": 69511070, + "outputQuantity": 15, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": -1557377697, + "outputId": 69511070, + "outputQuantity": 10, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": -151838493, + "outputId": -1938052175, + "outputQuantity": 1, + "outputProbability": 0.75, + "woodQuantity": 1, + "timeSeconds": 10 + } + ] + }, + { + "key": "-1196547867", + "name": "Electric Furnace", + "conversions": [ + { + "inputId": -4031221, + "outputId": 69511070, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 0, + "timeSeconds": 2 + }, + { + "inputId": -1157596551, + "outputId": -1581843485, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 0, + "timeSeconds": 1 + }, + { + "inputId": -1982036270, + "outputId": 317398316, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 0, + "timeSeconds": 4 + }, + { + "inputId": 1536610005, + "outputId": -682687162, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 0, + "timeSeconds": 12 + }, + { + "inputId": 813023040, + "outputId": 1827479659, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 0, + "timeSeconds": 12 + }, + { + "inputId": 1655979682, + "outputId": 69511070, + "outputQuantity": 15, + "outputProbability": 1, + "woodQuantity": 0, + "timeSeconds": 6 + }, + { + "inputId": -1557377697, + "outputId": 69511070, + "outputQuantity": 10, + "outputProbability": 1, + "woodQuantity": 0, + "timeSeconds": 6 + } + ] + }, + { + "key": "-1999722522", + "name": "Furnace", + "conversions": [ + { + "inputId": -4031221, + "outputId": 69511070, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1.67, + "timeSeconds": 3.33 + }, + { + "inputId": -1157596551, + "outputId": -1581843485, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 0.83, + "timeSeconds": 1.67 + }, + { + "inputId": -1982036270, + "outputId": 317398316, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 3.33, + "timeSeconds": 6.67 + }, + { + "inputId": 1536610005, + "outputId": -682687162, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 10, + "timeSeconds": 20 + }, + { + "inputId": 813023040, + "outputId": 1827479659, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 10, + "timeSeconds": 20 + }, + { + "inputId": 1655979682, + "outputId": 69511070, + "outputQuantity": 15, + "outputProbability": 1, + "woodQuantity": 5, + "timeSeconds": 10 + }, + { + "inputId": -1557377697, + "outputId": 69511070, + "outputQuantity": 10, + "outputProbability": 1, + "woodQuantity": 5, + "timeSeconds": 10 + }, + { + "inputId": -151838493, + "outputId": -1938052175, + "outputQuantity": 1, + "outputProbability": 0.75, + "woodQuantity": 1, + "timeSeconds": 2 + } + ] + }, + { + "key": "-1442559428", + "name": "Hobo Barrel", + "conversions": [ + { + "inputId": 989925924, + "outputId": 1668129151, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1, + "timeSeconds": 10 + }, + { + "inputId": -1440987069, + "outputId": -1848736516, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": 1422530437, + "outputId": -1509851560, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": -1130350864, + "outputId": -1162759543, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1.3, + "timeSeconds": 13 + }, + { + "inputId": -1709878924, + "outputId": 1536610005, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": -1520560807, + "outputId": 1873897110, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": -395377963, + "outputId": 813023040, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": 621915341, + "outputId": -242084766, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": 1668129151, + "outputId": -1938052175, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 3, + "timeSeconds": 30 + }, + { + "inputId": -1848736516, + "outputId": 1973684065, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 3, + "timeSeconds": 30 + }, + { + "inputId": -1509851560, + "outputId": -78533081, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 3, + "timeSeconds": 30 + }, + { + "inputId": -1162759543, + "outputId": 1917703890, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 3, + "timeSeconds": 30 + }, + { + "inputId": 1536610005, + "outputId": -682687162, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 3, + "timeSeconds": 30 + }, + { + "inputId": 1873897110, + "outputId": -989755543, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": 813023040, + "outputId": 1827479659, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 3, + "timeSeconds": 30 + }, + { + "inputId": 1391703481, + "outputId": -1938052175, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 6, + "timeSeconds": 60 + }, + { + "inputId": -242084766, + "outputId": 1391703481, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 3, + "timeSeconds": 30 + }, + { + "inputId": 1655979682, + "outputId": 69511070, + "outputQuantity": 15, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": -1557377697, + "outputId": 69511070, + "outputQuantity": 10, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": -151838493, + "outputId": -1938052175, + "outputQuantity": 1, + "outputProbability": 0.75, + "woodQuantity": 1, + "timeSeconds": 10 + } + ] + }, + { + "key": "-1992717673", + "name": "Large Furnace", + "conversions": [ + { + "inputId": -4031221, + "outputId": 69511070, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 0.33, + "timeSeconds": 0.67 + }, + { + "inputId": -1157596551, + "outputId": -1581843485, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 0.17, + "timeSeconds": 0.33 + }, + { + "inputId": -1982036270, + "outputId": 317398316, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 0.67, + "timeSeconds": 1.33 + }, + { + "inputId": 1536610005, + "outputId": -682687162, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 2, + "timeSeconds": 4 + }, + { + "inputId": 813023040, + "outputId": 1827479659, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 2, + "timeSeconds": 4 + }, + { + "inputId": 1655979682, + "outputId": 69511070, + "outputQuantity": 15, + "outputProbability": 1, + "woodQuantity": 1, + "timeSeconds": 2 + }, + { + "inputId": -1557377697, + "outputId": 69511070, + "outputQuantity": 10, + "outputProbability": 1, + "woodQuantity": 1, + "timeSeconds": 2 + }, + { + "inputId": -151838493, + "outputId": -1938052175, + "outputQuantity": 1, + "outputProbability": 0.75, + "woodQuantity": 1, + "timeSeconds": 2 + } + ] + }, + { + "key": "-1293296287", + "name": "Small Oil Refinery", + "conversions": [ + { + "inputId": -321733511, + "outputId": -946369541, + "outputQuantity": 3, + "outputProbability": 1, + "woodQuantity": 2.22, + "timeSeconds": 3.33 + }, + { + "inputId": 1536610005, + "outputId": -682687162, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 13.33, + "timeSeconds": 20 + }, + { + "inputId": 813023040, + "outputId": 1827479659, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 13.33, + "timeSeconds": 20 + }, + { + "inputId": 1655979682, + "outputId": 69511070, + "outputQuantity": 15, + "outputProbability": 1, + "woodQuantity": 6.67, + "timeSeconds": 10 + }, + { + "inputId": -1557377697, + "outputId": 69511070, + "outputQuantity": 10, + "outputProbability": 1, + "woodQuantity": 6.67, + "timeSeconds": 10 + }, + { + "inputId": -151838493, + "outputId": -1938052175, + "outputQuantity": 1, + "outputProbability": 0.75, + "woodQuantity": 1, + "timeSeconds": 1.5 + } + ] + }, + { + "key": "-1535621066", + "name": "Stone Fireplace", + "conversions": [ + { + "inputId": 989925924, + "outputId": 1668129151, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1, + "timeSeconds": 10 + }, + { + "inputId": -1440987069, + "outputId": -1848736516, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": 1422530437, + "outputId": -1509851560, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": -1130350864, + "outputId": -1162759543, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1.3, + "timeSeconds": 13 + }, + { + "inputId": -1709878924, + "outputId": 1536610005, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": -1520560807, + "outputId": 1873897110, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": -395377963, + "outputId": 813023040, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": 621915341, + "outputId": -242084766, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": 1668129151, + "outputId": -1938052175, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 3, + "timeSeconds": 30 + }, + { + "inputId": -1848736516, + "outputId": 1973684065, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 3, + "timeSeconds": 30 + }, + { + "inputId": -1509851560, + "outputId": -78533081, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 3, + "timeSeconds": 30 + }, + { + "inputId": -1162759543, + "outputId": 1917703890, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 3, + "timeSeconds": 30 + }, + { + "inputId": 1536610005, + "outputId": -682687162, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 3, + "timeSeconds": 30 + }, + { + "inputId": 1873897110, + "outputId": -989755543, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": 813023040, + "outputId": 1827479659, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 3, + "timeSeconds": 30 + }, + { + "inputId": 1391703481, + "outputId": -1938052175, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 6, + "timeSeconds": 60 + }, + { + "inputId": -242084766, + "outputId": 1391703481, + "outputQuantity": 1, + "outputProbability": 1, + "woodQuantity": 3, + "timeSeconds": 30 + }, + { + "inputId": 1655979682, + "outputId": 69511070, + "outputQuantity": 15, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": -1557377697, + "outputId": 69511070, + "outputQuantity": 10, + "outputProbability": 1, + "woodQuantity": 1.5, + "timeSeconds": 15 + }, + { + "inputId": -151838493, + "outputId": -1938052175, + "outputQuantity": 1, + "outputProbability": 0.75, + "woodQuantity": 1, + "timeSeconds": 10 + } + ] + } ] } \ No newline at end of file diff --git a/src/RustPlusBot.Features.ItemData/EmbeddedItemDatabase.cs b/src/RustPlusBot.Features.ItemData/EmbeddedItemDatabase.cs index 2e340303..6456425f 100644 --- a/src/RustPlusBot.Features.ItemData/EmbeddedItemDatabase.cs +++ b/src/RustPlusBot.Features.ItemData/EmbeddedItemDatabase.cs @@ -10,7 +10,7 @@ namespace RustPlusBot.Features.ItemData; /// Loads the embedded item-data.json once and serves lookups. Singleton. public sealed class EmbeddedItemDatabase : IItemDatabase { - private const int ExpectedSchemaVersion = 3; + private const int ExpectedSchemaVersion = 4; private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { diff --git a/tools/RustPlusBot.ItemData.Generator/Program.cs b/tools/RustPlusBot.ItemData.Generator/Program.cs index 10df31f8..2b132448 100644 --- a/tools/RustPlusBot.ItemData.Generator/Program.cs +++ b/tools/RustPlusBot.ItemData.Generator/Program.cs @@ -80,8 +80,13 @@ internal static int Main(string[] args) var researchCosts = rustLabsSource.LoadResearchCosts(); var decayInfos = rustLabsSource.LoadDecay(); var upkeepCosts = rustLabsSource.LoadUpkeep(); + var smeltingSource = new OfflineSmeltingSource( + Path.Combine(rustplusDir, "rustlabsSmeltingData.json")); + var raidTargets = durabilitySource.LoadRaidTargets(names); Console.WriteLine($"Loaded {raidTargets.Count} raid targets"); + var smelters = smeltingSource.LoadSmelters(names); + Console.WriteLine($"Loaded {smelters.Count} smelters"); var nameIds = new HashSet(names.Keys); @@ -115,14 +120,15 @@ internal static int Main(string[] args) .ToList(); var dataset = new ItemDataset( - 3, + 4, new DatasetSources(NamesAsOf, RecycleAsOf, CraftAsOf, ResearchAsOf, DecayAsOf, UpkeepAsOf, DurabilityAsOf, SmeltingAsOf), items, raidTargets, - []); + smelters); - var validationOptions = new ValidationOptions(MinItemCount: minItems, MinRaidTargetCount: 300); + var validationOptions = new ValidationOptions(MinItemCount: minItems, MinRaidTargetCount: 300, + MinSmelterCount: 8); var errors = DatasetValidator.Validate(dataset, validationOptions); if (errors.Count > 0) { From 95197b71ff29726a3e04bf90d85f04054cef6adf Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 28 Jun 2026 19:47:03 +0200 Subject: [PATCH 5/9] feat(itemdata): ResolveSmelter lookup + bundle smoke tests --- .../EmbeddedItemDatabase.cs | 15 ++++++ .../IItemDatabase.cs | 5 ++ .../Lookup/SmeltLookup.cs | 30 +++++++++++ .../Lookup/SmeltMatch.cs | 23 ++++++++ .../EmbeddedItemDatabaseTests.cs | 14 +++++ .../SmeltLookupTests.cs | 52 +++++++++++++++++++ 6 files changed, 139 insertions(+) create mode 100644 src/RustPlusBot.Features.ItemData/Lookup/SmeltLookup.cs create mode 100644 src/RustPlusBot.Features.ItemData/Lookup/SmeltMatch.cs create mode 100644 tests/RustPlusBot.Features.ItemData.Tests/SmeltLookupTests.cs diff --git a/src/RustPlusBot.Features.ItemData/EmbeddedItemDatabase.cs b/src/RustPlusBot.Features.ItemData/EmbeddedItemDatabase.cs index 6456425f..f02c730e 100644 --- a/src/RustPlusBot.Features.ItemData/EmbeddedItemDatabase.cs +++ b/src/RustPlusBot.Features.ItemData/EmbeddedItemDatabase.cs @@ -25,6 +25,10 @@ public sealed class EmbeddedItemDatabase : IItemDatabase private static readonly FrozenDictionary RaidById = IndexRaidById(Raid); + private static readonly IReadOnlyList Smelters = Dataset.Smelters ?? []; + + private static readonly FrozenDictionary SmelterById = IndexSmelterById(Smelters); + /// public DatasetSources Sources => Dataset.Sources; @@ -37,6 +41,10 @@ public sealed class EmbeddedItemDatabase : IItemDatabase /// public RaidMatch ResolveRaidTarget(string query) => RaidLookup.Resolve(query, RaidById.GetValueOrDefault, Raid); + /// + public SmeltMatch ResolveSmelter(string query) => + SmeltLookup.Resolve(query, SmelterById.GetValueOrDefault, Smelters); + /// /// Deserializes and validates an item dataset from . /// Throws when the schema version does not match @@ -78,6 +86,13 @@ internal static FrozenDictionary IndexRaidById(IReadOnlyList int.Parse(t.Key, CultureInfo.InvariantCulture)) .ToFrozenDictionary(g => g.Key, g => g.Last()); + /// Builds a frozen id→smelter index keyed by the smelter's item id. + /// All smelters. + /// A frozen dictionary keyed by smelter item id. + internal static FrozenDictionary IndexSmelterById(IReadOnlyList smelters) => + smelters.GroupBy(s => int.Parse(s.Key, CultureInfo.InvariantCulture)) + .ToFrozenDictionary(g => g.Key, g => g.Last()); + private static ItemDataset Load() { var assembly = typeof(EmbeddedItemDatabase).Assembly; diff --git a/src/RustPlusBot.Features.ItemData/IItemDatabase.cs b/src/RustPlusBot.Features.ItemData/IItemDatabase.cs index dfc19d98..fde14d93 100644 --- a/src/RustPlusBot.Features.ItemData/IItemDatabase.cs +++ b/src/RustPlusBot.Features.ItemData/IItemDatabase.cs @@ -22,4 +22,9 @@ public interface IItemDatabase /// The raw user input. /// A match describing the outcome. RaidMatch ResolveRaidTarget(string query); + + /// Resolves a user query (smelter name or id) to a smelter. + /// The raw user input. + /// A match describing the outcome. + SmeltMatch ResolveSmelter(string query); } diff --git a/src/RustPlusBot.Features.ItemData/Lookup/SmeltLookup.cs b/src/RustPlusBot.Features.ItemData/Lookup/SmeltLookup.cs new file mode 100644 index 00000000..dcb8511f --- /dev/null +++ b/src/RustPlusBot.Features.ItemData/Lookup/SmeltLookup.cs @@ -0,0 +1,30 @@ +using RustPlusBot.Features.ItemData.Data; + +namespace RustPlusBot.Features.ItemData.Lookup; + +/// Pure name-or-id resolution over smelters. Exact name beats substring. +public static class SmeltLookup +{ + private static readonly IComparer ByName = + Comparer.Create((a, b) => string.CompareOrdinal(a.Name, b.Name)); + + /// Resolves a user query to a smelter. + /// The raw user input (smelter name or item id). + /// Looks up a smelter by item id. + /// All smelters, for name matching. + /// The maximum number of ambiguous candidates to return. + /// A describing the outcome. + public static SmeltMatch Resolve(string query, + Func byId, + IReadOnlyList all, + int cap = 10) + { + var (kind, single, candidates) = NameMatcher.Resolve(query, byId, all, s => s.Name, ByName, cap); + return kind switch + { + NameMatchKind.Found => new SmeltMatch.Found(single!), + NameMatchKind.Ambiguous => new SmeltMatch.Ambiguous(candidates), + _ => new SmeltMatch.NotFound(), + }; + } +} diff --git a/src/RustPlusBot.Features.ItemData/Lookup/SmeltMatch.cs b/src/RustPlusBot.Features.ItemData/Lookup/SmeltMatch.cs new file mode 100644 index 00000000..55315bcf --- /dev/null +++ b/src/RustPlusBot.Features.ItemData/Lookup/SmeltMatch.cs @@ -0,0 +1,23 @@ +using System.Diagnostics.CodeAnalysis; +using RustPlusBot.Features.ItemData.Data; + +namespace RustPlusBot.Features.ItemData.Lookup; + +/// The outcome of resolving a user query to a smelter. +[SuppressMessage("Design", "CA1034:Nested types should not be visible", + Justification = "Discriminated-union pattern: nested sealed records are the intended public surface.")] +public abstract record SmeltMatch +{ + private SmeltMatch() { } + + /// Exactly one smelter resolved. + /// The resolved smelter. + public sealed record Found(Smelter Smelter) : SmeltMatch; + + /// Several smelters matched; present candidates for disambiguation. + /// The candidate smelters, capped and ranked. + public sealed record Ambiguous(IReadOnlyList Candidates) : SmeltMatch; + + /// No smelter matched. + public sealed record NotFound : SmeltMatch; +} diff --git a/tests/RustPlusBot.Features.ItemData.Tests/EmbeddedItemDatabaseTests.cs b/tests/RustPlusBot.Features.ItemData.Tests/EmbeddedItemDatabaseTests.cs index 5e75f4e6..07a8c4c3 100644 --- a/tests/RustPlusBot.Features.ItemData.Tests/EmbeddedItemDatabaseTests.cs +++ b/tests/RustPlusBot.Features.ItemData.Tests/EmbeddedItemDatabaseTests.cs @@ -99,4 +99,18 @@ public void Sources_DurabilityAsOf_IsPopulated() { Assert.Equal(new DateOnly(2024, 9, 7), _db.Sources.DurabilityAsOf); } + + [Fact] + public void ResolveSmelter_Furnace_ListsMetalFragmentsOutput() + { + var found = Assert.IsType(_db.ResolveSmelter("Furnace")); + Assert.Equal("Furnace", found.Smelter.Name); + Assert.Contains(found.Smelter.Conversions, c => c.OutputId == 69511070); // Metal Fragments + } + + [Fact] + public void Sources_SmeltingAsOf_IsPopulated() + { + Assert.Equal(new DateOnly(2023, 11, 5), _db.Sources.SmeltingAsOf); + } } diff --git a/tests/RustPlusBot.Features.ItemData.Tests/SmeltLookupTests.cs b/tests/RustPlusBot.Features.ItemData.Tests/SmeltLookupTests.cs new file mode 100644 index 00000000..e23e1090 --- /dev/null +++ b/tests/RustPlusBot.Features.ItemData.Tests/SmeltLookupTests.cs @@ -0,0 +1,52 @@ +using RustPlusBot.Features.ItemData.Data; +using RustPlusBot.Features.ItemData.Lookup; + +namespace RustPlusBot.Features.ItemData.Tests; + +public sealed class SmeltLookupTests +{ + private static readonly Smelter Furnace = new("100", "Furnace", []); + private static readonly Smelter Large = new("101", "Large Furnace", []); + private static readonly Smelter Camp = new("102", "Camp Fire", []); + private static readonly IReadOnlyList All = [Furnace, Large, Camp]; + + private static Smelter? ById(int id) => All.FirstOrDefault(s => int.TryParse(s.Key, out var k) && k == id); + + private static SmeltMatch Resolve(string q) => SmeltLookup.Resolve(q, ById, All); + + [Fact] + public void NumericInput_ResolvesById() + { + var found = Assert.IsType(Resolve("102")); + Assert.Equal("Camp Fire", found.Smelter.Name); + } + + [Fact] + public void ExactName_BeatsSubstring() + { + var found = Assert.IsType(Resolve("furnace")); // exact "Furnace" wins over "Large Furnace" + Assert.Equal("Furnace", found.Smelter.Name); + } + + [Fact] + public void UniqueSubstring_Found() + { + var found = Assert.IsType(Resolve("camp")); + Assert.Equal("Camp Fire", found.Smelter.Name); + } + + [Fact] + public void MultipleSubstring_Ambiguous() + { + var amb = Assert.IsType(Resolve("fur")); // Furnace + Large Furnace both contain "fur" + Assert.True(amb.Candidates.Count >= 2); + } + + [Fact] + public void NoMatch_NotFound() => Assert.IsType(Resolve("zzzzz")); + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void EmptyOrWhitespace_NotFound(string q) => Assert.IsType(Resolve(q)); +} From 62152d09d7f541c7304b0e309d92c5bcfba46157 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 28 Jun 2026 19:52:34 +0200 Subject: [PATCH 6/9] feat(commands): SmeltLine formatter + shared DurationFormat.Seconds (DRY) Co-Authored-By: Claude Sonnet 4.6 --- .../Formatting/DurabilityLine.cs | 16 +----- .../Formatting/DurationFormat.cs | 17 ++++++ .../Formatting/SmeltLine.cs | 38 +++++++++++++ .../Formatting/SmeltFormatterTests.cs | 55 +++++++++++++++++++ 4 files changed, 111 insertions(+), 15 deletions(-) create mode 100644 src/RustPlusBot.Features.Commands/Formatting/SmeltLine.cs create mode 100644 tests/RustPlusBot.Features.Commands.Tests/Formatting/SmeltFormatterTests.cs diff --git a/src/RustPlusBot.Features.Commands/Formatting/DurabilityLine.cs b/src/RustPlusBot.Features.Commands/Formatting/DurabilityLine.cs index 7313539d..1154c9b3 100644 --- a/src/RustPlusBot.Features.Commands/Formatting/DurabilityLine.cs +++ b/src/RustPlusBot.Features.Commands/Formatting/DurabilityLine.cs @@ -33,25 +33,11 @@ private static string FormatCost(RaidCost cost, IItemNameResolver names) ? string.Create(CultureInfo.InvariantCulture, $" — {s} sulfur") : string.Empty; var time = cost.TimeSeconds is { } t and > 0 - ? string.Create(CultureInfo.InvariantCulture, $" ({FormatTime(t)})") + ? string.Create(CultureInfo.InvariantCulture, $" ({DurationFormat.Seconds(t)})") : string.Empty; var caption = string.IsNullOrEmpty(cost.Caption) ? string.Empty : string.Create(CultureInfo.InvariantCulture, $" · {cost.Caption}"); return string.Create(CultureInfo.InvariantCulture, $"{tool} ×{quantity}{side}{sulfur}{time}{caption}"); } - - private static string FormatTime(double seconds) - { - if (seconds < 60) - { - return string.Create(CultureInfo.InvariantCulture, $"{seconds:0.#}s"); - } - - var span = TimeSpan.FromSeconds(seconds); - var minutes = (int)span.TotalMinutes; - return span.Seconds == 0 - ? string.Create(CultureInfo.InvariantCulture, $"{minutes}m") - : string.Create(CultureInfo.InvariantCulture, $"{minutes}m {span.Seconds}s"); - } } diff --git a/src/RustPlusBot.Features.Commands/Formatting/DurationFormat.cs b/src/RustPlusBot.Features.Commands/Formatting/DurationFormat.cs index 43f3ab2f..251d7d12 100644 --- a/src/RustPlusBot.Features.Commands/Formatting/DurationFormat.cs +++ b/src/RustPlusBot.Features.Commands/Formatting/DurationFormat.cs @@ -22,4 +22,21 @@ public static string Compact(TimeSpan span) return string.Create(CultureInfo.InvariantCulture, $"{(int)span.TotalMinutes}m"); } + + /// Renders a sub-minute-aware duration: "<n>s" under a minute, else "<m>m <s>s". + /// The duration in seconds. + /// A compact duration string. + public static string Seconds(double seconds) + { + if (seconds < 60) + { + return string.Create(CultureInfo.InvariantCulture, $"{seconds:0.#}s"); + } + + var span = TimeSpan.FromSeconds(seconds); + var minutes = (int)span.TotalMinutes; + return span.Seconds == 0 + ? string.Create(CultureInfo.InvariantCulture, $"{minutes}m") + : string.Create(CultureInfo.InvariantCulture, $"{minutes}m {span.Seconds}s"); + } } diff --git a/src/RustPlusBot.Features.Commands/Formatting/SmeltLine.cs b/src/RustPlusBot.Features.Commands/Formatting/SmeltLine.cs new file mode 100644 index 00000000..7231e4ab --- /dev/null +++ b/src/RustPlusBot.Features.Commands/Formatting/SmeltLine.cs @@ -0,0 +1,38 @@ +using System.Globalization; +using RustPlusBot.Features.ItemData.Data; +using RustPlusBot.Features.ItemData.Naming; + +namespace RustPlusBot.Features.Commands.Formatting; + +/// Formats the multi-line smelting reply for a smelter, one row per conversion (source order). +internal static class SmeltLine +{ + /// Lists each conversion a smelter performs. + /// The smelter (with at least one conversion). + /// Resolves input/output item ids to names. + public static string Format(Smelter smelter, IItemNameResolver names) + { + ArgumentNullException.ThrowIfNull(smelter); + ArgumentNullException.ThrowIfNull(names); + + var lines = smelter.Conversions.Select(c => FormatConversion(c, names)); + return string.Create(CultureInfo.InvariantCulture, $"{smelter.Name}:\n{string.Join("\n", lines)}"); + } + + private static string FormatConversion(SmeltConversion conversion, IItemNameResolver names) + { + var input = names.Resolve(conversion.InputId); + var output = names.Resolve(conversion.OutputId); + var quantity = conversion.OutputQuantity > 1 + ? string.Create(CultureInfo.InvariantCulture, $"{conversion.OutputQuantity}× ") + : string.Empty; + var probability = conversion.OutputProbability < 1 + ? string.Create(CultureInfo.InvariantCulture, $" ({conversion.OutputProbability:0.#%})") + : string.Empty; + var fuel = conversion.WoodQuantity > 0 + ? string.Create(CultureInfo.InvariantCulture, $"{conversion.WoodQuantity:0.##} wood") + : "no fuel"; + return string.Create(CultureInfo.InvariantCulture, + $"{input} → {quantity}{output}{probability} — {DurationFormat.Seconds(conversion.TimeSeconds)} · {fuel}"); + } +} diff --git a/tests/RustPlusBot.Features.Commands.Tests/Formatting/SmeltFormatterTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Formatting/SmeltFormatterTests.cs new file mode 100644 index 00000000..6556e555 --- /dev/null +++ b/tests/RustPlusBot.Features.Commands.Tests/Formatting/SmeltFormatterTests.cs @@ -0,0 +1,55 @@ +using RustPlusBot.Features.Commands.Formatting; +using RustPlusBot.Features.ItemData; +using RustPlusBot.Features.ItemData.Data; +using RustPlusBot.Features.ItemData.Naming; + +namespace RustPlusBot.Features.Commands.Tests.Formatting; + +public sealed class SmeltFormatterTests +{ + private readonly IItemNameResolver _names = new ItemDatabaseNameResolver(new EmbeddedItemDatabase()); + + [Fact] + public void SmeltLine_RendersHeader_Arrow_FuelAndTime() + { + var smelter = new Smelter("100", "Furnace", [new SmeltConversion(1, 2, 1, 1, 1.67, 3.33)]); + var line = SmeltLine.Format(smelter, _names); + Assert.StartsWith("Furnace:", line, StringComparison.Ordinal); + Assert.Contains("→", line, StringComparison.Ordinal); + Assert.Contains("1.67 wood", line, StringComparison.Ordinal); + Assert.Contains("3.3s", line, StringComparison.Ordinal); + } + + [Fact] + public void SmeltLine_ShowsQuantityPrefix_WhenAboveOne() + { + var smelter = new Smelter("100", "Furnace", [new SmeltConversion(1, 2, 15, 1, 5, 10)]); + var line = SmeltLine.Format(smelter, _names); + Assert.Contains("15× ", line, StringComparison.Ordinal); + } + + [Fact] + public void SmeltLine_OmitsQuantityPrefix_WhenOne() + { + var smelter = new Smelter("100", "Furnace", [new SmeltConversion(1, 2, 1, 1, 5, 10)]); + var line = SmeltLine.Format(smelter, _names); + Assert.DoesNotContain("1× ", line, StringComparison.Ordinal); + } + + [Fact] + public void SmeltLine_ShowsProbability_WhenBelowOne() + { + var smelter = new Smelter("100", "Furnace", [new SmeltConversion(1, 2, 1, 0.75, 1, 2)]); + var line = SmeltLine.Format(smelter, _names); + Assert.Contains("(75%)", line, StringComparison.Ordinal); + } + + [Fact] + public void SmeltLine_ShowsNoFuel_WhenWoodZero() + { + var smelter = new Smelter("100", "Electric Furnace", [new SmeltConversion(1, 2, 1, 1, 0, 2)]); + var line = SmeltLine.Format(smelter, _names); + Assert.Contains("no fuel", line, StringComparison.Ordinal); + Assert.DoesNotContain("wood", line, StringComparison.Ordinal); + } +} From 15cd64b21ec9a29508b8a4e969b775fe51f6b58d Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 28 Jun 2026 19:59:19 +0200 Subject: [PATCH 7/9] feat(commands): in-game !smelt handler + registration Co-Authored-By: Claude Sonnet 4.6 --- .../CommandServiceCollectionExtensions.cs | 1 + .../Handlers/SmeltCommandHandler.cs | 35 ++++++++++++++++++ src/RustPlusBot.Localization/Strings.fr.resx | 3 ++ src/RustPlusBot.Localization/Strings.resx | 3 ++ .../CommandRegistrationTests.cs | 3 +- .../Handlers/SmeltHandlerTests.cs | 37 +++++++++++++++++++ .../StringsResourceParityTests.cs | 2 +- 7 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 src/RustPlusBot.Features.Commands/Handlers/SmeltCommandHandler.cs create mode 100644 tests/RustPlusBot.Features.Commands.Tests/Handlers/SmeltHandlerTests.cs diff --git a/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs index d24e548a..59bebae6 100644 --- a/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs @@ -52,6 +52,7 @@ public static IServiceCollection AddCommands(this IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddHostedService(); diff --git a/src/RustPlusBot.Features.Commands/Handlers/SmeltCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/SmeltCommandHandler.cs new file mode 100644 index 00000000..4c73e45d --- /dev/null +++ b/src/RustPlusBot.Features.Commands/Handlers/SmeltCommandHandler.cs @@ -0,0 +1,35 @@ +using RustPlusBot.Features.Commands.Dispatching; +using RustPlusBot.Features.Commands.Formatting; +using RustPlusBot.Features.ItemData; +using RustPlusBot.Features.ItemData.Lookup; +using RustPlusBot.Features.ItemData.Naming; +using RustPlusBot.Localization; + +namespace RustPlusBot.Features.Commands.Handlers; + +/// !smelt — lists what a smelter converts and its fuel/time cost. +/// The item database. +/// Resolves input/output item ids to names. +/// The reply localizer. +internal sealed class SmeltCommandHandler(IItemDatabase database, IItemNameResolver names, ILocalizer localizer) + : ICommandHandler +{ + /// + public string Name => "smelt"; + + /// + public Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(context); + var query = string.Join(' ', context.Args); + var reply = database.ResolveSmelter(query) switch + { + SmeltMatch.Found f => + localizer.Get("command.smelt.ok", context.Culture, SmeltLine.Format(f.Smelter, names)), + SmeltMatch.Ambiguous a => localizer.Get("command.item.ambiguous", context.Culture, + string.Join(", ", a.Candidates.Select(c => c.Name))), + _ => localizer.Get("command.item.notfound", context.Culture, query), + }; + return Task.FromResult(reply); + } +} diff --git a/src/RustPlusBot.Localization/Strings.fr.resx b/src/RustPlusBot.Localization/Strings.fr.resx index ecf3f31c..d5f7eea4 100644 --- a/src/RustPlusBot.Localization/Strings.fr.resx +++ b/src/RustPlusBot.Localization/Strings.fr.resx @@ -264,6 +264,9 @@ {0} + + {0} + Vouliez-vous dire : {0} ? diff --git a/src/RustPlusBot.Localization/Strings.resx b/src/RustPlusBot.Localization/Strings.resx index e2e6d5ae..4b3a4ab0 100644 --- a/src/RustPlusBot.Localization/Strings.resx +++ b/src/RustPlusBot.Localization/Strings.resx @@ -264,6 +264,9 @@ {0} + + {0} + Did you mean: {0}? diff --git a/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs b/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs index c3d983b4..3e8015ff 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs @@ -51,7 +51,7 @@ public void Dispatcher_and_handlers_resolve() using var scope = provider.CreateScope(); Assert.NotNull(scope.ServiceProvider.GetRequiredService()); var handlers = scope.ServiceProvider.GetServices().ToList(); - Assert.Equal(26, handlers.Count); + Assert.Equal(27, handlers.Count); Assert.Contains(handlers, h => h.Name == "mute"); Assert.Contains(handlers, h => h.Name == "pop"); Assert.Contains(handlers, h => h.Name == "time"); @@ -72,6 +72,7 @@ public void Dispatcher_and_handlers_resolve() Assert.Contains(handlers, h => h.Name == "decay"); Assert.Contains(handlers, h => h.Name == "upkeep"); Assert.Contains(handlers, h => h.Name == "durability"); + Assert.Contains(handlers, h => h.Name == "smelt"); Assert.NotNull(scope.ServiceProvider.GetRequiredService()); Assert.NotNull(scope.ServiceProvider.GetRequiredService()); } diff --git a/tests/RustPlusBot.Features.Commands.Tests/Handlers/SmeltHandlerTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Handlers/SmeltHandlerTests.cs new file mode 100644 index 00000000..d4d42010 --- /dev/null +++ b/tests/RustPlusBot.Features.Commands.Tests/Handlers/SmeltHandlerTests.cs @@ -0,0 +1,37 @@ +using RustPlusBot.Features.Commands.Dispatching; +using RustPlusBot.Features.Commands.Handlers; +using RustPlusBot.Features.ItemData; +using RustPlusBot.Features.ItemData.Naming; +using RustPlusBot.Localization; + +namespace RustPlusBot.Features.Commands.Tests.Handlers; + +public sealed class SmeltHandlerTests +{ + private readonly IItemDatabase _db = new EmbeddedItemDatabase(); + private readonly ILocalizer _loc = new ResxLocalizer(); + private readonly IItemNameResolver _names = new ItemDatabaseNameResolver(new EmbeddedItemDatabase()); + + private static CommandContext Ctx(params string[] args) => new(1, Guid.NewGuid(), "en", 99, "Caller", args); + + [Fact] + public void Name_is_smelt() + => Assert.Equal("smelt", new SmeltCommandHandler(_db, _names, _loc).Name); + + [Fact] + public async Task Found_returnsConversions() + { + var reply = await new SmeltCommandHandler(_db, _names, _loc) + .ExecuteAsync(Ctx("Furnace"), CancellationToken.None); + Assert.Contains("Furnace", reply, StringComparison.Ordinal); + Assert.Contains("Metal Fragments", reply, StringComparison.Ordinal); + } + + [Fact] + public async Task NotFound_returnsMessage() + { + var reply = await new SmeltCommandHandler(_db, _names, _loc) + .ExecuteAsync(Ctx("zzzzz"), CancellationToken.None); + Assert.Contains("zzzzz", reply, StringComparison.Ordinal); + } +} diff --git a/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs b/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs index edd48e32..60509f3b 100644 --- a/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs +++ b/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs @@ -42,6 +42,6 @@ public void English_covers_every_french_key() [Fact] public void Catalog_has_expected_key_count() { - Assert.Equal(241, EnglishKeys().Count); + Assert.Equal(242, EnglishKeys().Count); } } From 673cc2e467e1cc0acded10e847cf0d81b08a97e4 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 28 Jun 2026 20:03:44 +0200 Subject: [PATCH 8/9] feat(commands): /smelt slash command + help catalog rows --- .../Help/CommandHelpCatalog.cs | 2 + .../Modules/ItemCommandModule.cs | 41 ++++++++++++++++++- src/RustPlusBot.Localization/Strings.fr.resx | 6 +++ src/RustPlusBot.Localization/Strings.resx | 6 +++ .../Help/CommandHelpCatalogTests.cs | 4 +- .../StringsResourceParityTests.cs | 2 +- 6 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.cs b/src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.cs index 692cfa1e..31a4203c 100644 --- a/src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.cs +++ b/src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.cs @@ -32,6 +32,7 @@ internal static class CommandHelpCatalog new("decay", CommandGroup.ItemDb, "help.decay"), new("upkeep", CommandGroup.ItemDb, "help.upkeep"), new("durability", CommandGroup.ItemDb, "help.durability"), + new("smelt", CommandGroup.ItemDb, "help.smelt"), ]; /// The Discord slash commands, in display order. @@ -47,5 +48,6 @@ internal static class CommandHelpCatalog new("decay", CommandGroup.ItemDb, "help.slash.decay"), new("upkeep", CommandGroup.ItemDb, "help.slash.upkeep"), new("durability", CommandGroup.ItemDb, "help.slash.durability"), + new("smelt", CommandGroup.ItemDb, "help.slash.smelt"), ]; } diff --git a/src/RustPlusBot.Features.Commands/Modules/ItemCommandModule.cs b/src/RustPlusBot.Features.Commands/Modules/ItemCommandModule.cs index ad467b22..bdc3513c 100644 --- a/src/RustPlusBot.Features.Commands/Modules/ItemCommandModule.cs +++ b/src/RustPlusBot.Features.Commands/Modules/ItemCommandModule.cs @@ -11,7 +11,7 @@ namespace RustPlusBot.Features.Commands.Modules; -/// The /item, /recycle, /craft, /research, /decay, /upkeep, and /durability slash commands. +/// The /item, /recycle, /craft, /research, /decay, /upkeep, /durability, and /smelt slash commands. /// Creates a short-lived DI scope per interaction. public sealed class ItemCommandModule(IServiceScopeFactory scopeFactory) : InteractionModuleBase @@ -69,6 +69,12 @@ public Task UpkeepAsync([Summary("item", "Item name or id")] string item) => public Task DurabilityAsync([Summary("target", "Item, wall/door, or vehicle name")] string target) => RespondForRaidAsync(target); + /// Shows what a smelter converts and its fuel/time cost. + /// The smelter name or id. + [SlashCommand("smelt", "Show what a smelter converts and its fuel/time cost")] + public Task SmeltAsync([Summary("smelter", "Furnace, Camp Fire, Electric Furnace, …")] string smelter) => + RespondForSmeltAsync(smelter); + private async Task RespondForAsync( string query, Func dateSelector, @@ -137,4 +143,37 @@ private async Task RespondForRaidAsync(string query) await RespondAsync(ephemeral: true, embed: embed).ConfigureAwait(false); } } + + private async Task RespondForSmeltAsync(string query) + { + if (Context.Guild is null) + { + await RespondAsync("This command must be used in a server.", ephemeral: true).ConfigureAwait(false); + return; + } + + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var db = scope.ServiceProvider.GetRequiredService(); + var names = scope.ServiceProvider.GetRequiredService(); + var loc = scope.ServiceProvider.GetRequiredService(); + var workspace = scope.ServiceProvider.GetRequiredService(); + var culture = await workspace.GetCultureAsync(Context.Guild.Id).ConfigureAwait(false); + + var text = db.ResolveSmelter(query) switch + { + SmeltMatch.Found f => loc.Get("command.smelt.ok", culture, SmeltLine.Format(f.Smelter, names)), + SmeltMatch.Ambiguous a => loc.Get("command.item.ambiguous", culture, + string.Join(", ", a.Candidates.Select(c => c.Name))), + _ => loc.Get("command.item.notfound", culture, query), + }; + + var embed = new EmbedBuilder() + .WithDescription(text) + .WithFooter($"data as of {db.Sources.SmeltingAsOf:yyyy-MM-dd}") + .Build(); + await RespondAsync(ephemeral: true, embed: embed).ConfigureAwait(false); + } + } } diff --git a/src/RustPlusBot.Localization/Strings.fr.resx b/src/RustPlusBot.Localization/Strings.fr.resx index d5f7eea4..58c9786f 100644 --- a/src/RustPlusBot.Localization/Strings.fr.resx +++ b/src/RustPlusBot.Localization/Strings.fr.resx @@ -396,6 +396,9 @@ Explosifs nécessaires pour détruire une cible + + Ce qu'un fondeur transforme, avec carburant et temps + Afficher le nom, l'id, la pile et la disparition d'un objet @@ -429,6 +432,9 @@ Affiche les explosifs nécessaires pour détruire une cible. + + Affiche ce qu'un fondeur transforme et son coût en carburant/temps. + Afficher ce message d'aide. diff --git a/src/RustPlusBot.Localization/Strings.resx b/src/RustPlusBot.Localization/Strings.resx index 4b3a4ab0..0523ab33 100644 --- a/src/RustPlusBot.Localization/Strings.resx +++ b/src/RustPlusBot.Localization/Strings.resx @@ -396,6 +396,9 @@ Explosives needed to destroy a target + + What a smelter converts, with fuel and time + Look up an item's name, id, stack, and despawn @@ -429,6 +432,9 @@ Show the explosives needed to destroy a target. + + Show what a smelter converts and its fuel/time cost. + Show this help message. diff --git a/tests/RustPlusBot.Features.Commands.Tests/Help/CommandHelpCatalogTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Help/CommandHelpCatalogTests.cs index 959380fc..3ad21123 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/Help/CommandHelpCatalogTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/Help/CommandHelpCatalogTests.cs @@ -5,12 +5,12 @@ namespace RustPlusBot.Features.Commands.Tests.Help; public sealed class CommandHelpCatalogTests { - /// The 20 registered in-game handler names (see AddCommands / CommandRegistrationTests). + /// The 21 registered in-game handler names (see AddCommands / CommandRegistrationTests). private static readonly string[] HandlerNames = [ "mute", "unmute", "uptime", "pop", "wipe", "time", "online", "offline", "team", "steamid", "alive", "afk", "prox", - "item", "recycle", "craft", "research", "decay", "upkeep", "durability", + "item", "recycle", "craft", "research", "decay", "upkeep", "durability", "smelt", ]; [Fact] diff --git a/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs b/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs index 60509f3b..c4fe7b3e 100644 --- a/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs +++ b/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs @@ -42,6 +42,6 @@ public void English_covers_every_french_key() [Fact] public void Catalog_has_expected_key_count() { - Assert.Equal(242, EnglishKeys().Count); + Assert.Equal(244, EnglishKeys().Count); } } From 8356cfa9271b65dde120777440fd212d6e18d98f Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Sun, 28 Jun 2026 20:10:49 +0200 Subject: [PATCH 9/9] docs(itemdata): document smelting source in generator README --- .../OfflineSmeltingSourceTests.cs | 16 +++++++++++++--- tools/RustPlusBot.ItemData.Generator/README.md | 12 +++++++++--- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/tests/RustPlusBot.ItemData.Generator.Tests/OfflineSmeltingSourceTests.cs b/tests/RustPlusBot.ItemData.Generator.Tests/OfflineSmeltingSourceTests.cs index cf01af7a..d60accfd 100644 --- a/tests/RustPlusBot.ItemData.Generator.Tests/OfflineSmeltingSourceTests.cs +++ b/tests/RustPlusBot.ItemData.Generator.Tests/OfflineSmeltingSourceTests.cs @@ -25,7 +25,11 @@ public void LoadSmelters_ProjectsConversions_ResolvingSmelterNameAndIds() """; var names = new Dictionary { - [100] = "Furnace", [10] = "Metal Ore", [20] = "Metal Fragments", [11] = "Wood", [21] = "Charcoal", + [100] = "Furnace", + [10] = "Metal Ore", + [20] = "Metal Fragments", + [11] = "Wood", + [21] = "Charcoal", }; var smelters = SourceWith(json).LoadSmelters(names); @@ -48,7 +52,10 @@ public void LoadSmelters_DropsSmelterWithUnknownId() { const string json = """{"999":[{"fromId":"10","woodQuantity":1,"toId":"20","toQuantity":1,"toProbability":1,"time":2}]}"""; - var names = new Dictionary { [10] = "Metal Ore", [20] = "Metal Fragments" }; + var names = new Dictionary + { + [10] = "Metal Ore", [20] = "Metal Fragments" + }; Assert.Empty(SourceWith(json).LoadSmelters(names)); } @@ -57,7 +64,10 @@ public void LoadSmelters_DropsConversionWithUnknownInputOrOutput() { const string json = """{"100":[{"fromId":"10","woodQuantity":1,"toId":"999","toQuantity":1,"toProbability":1,"time":2}]}"""; - var names = new Dictionary { [100] = "Furnace", [10] = "Metal Ore" }; + var names = new Dictionary + { + [100] = "Furnace", [10] = "Metal Ore" + }; Assert.Empty(SourceWith(json).LoadSmelters(names)); // smelter dropped: no valid conversions remain } } diff --git a/tools/RustPlusBot.ItemData.Generator/README.md b/tools/RustPlusBot.ItemData.Generator/README.md index 4f51d324..4c37dfcb 100644 --- a/tools/RustPlusBot.ItemData.Generator/README.md +++ b/tools/RustPlusBot.ItemData.Generator/README.md @@ -2,7 +2,7 @@ A maintainer CLI that regenerates the embedded Rust item dataset (`src/RustPlusBot.Features.ItemData/Data/item-data.json`) consumed by the bot's -`/item`, `/recycle`, `/craft`, `/research`, `/decay`, `/upkeep`, and `/durability` calculators. +`/item`, `/recycle`, `/craft`, `/research`, `/decay`, `/upkeep`, `/durability`, and `/smelt` calculators. This tool is **not** part of the running bot — it is referenced by nothing in the application graph. It exists so the bundled item data can be refreshed when @@ -38,6 +38,7 @@ garbage** if the upstream shape drifts. | `rustlabsDecayData.json` | decay time + HP | | `rustlabsUpkeepData.json` | upkeep cost (resource + quantity range) | | `rustlabsDurabilityData.json` | raid cost (explosives only — trimmed) | + | `rustlabsSmeltingData.json` | smelting/cooking conversions (per smelter) | 2. Projects them into our own typed schema (`ItemDataset` / `ItemRecord` / …, defined in `RustPlusBot.Features.ItemData`), keyed by item id, @@ -91,8 +92,8 @@ tests assert known items resolve correctly. The source dates stamped into the dataset are currently hard-coded constants in [`Program.cs`](Program.cs) (`NamesAsOf`, `RecycleAsOf`, `CraftAsOf`, -`ResearchAsOf`, `DecayAsOf`, `UpkeepAsOf`, `DurabilityAsOf`). Update them when -you refresh from newer upstream data. +`ResearchAsOf`, `DecayAsOf`, `UpkeepAsOf`, `DurabilityAsOf`, `SmeltingAsOf`). +Update them when you refresh from newer upstream data. ## Notes @@ -106,6 +107,11 @@ you refresh from newer upstream data. (item / building-block / vehicle). The raw source (`rustlabsDurabilityData.json`) is ~19 MB and is **never bundled** — only the projected, explosive-only rows are written into `item-data.json`. +- Smelting data (`rustlabsSmeltingData.json`, ~28 KB) is keyed by **smelter** + and projected into a dedicated `Smelters` table (`OfflineSmeltingSource`), + resolved by name like raid targets. Its provenance (`SmeltingAsOf`, + 2023-11-05) lags the other rustlabs sources because the upstream file has not + changed since then. ## Attribution