Skip to content

Commit fc9fcbd

Browse files
HandyS11claude
andauthored
Subsystem 6c: Durability (raid-cost) calculator (/durability + in-game, schema v3) (#30)
* feat(6c): add RaidTarget schema + DurabilityAsOf provenance (empty, v2) * feat(6c): generator durability source (explosive-only projection) * feat(6c): generalize name matcher + RaidLookup/ResolveRaidTarget * feat(6c): validator raid checks + regenerate bundle (schema v3, ~416 raid targets) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(6c): DurabilityLine formatter (sulfur-sorted, side + caption) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(6c): in-game !durability handler + registration + InGame help Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(6c): /durability slash command + Slash help Adds DurabilityAsync slash command to ItemCommandModule with a parallel RespondForRaidAsync helper (ResolveRaidTarget + DurabilityLine.Format + DurabilityAsOf footer). Adds help.slash.durability resx key (EN+FR) and Slash catalog entry; bumps parity count assertion 240→241. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(6c): document durability source + raid projection in generator README - Add /durability to the calculators list in the opening sentence - Add rustlabsDurabilityData.json row to the source-files table - Extend validation description to mention raid-target count floor and per-cost quantity/tool-id checks - Add DurabilityAsOf to the provenance section constant list - Note durability is trimmed to the explosive group and projected into RaidTargets (item/building-block/vehicle); raw source ~19 MB never bundled - Fix CommandHelpCatalogTests HandlerNames doc-comment: 19 → 20 - Apply cleanupcode ReformatAndReorder (method params, collection literals, raw-string indent) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(6c): render sub-minute raid times in seconds (not 0m) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a05739c commit fc9fcbd

30 files changed

Lines changed: 52029 additions & 83 deletions

File tree

src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ public static IServiceCollection AddCommands(this IServiceCollection services)
5151
services.AddScoped<ICommandHandler, ResearchCommandHandler>();
5252
services.AddScoped<ICommandHandler, DecayCommandHandler>();
5353
services.AddScoped<ICommandHandler, UpkeepCommandHandler>();
54+
services.AddScoped<ICommandHandler, DurabilityCommandHandler>();
5455

5556
services.AddScoped<CommandDispatcher>();
5657
services.AddHostedService<CommandsHostedService>();
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
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 durability (raid-cost) reply for a target.</summary>
8+
internal static class DurabilityLine
9+
{
10+
/// <summary>Lists each explosive cost for a target, cheapest by sulfur first.</summary>
11+
/// <param name="target">The raid target (with at least one cost).</param>
12+
/// <param name="names">Resolves tool item ids to names.</param>
13+
public static string Format(RaidTarget target, IItemNameResolver names)
14+
{
15+
ArgumentNullException.ThrowIfNull(target);
16+
ArgumentNullException.ThrowIfNull(names);
17+
18+
var lines = target.Costs
19+
.OrderBy(c => c.Sulfur ?? int.MaxValue)
20+
.ThenBy(c => c.Quantity)
21+
.Select(c => FormatCost(c, names));
22+
return string.Create(CultureInfo.InvariantCulture, $"{target.Name}:\n{string.Join("\n", lines)}");
23+
}
24+
25+
private static string FormatCost(RaidCost cost, IItemNameResolver names)
26+
{
27+
var quantity = (int)Math.Ceiling(cost.Quantity);
28+
var tool = names.Resolve(cost.ToolId);
29+
var side = cost.Side is "soft" or "hard"
30+
? string.Create(CultureInfo.InvariantCulture, $" ({cost.Side})")
31+
: string.Empty;
32+
var sulfur = cost.Sulfur is { } s
33+
? string.Create(CultureInfo.InvariantCulture, $" — {s} sulfur")
34+
: string.Empty;
35+
var time = cost.TimeSeconds is { } t and > 0
36+
? string.Create(CultureInfo.InvariantCulture, $" ({FormatTime(t)})")
37+
: string.Empty;
38+
var caption = string.IsNullOrEmpty(cost.Caption)
39+
? string.Empty
40+
: string.Create(CultureInfo.InvariantCulture, $" · {cost.Caption}");
41+
return string.Create(CultureInfo.InvariantCulture, $"{tool} ×{quantity}{side}{sulfur}{time}{caption}");
42+
}
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+
}
57+
}
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>!durability — lists the explosives needed to destroy a target.</summary>
11+
/// <param name="database">The item database.</param>
12+
/// <param name="names">Resolves tool ids to names.</param>
13+
/// <param name="localizer">The reply localizer.</param>
14+
internal sealed class DurabilityCommandHandler(IItemDatabase database, IItemNameResolver names, ILocalizer localizer)
15+
: ICommandHandler
16+
{
17+
/// <inheritdoc />
18+
public string Name => "durability";
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.ResolveRaidTarget(query) switch
26+
{
27+
RaidMatch.Found f =>
28+
localizer.Get("command.durability.ok", context.Culture, DurabilityLine.Format(f.Target, names)),
29+
RaidMatch.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
@@ -31,6 +31,7 @@ internal static class CommandHelpCatalog
3131
new("research", CommandGroup.ItemDb, "help.research"),
3232
new("decay", CommandGroup.ItemDb, "help.decay"),
3333
new("upkeep", CommandGroup.ItemDb, "help.upkeep"),
34+
new("durability", CommandGroup.ItemDb, "help.durability"),
3435
];
3536

3637
/// <summary>The Discord slash commands, in display order.</summary>
@@ -45,5 +46,6 @@ internal static class CommandHelpCatalog
4546
new("research", CommandGroup.ItemDb, "help.slash.research"),
4647
new("decay", CommandGroup.ItemDb, "help.slash.decay"),
4748
new("upkeep", CommandGroup.ItemDb, "help.slash.upkeep"),
49+
new("durability", CommandGroup.ItemDb, "help.slash.durability"),
4850
];
4951
}

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, and /upkeep slash commands.</summary>
14+
/// <summary>The /item, /recycle, /craft, /research, /decay, /upkeep, and /durability 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>
@@ -63,6 +63,12 @@ public Task UpkeepAsync([Summary("item", "Item name or id")] string item) =>
6363
? loc.Get("command.upkeep.ok", culture, UpkeepLine.Format(rec, names))
6464
: loc.Get("command.upkeep.none", culture, rec.Name));
6565

