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
27 changes: 24 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ A self-hosted Discord bot for the Rust+ companion app.

The bot is live: it pairs with Rust+ over FCM, holds a socket per server, and
auto-provisions its own Discord channels. Pairing/connection, the chat bridge,
in-game `!commands` and slash surfaces, live map events, and map rendering are all
shipped. Smart devices and cameras are next.
in-game `!commands` and slash surfaces, live map events, map rendering, smart
devices (switches, alarms, storage monitors), and an offline item database with
recycle/craft/research calculators are all shipped. Cameras are next.

## Features

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

### Slash commands

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

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

### Item database & calculators

- An **offline, versioned item dataset** (bundled, ~1200 items) behind one
lookup seam, exposing four calculators both in-game and as ephemeral slash
commands: item info, recycler yields, craft recipes, and research scrap cost.
- **Name-or-id lookup** — type a name (case-insensitive, partial), an exact name,
or a numeric id; multiple matches return a short "did you mean" list.
- **Provenance-aware** — each result footers the date its data was sourced, and a
maintainer [generator tool](tools/RustPlusBot.ItemData.Generator) regenerates the
bundle from upstream (with strict validation) so it never silently rots.

### Foundation

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

The `tools/` folder holds maintainer utilities that are not part of the running
bot — currently
[`RustPlusBot.ItemData.Generator`](tools/RustPlusBot.ItemData.Generator), which
regenerates the embedded item dataset.

## Roadmap

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

## License

Expand Down
6 changes: 6 additions & 0 deletions RustPlusBot.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,24 @@
<Project Path="src/RustPlusBot.Features.Chat/RustPlusBot.Features.Chat.csproj" />
<Project Path="src/RustPlusBot.Features.Events/RustPlusBot.Features.Events.csproj" />
<Project Path="src/RustPlusBot.Features.Players/RustPlusBot.Features.Players.csproj" />
<Project Path="src/RustPlusBot.Features.ItemData/RustPlusBot.Features.ItemData.csproj" />
<Project Path="src/RustPlusBot.Features.StorageMonitors/RustPlusBot.Features.StorageMonitors.csproj" />
<Project Path="src/RustPlusBot.Features.Switches/RustPlusBot.Features.Switches.csproj" />
<Project Path="src/RustPlusBot.Features.Workspace/RustPlusBot.Features.Workspace.csproj" />
<Project Path="src/RustPlusBot.Localization/RustPlusBot.Localization.csproj" />
<Project Path="src/RustPlusBot.Persistence/RustPlusBot.Persistence.csproj" />
</Folder>
<Folder Name="/tools/">
<Project Path="tools/RustPlusBot.ItemData.Generator/RustPlusBot.ItemData.Generator.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/RustPlusBot.Abstractions.Tests/RustPlusBot.Abstractions.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Commands.Tests/RustPlusBot.Features.Commands.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Connections.Tests/RustPlusBot.Features.Connections.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Map.Tests/RustPlusBot.Features.Map.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Pairing.Tests/RustPlusBot.Features.Pairing.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Players.Tests/RustPlusBot.Features.Players.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.ItemData.Tests/RustPlusBot.Features.ItemData.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.StorageMonitors.Tests/RustPlusBot.Features.StorageMonitors.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Switches.Tests/RustPlusBot.Features.Switches.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Workspace.Tests/RustPlusBot.Features.Workspace.Tests.csproj" />
Expand All @@ -33,5 +38,6 @@
<Project Path="tests/RustPlusBot.Features.Events.Tests/RustPlusBot.Features.Events.Tests.csproj" />
<Project Path="tests/RustPlusBot.Localization.Tests/RustPlusBot.Localization.Tests.csproj" />
<Project Path="tests/RustPlusBot.Persistence.Tests/RustPlusBot.Persistence.Tests.csproj" />
<Project Path="tests/RustPlusBot.ItemData.Generator.Tests/RustPlusBot.ItemData.Generator.Tests.csproj" />
</Folder>
</Solution>
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using RustPlusBot.Features.Commands.Hosting;
using RustPlusBot.Features.Commands.Leader;
using RustPlusBot.Features.Commands.Servers;
using RustPlusBot.Features.ItemData;
using RustPlusBot.Localization;

