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
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ public static IServiceCollection AddCommands(this IServiceCollection services)
services.AddScoped<ICommandHandler, UpkeepCommandHandler>();
services.AddScoped<ICommandHandler, DurabilityCommandHandler>();
services.AddScoped<ICommandHandler, SmeltCommandHandler>();
services.AddScoped<ICommandHandler, CctvCommandHandler>();

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

namespace RustPlusBot.Features.Commands.Formatting;

/// <summary>Formats a monument's CCTV reply: a header line then one code per line (source order).</summary>
internal static class CctvLine
{
/// <summary>Lists a monument's camera codes under a header. The wildcard note is added by the
/// caller (it is localized and surface-specific), not here.</summary>
/// <param name="monument">The monument (with at least one code).</param>
public static string Format(CctvMonument monument)
{
ArgumentNullException.ThrowIfNull(monument);
return string.Create(CultureInfo.InvariantCulture,
$"{monument.Name} CCTV:\n{string.Join("\n", monument.Codes)}");
}
}
40 changes: 40 additions & 0 deletions src/RustPlusBot.Features.Commands/Handlers/CctvCommandHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using RustPlusBot.Features.Commands.Dispatching;
using RustPlusBot.Features.Commands.Formatting;
using RustPlusBot.Features.ItemData;
using RustPlusBot.Features.ItemData.Data;
using RustPlusBot.Features.ItemData.Lookup;
using RustPlusBot.Localization;

namespace RustPlusBot.Features.Commands.Handlers;

/// <summary>!cctv — lists a monument's Computer Station CCTV camera codes.</summary>
/// <param name="database">The item database.</param>
/// <param name="localizer">The reply localizer.</param>
internal sealed class CctvCommandHandler(IItemDatabase database, ILocalizer localizer) : ICommandHandler
{
/// <inheritdoc />
public string Name => "cctv";

/// <inheritdoc />
public Task<string?> ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
var query = string.Join(' ', context.Args);
var reply = database.ResolveCctv(query) switch
{
CctvMatch.Found f => Render(f.Monument, localizer, context.Culture),
CctvMatch.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);
}

private static string Render(CctvMonument monument, ILocalizer localizer, string culture)
{
var body = localizer.Get("command.cctv.ok", culture, CctvLine.Format(monument));
return monument.Dynamic
? $"{body}\n\n{localizer.Get("command.cctv.note", culture)}"
: body;
}
}
2 changes: 2 additions & 0 deletions src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ internal static class CommandHelpCatalog
new("upkeep", CommandGroup.ItemDb, "help.upkeep"),
new("durability", CommandGroup.ItemDb, "help.durability"),
new("smelt", CommandGroup.ItemDb, "help.smelt"),
new("cctv", CommandGroup.ItemDb, "help.cctv"),
];

