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 @@ -51,6 +51,7 @@ public static IServiceCollection AddCommands(this IServiceCollection services)
services.AddScoped<ICommandHandler, ResearchCommandHandler>();
services.AddScoped<ICommandHandler, DecayCommandHandler>();
services.AddScoped<ICommandHandler, UpkeepCommandHandler>();
services.AddScoped<ICommandHandler, DurabilityCommandHandler>();

services.AddScoped<CommandDispatcher>();
services.AddHostedService<CommandsHostedService>();
Expand Down
57 changes: 57 additions & 0 deletions src/RustPlusBot.Features.Commands/Formatting/DurabilityLine.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
using System.Globalization;
using RustPlusBot.Features.ItemData.Data;
using RustPlusBot.Features.ItemData.Naming;

namespace RustPlusBot.Features.Commands.Formatting;

/// <summary>Formats the multi-line durability (raid-cost) reply for a target.</summary>
internal static class DurabilityLine
{
/// <summary>Lists each explosive cost for a target, cheapest by sulfur first.</summary>
/// <param name="target">The raid target (with at least one cost).</param>
/// <param name="names">Resolves tool item ids to names.</param>
public static string Format(RaidTarget target, IItemNameResolver names)
{
ArgumentNullException.ThrowIfNull(target);
ArgumentNullException.ThrowIfNull(names);

var lines = target.Costs
.OrderBy(c => c.Sulfur ?? int.MaxValue)
.ThenBy(c => c.Quantity)
.Select(c => FormatCost(c, names));
return string.Create(CultureInfo.InvariantCulture, $"{target.Name}:\n{string.Join("\n", lines)}");
}

private static string FormatCost(RaidCost cost, IItemNameResolver names)
{
var quantity = (int)Math.Ceiling(cost.Quantity);
var tool = names.Resolve(cost.ToolId);
var side = cost.Side is "soft" or "hard"
? string.Create(CultureInfo.InvariantCulture, $" ({cost.Side})")
: string.Empty;
var sulfur = cost.Sulfur is { } s
? string.Create(CultureInfo.InvariantCulture, $" — {s} sulfur")
: string.Empty;
var time = cost.TimeSeconds is { } t and > 0
? string.Create(CultureInfo.InvariantCulture, $" ({FormatTime(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");
}
}
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>!durability — lists the explosives needed to destroy a target.</summary>
/// <param name="database">The item database.</param>
/// <param name="names">Resolves tool ids to names.</param>
/// <param name="localizer">The reply localizer.</param>
internal sealed class DurabilityCommandHandler(IItemDatabase database, IItemNameResolver names, ILocalizer localizer)
: ICommandHandler
{
/// <inheritdoc />
public string Name => "durability";

/// <inheritdoc />
public Task<string?> ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
var query = string.Join(' ', context.Args);
var reply = database.ResolveRaidTarget(query) switch
{
RaidMatch.Found f =>
localizer.Get("command.durability.ok", context.Culture, DurabilityLine.Format(f.Target, names)),
RaidMatch.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 @@ -31,6 +31,7 @@ internal static class CommandHelpCatalog
new("research", CommandGroup.ItemDb, "help.research"),
new("decay", CommandGroup.ItemDb, "help.decay"),
new("upkeep", CommandGroup.ItemDb, "help.upkeep"),
new("durability", CommandGroup.ItemDb, "help.durability"),
];

/// <summary>The Discord slash commands, in display order.</summary>
Expand All @@ -45,5 +46,6 @@ internal static class CommandHelpCatalog
new("research", CommandGroup.ItemDb, "help.slash.research"),
new("decay", CommandGroup.ItemDb, "help.slash.decay"),
new("upkeep", CommandGroup.ItemDb, "help.slash.upkeep"),
new("durability", CommandGroup.ItemDb, "help.slash.durability"),
];
}
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, and /upkeep slash commands.</summary>
/// <summary>The /item, /recycle, /craft, /research, /decay, /upkeep, and /durability 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 @@ -63,6 +63,12 @@ public Task UpkeepAsync([Summary("item", "Item name or id")] string item) =>
? loc.Get("command.upkeep.ok", culture, UpkeepLine.Format(rec, names))
: loc.Get("command.upkeep.none", culture, rec.Name));