namespace RustPlusBot.Features.Commands;
Expand All @@ -23,6 +24,7 @@ public static IServiceCollection AddCommands(this IServiceCollection services)
services.AddSingleton<CommandCooldown>();
services.AddSingleton<BotUptime>();
services.AddRustPlusBotLocalization();
services.AddItemData();

services.AddScoped<ICommandHandler, MuteCommandHandler>();
services.AddScoped<ICommandHandler, UnmuteCommandHandler>();
Expand All @@ -43,6 +45,10 @@ public static IServiceCollection AddCommands(this IServiceCollection services)
services.AddScoped<ICommandHandler, EventsCommandHandler>();
services.AddScoped<ICommandHandler, SmallCommandHandler>();
services.AddScoped<ICommandHandler, LargeCommandHandler>();
services.AddScoped<ICommandHandler, ItemCommandHandler>();
services.AddScoped<ICommandHandler, RecycleCommandHandler>();
services.AddScoped<ICommandHandler, CraftCommandHandler>();
services.AddScoped<ICommandHandler, ResearchCommandHandler>();

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

namespace RustPlusBot.Features.Commands.Formatting;

/// <summary>Formats the one-line craft-recipe reply.</summary>
internal static class CraftLine
{
/// <summary>Formats the ingredients and craft time for an item.</summary>
/// <param name="item">The item (must have non-null <see cref="ItemRecord.Craft"/>).</param>
/// <param name="names">Resolves ingredient item ids to names.</param>
public static string Format(ItemRecord item, IItemNameResolver names)
{
ArgumentNullException.ThrowIfNull(item);
ArgumentNullException.ThrowIfNull(names);
ArgumentNullException.ThrowIfNull(item.Craft);
var parts = item.Craft.Ingredients
.Select(g => string.Create(CultureInfo.InvariantCulture, $"{names.Resolve(g.ItemId)} ×{g.Quantity}"));
return string.Create(CultureInfo.InvariantCulture, $"{item.Name}: {string.Join(", ", parts)}");
}
}
20 changes: 20 additions & 0 deletions src/RustPlusBot.Features.Commands/Formatting/ItemLine.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using System.Globalization;
using RustPlusBot.Features.ItemData.Data;

namespace RustPlusBot.Features.Commands.Formatting;

/// <summary>Formats the one-line item lookup card.</summary>
internal static class ItemLine
{
/// <summary>Formats name, id, stack size, and despawn time.</summary>
/// <param name="item">The item to describe.</param>
public static string Format(ItemRecord item)
{
ArgumentNullException.ThrowIfNull(item);
var despawn = item.DespawnSeconds is { } seconds
? DurationFormat.Compact(TimeSpan.FromSeconds(seconds))
: "—";
return string.Create(CultureInfo.InvariantCulture,
$"{item.Name} (id {item.Id}) · stack {item.StackSize} · despawn {despawn}");
}
}
22 changes: 22 additions & 0 deletions src/RustPlusBot.Features.Commands/Formatting/RecycleLine.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using System.Globalization;
using RustPlusBot.Features.ItemData.Data;
using RustPlusBot.Features.ItemData.Naming;

namespace RustPlusBot.Features.Commands.Formatting;