/// <summary>The Discord slash commands, in display order.</summary>
Expand All @@ -49,5 +50,6 @@ internal static class CommandHelpCatalog
new("upkeep", CommandGroup.ItemDb, "help.slash.upkeep"),
new("durability", CommandGroup.ItemDb, "help.slash.durability"),
new("smelt", CommandGroup.ItemDb, "help.slash.smelt"),
new("cctv", CommandGroup.ItemDb, "help.slash.cctv"),
];
}
66 changes: 66 additions & 0 deletions src/RustPlusBot.Features.Commands/Modules/ItemCommandModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,24 @@ public Task DurabilityAsync([Summary("target", "Item, wall/door, or vehicle name
public Task SmeltAsync([Summary("smelter", "Furnace, Camp Fire, Electric Furnace, …")] string smelter) =>
RespondForSmeltAsync(smelter);

/// <summary>Shows a monument's Computer Station CCTV camera codes.</summary>
/// <param name="monument">The monument to look up.</param>
[SlashCommand("cctv", "Show the CCTV camera codes for a monument")]
public Task CctvAsync(
[Summary("monument", "The monument to look up")]
[Choice("Abandoned Military Base", "Abandoned Military Base")]
[Choice("Airfield", "Airfield")]
[Choice("Bandit Camp", "Bandit Camp")]
[Choice("Dome", "Dome")]
[Choice("Large Oil Rig", "Large Oil Rig")]
[Choice("Missile Silo", "Missile Silo")]
[Choice("Outpost", "Outpost")]
[Choice("Small Oil Rig", "Small Oil Rig")]
[Choice("Underwater Labs", "Underwater Labs")]
[Choice("Cargo Ship", "Cargo Ship")]
[Choice("Ferry Terminal", "Ferry Terminal")]
string monument) => RespondForCctvAsync(monument);

private async Task RespondForAsync(
string query,
Func<IItemDatabase, DateOnly> dateSelector,
Expand Down Expand Up @@ -176,4 +194,52 @@ private async Task RespondForSmeltAsync(string query)
await RespondAsync(ephemeral: true, embed: embed).ConfigureAwait(false);
}
}

private async Task RespondForCctvAsync(string query)
{
if (Context.Guild is null)
{
await RespondAsync("This command must be used in a server.", ephemeral: true).ConfigureAwait(false);
return;
}

var scope = scopeFactory.CreateAsyncScope();
await using (scope.ConfigureAwait(false))
{
var db = scope.ServiceProvider.GetRequiredService<IItemDatabase>();
var loc = scope.ServiceProvider.GetRequiredService<ILocalizer>();
var workspace = scope.ServiceProvider.GetRequiredService<IWorkspaceStore>();
var culture = await workspace.GetCultureAsync(Context.Guild.Id).ConfigureAwait(false);

var text = db.ResolveCctv(query) switch
{
CctvMatch.Found f => RenderEmbed(f.Monument, loc, culture),
CctvMatch.Ambiguous a => loc.Get("command.item.ambiguous", culture,
string.Join(", ", a.Candidates.Select(c => c.Name))),
_ => loc.Get("command.item.notfound", culture, query),
};

var embed = new EmbedBuilder()
.WithDescription(text)
.WithFooter($"data as of {db.Sources.CctvAsOf:yyyy-MM-dd}")
.Build();
await RespondAsync(ephemeral: true, embed: embed).ConfigureAwait(false);
}
}

/// <summary>
/// Discord-only presentation: fence the codes so wildcard asterisks render literally and the
/// codes are copy-clean; the localized wildcard note rides outside the fence as prose.
/// </summary>
/// <param name="monument">The resolved CCTV monument.</param>
/// <param name="loc">The localizer for string resources.</param>
/// <param name="culture">The guild culture code.</param>
private static string RenderEmbed(CctvMonument monument, ILocalizer loc, string culture)
{
var fenced = loc.Get("command.cctv.ok", culture,
$"```\n{CctvLine.Format(monument)}\n```");
return monument.Dynamic
? $"{fenced}\n\n{loc.Get("command.cctv.note", culture)}"
: fenced;
}
}
15 changes: 13 additions & 2 deletions src/RustPlusBot.Features.ItemData/Data/ItemDataset.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ namespace RustPlusBot.Features.ItemData.Data;
/// <param name="Items">Every known item, one record each.</param>
/// <param name="RaidTargets">Every raid target (item/building-block/vehicle) and its explosive cost.</param>
/// <param name="Smelters">Every known smelter and the conversions it performs.</param>
/// <param name="Cctv">Every monument and its Computer Station CCTV codes.</param>
public sealed record ItemDataset(
int SchemaVersion,
DatasetSources Sources,
IReadOnlyList<ItemRecord> Items,
IReadOnlyList<RaidTarget> RaidTargets,
IReadOnlyList<Smelter> Smelters);
IReadOnlyList<Smelter> Smelters,
IReadOnlyList<CctvMonument> Cctv);

