Skip to content

Commit 0458284

Browse files
HandyS11claude
andauthored
Subsystem 6d: Smelting calculator (/smelt + in-game, schema v4) (#31)
* feat(itemdata): add Smelter/SmeltConversion schema (v4 members, bundle unchanged) * feat(itemdata): offline smelting source (per-smelter conversion transform) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(itemdata): validate smelter conversions (ids, time, probability, floor) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(itemdata): emit Smelters table, bump schema v3->v4, regenerate bundle * feat(itemdata): ResolveSmelter lookup + bundle smoke tests * feat(commands): SmeltLine formatter + shared DurationFormat.Seconds (DRY) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(commands): in-game !smelt handler + registration Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(commands): /smelt slash command + help catalog rows * docs(itemdata): document smelting source in generator README --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent fc9fcbd commit 0458284

29 files changed

Lines changed: 1958 additions & 35 deletions

File tree

src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ public static IServiceCollection AddCommands(this IServiceCollection services)
5252
services.AddScoped<ICommandHandler, DecayCommandHandler>();
5353
services.AddScoped<ICommandHandler, UpkeepCommandHandler>();
5454
services.AddScoped<ICommandHandler, DurabilityCommandHandler>();
55+
services.AddScoped<ICommandHandler, SmeltCommandHandler>();
5556

5657
services.AddScoped<CommandDispatcher>();
5758
services.AddHostedService<CommandsHostedService>();

src/RustPlusBot.Features.Commands/Formatting/DurabilityLine.cs

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -33,25 +33,11 @@ private static string FormatCost(RaidCost cost, IItemNameResolver names)
3333
? string.Create(CultureInfo.InvariantCulture, $" — {s} sulfur")
3434
: string.Empty;
3535
var time = cost.TimeSeconds is { } t and > 0
36-
? string.Create(CultureInfo.InvariantCulture, $" ({FormatTime(t)})")
36+
? string.Create(CultureInfo.InvariantCulture, $" ({DurationFormat.Seconds(t)})")
3737
: string.Empty;
3838
var caption = string.IsNullOrEmpty(cost.Caption)
3939
? string.Empty
4040
: string.Create(CultureInfo.InvariantCulture, $" · {cost.Caption}");
4141
return string.Create(CultureInfo.InvariantCulture, $"{tool} ×{quantity}{side}{sulfur}{time}{caption}");
4242
}
43-
44-
private static string FormatTime(double seconds)
45-
{
46-
if (seconds < 60)
47-
{
48-
return string.Create(CultureInfo.InvariantCulture, $"{seconds:0.#}s");
49-
}
50-
51-
var span = TimeSpan.FromSeconds(seconds);
52-
var minutes = (int)span.TotalMinutes;
53-
return span.Seconds == 0
54-
? string.Create(CultureInfo.InvariantCulture, $"{minutes}m")
55-
: string.Create(CultureInfo.InvariantCulture, $"{minutes}m {span.Seconds}s");
56-
}
5743
}

src/RustPlusBot.Features.Commands/Formatting/DurationFormat.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,21 @@ public static string Compact(TimeSpan span)
2222

