Skip to content

Commit 6270b4e

Browse files
HandyS11claude
andauthored
Subsystem 6a: Item Database + /item /recycle /craft /research calculators + refresh tool (#28)
* feat(itemdata): scaffold ItemData leaf project + dataset records * feat(itemdata): EmbeddedItemDatabase loads seed bundle by id Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(itemdata): pure ItemLookup + IItemDatabase.Resolve Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(itemdata): assert prefix-first ordering in ambiguous lookup Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(itemdata): IItemNameResolver adapter + AddItemData registration Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(storagemonitors): use shared ItemData name resolver, drop private items.json Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(commands): pure item/recycle/craft/research formatters Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(commands): in-game !item/!recycle/!craft/!research handlers Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(commands): render ItemDb group in /help (heading key + coverage test) * feat(commands): /item /recycle /craft /research slash module Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(itemdata): generator tool + DatasetValidator (offline transform) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(itemdata): regenerate full item-data bundle from rustplusplus sources Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(itemdata): exact-name collisions → Ambiguous; add fail-fast/dup-id/craft-validator tests Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs: document item database feature + generator tool README --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d2d57a1 commit 6270b4e

63 files changed

Lines changed: 26705 additions & 1266 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ A self-hosted Discord bot for the Rust+ companion app.
88

99
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,
11-
in-game `!commands` and slash surfaces, live map events, and map rendering are all
12-
shipped. Smart devices and cameras are next.
11+
in-game `!commands` and slash surfaces, live map events, map rendering, smart
12+
devices (switches, alarms, storage monitors), and an offline item database with
13+
recycle/craft/research calculators are all shipped. Cameras are next.
1314

1415
## Features
1516

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

5254
### Slash commands
5355

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

@@ -69,6 +72,17 @@ configurable per-server prefix (default `!`) and per-command cooldowns:
6972
layers — **Grid**, **Markers**, **Monuments**, **Vendor**, **Players**, **Rigs**
7073
controlled from a Manage-Server-gated control message.
7174

75+
### Item database & calculators
76+
77+
- 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.
80+
- **Name-or-id lookup** — type a name (case-insensitive, partial), an exact name,
81+
or a numeric id; multiple matches return a short "did you mean" list.
82+
- **Provenance-aware** — each result footers the date its data was sourced, and a
83+
maintainer [generator tool](tools/RustPlusBot.ItemData.Generator) regenerates the
84+
bundle from upstream (with strict validation) so it never silently rots.
85+
7286
### Foundation
7387

7488
- Multi-guild, self-hosted, per-guild isolation everywhere; SQLite persistence;
@@ -113,8 +127,14 @@ See [docs/development/running-locally.md](docs/development/running-locally.md).
113127
| `RustPlusBot.Features.Commands` | In-game `!commands`, slash surfaces, `/help`/`/leader` |
114128
| `RustPlusBot.Features.Events` | Live map-event classification + `#events` feed |
115129
| `RustPlusBot.Features.Map` | Map image rendering with toggleable layers |
130+
| `RustPlusBot.Features.ItemData` | Bundled item dataset, lookup seam, recycle/craft/research data |
116131
| `RustPlusBot.Host` | Generic Host entry point, DI wiring, startup validation |
117132

133+
The `tools/` folder holds maintainer utilities that are not part of the running
134+
bot — currently
135+
[`RustPlusBot.ItemData.Generator`](tools/RustPlusBot.ItemData.Generator), which
136+
regenerates the embedded item dataset.
137+
118138
## Roadmap
119139

120140
Subsystems are built in order; each has its own spec → plan → build cycle.
@@ -125,8 +145,9 @@ Subsystems are built in order; each has its own spec → plan → build cycle.
125145
| 1 | Pairing & connection (FCM, credential pool, hot-swap, auto-failover) | Done |
126146
| 2 | Map + live events (events feed, oil-rig detection, map render + layers) | Done |
127147
| 3 | Chat bridge + `!commands` + slash surfaces | Done |
128-
| 4 | Smart devices | Planned |
148+
| 4 | Smart devices (switches, alarms, storage monitors) | Done |
129149
| 5 | Cameras | Planned |
150+
| 6 | Item database & calculators (recycle/craft/research) | In progress |
130151

131152
## License
132153

RustPlusBot.slnx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,24 @@
1212
<Project Path="src/RustPlusBot.Features.Chat/RustPlusBot.Features.Chat.csproj" />
1313
<Project Path="src/RustPlusBot.Features.Events/RustPlusBot.Features.Events.csproj" />
1414
<Project Path="src/RustPlusBot.Features.Players/RustPlusBot.Features.Players.csproj" />
15+
<Project Path="src/RustPlusBot.Features.ItemData/RustPlusBot.Features.ItemData.csproj" />
1516
<Project Path="src/RustPlusBot.Features.StorageMonitors/RustPlusBot.Features.StorageMonitors.csproj" />
1617
<Project Path="src/RustPlusBot.Features.Switches/RustPlusBot.Features.Switches.csproj" />
1718
<Project Path="src/RustPlusBot.Features.Workspace/RustPlusBot.Features.Workspace.csproj" />
1819
<Project Path="src/RustPlusBot.Localization/RustPlusBot.Localization.csproj" />
1920
<Project Path="src/RustPlusBot.Persistence/RustPlusBot.Persistence.csproj" />
2021
</Folder>
22+
<Folder Name="/tools/">
23+
<Project Path="tools/RustPlusBot.ItemData.Generator/RustPlusBot.ItemData.Generator.csproj" />
24+
</Folder>
2125
<Folder Name="/tests/">
2226
<Project Path="tests/RustPlusBot.Abstractions.Tests/RustPlusBot.Abstractions.Tests.csproj" />
2327
<Project Path="tests/RustPlusBot.Features.Commands.Tests/RustPlusBot.Features.Commands.Tests.csproj" />
2428
<Project Path="tests/RustPlusBot.Features.Connections.Tests/RustPlusBot.Features.Connections.Tests.csproj" />
2529
<Project Path="tests/RustPlusBot.Features.Map.Tests/RustPlusBot.Features.Map.Tests.csproj" />
2630
<Project Path="tests/RustPlusBot.Features.Pairing.Tests/RustPlusBot.Features.Pairing.Tests.csproj" />
2731
<Project Path="tests/RustPlusBot.Features.Players.Tests/RustPlusBot.Features.Players.Tests.csproj" />
32+
<Project Path="tests/RustPlusBot.Features.ItemData.Tests/RustPlusBot.Features.ItemData.Tests.csproj" />
2833
<Project Path="tests/RustPlusBot.Features.StorageMonitors.Tests/RustPlusBot.Features.StorageMonitors.Tests.csproj" />
2934
<Project Path="tests/RustPlusBot.Features.Switches.Tests/RustPlusBot.Features.Switches.Tests.csproj" />
3035
<Project Path="tests/RustPlusBot.Features.Workspace.Tests/RustPlusBot.Features.Workspace.Tests.csproj" />
@@ -33,5 +38,6 @@
3338
<Project Path="tests/RustPlusBot.Features.Events.Tests/RustPlusBot.Features.Events.Tests.csproj" />
3439
<Project Path="tests/RustPlusBot.Localization.Tests/RustPlusBot.Localization.Tests.csproj" />
3540
<Project Path="tests/RustPlusBot.Persistence.Tests/RustPlusBot.Persistence.Tests.csproj" />
41+
<Project Path="tests/RustPlusBot.ItemData.Generator.Tests/RustPlusBot.ItemData.Generator.Tests.csproj" />
3642
</Folder>
3743
</Solution>

src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using RustPlusBot.Features.Commands.Hosting;
77
using RustPlusBot.Features.Commands.Leader;
88
using RustPlusBot.Features.Commands.Servers;
9+
using RustPlusBot.Features.ItemData;
910
using RustPlusBot.Localization;
1011

1112
namespace RustPlusBot.Features.Commands;
@@ -23,6 +24,7 @@ public static IServiceCollection AddCommands(this IServiceCollection services)
2324
services.AddSingleton<CommandCooldown>();
2425
services.AddSingleton<BotUptime>();
2526
services.AddRustPlusBotLocalization();
27+
services.AddItemData();
2628

2729
services.AddScoped<ICommandHandler, MuteCommandHandler>();
2830
services.AddScoped<ICommandHandler, UnmuteCommandHandler>();
@@ -43,6 +45,10 @@ public static IServiceCollection AddCommands(this IServiceCollection services)
4345
services.AddScoped<ICommandHandler, EventsCommandHandler>();
4446
services.AddScoped<ICommandHandler, SmallCommandHandler>();
4547
services.AddScoped<ICommandHandler, LargeCommandHandler>();
48+
services.AddScoped<ICommandHandler, ItemCommandHandler>();
49+
services.AddScoped<ICommandHandler, RecycleCommandHandler>();
50+
services.AddScoped<ICommandHandler, CraftCommandHandler>();
51+
services.AddScoped<ICommandHandler, ResearchCommandHandler>();
4652

4753
services.AddScoped<CommandDispatcher>();
4854
services.AddHostedService<CommandsHostedService>();
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
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 craft-recipe reply.</summary>
8+
internal static class CraftLine
9+
{
10+
/// <summary>Formats the ingredients and craft time for an item.</summary>
11+
/// <param name="item">The item (must have non-null <see cref="ItemRecord.Craft"/>).</param>
12+
/// <param name="names">Resolves ingredient 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.Craft);
18+
var parts = item.Craft.Ingredients
19+
.Select(g => string.Create(CultureInfo.InvariantCulture, $"{names.Resolve(g.ItemId)} ×{g.Quantity}"));
20+
return string.Create(CultureInfo.InvariantCulture, $"{item.Name}: {string.Join(", ", parts)}");
21+
}
22+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
using System.Globalization;
2+
using RustPlusBot.Features.ItemData.Data;
3+
4+
namespace RustPlusBot.Features.Commands.Formatting;
5+
6+
/// <summary>Formats the one-line item lookup card.</summary>
7+
internal static class ItemLine
8+
{
9+
/// <summary>Formats name, id, stack size, and despawn time.</summary>
10+
/// <param name="item">The item to describe.</param>
11+
public static string Format(ItemRecord item)
12+
{
13+
ArgumentNullException.ThrowIfNull(item);
14+
var despawn = item.DespawnSeconds is { } seconds
15+
? DurationFormat.Compact(TimeSpan.FromSeconds(seconds))
16+
: "—";
17+
return string.Create(CultureInfo.InvariantCulture,
18+
$"{item.Name} (id {item.Id}) · stack {item.StackSize} · despawn {despawn}");
19+
}
20+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
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 recycler-yield reply.</summary>
8+
internal static class RecycleLine
9+
{
10+
/// <summary>Formats the recycler outputs for an item, or a "not recyclable" sentinel via the caller.</summary>
11+
/// <param name="item">The item (must have non-null <see cref="ItemRecord.Recycle"/>).</param>
12+
/// <param name="names">Resolves output 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.Recycle);
18+
var parts = item.Recycle.Recycler
19+
.Select(y => string.Create(CultureInfo.InvariantCulture, $"{names.Resolve(y.ItemId)} ×{y.Quantity}"));
20+
return string.Create(CultureInfo.InvariantCulture, $"{item.Name}{string.Join(", ", parts)}");
21+
}
22+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
using System.Globalization;
2+
using RustPlusBot.Features.ItemData.Data;
3+
4+
namespace RustPlusBot.Features.Commands.Formatting;
5+
6+
/// <summary>Formats the one-line research-cost reply.</summary>
7+
internal static class ResearchLine
8+
{
9+
/// <summary>Formats the scrap research cost for an item.</summary>
10+
/// <param name="item">The item (must have non-null <see cref="ItemRecord.Research"/>).</param>
11+
public static string Format(ItemRecord item)
12+
{
13+
ArgumentNullException.ThrowIfNull(item);
14+
ArgumentNullException.ThrowIfNull(item.Research);
15+
return string.Create(CultureInfo.InvariantCulture, $"{item.Name}: {item.Research.Scrap} scrap");
16+
}
17+
}
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>!craft — shows the craft recipe for an item.</summary>
11+
/// <param name="database">The item database.</param>
12+
/// <param name="names">Resolves ingredient item ids to names.</param>
13+
/// <param name="localizer">The reply localizer.</param>
14+
internal sealed class CraftCommandHandler(IItemDatabase database, IItemNameResolver names, ILocalizer localizer)
15+
: ICommandHandler
16+
{
17+
/// <inheritdoc />
18+
public string Name => "craft";
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.Craft: not null } f =>
28+
localizer.Get("command.craft.ok", context.Culture, CraftLine.Format(f.Item, names)),
29+
ItemMatch.Found f => localizer.Get("command.craft.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+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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>!item — looks up an item's name, id, stack size, and despawn time.</summary>
10+
/// <param name="database">The item database.</param>
11+
/// <param name="localizer">The reply localizer.</param>
12+
internal sealed class ItemCommandHandler(IItemDatabase database, ILocalizer localizer) : ICommandHandler
13+
{
14+
/// <inheritdoc />
15+
public string Name => "item";
16+
17+
/// <inheritdoc />
18+
public Task<string?> ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
19+
{
20+
ArgumentNullException.ThrowIfNull(context);
21+
var query = string.Join(' ', context.Args);
22+
var reply = database.Resolve(query) switch
23+
{
24+
ItemMatch.Found f => localizer.Get("command.item.ok", context.Culture, ItemLine.Format(f.Item)),
25+
ItemMatch.Ambiguous a => localizer.Get("command.item.ambiguous", context.Culture,
26+
string.Join(", ", a.Candidates.Select(c => c.Name))),
27+
_ => localizer.Get("command.item.notfound", context.Culture, query),
28+
};
29+
return Task.FromResult<string?>(reply);
30+
}
31+
}
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>!recycle — shows the recycler output for an item.</summary>
11+
/// <param name="database">The item database.</param>
12+
/// <param name="names">Resolves output item ids to names.</param>
13+
/// <param name="localizer">The reply localizer.</param>
14+
internal sealed class RecycleCommandHandler(IItemDatabase database, IItemNameResolver names, ILocalizer localizer)
15+
: ICommandHandler
16+
{
17+
/// <inheritdoc />
18+
public string Name => "recycle";
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.Recycle: not null } f =>
28+
localizer.Get("command.recycle.ok", context.Culture, RecycleLine.Format(f.Item, names)),
29+
ItemMatch.Found f => localizer.Get("command.recycle.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+
}

0 commit comments

Comments
 (0)