/// <summary>Formats the one-line recycler-yield reply.</summary>
internal static class RecycleLine
{
/// <summary>Formats the recycler outputs for an item, or a "not recyclable" sentinel via the caller.</summary>
/// <param name="item">The item (must have non-null <see cref="ItemRecord.Recycle"/>).</param>
/// <param name="names">Resolves output item ids to names.</param>
public static string Format(ItemRecord item, IItemNameResolver names)
{
ArgumentNullException.ThrowIfNull(item);
ArgumentNullException.ThrowIfNull(names);
ArgumentNullException.ThrowIfNull(item.Recycle);
var parts = item.Recycle.Recycler
.Select(y => string.Create(CultureInfo.InvariantCulture, $"{names.Resolve(y.ItemId)} ×{y.Quantity}"));
return string.Create(CultureInfo.InvariantCulture, $"{item.Name} → {string.Join(", ", parts)}");
}
}
17 changes: 17 additions & 0 deletions src/RustPlusBot.Features.Commands/Formatting/ResearchLine.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using System.Globalization;
using RustPlusBot.Features.ItemData.Data;

namespace RustPlusBot.Features.Commands.Formatting;

/// <summary>Formats the one-line research-cost reply.</summary>
internal static class ResearchLine
{
/// <summary>Formats the scrap research cost for an item.</summary>
/// <param name="item">The item (must have non-null <see cref="ItemRecord.Research"/>).</param>
public static string Format(ItemRecord item)
{
ArgumentNullException.ThrowIfNull(item);
ArgumentNullException.ThrowIfNull(item.Research);
return string.Create(CultureInfo.InvariantCulture, $"{item.Name}: {item.Research.Scrap} scrap");
}
}
36 changes: 36 additions & 0 deletions src/RustPlusBot.Features.Commands/Handlers/CraftCommandHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
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>!craft — shows the craft recipe for an item.</summary>
/// <param name="database">The item database.</param>
/// <param name="names">Resolves ingredient item ids to names.</param>
/// <param name="localizer">The reply localizer.</param>
internal sealed class CraftCommandHandler(IItemDatabase database, IItemNameResolver names, ILocalizer localizer)
: ICommandHandler
{
/// <inheritdoc />
public string Name => "craft";

/// <inheritdoc />
public Task<string?> ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
var query = string.Join(' ', context.Args);
var reply = database.Resolve(query) switch
{
ItemMatch.Found { Item.Craft: not null } f =>
localizer.Get("command.craft.ok", context.Culture, CraftLine.Format(f.Item, names)),
ItemMatch.Found f => localizer.Get("command.craft.none", context.Culture, f.Item.Name),
ItemMatch.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);
}
}
31 changes: 31 additions & 0 deletions src/RustPlusBot.Features.Commands/Handlers/ItemCommandHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using RustPlusBot.Features.Commands.Dispatching;
using RustPlusBot.Features.Commands.Formatting;
using RustPlusBot.Features.ItemData;
using RustPlusBot.Features.ItemData.Lookup;
using RustPlusBot.Localization;

namespace RustPlusBot.Features.Commands.Handlers;