2323
return string.Create(CultureInfo.InvariantCulture, $"{(int)span.TotalMinutes}m");
2424
}
25+
26+
/// <summary>Renders a sub-minute-aware duration: "&lt;n&gt;s" under a minute, else "&lt;m&gt;m &lt;s&gt;s".</summary>
27+
/// <param name="seconds">The duration in seconds.</param>
28+
/// <returns>A compact duration string.</returns>
29+
public static string Seconds(double seconds)
30+
{
31+
if (seconds < 60)
32+
{
33+
return string.Create(CultureInfo.InvariantCulture, $"{seconds:0.#}s");
34+
}
35+
36+
var span = TimeSpan.FromSeconds(seconds);
37+
var minutes = (int)span.TotalMinutes;
38+
return span.Seconds == 0
39+
? string.Create(CultureInfo.InvariantCulture, $"{minutes}m")
40+
: string.Create(CultureInfo.InvariantCulture, $"{minutes}m {span.Seconds}s");
41+
}
2542
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
using System.Globalization;
2+
using RustPlusBot.Features.ItemData.Data;
3+
using RustPlusBot.Features.ItemData.Naming;
4+
5+
namespace RustPlusBot.Features.Commands.Formatting;
6+
7+
/// <summary>Formats the multi-line smelting reply for a smelter, one row per conversion (source order).</summary>
8+
internal static class SmeltLine
9+
{
10+
/// <summary>Lists each conversion a smelter performs.</summary>
11+
/// <param name="smelter">The smelter (with at least one conversion).</param>
12+
/// <param name="names">Resolves input/output item ids to names.</param>
13+
public static string Format(Smelter smelter, IItemNameResolver names)
14+
{
15+
ArgumentNullException.ThrowIfNull(smelter);
16+
ArgumentNullException.ThrowIfNull(names);
17+
18+
var lines = smelter.Conversions.Select(c => FormatConversion(c, names));
19+
return string.Create(CultureInfo.InvariantCulture, $"{smelter.Name}:\n{string.Join("\n", lines)}");
20+
}
21+
22+
private static string FormatConversion(SmeltConversion conversion, IItemNameResolver names)
23+
{
24+
var input = names.Resolve(conversion.InputId);
25+
var output = names.Resolve(conversion.OutputId);
26+
var quantity = conversion.OutputQuantity > 1
27+
? string.Create(CultureInfo.InvariantCulture, $"{conversion.OutputQuantity}× ")
28+
: string.Empty;
29+
var probability = conversion.OutputProbability < 1
30+
? string.Create(CultureInfo.InvariantCulture, $" ({conversion.OutputProbability:0.#%})")
31+
: string.Empty;
32+
var fuel = conversion.WoodQuantity > 0
33+
? string.Create(CultureInfo.InvariantCulture, $"{conversion.WoodQuantity:0.##} wood")
34+
: "no fuel";
35+
return string.Create(CultureInfo.InvariantCulture,
36+
$"{input}{quantity}{output}{probability}{DurationFormat.Seconds(conversion.TimeSeconds)} · {fuel}");
37+
}
38+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
using RustPlusBot.Features.Commands.Dispatching;
2+
using RustPlusBot.Features.Commands.Formatting;
3+
using RustPlusBot.Features.ItemData;
4+
using RustPlusBot.Features.ItemData.Lookup;
5+
using RustPlusBot.Features.ItemData.Naming;
6+
using RustPlusBot.Localization;
7+
8+
namespace RustPlusBot.Features.Commands.Handlers;
9+
10+
/// <summary>!smelt — lists what a smelter converts and its fuel/time cost.</summary>
11+
/// <param name="database">The item database.</param>
12+
/// <param name="names">Resolves input/output item ids to names.</param>
13+
/// <param name="localizer">The reply localizer.</param>
14+
internal sealed class SmeltCommandHandler(IItemDatabase database, IItemNameResolver names, ILocalizer localizer)
15+
: ICommandHandler
16+
{
17+
/// <inheritdoc />
18+
public string Name => "smelt";
19+
20+
/// <inheritdoc />
21+
public Task<string?> ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
22+
{
23+
ArgumentNullException.ThrowIfNull(context);
24+
var query = string.Join(' ', context.Args);
25+
var reply = database.ResolveSmelter(query) switch
26+
{
27+
SmeltMatch.Found f =>
28+
localizer.Get("command.smelt.ok", context.Culture, SmeltLine.Format(f.Smelter, names)),
29+
SmeltMatch.Ambiguous a => localizer.Get("command.item.ambiguous", context.Culture,
30+
string.Join(", ", a.Candidates.Select(c => c.Name))),
31+
_ => localizer.Get("command.item.notfound", context.Culture, query),
32+
};
33+
return Task.FromResult<string?>(reply);
34+
}
35+
}

src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ internal static class CommandHelpCatalog
3232
new("decay", CommandGroup.ItemDb, "help.decay"),
3333
new("upkeep", CommandGroup.ItemDb, "help.upkeep"),
3434
new("durability", CommandGroup.ItemDb, "help.durability"),
35+
new("smelt", CommandGroup.ItemDb, "help.smelt"),
3536
];
3637