/// <summary>When each section of the dataset was last sourced, for "data as of" display.</summary>
/// <param name="NamesAsOf">Names/ids/stack source date.</param>
Expand All @@ -22,6 +24,7 @@ public sealed record ItemDataset(
/// <param name="UpkeepAsOf">Upkeep data source date.</param>
/// <param name="DurabilityAsOf">Durability/raid-cost data source date.</param>
/// <param name="SmeltingAsOf">Smelting data source date.</param>
/// <param name="CctvAsOf">CCTV codes source date.</param>
public sealed record DatasetSources(
DateOnly NamesAsOf,
DateOnly RecycleAsOf,
Expand All @@ -30,7 +33,8 @@ public sealed record DatasetSources(
DateOnly DecayAsOf,
DateOnly UpkeepAsOf,
DateOnly DurabilityAsOf,
DateOnly SmeltingAsOf);
DateOnly SmeltingAsOf,
DateOnly CctvAsOf);

/// <summary>One item, with all calculator data inlined (null where not applicable).</summary>
/// <param name="Id">The Rust item id.</param>
Expand Down Expand Up @@ -160,3 +164,10 @@ public sealed record SmeltConversion(
double OutputProbability,
double WoodQuantity,
double TimeSeconds);

/// <summary>One monument and the CCTV codes for its Computer Station cameras.</summary>
/// <param name="Name">The monument display name (e.g. "Small Oil Rig", "Underwater Labs").</param>
/// <param name="Codes">The camera codes, in source order; always non-empty. Wildcard codes keep
/// literal asterisks (e.g. "COMPOUND******").</param>
/// <param name="Dynamic">True when the codes contain a per-map numerical wildcard (the asterisks).</param>
public sealed record CctvMonument(string Name, IReadOnlyList<string> Codes, bool Dynamic);
130 changes: 128 additions & 2 deletions src/RustPlusBot.Features.ItemData/Data/item-data.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"schemaVersion": 4,
"schemaVersion": 5,
"sources": {
"namesAsOf": "2026-04-08",
"recycleAsOf": "2024-09-07",
Expand All @@ -8,7 +8,8 @@
"decayAsOf": "2024-09-07",
"upkeepAsOf": "2024-09-07",
"durabilityAsOf": "2024-09-07",
"smeltingAsOf": "2023-11-05"
"smeltingAsOf": "2023-11-05",
"cctvAsOf": "2025-11-12"
},
"items": [
{
Expand Down Expand Up @@ -80760,5 +80761,130 @@
}
]
}
],
"cctv": [
{
"name": "Abandoned Military Base",
"codes": [
"COMPOUND******",
"OUTDOOR******"
],
"dynamic": true
},
{
"name": "Airfield",
"codes": [
"AIRFIELDHELIPAD"
],
"dynamic": false
},
{
"name": "Bandit Camp",
"codes": [
"TOWNWEAPONS",
"CASINO"
],
"dynamic": false
},
{
"name": "Dome",
"codes": [
"DOME1",
"DOMETOP"
],
"dynamic": false
},
{
"name": "Large Oil Rig",
"codes": [
"OILRIG2HELI",
"OILRIG2DOCK",
"OILRIG2EXHAUST",
"OILRIG2L1",
"OILRIG2L2",
"OILRIG2L3A",
"OILRIG2L3B",
"OILRIG2L4",
"OILRIG2L5",
"OILRIG2L6A",
"OILRIG2L6B",
"OILRIG2L6C",
"OILRIG2L6D"
],
"dynamic": false
},
{
"name": "Missile Silo",
"codes": [
"SILOTOWER",
"SILOEXIT1",
"SILOEXIT2",
"SILOSHIPPING",
"SILOMISSILE"
],
"dynamic": false
},
{
"name": "Outpost",
"codes": [
"COMPOUNDSTREET",
"COMPOUNDMUSIC",
"COMPOUNDCRUDE",
"COMPOUNDCHILL"
],
"dynamic": false
},
{
"name": "Small Oil Rig",
"codes": [
"OILRIG1HELI",
"OILRIG1DOCK",
"OILRIG1L1",
"OILRIG1L2",
"OILRIG1L3",
"OILRIG1L4"
],
"dynamic": false
},
{
"name": "Underwater Labs",
"codes": [
"AUXPOWER****",
"BRIG****",
"CANTINA****",
"CAPTAINQUARTER****",
"CLASSIFIED****",
"CREWQUARTERS****",
"HALLWAY****",
"INFIRMARY****",
"LAB****",
"LOCKERROOM****",
"OPERATIONS****",
"SECURITYHALL****",
"TECHCABINET****"
],
"dynamic": true
},
{
"name": "Cargo Ship",
"codes": [
"CARGODECK",
"CARGOBRIDGE",
"CARGOSTERN",
"CARGOHOLD1",
"CARGOHOLD2"
],
"dynamic": false
},
{
"name": "Ferry Terminal",
"codes": [
"FERRYDOCK",
"FERRYLOGISTICS",
"FERRYPARKING",
"FERRYUTILITIES"
],
"dynamic": false
}
]
}
10 changes: 9 additions & 1 deletion src/RustPlusBot.Features.ItemData/EmbeddedItemDatabase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ namespace RustPlusBot.Features.ItemData;
/// <summary>Loads the embedded <c>item-data.json</c> once and serves lookups. Singleton.</summary>
public sealed class EmbeddedItemDatabase : IItemDatabase
{
private const int ExpectedSchemaVersion = 4;
private const int ExpectedSchemaVersion = 5;

private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
Expand All @@ -29,6 +29,8 @@ public sealed class EmbeddedItemDatabase : IItemDatabase

private static readonly FrozenDictionary<int, Smelter> SmelterById = IndexSmelterById(Smelters);

private static readonly IReadOnlyList<CctvMonument> CctvList = Dataset.Cctv ?? [];

/// <inheritdoc />
public DatasetSources Sources => Dataset.Sources;

Expand All @@ -45,6 +47,12 @@ public sealed class EmbeddedItemDatabase : IItemDatabase
public SmeltMatch ResolveSmelter(string query) =>
SmeltLookup.Resolve(query, SmelterById.GetValueOrDefault, Smelters);

/// <inheritdoc />
public IReadOnlyList<CctvMonument> CctvMonuments => CctvList;

/// <inheritdoc />
public CctvMatch ResolveCctv(string query) => CctvLookup.Resolve(query, CctvList);

/// <summary>
/// Deserializes and validates an item dataset from <paramref name="stream"/>.
/// Throws <see cref="InvalidOperationException"/> when the schema version does not match
Expand Down
Loading
Loading