66+
/// <summary>Lists the explosives needed to destroy a target.</summary>
67+
/// <param name="target">The item, building block, or vehicle name.</param>
68+
[SlashCommand("durability", "Show the explosives needed to destroy a target")]
69+
public Task DurabilityAsync([Summary("target", "Item, wall/door, or vehicle name")] string target) =>
70+
RespondForRaidAsync(target);
71+
6672
private async Task RespondForAsync(
6773
string query,
6874
Func<IItemDatabase, DateOnly> dateSelector,
@@ -98,4 +104,37 @@ private async Task RespondForAsync(
98104
await RespondAsync(ephemeral: true, embed: embed).ConfigureAwait(false);
99105
}
100106
}
107+
108+
private async Task RespondForRaidAsync(string query)
109+
{
110+
if (Context.Guild is null)
111+
{
112+
await RespondAsync("This command must be used in a server.", ephemeral: true).ConfigureAwait(false);
113+
return;
114+
}
115+
116+
var scope = scopeFactory.CreateAsyncScope();
117+
await using (scope.ConfigureAwait(false))
118+
{
119+
var db = scope.ServiceProvider.GetRequiredService<IItemDatabase>();
120+
var names = scope.ServiceProvider.GetRequiredService<IItemNameResolver>();
121+
var loc = scope.ServiceProvider.GetRequiredService<ILocalizer>();
122+
var workspace = scope.ServiceProvider.GetRequiredService<IWorkspaceStore>();
123+
var culture = await workspace.GetCultureAsync(Context.Guild.Id).ConfigureAwait(false);
124+
125+
var text = db.ResolveRaidTarget(query) switch
126+
{
127+
RaidMatch.Found f => loc.Get("command.durability.ok", culture, DurabilityLine.Format(f.Target, names)),
128+
RaidMatch.Ambiguous a => loc.Get("command.item.ambiguous", culture,
129+
string.Join(", ", a.Candidates.Select(c => c.Name))),
130+
_ => loc.Get("command.item.notfound", culture, query),
131+
};
132+
133+
var embed = new EmbedBuilder()
134+
.WithDescription(text)
135+
.WithFooter($"data as of {db.Sources.DurabilityAsOf:yyyy-MM-dd}")
136+
.Build();
137+
await RespondAsync(ephemeral: true, embed: embed).ConfigureAwait(false);
138+
}
139+
}
101140
}

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

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ namespace RustPlusBot.Features.ItemData.Data;
44
/// <param name="SchemaVersion">The schema version; the loader rejects a mismatched bundle.</param>
55
/// <param name="Sources">Per-section provenance dates.</param>
66
/// <param name="Items">Every known item, one record each.</param>
7-
public sealed record ItemDataset(int SchemaVersion, DatasetSources Sources, IReadOnlyList<ItemRecord> Items);
7+
/// <param name="RaidTargets">Every raid target (item/building-block/vehicle) and its explosive cost.</param>
8+
public sealed record ItemDataset(
9+
int SchemaVersion,
10+
DatasetSources Sources,
11+
IReadOnlyList<ItemRecord> Items,
12+
IReadOnlyList<RaidTarget> RaidTargets);
813