3738
/// <summary>The Discord slash commands, in display order.</summary>
@@ -47,5 +48,6 @@ internal static class CommandHelpCatalog
4748
new("decay", CommandGroup.ItemDb, "help.slash.decay"),
4849
new("upkeep", CommandGroup.ItemDb, "help.slash.upkeep"),
4950
new("durability", CommandGroup.ItemDb, "help.slash.durability"),
51+
new("smelt", CommandGroup.ItemDb, "help.slash.smelt"),
5052
];
5153
}

src/RustPlusBot.Features.Commands/Modules/ItemCommandModule.cs

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
namespace RustPlusBot.Features.Commands.Modules;
1313

14-
/// <summary>The /item, /recycle, /craft, /research, /decay, /upkeep, and /durability slash commands.</summary>
14+
/// <summary>The /item, /recycle, /craft, /research, /decay, /upkeep, /durability, and /smelt slash commands.</summary>
1515
/// <param name="scopeFactory">Creates a short-lived DI scope per interaction.</param>
1616
public sealed class ItemCommandModule(IServiceScopeFactory scopeFactory)
1717
: InteractionModuleBase<SocketInteractionContext>
@@ -69,6 +69,12 @@ public Task UpkeepAsync([Summary("item", "Item name or id")] string item) =>
6969
public Task DurabilityAsync([Summary("target", "Item, wall/door, or vehicle name")] string target) =>
7070
RespondForRaidAsync(target);
7171

72+
/// <summary>Shows what a smelter converts and its fuel/time cost.</summary>
73+
/// <param name="smelter">The smelter name or id.</param>
74+
[SlashCommand("smelt", "Show what a smelter converts and its fuel/time cost")]
75+
public Task SmeltAsync([Summary("smelter", "Furnace, Camp Fire, Electric Furnace, …")] string smelter) =>
76+
RespondForSmeltAsync(smelter);
77+
7278
private async Task RespondForAsync(
7379
string query,
7480
Func<IItemDatabase, DateOnly> dateSelector,
@@ -137,4 +143,37 @@ private async Task RespondForRaidAsync(string query)
137143
await RespondAsync(ephemeral: true, embed: embed).ConfigureAwait(false);
138144
}
139145
}
146+
147+
private async Task RespondForSmeltAsync(string query)
148+
{
149+
if (Context.Guild is null)
150+
{
151+
await RespondAsync("This command must be used in a server.", ephemeral: true).ConfigureAwait(false);
152+
return;
153+
}
154+
155+
var scope = scopeFactory.CreateAsyncScope();
156+
await using (scope.ConfigureAwait(false))
157+
{
158+
var db = scope.ServiceProvider.GetRequiredService<IItemDatabase>();
159+
var names = scope.ServiceProvider.GetRequiredService<IItemNameResolver>();
160+
var loc = scope.ServiceProvider.GetRequiredService<ILocalizer>();
161+
var workspace = scope.ServiceProvider.GetRequiredService<IWorkspaceStore>();
162+
var culture = await workspace.GetCultureAsync(Context.Guild.Id).ConfigureAwait(false);
163+
164+
var text = db.ResolveSmelter(query) switch
165+
{
166+
SmeltMatch.Found f => loc.Get("command.smelt.ok", culture, SmeltLine.Format(f.Smelter, names)),
167+
SmeltMatch.Ambiguous a => loc.Get("command.item.ambiguous", culture,
168+
string.Join(", ", a.Candidates.Select(c => c.Name))),
169+
_ => loc.Get("command.item.notfound", culture, query),
170+
};
171+
172+
var embed = new EmbedBuilder()
173+
.WithDescription(text)
174+
.WithFooter($"data as of {db.Sources.SmeltingAsOf:yyyy-MM-dd}")
175+
.Build();
176+
await RespondAsync(ephemeral: true, embed: embed).ConfigureAwait(false);
177+
}
178+
}
140179
}