/// <summary>!item — looks up an item's name, id, stack size, and despawn time.</summary>
/// <param name="database">The item database.</param>
/// <param name="localizer">The reply localizer.</param>
internal sealed class ItemCommandHandler(IItemDatabase database, ILocalizer localizer) : ICommandHandler
{
/// <inheritdoc />
public string Name => "item";

/// <inheritdoc />
public Task<string?> ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
var query = string.Join(' ', context.Args);
var reply = database.Resolve(query) switch
{
ItemMatch.Found f => localizer.Get("command.item.ok", context.Culture, ItemLine.Format(f.Item)),
ItemMatch.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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
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>!recycle — shows the recycler output for an item.</summary>
/// <param name="database">The item database.</param>
/// <param name="names">Resolves output item ids to names.</param>
/// <param name="localizer">The reply localizer.</param>
internal sealed class RecycleCommandHandler(IItemDatabase database, IItemNameResolver names, ILocalizer localizer)
: ICommandHandler
{
/// <inheritdoc />
public string Name => "recycle";

/// <inheritdoc />
public Task<string?> ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
var query = string.Join(' ', context.Args);
var reply = database.Resolve(query) switch
{
ItemMatch.Found { Item.Recycle: not null } f =>
localizer.Get("command.recycle.ok", context.Culture, RecycleLine.Format(f.Item, names)),
ItemMatch.Found f => localizer.Get("command.recycle.none", context.Culture, f.Item.Name),
ItemMatch.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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using RustPlusBot.Features.Commands.Dispatching;
using RustPlusBot.Features.Commands.Formatting;
using RustPlusBot.Features.ItemData;
using RustPlusBot.Features.ItemData.Lookup;
using RustPlusBot.Localization;

namespace RustPlusBot.Features.Commands.Handlers;

/// <summary>!research — shows the scrap research cost for an item.</summary>
/// <param name="database">The item database.</param>
/// <param name="localizer">The reply localizer.</param>
internal sealed class ResearchCommandHandler(IItemDatabase database, ILocalizer localizer) : ICommandHandler
{
/// <inheritdoc />
public string Name => "research";

/// <inheritdoc />
public Task<string?> ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
var query = string.Join(' ', context.Args);
var reply = database.Resolve(query) switch
{
ItemMatch.Found { Item.Research: not null } f =>
localizer.Get("command.research.ok", context.Culture, ResearchLine.Format(f.Item)),
ItemMatch.Found f => localizer.Get("command.research.none", context.Culture, f.Item.Name),
ItemMatch.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);
}
}
3 changes: 3 additions & 0 deletions src/RustPlusBot.Features.Commands/Help/CommandGroup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,7 @@ internal enum CommandGroup

/// <summary>Bot-meta commands (uptime).</summary>
Bot = 3,

/// <summary>Item-database commands (item/recycle/craft/research).</summary>
ItemDb = 4,
}
8 changes: 8 additions & 0 deletions src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ internal static class CommandHelpCatalog
new("afk", CommandGroup.TeamIntel, "help.afk"),
new("prox", CommandGroup.TeamIntel, "help.prox"),
new("uptime", CommandGroup.Bot, "help.uptime"),
new("item", CommandGroup.ItemDb, "help.item"),
new("recycle", CommandGroup.ItemDb, "help.recycle"),
new("craft", CommandGroup.ItemDb, "help.craft"),
new("research", CommandGroup.ItemDb, "help.research"),
];

/// <summary>The Discord slash commands, in display order.</summary>
Expand All @@ -33,5 +37,9 @@ internal static class CommandHelpCatalog
new("help", CommandGroup.Bot, "help.slash.help"),
new("uptime", CommandGroup.Bot, "help.slash.uptime"),
new("leader", CommandGroup.Bot, "help.slash.leader"),
new("item", CommandGroup.ItemDb, "help.slash.item"),
new("recycle", CommandGroup.ItemDb, "help.slash.recycle"),
new("craft", CommandGroup.ItemDb, "help.slash.craft"),
new("research", CommandGroup.ItemDb, "help.slash.research"),
];
}
3 changes: 2 additions & 1 deletion src/RustPlusBot.Features.Commands/Help/HelpEmbedRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@ namespace RustPlusBot.Features.Commands.Help;
/// <param name="localizer">Resolves the title, group headings, and per-command descriptions.</param>
internal sealed class HelpEmbedRenderer(ILocalizer localizer)
{
private static readonly (CommandGroup Group, string HeadingKey)[] GroupOrder =
internal static readonly (CommandGroup Group, string HeadingKey)[] GroupOrder =
[
(CommandGroup.Control, "help.group.control"),
(CommandGroup.Server, "help.group.server"),
(CommandGroup.TeamIntel, "help.group.teamintel"),
(CommandGroup.Bot, "help.group.bot"),
(CommandGroup.ItemDb, "help.group.itemdb"),
];

/// <summary>Renders the help embed.</summary>
Expand Down
Loading
Loading