/// <summary>Lists the explosives needed to destroy a target.</summary>
/// <param name="target">The item, building block, or vehicle name.</param>
[SlashCommand("durability", "Show the explosives needed to destroy a target")]
public Task DurabilityAsync([Summary("target", "Item, wall/door, or vehicle name")] string target) =>
RespondForRaidAsync(target);

private async Task RespondForAsync(
string query,
Func<IItemDatabase, DateOnly> dateSelector,
Expand Down Expand Up @@ -98,4 +104,37 @@ private async Task RespondForAsync(
await RespondAsync(ephemeral: true, embed: embed).ConfigureAwait(false);
}
}

private async Task RespondForRaidAsync(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.ResolveRaidTarget(query) switch
{
RaidMatch.Found f => loc.Get("command.durability.ok", culture, DurabilityLine.Format(f.Target, names)),
RaidMatch.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.DurabilityAsOf:yyyy-MM-dd}")
.Build();
await RespondAsync(ephemeral: true, embed: embed).ConfigureAwait(false);
}
}
}
48 changes: 46 additions & 2 deletions src/RustPlusBot.Features.ItemData/Data/ItemDataset.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ namespace RustPlusBot.Features.ItemData.Data;
/// <param name="SchemaVersion">The schema version; the loader rejects a mismatched bundle.</param>
/// <param name="Sources">Per-section provenance dates.</param>
/// <param name="Items">Every known item, one record each.</param>
public sealed record ItemDataset(int SchemaVersion, DatasetSources Sources, IReadOnlyList<ItemRecord> Items);
/// <param name="RaidTargets">Every raid target (item/building-block/vehicle) and its explosive cost.</param>
public sealed record ItemDataset(
int SchemaVersion,
DatasetSources Sources,
IReadOnlyList<ItemRecord> Items,
IReadOnlyList<RaidTarget> RaidTargets);

/// <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 @@ -13,13 +18,15 @@ public sealed record ItemDataset(int SchemaVersion, DatasetSources Sources, IRea
/// <param name="ResearchAsOf">Research data source date.</param>
/// <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>
public sealed record DatasetSources(
DateOnly NamesAsOf,
DateOnly RecycleAsOf,
DateOnly CraftAsOf,
DateOnly ResearchAsOf,
DateOnly DecayAsOf,
DateOnly UpkeepAsOf);
DateOnly UpkeepAsOf,
DateOnly DurabilityAsOf);

/// <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 @@ -91,3 +98,40 @@ public sealed record UpkeepCost(IReadOnlyList<UpkeepEntry> Entries);
/// <param name="QuantityMin">The lower bound of the cost.</param>
/// <param name="QuantityMax">The upper bound of the cost.</param>
public sealed record UpkeepEntry(int ItemId, int QuantityMin, int QuantityMax);

/// <summary>The kind of raid target, mapping to the three sections of the RustLabs durability source.</summary>
public enum RaidTargetKind
{
/// <summary>A deployable item (resolves to a known item id).</summary>
Item = 0,

/// <summary>A building block (wall, door, floor) — name-keyed, not an item.</summary>
BuildingBlock = 1,

/// <summary>A vehicle or NPC target — name-keyed, not an item.</summary>
Vehicle = 2,
}

/// <summary>One raid target and the explosive cost to destroy it.</summary>
/// <param name="Key">The item id as a string (<see cref="RaidTargetKind.Item"/>) or the target name otherwise.</param>
/// <param name="Name">The display name.</param>
/// <param name="Kind">The target kind.</param>
/// <param name="Costs">The per-explosive cost entries (always non-empty).</param>
public sealed record RaidTarget(string Key, string Name, RaidTargetKind Kind, IReadOnlyList<RaidCost> Costs);

/// <summary>One explosive's cost against a target. Fields are null where RustLabs omits them.</summary>
/// <param name="ToolId">The explosive item id (resolves via the item spine).</param>
/// <param name="Side">The building-block face: "soft", "hard", "both", or null.</param>
/// <param name="Caption">A sub-label (e.g. ammo variant or placement note), or null.</param>
/// <param name="Quantity">Units of the tool required.</param>
/// <param name="TimeSeconds">Total time in seconds, or null.</param>
/// <param name="Sulfur">Total sulfur cost, or null.</param>
/// <param name="Fuel">Total low-grade fuel cost, or null.</param>
public sealed record RaidCost(
int ToolId,
string? Side,
string? Caption,
double Quantity,
double? TimeSeconds,
int? Sulfur,
int? Fuel);
Loading
Loading