src/RustPlusBot.Features.ItemData/Data/ItemDataset.cs

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ namespace RustPlusBot.Features.ItemData.Data;
55
/// <param name="Sources">Per-section provenance dates.</param>
66
/// <param name="Items">Every known item, one record each.</param>
77
/// <param name="RaidTargets">Every raid target (item/building-block/vehicle) and its explosive cost.</param>
8+
/// <param name="Smelters">Every known smelter and the conversions it performs.</param>
89
public sealed record ItemDataset(
910
int SchemaVersion,
1011
DatasetSources Sources,
1112
IReadOnlyList<ItemRecord> Items,
12-
IReadOnlyList<RaidTarget> RaidTargets);
13+
IReadOnlyList<RaidTarget> RaidTargets,
14+
IReadOnlyList<Smelter> Smelters);
1315

1416
/// <summary>When each section of the dataset was last sourced, for "data as of" display.</summary>
1517
/// <param name="NamesAsOf">Names/ids/stack source date.</param>
@@ -19,14 +21,16 @@ public sealed record ItemDataset(
1921
/// <param name="DecayAsOf">Decay data source date.</param>
2022
/// <param name="UpkeepAsOf">Upkeep data source date.</param>
2123
/// <param name="DurabilityAsOf">Durability/raid-cost data source date.</param>
24+
/// <param name="SmeltingAsOf">Smelting data source date.</param>
2225
public sealed record DatasetSources(
2326
DateOnly NamesAsOf,
2427
DateOnly RecycleAsOf,
2528
DateOnly CraftAsOf,
2629
DateOnly ResearchAsOf,
2730
DateOnly DecayAsOf,
2831
DateOnly UpkeepAsOf,
29-
DateOnly DurabilityAsOf);
32+
DateOnly DurabilityAsOf,
33+
DateOnly SmeltingAsOf);
3034

3135
/// <summary>One item, with all calculator data inlined (null where not applicable).</summary>
3236
/// <param name="Id">The Rust item id.</param>
@@ -135,3 +139,24 @@ public sealed record RaidCost(
135139
double? TimeSeconds,
136140
int? Sulfur,
137141
int? Fuel);
142+
143+
/// <summary>One smelter (furnace, oven, refinery, cooker) and the conversions it performs.</summary>
144+
/// <param name="Key">The smelter's Rust item id, as a string.</param>
145+
/// <param name="Name">The display name (e.g. "Furnace", "Camp Fire").</param>
146+
/// <param name="Conversions">The input → output conversions, in source order (always non-empty).</param>
147+
public sealed record Smelter(string Key, string Name, IReadOnlyList<SmeltConversion> Conversions);
148+
149+
/// <summary>One input → output conversion within a smelter.</summary>
150+
/// <param name="InputId">The smeltable input item id (resolves via the item spine).</param>
151+
/// <param name="OutputId">The produced item id (resolves via the item spine).</param>
152+
/// <param name="OutputQuantity">Units produced per smelt.</param>
153+
/// <param name="OutputProbability">The probability (0..1] of producing the output.</param>
154+
/// <param name="WoodQuantity">Wood (fuel) consumed per smelt; 0 means electric / no fuel.</param>
155+
/// <param name="TimeSeconds">Seconds per smelt.</param>
156+
public sealed record SmeltConversion(
157+
int InputId,
158+
int OutputId,
159+
int OutputQuantity,
160+
double OutputProbability,
161+
double WoodQuantity,
162+
double TimeSeconds);

0 commit comments

Comments
 (0)