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
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ 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, map rendering, smart
devices (switches, alarms, storage monitors), and an offline item database with
recycle/craft/research calculators are all shipped. Cameras are next.
recycle/craft/research/decay/upkeep calculators are all shipped. Cameras are next.

## Features

Expand Down Expand Up @@ -48,14 +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)
- **Items** — `!item`, `!recycle`, `!craft`, `!research`, `!decay`, `!upkeep` (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)
- **Items** (ephemeral) — `/item`, `/recycle`, `/craft`, `/research`, `/decay`, `/upkeep` (name or id)
- **Utility** — `/help`, `/uptime`, `/leader` (Manage Server — transfer in-game team leadership)
- **Admin** — `/setup`, `/workspace reset`, `/workspace simulate-server`

Expand All @@ -75,8 +75,9 @@ configurable per-server prefix (default `!`) and per-command cooldowns:
### 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.
lookup seam, exposing six calculators both in-game and as ephemeral slash
commands: item info, recycler yields, craft recipes, research scrap cost,
decay time, and building-block upkeep 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
Expand Down Expand Up @@ -127,7 +128,7 @@ 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.Features.ItemData` | Bundled item dataset, lookup seam, recycle/craft/research/decay/upkeep data |
| `RustPlusBot.Host` | Generic Host entry point, DI wiring, startup validation |

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

## License

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ public static IServiceCollection AddCommands(this IServiceCollection services)
services.AddScoped<ICommandHandler, RecycleCommandHandler>();
services.AddScoped<ICommandHandler, CraftCommandHandler>();
services.AddScoped<ICommandHandler, ResearchCommandHandler>();
services.AddScoped<ICommandHandler, DecayCommandHandler>();
services.AddScoped<ICommandHandler, UpkeepCommandHandler>();

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

namespace RustPlusBot.Features.Commands.Formatting;

/// <summary>Formats the one-line decay reply.</summary>
internal static class DecayLine
{
/// <summary>Formats base decay, any present environment variant, and HP for an item.</summary>
/// <param name="item">The item (must have non-null <see cref="ItemRecord.Decay"/>).</param>
public static string Format(ItemRecord item)
{
ArgumentNullException.ThrowIfNull(item);
ArgumentNullException.ThrowIfNull(item.Decay);
var decay = item.Decay;

var parts = new List<string>();
Add(parts, null, decay.Seconds);
Add(parts, "outside", decay.OutsideSeconds);
Add(parts, "inside", decay.InsideSeconds);
Add(parts, "underwater", decay.UnderwaterSeconds);

var decayPart = parts.Count > 0 ? string.Join(", ", parts) : "—";
var hp = decay.Hp is { } h
? string.Create(CultureInfo.InvariantCulture, $" · {h} HP")
: string.Empty;
return string.Create(CultureInfo.InvariantCulture, $"{item.Name} decays in {decayPart}{hp}");

static void Add(List<string> into, string? label, int? seconds)
{
if (seconds is not { } s)
{
return;
}

var compact = DurationFormat.Compact(TimeSpan.FromSeconds(s));
into.Add(label is null
? compact
: string.Create(CultureInfo.InvariantCulture, $"{label} {compact}"));
}
}
}
28 changes: 28 additions & 0 deletions src/RustPlusBot.Features.Commands/Formatting/UpkeepLine.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using System.Globalization;
using RustPlusBot.Features.ItemData.Data;
using RustPlusBot.Features.ItemData.Naming;

namespace RustPlusBot.Features.Commands.Formatting;

/// <summary>Formats the one-line upkeep-cost reply.</summary>
internal static class UpkeepLine
{
/// <summary>Formats the per-resource upkeep cost for a building block.</summary>
/// <param name="item">The item (must have non-null <see cref="ItemRecord.Upkeep"/>).</param>
/// <param name="names">Resolves resource item ids to names.</param>
public static string Format(ItemRecord item, IItemNameResolver names)
{
ArgumentNullException.ThrowIfNull(item);
ArgumentNullException.ThrowIfNull(names);
ArgumentNullException.ThrowIfNull(item.Upkeep);

var parts = item.Upkeep.Entries.Select(e =>
{
var quantity = e.QuantityMin == e.QuantityMax
? e.QuantityMin.ToString(CultureInfo.InvariantCulture)
: string.Create(CultureInfo.InvariantCulture, $"{e.QuantityMin}–{e.QuantityMax}");
return string.Create(CultureInfo.InvariantCulture, $"{quantity} {names.Resolve(e.ItemId)}");
});
return string.Create(CultureInfo.InvariantCulture, $"{item.Name} upkeep: {string.Join(", ", parts)}");
}
}
34 changes: 34 additions & 0 deletions src/RustPlusBot.Features.Commands/Handlers/DecayCommandHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
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>!decay — shows an item's decay time and HP.</summary>
/// <param name="database">The item database.</param>
/// <param name="localizer">The reply localizer.</param>
internal sealed class DecayCommandHandler(IItemDatabase database, ILocalizer localizer)
: ICommandHandler
{
/// <inheritdoc />
public string Name => "decay";

/// <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.Decay: not null } f =>
localizer.Get("command.decay.ok", context.Culture, DecayLine.Format(f.Item)),
ItemMatch.Found f => localizer.Get("command.decay.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);
}
}
36 changes: 36 additions & 0 deletions src/RustPlusBot.Features.Commands/Handlers/UpkeepCommandHandler.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>!upkeep — shows a building block's upkeep cost.</summary>
/// <param name="database">The item database.</param>
/// <param name="names">Resolves resource ids to names.</param>
/// <param name="localizer">The reply localizer.</param>
internal sealed class UpkeepCommandHandler(IItemDatabase database, IItemNameResolver names, ILocalizer localizer)
: ICommandHandler
{
/// <inheritdoc />
public string Name => "upkeep";

/// <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.Upkeep: not null } f =>
localizer.Get("command.upkeep.ok", context.Culture, UpkeepLine.Format(f.Item, names)),
ItemMatch.Found f => localizer.Get("command.upkeep.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);
}
}
4 changes: 4 additions & 0 deletions src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ internal static class CommandHelpCatalog
new("recycle", CommandGroup.ItemDb, "help.recycle"),
new("craft", CommandGroup.ItemDb, "help.craft"),
new("research", CommandGroup.ItemDb, "help.research"),
new("decay", CommandGroup.ItemDb, "help.decay"),
new("upkeep", CommandGroup.ItemDb, "help.upkeep"),
];

/// <summary>The Discord slash commands, in display order.</summary>
Expand All @@ -41,5 +43,7 @@ internal static class CommandHelpCatalog
new("recycle", CommandGroup.ItemDb, "help.slash.recycle"),
new("craft", CommandGroup.ItemDb, "help.slash.craft"),
new("research", CommandGroup.ItemDb, "help.slash.research"),
new("decay", CommandGroup.ItemDb, "help.slash.decay"),
new("upkeep", CommandGroup.ItemDb, "help.slash.upkeep"),
];
}
29 changes: 23 additions & 6 deletions 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, and /research slash commands.</summary>
/// <summary>The /item, /recycle, /craft, /research, /decay, and /upkeep slash commands.</summary>
/// <param name="scopeFactory">Creates a short-lived DI scope per interaction.</param>
public sealed class ItemCommandModule(IServiceScopeFactory scopeFactory)
: InteractionModuleBase<SocketInteractionContext>
Expand All @@ -20,35 +20,52 @@ public sealed class ItemCommandModule(IServiceScopeFactory scopeFactory)
/// <param name="item">The item name or id.</param>
[SlashCommand("item", "Look up an item")]
public Task ItemAsync([Summary("item", "Item name or id")] string item) =>
RespondForAsync(item, (_, _, rec, loc, culture) =>
RespondForAsync(item, db => db.Sources.NamesAsOf, (_, _, rec, loc, culture) =>
loc.Get("command.item.ok", culture, ItemLine.Format(rec)));

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

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

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

/// <summary>Shows an item's decay time.</summary>
/// <param name="item">The item name or id.</param>
[SlashCommand("decay", "Show an item's decay time")]
public Task DecayAsync([Summary("item", "Item name or id")] string item) =>
RespondForAsync(item, db => db.Sources.DecayAsOf, (_, _, rec, loc, culture) => rec.Decay is not null
? loc.Get("command.decay.ok", culture, DecayLine.Format(rec))
: loc.Get("command.decay.none", culture, rec.Name));

/// <summary>Shows a building block's upkeep cost.</summary>
/// <param name="item">The item name or id.</param>
[SlashCommand("upkeep", "Show a building block's upkeep cost")]
public Task UpkeepAsync([Summary("item", "Item name or id")] string item) =>
RespondForAsync(item, db => db.Sources.UpkeepAsOf, (_, names, rec, loc, culture) => rec.Upkeep is not null
? loc.Get("command.upkeep.ok", culture, UpkeepLine.Format(rec, names))
: loc.Get("command.upkeep.none", culture, rec.Name));

private async Task RespondForAsync(
string query,
Func<IItemDatabase, DateOnly> dateSelector,
Func<IItemDatabase, IItemNameResolver, ItemRecord, ILocalizer, string, string> onFound)
{
if (Context.Guild is null)
Expand Down Expand Up @@ -76,7 +93,7 @@ private async Task RespondForAsync(

var embed = new EmbedBuilder()
.WithDescription(text)
.WithFooter($"data as of {db.Sources.NamesAsOf:yyyy-MM-dd}")
.WithFooter($"data as of {dateSelector(db):yyyy-MM-dd}")
.Build();
await RespondAsync(ephemeral: true, embed: embed).ConfigureAwait(false);
}
Expand Down
39 changes: 36 additions & 3 deletions src/RustPlusBot.Features.ItemData/Data/ItemDataset.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,28 +11,36 @@ public sealed record ItemDataset(int SchemaVersion, DatasetSources Sources, IRea
/// <param name="RecycleAsOf">Recycle data source date.</param>
/// <param name="CraftAsOf">Craft data source date.</param>
/// <param name="ResearchAsOf">Research data source date.</param>
/// <param name="DecayAsOf">Decay data source date.</param>
/// <param name="UpkeepAsOf">Upkeep data source date.</param>
public sealed record DatasetSources(
DateOnly NamesAsOf,
DateOnly RecycleAsOf,
DateOnly CraftAsOf,
DateOnly ResearchAsOf);
DateOnly ResearchAsOf,
DateOnly DecayAsOf,
DateOnly UpkeepAsOf);

/// <summary>One item, with all 6a calculator data inlined (null where not applicable).</summary>
/// <summary>One item, with all calculator data inlined (null where not applicable).</summary>
/// <param name="Id">The Rust item id.</param>
/// <param name="Name">The display name.</param>
/// <param name="StackSize">Max stack size.</param>
/// <param name="DespawnSeconds">Despawn time in seconds, or null if it does not despawn / unknown.</param>
/// <param name="Recycle">Recycler yield, or null if not recyclable.</param>
/// <param name="Craft">Craft recipe, or null if not craftable.</param>
/// <param name="Research">Research cost, or null if not researchable.</param>
/// <param name="Decay">Decay timing, or null if the item does not decay / unknown.</param>
/// <param name="Upkeep">Upkeep cost, or null if the item has no upkeep.</param>
public sealed record ItemRecord(
int Id,
string Name,
int StackSize,
int? DespawnSeconds,
RecycleYield? Recycle,
CraftRecipe? Craft,
ResearchCost? Research);
ResearchCost? Research,
DecayInfo? Decay,
UpkeepCost? Upkeep);

/// <summary>Recycler output for an item (6a covers the standard recycler only).</summary>
/// <param name="Recycler">The yield entries produced by the standard recycler.</param>
Expand All @@ -58,3 +66,28 @@ public sealed record Ingredient(int ItemId, int Quantity);
/// <summary>The research cost for an item.</summary>
/// <param name="Scrap">The scrap cost to research.</param>
public sealed record ResearchCost(int Scrap);

/// <summary>Decay timing for an item/deployable/building block. All fields nullable — a field is
/// populated only when RustLabs provides it. <paramref name="Seconds"/> is the base/default decay.</summary>
/// <param name="Seconds">Base decay time in seconds.</param>
/// <param name="OutsideSeconds">Decay time when placed outside, if distinct.</param>
/// <param name="InsideSeconds">Decay time when placed inside, if distinct.</param>
/// <param name="UnderwaterSeconds">Decay time when underwater, if distinct.</param>
/// <param name="Hp">The item's hit points.</param>
public sealed record DecayInfo(
int? Seconds,
int? OutsideSeconds,
int? InsideSeconds,
int? UnderwaterSeconds,
int? Hp);

/// <summary>The upkeep cost to maintain a building block.</summary>
/// <param name="Entries">The per-resource upkeep cost entries.</param>
public sealed record UpkeepCost(IReadOnlyList<UpkeepEntry> Entries);

/// <summary>One upkeep resource cost. <paramref name="QuantityMin"/> equals
/// <paramref name="QuantityMax"/> for a single (non-range) quantity.</summary>
/// <param name="ItemId">The resource item id.</param>
/// <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);
Loading
Loading