914
/// <summary>When each section of the dataset was last sourced, for "data as of" display.</summary>
1015
/// <param name="NamesAsOf">Names/ids/stack source date.</param>
@@ -13,13 +18,15 @@ public sealed record ItemDataset(int SchemaVersion, DatasetSources Sources, IRea
1318
/// <param name="ResearchAsOf">Research data source date.</param>
1419
/// <param name="DecayAsOf">Decay data source date.</param>
1520
/// <param name="UpkeepAsOf">Upkeep data source date.</param>
21+
/// <param name="DurabilityAsOf">Durability/raid-cost data source date.</param>
1622
public sealed record DatasetSources(
1723
DateOnly NamesAsOf,
1824
DateOnly RecycleAsOf,
1925
DateOnly CraftAsOf,
2026
DateOnly ResearchAsOf,
2127
DateOnly DecayAsOf,
22-
DateOnly UpkeepAsOf);
28+
DateOnly UpkeepAsOf,
29+
DateOnly DurabilityAsOf);
2330

2431
/// <summary>One item, with all calculator data inlined (null where not applicable).</summary>
2532
/// <param name="Id">The Rust item id.</param>
@@ -91,3 +98,40 @@ public sealed record UpkeepCost(IReadOnlyList<UpkeepEntry> Entries);
9198
/// <param name="QuantityMin">The lower bound of the cost.</param>
9299
/// <param name="QuantityMax">The upper bound of the cost.</param>
93100
public sealed record UpkeepEntry(int ItemId, int QuantityMin, int QuantityMax);
101+
102+
/// <summary>The kind of raid target, mapping to the three sections of the RustLabs durability source.</summary>
103+
public enum RaidTargetKind
104+
{
105+
/// <summary>A deployable item (resolves to a known item id).</summary>
106+
Item = 0,
107+
108+
/// <summary>A building block (wall, door, floor) — name-keyed, not an item.</summary>
109+
BuildingBlock = 1,
110+
111+
/// <summary>A vehicle or NPC target — name-keyed, not an item.</summary>
112+
Vehicle = 2,
113+
}
114+
115+
/// <summary>One raid target and the explosive cost to destroy it.</summary>
116+
/// <param name="Key">The item id as a string (<see cref="RaidTargetKind.Item"/>) or the target name otherwise.</param>
117+
/// <param name="Name">The display name.</param>
118+
/// <param name="Kind">The target kind.</param>
119+
/// <param name="Costs">The per-explosive cost entries (always non-empty).</param>
120+
public sealed record RaidTarget(string Key, string Name, RaidTargetKind Kind, IReadOnlyList<RaidCost> Costs);
121+
122+
/// <summary>One explosive's cost against a target. Fields are null where RustLabs omits them.</summary>
123+
/// <param name="ToolId">The explosive item id (resolves via the item spine).</param>
124+
/// <param name="Side">The building-block face: "soft", "hard", "both", or null.</param>
125+
/// <param name="Caption">A sub-label (e.g. ammo variant or placement note), or null.</param>
126+
/// <param name="Quantity">Units of the tool required.</param>
127+
/// <param name="TimeSeconds">Total time in seconds, or null.</param>
128+
/// <param name="Sulfur">Total sulfur cost, or null.</param>
129+
/// <param name="Fuel">Total low-grade fuel cost, or null.</param>
130+
public sealed record RaidCost(
131+
int ToolId,
132+
string? Side,
133+
string? Caption,
134+
double Quantity,
135+
double? TimeSeconds,
136+
int? Sulfur,
137+
int? Fuel);

0 commit comments

Comments
 (0)