Skip to content

Commit a05739c

Browse files
HandyS11claude
andauthored
Subsystem 6b: Item DB decay + upkeep calculators (/decay /upkeep + in-game, schema v2) (#29)
* feat(itemdata): add Decay + Upkeep schema fields (6b) * feat(generator): add upkeep quantity parser (6b) * feat(generator): load decay + upkeep from rustlabs files (6b) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(generator): validate decay + upkeep (6b) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(itemdata): generate decay + upkeep into v2 bundle (6b) * feat(commands): add DecayLine formatter (6b) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(commands): add UpkeepLine formatter (6b) * feat(commands): add !decay in-game handler (6b) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(commands): add !upkeep in-game handler (6b) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(commands): add /decay + /upkeep slash commands + per-section footer (6b) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: document decay + upkeep in item-data READMEs (6b) * test+style: tighten 6b upkeep/decay tests + resx period consistency (6b review) - EmbeddedItemDatabaseTests: Assert.Single on WoodenDoor upkeep entry with ItemId/QuantityMin/QuantityMax - DecayFormatterTests: add DecayLine_ShowsDashAndHpWhenNoDecaySeconds fact; assert HP(250) in variant test - Strings.resx + Strings.fr.resx: append period to help.slash.decay and help.slash.upkeep values Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6270b4e commit a05739c

32 files changed

Lines changed: 5441 additions & 1245 deletions

README.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ The bot is live: it pairs with Rust+ over FCM, holds a socket per server, and
1010
auto-provisions its own Discord channels. Pairing/connection, the chat bridge,
1111
in-game `!commands` and slash surfaces, live map events, map rendering, smart
1212
devices (switches, alarms, storage monitors), and an offline item database with
13-
recycle/craft/research calculators are all shipped. Cameras are next.
13+
recycle/craft/research/decay/upkeep calculators are all shipped. Cameras are next.
1414

1515
## Features
1616

@@ -48,14 +48,14 @@ configurable per-server prefix (default `!`) and per-command cooldowns:
4848
- **Server**`!pop`, `!time`, `!wipe`
4949
- **Team**`!online`, `!offline`, `!team`, `!alive`, `!steamid [name]`, `!prox [name]`
5050
- **Live events**`!cargo`, `!heli`, `!chinook`, `!small`, `!large`, `!events`
51-
- **Items**`!item`, `!recycle`, `!craft`, `!research` (name or id)
51+
- **Items**`!item`, `!recycle`, `!craft`, `!research`, `!decay`, `!upkeep` (name or id)
5252
- **Control**`!mute` / `!unmute` (gate all bot→game output)
5353

5454
### Slash commands
5555

5656
- **Server data** (ephemeral, with a `server` selector when several are paired) —
5757
`/pop`, `/time`, `/wipe`, `/online`, `/offline`, `/team`, `/alive`, `/small`, `/large`
58-
- **Items** (ephemeral) — `/item`, `/recycle`, `/craft`, `/research` (name or id)
58+
- **Items** (ephemeral) — `/item`, `/recycle`, `/craft`, `/research`, `/decay`, `/upkeep` (name or id)
5959
- **Utility**`/help`, `/uptime`, `/leader` (Manage Server — transfer in-game team leadership)
6060
- **Admin**`/setup`, `/workspace reset`, `/workspace simulate-server`
6161

@@ -75,8 +75,9 @@ configurable per-server prefix (default `!`) and per-command cooldowns:
7575
### Item database & calculators
7676

7777
- An **offline, versioned item dataset** (bundled, ~1200 items) behind one
78-
lookup seam, exposing four calculators both in-game and as ephemeral slash
79-
commands: item info, recycler yields, craft recipes, and research scrap cost.
78+
lookup seam, exposing six calculators both in-game and as ephemeral slash
79+
commands: item info, recycler yields, craft recipes, research scrap cost,
80+
decay time, and building-block upkeep cost.
8081
- **Name-or-id lookup** — type a name (case-insensitive, partial), an exact name,
8182
or a numeric id; multiple matches return a short "did you mean" list.
8283
- **Provenance-aware** — each result footers the date its data was sourced, and a
@@ -127,7 +128,7 @@ See [docs/development/running-locally.md](docs/development/running-locally.md).
127128
| `RustPlusBot.Features.Commands` | In-game `!commands`, slash surfaces, `/help`/`/leader` |
128129
| `RustPlusBot.Features.Events` | Live map-event classification + `#events` feed |
129130
| `RustPlusBot.Features.Map` | Map image rendering with toggleable layers |
130-
| `RustPlusBot.Features.ItemData` | Bundled item dataset, lookup seam, recycle/craft/research data |
131+
| `RustPlusBot.Features.ItemData` | Bundled item dataset, lookup seam, recycle/craft/research/decay/upkeep data |
131132
| `RustPlusBot.Host` | Generic Host entry point, DI wiring, startup validation |
132133

133134
The `tools/` folder holds maintainer utilities that are not part of the running
@@ -147,7 +148,7 @@ Subsystems are built in order; each has its own spec → plan → build cycle.
147148
| 3 | Chat bridge + `!commands` + slash surfaces | Done |
148149
| 4 | Smart devices (switches, alarms, storage monitors) | Done |
149150
| 5 | Cameras | Planned |
150-
| 6 | Item database & calculators (recycle/craft/research) | In progress |
151+
| 6 | Item database & calculators (recycle/craft/research/decay/upkeep) | In progress |
151152

152153
## License
153154

src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ public static IServiceCollection AddCommands(this IServiceCollection services)
4949
services.AddScoped<ICommandHandler, RecycleCommandHandler>();
5050
services.AddScoped<ICommandHandler, CraftCommandHandler>();
5151
services.AddScoped<ICommandHandler, ResearchCommandHandler>();
52+
services.AddScoped<ICommandHandler, DecayCommandHandler>();
53+
services.AddScoped<ICommandHandler, UpkeepCommandHandler>();
5254

5355
services.AddScoped<CommandDispatcher>();
5456
services.AddHostedService<CommandsHostedService>();
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
using System.Globalization;
2+
using RustPlusBot.Features.ItemData.Data;
3+
4+
namespace RustPlusBot.Features.Commands.Formatting;
5+
6+
/// <summary>Formats the one-line decay reply.</summary>
7+
internal static class DecayLine
8+
{
9+
/// <summary>Formats base decay, any present environment variant, and HP for an item.</summary>
10+
/// <param name="item">The item (must have non-null <see cref="ItemRecord.Decay"/>).</param>
11+
public static string Format(ItemRecord item)
12+
{
13+
ArgumentNullException.ThrowIfNull(item);
14+
ArgumentNullException.ThrowIfNull(item.Decay);
15+
var decay = item.Decay;
16+
17+
var parts = new List<string>();
18+
Add(parts, null, decay.Seconds);
19+
Add(parts, "outside", decay.OutsideSeconds);
20+
Add(parts, "inside", decay.InsideSeconds);
21+
Add(parts, "underwater", decay.UnderwaterSeconds);
22+
23+
var decayPart = parts.Count > 0 ? string.Join(", ", parts) : "—";
24+
var hp = decay.Hp is { } h
25+
? string.Create(CultureInfo.InvariantCulture, $" · {h} HP")
26+
: string.Empty;
27+
return string.Create(CultureInfo.InvariantCulture, $"{item.Name} decays in {decayPart}{hp}");
28+
29+
static void Add(List<string> into, string? label, int? seconds)
30+
{
31+
if (seconds is not { } s)
32+
{
33+
return;
34+
}
35+
36+
var compact = DurationFormat.Compact(TimeSpan.FromSeconds(s));
37+
into.Add(label is null
38+
? compact
39+
: string.Create(CultureInfo.InvariantCulture, $"{label} {compact}"));
40+
}
41+
}
42+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
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 one-line upkeep-cost reply.</summary>
8+
internal static class UpkeepLine
9+
{
10+
/// <summary>Formats the per-resource upkeep cost for a building block.</summary>
11+
/// <param name="item">The item (must have non-null <see cref="ItemRecord.Upkeep"/>).</param>
12+
/// <param name="names">Resolves resource item ids to names.</param>
13+
public static string Format(ItemRecord item, IItemNameResolver names)
14+
{
15+
ArgumentNullException.ThrowIfNull(item);
16+
ArgumentNullException.ThrowIfNull(names);
17+
ArgumentNullException.ThrowIfNull(item.Upkeep);
18+
19+
var parts = item.Upkeep.Entries.Select(e =>
20+
{
21+
var quantity = e.QuantityMin == e.QuantityMax
22+
? e.QuantityMin.ToString(CultureInfo.InvariantCulture)
23+
: string.Create(CultureInfo.InvariantCulture, $"{e.QuantityMin}{e.QuantityMax}");
24+
return string.Create(CultureInfo.InvariantCulture, $"{quantity} {names.Resolve(e.ItemId)}");
25+
});
26+
return string.Create(CultureInfo.InvariantCulture, $"{item.Name} upkeep: {string.Join(", ", parts)}");
27+
}
28+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
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.Localization;
6+
7+
namespace RustPlusBot.Features.Commands.Handlers;
8+
9+
/// <summary>!decay — shows an item's decay time and HP.</summary>
10+
/// <param name="database">The item database.</param>
11+
/// <param name="localizer">The reply localizer.</param>
12+
internal sealed class DecayCommandHandler(IItemDatabase database, ILocalizer localizer)
13+
: ICommandHandler
14+
{
15+
/// <inheritdoc />
16+
public string Name => "decay";
17+
18+
/// <inheritdoc />
19+
public Task<string?> ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
20+
{
21+
ArgumentNullException.ThrowIfNull(context);
22+
var query = string.Join(' ', context.Args);
23+
var reply = database.Resolve(query) switch
24+
{
25+
ItemMatch.Found { Item.Decay: not null } f =>
26+
localizer.Get("command.decay.ok", context.Culture, DecayLine.Format(f.Item)),
27+
ItemMatch.Found f => localizer.Get("command.decay.none", context.Culture, f.Item.Name),
28+
ItemMatch.Ambiguous a => localizer.Get("command.item.ambiguous", context.Culture,
29+
string.Join(", ", a.Candidates.Select(c => c.Name))),
30+
_ => localizer.Get("command.item.notfound", context.Culture, query),
31+
};
32+
return Task.FromResult<string?>(reply);
33+
}
34+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
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>!upkeep — shows a building block's upkeep cost.</summary>
11+
/// <param name="database">The item database.</param>
12+
/// <param name="names">Resolves resource ids to names.</param>
13+
/// <param name="localizer">The reply localizer.</param>
14+
internal sealed class UpkeepCommandHandler(IItemDatabase database, IItemNameResolver names, ILocalizer localizer)
15+
: ICommandHandler
16+
{
17+
/// <inheritdoc />
18+
public string Name => "upkeep";
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.Resolve(query) switch
26+
{
27+
ItemMatch.Found { Item.Upkeep: not null } f =>
28+
localizer.Get("command.upkeep.ok", context.Culture, UpkeepLine.Format(f.Item, names)),
29+
ItemMatch.Found f => localizer.Get("command.upkeep.none", context.Culture, f.Item.Name),
30+
ItemMatch.Ambiguous a => localizer.Get("command.item.ambiguous", context.Culture,
31+
string.Join(", ", a.Candidates.Select(c => c.Name))),
32+
_ => localizer.Get("command.item.notfound", context.Culture, query),
33+
};
34+
return Task.FromResult<string?>(reply);
35+
}
36+
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ internal static class CommandHelpCatalog
2929
new("recycle", CommandGroup.ItemDb, "help.recycle"),
3030
new("craft", CommandGroup.ItemDb, "help.craft"),
3131
new("research", CommandGroup.ItemDb, "help.research"),
32+
new("decay", CommandGroup.ItemDb, "help.decay"),
33+
new("upkeep", CommandGroup.ItemDb, "help.upkeep"),
3234
];
3335

3436
/// <summary>The Discord slash commands, in display order.</summary>
@@ -41,5 +43,7 @@ internal static class CommandHelpCatalog
4143
new("recycle", CommandGroup.ItemDb, "help.slash.recycle"),
4244
new("craft", CommandGroup.ItemDb, "help.slash.craft"),
4345
new("research", CommandGroup.ItemDb, "help.slash.research"),
46+
new("decay", CommandGroup.ItemDb, "help.slash.decay"),
47+
new("upkeep", CommandGroup.ItemDb, "help.slash.upkeep"),
4448
];
4549
}

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

Lines changed: 23 additions & 6 deletions
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, and /research slash commands.</summary>
14+
/// <summary>The /item, /recycle, /craft, /research, /decay, and /upkeep 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>
@@ -20,35 +20,52 @@ public sealed class ItemCommandModule(IServiceScopeFactory scopeFactory)
2020
/// <param name="item">The item name or id.</param>
2121
[SlashCommand("item", "Look up an item")]
2222
public Task ItemAsync([Summary("item", "Item name or id")] string item) =>
23-
RespondForAsync(item, (_, _, rec, loc, culture) =>
23+
RespondForAsync(item, db => db.Sources.NamesAsOf, (_, _, rec, loc, culture) =>
2424
loc.Get("command.item.ok", culture, ItemLine.Format(rec)));
2525

2626
/// <summary>Shows recycler output for an item.</summary>
2727
/// <param name="item">The item name or id.</param>
2828
[SlashCommand("recycle", "Show recycler output for an item")]
2929
public Task RecycleAsync([Summary("item", "Item name or id")] string item) =>
30-
RespondForAsync(item, (_, names, rec, loc, culture) => rec.Recycle is not null
30+
RespondForAsync(item, db => db.Sources.RecycleAsOf, (_, names, rec, loc, culture) => rec.Recycle is not null
3131
? loc.Get("command.recycle.ok", culture, RecycleLine.Format(rec, names))
3232
: loc.Get("command.recycle.none", culture, rec.Name));
3333

3434
/// <summary>Shows an item's craft recipe.</summary>
3535
/// <param name="item">The item name or id.</param>
3636
[SlashCommand("craft", "Show an item's craft recipe")]
3737
public Task CraftAsync([Summary("item", "Item name or id")] string item) =>
38-
RespondForAsync(item, (_, names, rec, loc, culture) => rec.Craft is not null
38+
RespondForAsync(item, db => db.Sources.CraftAsOf, (_, names, rec, loc, culture) => rec.Craft is not null
3939
? loc.Get("command.craft.ok", culture, CraftLine.Format(rec, names))
4040
: loc.Get("command.craft.none", culture, rec.Name));
4141

4242
/// <summary>Shows an item's research scrap cost.</summary>
4343
/// <param name="item">The item name or id.</param>
4444
[SlashCommand("research", "Show an item's research scrap cost")]
4545
public Task ResearchAsync([Summary("item", "Item name or id")] string item) =>
46-
RespondForAsync(item, (_, _, rec, loc, culture) => rec.Research is not null
46+
RespondForAsync(item, db => db.Sources.ResearchAsOf, (_, _, rec, loc, culture) => rec.Research is not null
4747
? loc.Get("command.research.ok", culture, ResearchLine.Format(rec))
4848
: loc.Get("command.research.none", culture, rec.Name));
4949

50+
/// <summary>Shows an item's decay time.</summary>
51+
/// <param name="item">The item name or id.</param>
52+
[SlashCommand("decay", "Show an item's decay time")]
53+
public Task DecayAsync([Summary("item", "Item name or id")] string item) =>
54+
RespondForAsync(item, db => db.Sources.DecayAsOf, (_, _, rec, loc, culture) => rec.Decay is not null
55+
? loc.Get("command.decay.ok", culture, DecayLine.Format(rec))
56+
: loc.Get("command.decay.none", culture, rec.Name));
57+
58+
/// <summary>Shows a building block's upkeep cost.</summary>
59+
/// <param name="item">The item name or id.</param>
60+
[SlashCommand("upkeep", "Show a building block's upkeep cost")]
61+
public Task UpkeepAsync([Summary("item", "Item name or id")] string item) =>
62+
RespondForAsync(item, db => db.Sources.UpkeepAsOf, (_, names, rec, loc, culture) => rec.Upkeep is not null
63+
? loc.Get("command.upkeep.ok", culture, UpkeepLine.Format(rec, names))
64+
: loc.Get("command.upkeep.none", culture, rec.Name));
65+
5066
private async Task RespondForAsync(
5167
string query,
68+
Func<IItemDatabase, DateOnly> dateSelector,
5269
Func<IItemDatabase, IItemNameResolver, ItemRecord, ILocalizer, string, string> onFound)
5370
{
5471
if (Context.Guild is null)
@@ -76,7 +93,7 @@ private async Task RespondForAsync(
7693

7794
var embed = new EmbedBuilder()
7895
.WithDescription(text)
79-
.WithFooter($"data as of {db.Sources.NamesAsOf:yyyy-MM-dd}")
96+
.WithFooter($"data as of {dateSelector(db):yyyy-MM-dd}")
8097
.Build();
8198
await RespondAsync(ephemeral: true, embed: embed).ConfigureAwait(false);
8299
}

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

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,28 +11,36 @@ public sealed record ItemDataset(int SchemaVersion, DatasetSources Sources, IRea
1111
/// <param name="RecycleAsOf">Recycle data source date.</param>
1212
/// <param name="CraftAsOf">Craft data source date.</param>
1313
/// <param name="ResearchAsOf">Research data source date.</param>
14+
/// <param name="DecayAsOf">Decay data source date.</param>
15+
/// <param name="UpkeepAsOf">Upkeep data source date.</param>
1416
public sealed record DatasetSources(
1517
DateOnly NamesAsOf,
1618
DateOnly RecycleAsOf,
1719
DateOnly CraftAsOf,
18-
DateOnly ResearchAsOf);
20+
DateOnly ResearchAsOf,
21+
DateOnly DecayAsOf,
22+
DateOnly UpkeepAsOf);
1923

20-
/// <summary>One item, with all 6a calculator data inlined (null where not applicable).</summary>
24+
/// <summary>One item, with all calculator data inlined (null where not applicable).</summary>
2125
/// <param name="Id">The Rust item id.</param>
2226
/// <param name="Name">The display name.</param>
2327
/// <param name="StackSize">Max stack size.</param>
2428
/// <param name="DespawnSeconds">Despawn time in seconds, or null if it does not despawn / unknown.</param>
2529
/// <param name="Recycle">Recycler yield, or null if not recyclable.</param>
2630
/// <param name="Craft">Craft recipe, or null if not craftable.</param>
2731
/// <param name="Research">Research cost, or null if not researchable.</param>
32+
/// <param name="Decay">Decay timing, or null if the item does not decay / unknown.</param>
33+
/// <param name="Upkeep">Upkeep cost, or null if the item has no upkeep.</param>
2834
public sealed record ItemRecord(
2935
int Id,
3036
string Name,
3137
int StackSize,
3238
int? DespawnSeconds,
3339
RecycleYield? Recycle,
3440
CraftRecipe? Craft,
35-
ResearchCost? Research);
41+
ResearchCost? Research,
42+
DecayInfo? Decay,
43+
UpkeepCost? Upkeep);
3644

3745
/// <summary>Recycler output for an item (6a covers the standard recycler only).</summary>
3846
/// <param name="Recycler">The yield entries produced by the standard recycler.</param>
@@ -58,3 +66,28 @@ public sealed record Ingredient(int ItemId, int Quantity);
5866
/// <summary>The research cost for an item.</summary>
5967
/// <param name="Scrap">The scrap cost to research.</param>
6068
public sealed record ResearchCost(int Scrap);
69+
70+
/// <summary>Decay timing for an item/deployable/building block. All fields nullable — a field is
71+
/// populated only when RustLabs provides it. <paramref name="Seconds"/> is the base/default decay.</summary>
72+
/// <param name="Seconds">Base decay time in seconds.</param>
73+
/// <param name="OutsideSeconds">Decay time when placed outside, if distinct.</param>
74+
/// <param name="InsideSeconds">Decay time when placed inside, if distinct.</param>
75+
/// <param name="UnderwaterSeconds">Decay time when underwater, if distinct.</param>
76+
/// <param name="Hp">The item's hit points.</param>
77+
public sealed record DecayInfo(
78+
int? Seconds,
79+
int? OutsideSeconds,
80+
int? InsideSeconds,
81+
int? UnderwaterSeconds,
82+
int? Hp);
83+
84+
/// <summary>The upkeep cost to maintain a building block.</summary>
85+
/// <param name="Entries">The per-resource upkeep cost entries.</param>
86+
public sealed record UpkeepCost(IReadOnlyList<UpkeepEntry> Entries);
87+
88+
/// <summary>One upkeep resource cost. <paramref name="QuantityMin"/> equals
89+
/// <paramref name="QuantityMax"/> for a single (non-range) quantity.</summary>
90+
/// <param name="ItemId">The resource item id.</param>
91+
/// <param name="QuantityMin">The lower bound of the cost.</param>
92+
/// <param name="QuantityMax">The upper bound of the cost.</param>
93+
public sealed record UpkeepEntry(int ItemId, int QuantityMin, int QuantityMax);

0 commit comments

Comments
 (0)