Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ public static IServiceCollection AddCommands(this IServiceCollection services)
services.AddScoped<ICommandHandler, DecayCommandHandler>();
services.AddScoped<ICommandHandler, UpkeepCommandHandler>();
services.AddScoped<ICommandHandler, DurabilityCommandHandler>();
services.AddScoped<ICommandHandler, SmeltCommandHandler>();

services.AddScoped<CommandDispatcher>();
services.AddHostedService<CommandsHostedService>();
Expand Down
16 changes: 1 addition & 15 deletions src/RustPlusBot.Features.Commands/Formatting/DurabilityLine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}
17 changes: 17 additions & 0 deletions src/RustPlusBot.Features.Commands/Formatting/DurationFormat.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,21 @@ public static string Compact(TimeSpan span)

return string.Create(CultureInfo.InvariantCulture, $"{(int)span.TotalMinutes}m");
}

/// <summary>Renders a sub-minute-aware duration: "&lt;n&gt;s" under a minute, else "&lt;m&gt;m &lt;s&gt;s".</summary>
/// <param name="seconds">The duration in seconds.</param>
/// <returns>A compact duration string.</returns>
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");
}
}
38 changes: 38 additions & 0 deletions src/RustPlusBot.Features.Commands/Formatting/SmeltLine.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using System.Globalization;
using RustPlusBot.Features.ItemData.Data;
using RustPlusBot.Features.ItemData.Naming;

namespace RustPlusBot.Features.Commands.Formatting;

/// <summary>Formats the multi-line smelting reply for a smelter, one row per conversion (source order).</summary>
internal static class SmeltLine
{
/// <summary>Lists each conversion a smelter performs.</summary>
/// <param name="smelter">The smelter (with at least one conversion).</param>
/// <param name="names">Resolves input/output item ids to names.</param>
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}");
}
}
35 changes: 35 additions & 0 deletions src/RustPlusBot.Features.Commands/Handlers/SmeltCommandHandler.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>!smelt — lists what a smelter converts and its fuel/time cost.</summary>
/// <param name="database">The item database.</param>
/// <param name="names">Resolves input/output item ids to names.</param>
/// <param name="localizer">The reply localizer.</param>
internal sealed class SmeltCommandHandler(IItemDatabase database, IItemNameResolver names, ILocalizer localizer)
: ICommandHandler
{
/// <inheritdoc />
public string Name => "smelt";

/// <inheritdoc />
public Task<string?> 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<string?>(reply);
}
}
2 changes: 2 additions & 0 deletions src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
];

/// <summary>The Discord slash commands, in display order.</summary>
Expand All @@ -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"),
];
}
41 changes: 40 additions & 1 deletion src/RustPlusBot.Features.Commands/Modules/ItemCommandModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

namespace RustPlusBot.Features.Commands.Modules;

/// <summary>The /item, /recycle, /craft, /research, /decay, /upkeep, and /durability slash commands.</summary>
/// <summary>The /item, /recycle, /craft, /research, /decay, /upkeep, /durability, and /smelt slash commands.</summary>
/// <param name="scopeFactory">Creates a short-lived DI scope per interaction.</param>
public sealed class ItemCommandModule(IServiceScopeFactory scopeFactory)
: InteractionModuleBase<SocketInteractionContext>
Expand Down Expand Up @@ -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);

/// <summary>Shows what a smelter converts and its fuel/time cost.</summary>
/// <param name="smelter">The smelter name or id.</param>
[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<IItemDatabase, DateOnly> dateSelector,
Expand Down Expand Up @@ -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<IItemDatabase>();
var names = scope.ServiceProvider.GetRequiredService<IItemNameResolver>();
var loc = scope.ServiceProvider.GetRequiredService<ILocalizer>();
var workspace = scope.ServiceProvider.GetRequiredService<IWorkspaceStore>();
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);
}
}
}
29 changes: 27 additions & 2 deletions src/RustPlusBot.Features.ItemData/Data/ItemDataset.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ namespace RustPlusBot.Features.ItemData.Data;
/// <param name="Sources">Per-section provenance dates.</param>
/// <param name="Items">Every known item, one record each.</param>
/// <param name="RaidTargets">Every raid target (item/building-block/vehicle) and its explosive cost.</param>
/// <param name="Smelters">Every known smelter and the conversions it performs.</param>
public sealed record ItemDataset(
int SchemaVersion,
DatasetSources Sources,
IReadOnlyList<ItemRecord> Items,
IReadOnlyList<RaidTarget> RaidTargets);
IReadOnlyList<RaidTarget> RaidTargets,
IReadOnlyList<Smelter> Smelters);

/// <summary>When each section of the dataset was last sourced, for "data as of" display.</summary>
/// <param name="NamesAsOf">Names/ids/stack source date.</param>
Expand All @@ -19,14 +21,16 @@ public sealed record ItemDataset(
/// <param name="DecayAsOf">Decay data source date.</param>
/// <param name="UpkeepAsOf">Upkeep data source date.</param>
/// <param name="DurabilityAsOf">Durability/raid-cost data source date.</param>
/// <param name="SmeltingAsOf">Smelting data source date.</param>
public sealed record DatasetSources(
DateOnly NamesAsOf,
DateOnly RecycleAsOf,
DateOnly CraftAsOf,
DateOnly ResearchAsOf,
DateOnly DecayAsOf,
DateOnly UpkeepAsOf,
DateOnly DurabilityAsOf);
DateOnly DurabilityAsOf,
DateOnly SmeltingAsOf);

/// <summary>One item, with all calculator data inlined (null where not applicable).</summary>
/// <param name="Id">The Rust item id.</param>
Expand Down Expand Up @@ -135,3 +139,24 @@ public sealed record RaidCost(
double? TimeSeconds,
int? Sulfur,
int? Fuel);

/// <summary>One smelter (furnace, oven, refinery, cooker) and the conversions it performs.</summary>
/// <param name="Key">The smelter's Rust item id, as a string.</param>
/// <param name="Name">The display name (e.g. "Furnace", "Camp Fire").</param>
/// <param name="Conversions">The input → output conversions, in source order (always non-empty).</param>
public sealed record Smelter(string Key, string Name, IReadOnlyList<SmeltConversion> Conversions);

/// <summary>One input → output conversion within a smelter.</summary>
/// <param name="InputId">The smeltable input item id (resolves via the item spine).</param>
/// <param name="OutputId">The produced item id (resolves via the item spine).</param>
/// <param name="OutputQuantity">Units produced per smelt.</param>
/// <param name="OutputProbability">The probability (0..1] of producing the output.</param>
/// <param name="WoodQuantity">Wood (fuel) consumed per smelt; 0 means electric / no fuel.</param>
/// <param name="TimeSeconds">Seconds per smelt.</param>
public sealed record SmeltConversion(
int InputId,
int OutputId,
int OutputQuantity,
double OutputProbability,
double WoodQuantity,
double TimeSeconds);
Loading
Loading