diff --git a/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs index 59bebae6..ddb38db3 100644 --- a/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs @@ -53,6 +53,7 @@ public static IServiceCollection AddCommands(this IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddHostedService(); diff --git a/src/RustPlusBot.Features.Commands/Formatting/CctvLine.cs b/src/RustPlusBot.Features.Commands/Formatting/CctvLine.cs new file mode 100644 index 00000000..6e4f749b --- /dev/null +++ b/src/RustPlusBot.Features.Commands/Formatting/CctvLine.cs @@ -0,0 +1,18 @@ +using System.Globalization; +using RustPlusBot.Features.ItemData.Data; + +namespace RustPlusBot.Features.Commands.Formatting; + +/// Formats a monument's CCTV reply: a header line then one code per line (source order). +internal static class CctvLine +{ + /// 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. + /// The monument (with at least one code). + public static string Format(CctvMonument monument) + { + ArgumentNullException.ThrowIfNull(monument); + return string.Create(CultureInfo.InvariantCulture, + $"{monument.Name} CCTV:\n{string.Join("\n", monument.Codes)}"); + } +} diff --git a/src/RustPlusBot.Features.Commands/Handlers/CctvCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/CctvCommandHandler.cs new file mode 100644 index 00000000..a28fee40 --- /dev/null +++ b/src/RustPlusBot.Features.Commands/Handlers/CctvCommandHandler.cs @@ -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; + +/// !cctv — lists a monument's Computer Station CCTV camera codes. +/// The item database. +/// The reply localizer. +internal sealed class CctvCommandHandler(IItemDatabase database, ILocalizer localizer) : ICommandHandler +{ + /// + public string Name => "cctv"; + + /// + public Task 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(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; + } +} diff --git a/src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.cs b/src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.cs index 31a4203c..0af5acf7 100644 --- a/src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.cs +++ b/src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.cs @@ -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"), ]; /// The Discord slash commands, in display order. @@ -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"), ]; } diff --git a/src/RustPlusBot.Features.Commands/Modules/ItemCommandModule.cs b/src/RustPlusBot.Features.Commands/Modules/ItemCommandModule.cs index bdc3513c..f3d1b3ee 100644 --- a/src/RustPlusBot.Features.Commands/Modules/ItemCommandModule.cs +++ b/src/RustPlusBot.Features.Commands/Modules/ItemCommandModule.cs @@ -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); + /// Shows a monument's Computer Station CCTV camera codes. + /// The monument to look up. + [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 dateSelector, @@ -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(); + var loc = scope.ServiceProvider.GetRequiredService(); + var workspace = scope.ServiceProvider.GetRequiredService(); + 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); + } + } + + /// + /// 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. + /// + /// The resolved CCTV monument. + /// The localizer for string resources. + /// The guild culture code. + 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; + } } diff --git a/src/RustPlusBot.Features.ItemData/Data/ItemDataset.cs b/src/RustPlusBot.Features.ItemData/Data/ItemDataset.cs index 19b59996..2cb64a1b 100644 --- a/src/RustPlusBot.Features.ItemData/Data/ItemDataset.cs +++ b/src/RustPlusBot.Features.ItemData/Data/ItemDataset.cs @@ -6,12 +6,14 @@ namespace RustPlusBot.Features.ItemData.Data; /// Every known item, one record each. /// Every raid target (item/building-block/vehicle) and its explosive cost. /// Every known smelter and the conversions it performs. +/// Every monument and its Computer Station CCTV codes. public sealed record ItemDataset( int SchemaVersion, DatasetSources Sources, IReadOnlyList Items, IReadOnlyList RaidTargets, - IReadOnlyList Smelters); + IReadOnlyList Smelters, + IReadOnlyList Cctv); /// When each section of the dataset was last sourced, for "data as of" display. /// Names/ids/stack source date. @@ -22,6 +24,7 @@ public sealed record ItemDataset( /// Upkeep data source date. /// Durability/raid-cost data source date. /// Smelting data source date. +/// CCTV codes source date. public sealed record DatasetSources( DateOnly NamesAsOf, DateOnly RecycleAsOf, @@ -30,7 +33,8 @@ public sealed record DatasetSources( DateOnly DecayAsOf, DateOnly UpkeepAsOf, DateOnly DurabilityAsOf, - DateOnly SmeltingAsOf); + DateOnly SmeltingAsOf, + DateOnly CctvAsOf); /// One item, with all calculator data inlined (null where not applicable). /// The Rust item id. @@ -160,3 +164,10 @@ public sealed record SmeltConversion( double OutputProbability, double WoodQuantity, double TimeSeconds); + +/// One monument and the CCTV codes for its Computer Station cameras. +/// The monument display name (e.g. "Small Oil Rig", "Underwater Labs"). +/// The camera codes, in source order; always non-empty. Wildcard codes keep +/// literal asterisks (e.g. "COMPOUND******"). +/// True when the codes contain a per-map numerical wildcard (the asterisks). +public sealed record CctvMonument(string Name, IReadOnlyList Codes, bool Dynamic); diff --git a/src/RustPlusBot.Features.ItemData/Data/item-data.json b/src/RustPlusBot.Features.ItemData/Data/item-data.json index c6450353..cc5e514b 100644 --- a/src/RustPlusBot.Features.ItemData/Data/item-data.json +++ b/src/RustPlusBot.Features.ItemData/Data/item-data.json @@ -1,5 +1,5 @@ { - "schemaVersion": 4, + "schemaVersion": 5, "sources": { "namesAsOf": "2026-04-08", "recycleAsOf": "2024-09-07", @@ -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": [ { @@ -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 + } ] } \ No newline at end of file diff --git a/src/RustPlusBot.Features.ItemData/EmbeddedItemDatabase.cs b/src/RustPlusBot.Features.ItemData/EmbeddedItemDatabase.cs index f02c730e..c1083e1d 100644 --- a/src/RustPlusBot.Features.ItemData/EmbeddedItemDatabase.cs +++ b/src/RustPlusBot.Features.ItemData/EmbeddedItemDatabase.cs @@ -10,7 +10,7 @@ namespace RustPlusBot.Features.ItemData; /// Loads the embedded item-data.json once and serves lookups. Singleton. public sealed class EmbeddedItemDatabase : IItemDatabase { - private const int ExpectedSchemaVersion = 4; + private const int ExpectedSchemaVersion = 5; private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { @@ -29,6 +29,8 @@ public sealed class EmbeddedItemDatabase : IItemDatabase private static readonly FrozenDictionary SmelterById = IndexSmelterById(Smelters); + private static readonly IReadOnlyList CctvList = Dataset.Cctv ?? []; + /// public DatasetSources Sources => Dataset.Sources; @@ -45,6 +47,12 @@ public sealed class EmbeddedItemDatabase : IItemDatabase public SmeltMatch ResolveSmelter(string query) => SmeltLookup.Resolve(query, SmelterById.GetValueOrDefault, Smelters); + /// + public IReadOnlyList CctvMonuments => CctvList; + + /// + public CctvMatch ResolveCctv(string query) => CctvLookup.Resolve(query, CctvList); + /// /// Deserializes and validates an item dataset from . /// Throws when the schema version does not match diff --git a/src/RustPlusBot.Features.ItemData/IItemDatabase.cs b/src/RustPlusBot.Features.ItemData/IItemDatabase.cs index fde14d93..06de599a 100644 --- a/src/RustPlusBot.Features.ItemData/IItemDatabase.cs +++ b/src/RustPlusBot.Features.ItemData/IItemDatabase.cs @@ -9,6 +9,9 @@ public interface IItemDatabase /// Per-section provenance dates, for "data as of" display. DatasetSources Sources { get; } + /// All CCTV monument entries from the bundle. + IReadOnlyList CctvMonuments { get; } + /// Gets the record for an item id, or null if unknown. /// The Rust item id. ItemRecord? GetById(int id); @@ -27,4 +30,9 @@ public interface IItemDatabase /// The raw user input. /// A match describing the outcome. SmeltMatch ResolveSmelter(string query); + + /// Resolves a user query (monument name) to a CCTV monument. + /// The raw user input. + /// A match describing the outcome. + CctvMatch ResolveCctv(string query); } diff --git a/src/RustPlusBot.Features.ItemData/Lookup/CctvLookup.cs b/src/RustPlusBot.Features.ItemData/Lookup/CctvLookup.cs new file mode 100644 index 00000000..fb9d5b3d --- /dev/null +++ b/src/RustPlusBot.Features.ItemData/Lookup/CctvLookup.cs @@ -0,0 +1,27 @@ +using RustPlusBot.Features.ItemData.Data; + +namespace RustPlusBot.Features.ItemData.Lookup; + +/// Pure name resolution over CCTV monuments. Exact name beats substring. +public static class CctvLookup +{ + private static readonly IComparer ByName = + Comparer.Create((a, b) => string.CompareOrdinal(a.Name, b.Name)); + + /// Resolves a user query to a CCTV monument. + /// The raw user input (monument name). + /// All monuments, for name matching. + /// The maximum number of ambiguous candidates to return. + /// A describing the outcome. + public static CctvMatch Resolve(string query, IReadOnlyList all, int cap = 10) + { + var (kind, single, candidates) = + NameMatcher.Resolve(query, static _ => (CctvMonument?)null, all, m => m.Name, ByName, cap); + return kind switch + { + NameMatchKind.Found => new CctvMatch.Found(single!), + NameMatchKind.Ambiguous => new CctvMatch.Ambiguous(candidates), + _ => new CctvMatch.NotFound(), + }; + } +} diff --git a/src/RustPlusBot.Features.ItemData/Lookup/CctvMatch.cs b/src/RustPlusBot.Features.ItemData/Lookup/CctvMatch.cs new file mode 100644 index 00000000..782cd75b --- /dev/null +++ b/src/RustPlusBot.Features.ItemData/Lookup/CctvMatch.cs @@ -0,0 +1,23 @@ +using System.Diagnostics.CodeAnalysis; +using RustPlusBot.Features.ItemData.Data; + +namespace RustPlusBot.Features.ItemData.Lookup; + +/// The outcome of resolving a user query to a CCTV monument. +[SuppressMessage("Design", "CA1034:Nested types should not be visible", + Justification = "Discriminated-union pattern: nested sealed records are the intended public surface.")] +public abstract record CctvMatch +{ + private CctvMatch() { } + + /// Exactly one monument resolved. + /// The resolved monument. + public sealed record Found(CctvMonument Monument) : CctvMatch; + + /// Several monuments matched; present candidates for disambiguation. + /// The candidate monuments, capped and ranked. + public sealed record Ambiguous(IReadOnlyList Candidates) : CctvMatch; + + /// No monument matched. + public sealed record NotFound : CctvMatch; +} diff --git a/src/RustPlusBot.Localization/Strings.fr.resx b/src/RustPlusBot.Localization/Strings.fr.resx index 58c9786f..0bdb9ef7 100644 --- a/src/RustPlusBot.Localization/Strings.fr.resx +++ b/src/RustPlusBot.Localization/Strings.fr.resx @@ -267,6 +267,12 @@ {0} + + {0} + + + * = un code numérique différent pour chaque carte. + Vouliez-vous dire : {0} ? @@ -399,6 +405,9 @@ Ce qu'un fondeur transforme, avec carburant et temps + + Codes de vidéosurveillance d'un monument + Afficher le nom, l'id, la pile et la disparition d'un objet @@ -435,6 +444,9 @@ Affiche ce qu'un fondeur transforme et son coût en carburant/temps. + + Afficher les codes de vidéosurveillance d'un monument. + Afficher ce message d'aide. diff --git a/src/RustPlusBot.Localization/Strings.resx b/src/RustPlusBot.Localization/Strings.resx index 0523ab33..e99a8781 100644 --- a/src/RustPlusBot.Localization/Strings.resx +++ b/src/RustPlusBot.Localization/Strings.resx @@ -267,6 +267,12 @@ {0} + + {0} + + + * = a numerical code that differs on every map. + Did you mean: {0}? @@ -399,6 +405,9 @@ What a smelter converts, with fuel and time + + CCTV camera codes for a monument + Look up an item's name, id, stack, and despawn @@ -435,6 +444,9 @@ Show what a smelter converts and its fuel/time cost. + + Show the CCTV camera codes for a monument. + Show this help message. diff --git a/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs b/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs index 3e8015ff..c6b4b6ce 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs @@ -51,7 +51,7 @@ public void Dispatcher_and_handlers_resolve() using var scope = provider.CreateScope(); Assert.NotNull(scope.ServiceProvider.GetRequiredService()); var handlers = scope.ServiceProvider.GetServices().ToList(); - Assert.Equal(27, handlers.Count); + Assert.Equal(28, handlers.Count); Assert.Contains(handlers, h => h.Name == "mute"); Assert.Contains(handlers, h => h.Name == "pop"); Assert.Contains(handlers, h => h.Name == "time"); @@ -73,6 +73,7 @@ public void Dispatcher_and_handlers_resolve() Assert.Contains(handlers, h => h.Name == "upkeep"); Assert.Contains(handlers, h => h.Name == "durability"); Assert.Contains(handlers, h => h.Name == "smelt"); + Assert.Contains(handlers, h => h.Name == "cctv"); Assert.NotNull(scope.ServiceProvider.GetRequiredService()); Assert.NotNull(scope.ServiceProvider.GetRequiredService()); } diff --git a/tests/RustPlusBot.Features.Commands.Tests/Formatting/CctvFormatterTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Formatting/CctvFormatterTests.cs new file mode 100644 index 00000000..bfcc8c1f --- /dev/null +++ b/tests/RustPlusBot.Features.Commands.Tests/Formatting/CctvFormatterTests.cs @@ -0,0 +1,23 @@ +using RustPlusBot.Features.Commands.Formatting; +using RustPlusBot.Features.ItemData.Data; + +namespace RustPlusBot.Features.Commands.Tests.Formatting; + +public sealed class CctvFormatterTests +{ + [Fact] + public void CctvLine_RendersHeaderThenCodesInOrder() + { + var monument = new CctvMonument("Small Oil Rig", ["OILRIG1HELI", "OILRIG1DOCK"], false); + var line = CctvLine.Format(monument); + Assert.Equal("Small Oil Rig CCTV:\nOILRIG1HELI\nOILRIG1DOCK", line); + } + + [Fact] + public void CctvLine_DoesNotAppendNote_EvenWhenDynamic() + { + var monument = new CctvMonument("Underwater Labs", ["LAB****"], true); + var line = CctvLine.Format(monument); + Assert.Equal("Underwater Labs CCTV:\nLAB****", line); + } +} diff --git a/tests/RustPlusBot.Features.Commands.Tests/Handlers/CctvHandlerTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Handlers/CctvHandlerTests.cs new file mode 100644 index 00000000..24eb8094 --- /dev/null +++ b/tests/RustPlusBot.Features.Commands.Tests/Handlers/CctvHandlerTests.cs @@ -0,0 +1,50 @@ +using RustPlusBot.Features.Commands.Dispatching; +using RustPlusBot.Features.Commands.Handlers; +using RustPlusBot.Features.ItemData; +using RustPlusBot.Localization; + +namespace RustPlusBot.Features.Commands.Tests.Handlers; + +public sealed class CctvHandlerTests +{ + private readonly IItemDatabase _db = new EmbeddedItemDatabase(); + private readonly ILocalizer _loc = new ResxLocalizer(); + + private static CommandContext Ctx(params string[] args) => new(1, Guid.NewGuid(), "en", 99, "Caller", args); + + [Fact] + public void Name_is_cctv() => Assert.Equal("cctv", new CctvCommandHandler(_db, _loc).Name); + + [Fact] + public async Task Found_returnsCodes() + { + var reply = await new CctvCommandHandler(_db, _loc) + .ExecuteAsync(Ctx("Small", "Oil", "Rig"), CancellationToken.None); + Assert.Contains("Small Oil Rig CCTV:", reply, StringComparison.Ordinal); + Assert.Contains("OILRIG1HELI", reply, StringComparison.Ordinal); + } + + [Fact] + public async Task DynamicMonument_appendsNote() + { + var reply = await new CctvCommandHandler(_db, _loc) + .ExecuteAsync(Ctx("Underwater", "Labs"), CancellationToken.None); + Assert.Contains("every map", reply, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task NonDynamicMonument_hasNoNote() + { + var reply = await new CctvCommandHandler(_db, _loc) + .ExecuteAsync(Ctx("Dome"), CancellationToken.None); + Assert.DoesNotContain("every map", reply, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task NotFound_returnsMessage() + { + var reply = await new CctvCommandHandler(_db, _loc) + .ExecuteAsync(Ctx("zzzzz"), CancellationToken.None); + Assert.Contains("zzzzz", reply, StringComparison.Ordinal); + } +} diff --git a/tests/RustPlusBot.Features.Commands.Tests/Help/CommandHelpCatalogTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Help/CommandHelpCatalogTests.cs index 3ad21123..f248c626 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/Help/CommandHelpCatalogTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/Help/CommandHelpCatalogTests.cs @@ -5,12 +5,12 @@ namespace RustPlusBot.Features.Commands.Tests.Help; public sealed class CommandHelpCatalogTests { - /// The 21 registered in-game handler names (see AddCommands / CommandRegistrationTests). + /// The 22 registered in-game handler names (see AddCommands / CommandRegistrationTests). private static readonly string[] HandlerNames = [ "mute", "unmute", "uptime", "pop", "wipe", "time", "online", "offline", "team", "steamid", "alive", "afk", "prox", - "item", "recycle", "craft", "research", "decay", "upkeep", "durability", "smelt", + "item", "recycle", "craft", "research", "decay", "upkeep", "durability", "smelt", "cctv", ]; [Fact] diff --git a/tests/RustPlusBot.Features.Commands.Tests/Modules/CctvChoiceDriftTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Modules/CctvChoiceDriftTests.cs new file mode 100644 index 00000000..245bb7b3 --- /dev/null +++ b/tests/RustPlusBot.Features.Commands.Tests/Modules/CctvChoiceDriftTests.cs @@ -0,0 +1,40 @@ +using System.Reflection; +using Discord.Interactions; +using RustPlusBot.Features.Commands.Modules; +using RustPlusBot.Features.ItemData; + +namespace RustPlusBot.Features.Commands.Tests.Modules; + +public sealed class CctvChoiceDriftTests +{ + private static IReadOnlyList ChoiceValues() + { + var parameter = typeof(ItemCommandModule) + .GetMethod(nameof(ItemCommandModule.CctvAsync))! + .GetParameters()[0]; + return + [ + ..parameter.GetCustomAttributes() + .Select(c => (string)c.Value!) + ]; + } + + [Fact] + public void EveryChoiceResolvesToAMonument() + { + var db = new EmbeddedItemDatabase(); + foreach (var value in ChoiceValues()) + { + Assert.IsType(db.ResolveCctv(value)); + } + } + + [Fact] + public void ChoiceSetEqualsMonumentSet() + { + var db = new EmbeddedItemDatabase(); + var monuments = db.CctvMonuments.Select(m => m.Name).ToHashSet(StringComparer.Ordinal); + var choices = ChoiceValues().ToHashSet(StringComparer.Ordinal); + Assert.Equal(monuments, choices); + } +} diff --git a/tests/RustPlusBot.Features.ItemData.Tests/CctvBundleTests.cs b/tests/RustPlusBot.Features.ItemData.Tests/CctvBundleTests.cs new file mode 100644 index 00000000..8ebfc901 --- /dev/null +++ b/tests/RustPlusBot.Features.ItemData.Tests/CctvBundleTests.cs @@ -0,0 +1,16 @@ +using RustPlusBot.Features.ItemData; + +namespace RustPlusBot.Features.ItemData.Tests; + +public sealed class CctvBundleTests +{ + private readonly IItemDatabase _db = new EmbeddedItemDatabase(); + + [Fact] + public void Bundle_LoadsAtLeastEightMonuments() => + Assert.True(_db.CctvMonuments.Count >= 8); + + [Fact] + public void Bundle_HasCctvProvenanceDate() => + Assert.Equal(new DateOnly(2025, 11, 12), _db.Sources.CctvAsOf); +} diff --git a/tests/RustPlusBot.Features.ItemData.Tests/CctvLookupTests.cs b/tests/RustPlusBot.Features.ItemData.Tests/CctvLookupTests.cs new file mode 100644 index 00000000..11ccd966 --- /dev/null +++ b/tests/RustPlusBot.Features.ItemData.Tests/CctvLookupTests.cs @@ -0,0 +1,43 @@ +using RustPlusBot.Features.ItemData.Data; +using RustPlusBot.Features.ItemData.Lookup; + +namespace RustPlusBot.Features.ItemData.Tests; + +public sealed class CctvLookupTests +{ + private static readonly CctvMonument Dome = new("Dome", ["DOME1"], false); + private static readonly CctvMonument SmallRig = new("Small Oil Rig", ["OILRIG1HELI"], false); + private static readonly CctvMonument LargeRig = new("Large Oil Rig", ["OILRIG2HELI"], false); + private static readonly IReadOnlyList All = [Dome, SmallRig, LargeRig]; + + private static CctvMatch Resolve(string q) => CctvLookup.Resolve(q, All); + + [Fact] + public void ExactName_Found() + { + var found = Assert.IsType(Resolve("Dome")); + Assert.Equal("Dome", found.Monument.Name); + } + + [Fact] + public void UniqueSubstring_Found() + { + var found = Assert.IsType(Resolve("small")); + Assert.Equal("Small Oil Rig", found.Monument.Name); + } + + [Fact] + public void MultipleSubstring_Ambiguous() + { + var amb = Assert.IsType(Resolve("oil rig")); + Assert.True(amb.Candidates.Count >= 2); + } + + [Fact] + public void NoMatch_NotFound() => Assert.IsType(Resolve("zzzzz")); + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void EmptyOrWhitespace_NotFound(string q) => Assert.IsType(Resolve(q)); +} diff --git a/tests/RustPlusBot.Features.ItemData.Tests/CctvMonumentTests.cs b/tests/RustPlusBot.Features.ItemData.Tests/CctvMonumentTests.cs new file mode 100644 index 00000000..28918df8 --- /dev/null +++ b/tests/RustPlusBot.Features.ItemData.Tests/CctvMonumentTests.cs @@ -0,0 +1,16 @@ +using RustPlusBot.Features.ItemData.Data; + +namespace RustPlusBot.Features.ItemData.Tests; + +public sealed class CctvMonumentTests +{ + [Fact] + public void CctvMonument_ExposesNameCodesAndDynamic() + { + var monument = new CctvMonument("Dome", ["DOME1", "DOMETOP"], false); + Assert.Equal("Dome", monument.Name); + Assert.Equal(2, monument.Codes.Count); + Assert.Equal("DOME1", monument.Codes[0]); + Assert.False(monument.Dynamic); + } +} diff --git a/tests/RustPlusBot.ItemData.Generator.Tests/DatasetValidatorTests.cs b/tests/RustPlusBot.ItemData.Generator.Tests/DatasetValidatorTests.cs index 7a04a044..b69e5e50 100644 --- a/tests/RustPlusBot.ItemData.Generator.Tests/DatasetValidatorTests.cs +++ b/tests/RustPlusBot.ItemData.Generator.Tests/DatasetValidatorTests.cs @@ -8,20 +8,24 @@ public sealed class DatasetValidatorTests { private static ItemDataset Good() => new(1, new DatasetSources(new(2026, 4, 8), new(2024, 9, 7), new(2024, 9, 7), new(2024, 9, 7), new(2024, 9, 7), - new(2024, 9, 7), new(2024, 9, 7), new(2023, 11, 5)), + new(2024, 9, 7), new(2024, 9, 7), new(2023, 11, 5), new(2025, 11, 12)), [ new ItemRecord(1, "AK-47", 1, 3600, new RecycleYield([new YieldEntry(2, 4, 1.0)]), null, null, null, null), new ItemRecord(2, "Metal Fragments", 1000, null, null, null, null, null, null), ], [], + [], []); private static ItemDataset WithRaid(params RaidTarget[] raid) => - new(3, Good().Sources, Good().Items, raid, []); + new(3, Good().Sources, Good().Items, raid, [], []); private static ItemDataset WithSmelters(params Smelter[] smelters) => - new(4, Good().Sources, Good().Items, [], smelters); + new(4, Good().Sources, Good().Items, [], smelters, []); + + private static ItemDataset WithCctv(params CctvMonument[] cctv) => + new(5, Good().Sources, Good().Items, [], [], cctv); /// A dataset with all referential constraints satisfied should produce no errors. [Fact] @@ -49,6 +53,7 @@ public void UnresolvableYieldId_isError() new RecycleYield([new YieldEntry(99999, 4, 1.0)]), null, null, null, null), ], [], + [], []); var errors = DatasetValidator.Validate(bad, new ValidationOptions(MinItemCount: 1)); Assert.Contains(errors, e => e.Contains("99999", StringComparison.Ordinal)); @@ -64,6 +69,7 @@ public void UnresolvableCraftIngredientId_isError() new CraftRecipe([new Ingredient(88888, 100)], 30.0, 3), null, null, null), ], [], + [], []); var errors = DatasetValidator.Validate(bad, new ValidationOptions(MinItemCount: 1)); Assert.Contains(errors, e => e.Contains("88888", StringComparison.Ordinal)); @@ -79,6 +85,7 @@ public void UnresolvableUpkeepId_isError() new UpkeepCost([new UpkeepEntry(77777, 8, 25)])), ], [], + [], []); var errors = DatasetValidator.Validate(bad, new ValidationOptions(MinItemCount: 1)); Assert.Contains(errors, e => e.Contains("77777", StringComparison.Ordinal)); @@ -94,6 +101,7 @@ public void UpkeepMinGreaterThanMax_isError() new UpkeepCost([new UpkeepEntry(1, 25, 8)])), ], [], + [], []); var errors = DatasetValidator.Validate(bad, new ValidationOptions(MinItemCount: 1)); Assert.Contains(errors, e => e.Contains("upkeep", StringComparison.OrdinalIgnoreCase)); @@ -109,6 +117,7 @@ public void NegativeDecay_isError() new DecayInfo(-900, null, null, null, 100), null), ], [], + [], []); var errors = DatasetValidator.Validate(bad, new ValidationOptions(MinItemCount: 1)); Assert.Contains(errors, e => e.Contains("decay", StringComparison.OrdinalIgnoreCase)); @@ -175,4 +184,36 @@ public void TooFewSmelters_isError() var errors = DatasetValidator.Validate(Good(), new ValidationOptions(MinItemCount: 1, MinSmelterCount: 8)); Assert.Contains(errors, e => e.Contains("smelter count", StringComparison.OrdinalIgnoreCase)); } + + [Fact] + public void GoodCctv_hasNoErrors() + { + var ds = WithCctv(new CctvMonument("Dome", ["DOME1"], false)); + var errors = DatasetValidator.Validate(ds, new ValidationOptions(MinItemCount: 1, MinCctvCount: 1)); + Assert.Empty(errors); + } + + [Fact] + public void TooFewMonuments_isError() + { + var ds = WithCctv(new CctvMonument("Dome", ["DOME1"], false)); + var errors = DatasetValidator.Validate(ds, new ValidationOptions(MinItemCount: 1, MinCctvCount: 8)); + Assert.Contains(errors, e => e.Contains("cctv monument count", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void MonumentWithNoCodes_isError() + { + var ds = WithCctv(new CctvMonument("Dome", [], false)); + var errors = DatasetValidator.Validate(ds, new ValidationOptions(MinItemCount: 1, MinCctvCount: 1)); + Assert.Contains(errors, e => e.Contains("no codes", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void MonumentWithEmptyCode_isError() + { + var ds = WithCctv(new CctvMonument("Dome", [" "], false)); + var errors = DatasetValidator.Validate(ds, new ValidationOptions(MinItemCount: 1, MinCctvCount: 1)); + Assert.Contains(errors, e => e.Contains("empty code", StringComparison.OrdinalIgnoreCase)); + } } diff --git a/tests/RustPlusBot.ItemData.Generator.Tests/OfflineCctvSourceTests.cs b/tests/RustPlusBot.ItemData.Generator.Tests/OfflineCctvSourceTests.cs new file mode 100644 index 00000000..773cd282 --- /dev/null +++ b/tests/RustPlusBot.ItemData.Generator.Tests/OfflineCctvSourceTests.cs @@ -0,0 +1,47 @@ +using RustPlusBot.ItemData.Generator.Sources; + +namespace RustPlusBot.ItemData.Generator.Tests; + +public sealed class OfflineCctvSourceTests +{ + private static OfflineCctvSource SourceWith(string json) + { + var path = Path.GetTempFileName(); + File.WriteAllText(path, json); + return new OfflineCctvSource(path); + } + + [Fact] + public void LoadMonuments_ProjectsCodesInOrder_AndDynamicFlag() + { + const string json = """ + { + "Small Oil Rig": { "codes": ["OILRIG1HELI", "OILRIG1DOCK"], "dynamic": false } + } + """; + var monument = Assert.Single(SourceWith(json).LoadMonuments()); + Assert.Equal("Small Oil Rig", monument.Name); + Assert.Equal(["OILRIG1HELI", "OILRIG1DOCK"], monument.Codes); + Assert.False(monument.Dynamic); + } + + [Fact] + public void LoadMonuments_UnescapesAsterisks_PreservingCount() + { + const string json = """ + { + "Underwater Labs": { "codes": ["AUXPOWER\\*\\*\\*\\*"], "dynamic": true } + } + """; + var monument = Assert.Single(SourceWith(json).LoadMonuments()); + Assert.True(monument.Dynamic); + Assert.Equal("AUXPOWER****", monument.Codes[0]); + } + + [Fact] + public void LoadMonuments_DropsMonumentWithNoCodes() + { + const string json = """{ "Empty": { "codes": [], "dynamic": false } }"""; + Assert.Empty(SourceWith(json).LoadMonuments()); + } +} diff --git a/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs b/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs index c4fe7b3e..90a45464 100644 --- a/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs +++ b/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs @@ -42,6 +42,6 @@ public void English_covers_every_french_key() [Fact] public void Catalog_has_expected_key_count() { - Assert.Equal(244, EnglishKeys().Count); + Assert.Equal(248, EnglishKeys().Count); } } diff --git a/tools/RustPlusBot.ItemData.Generator/Program.cs b/tools/RustPlusBot.ItemData.Generator/Program.cs index 2b132448..ccf60132 100644 --- a/tools/RustPlusBot.ItemData.Generator/Program.cs +++ b/tools/RustPlusBot.ItemData.Generator/Program.cs @@ -16,6 +16,7 @@ internal static class Program private static readonly DateOnly UpkeepAsOf = new(2024, 9, 7); private static readonly DateOnly DurabilityAsOf = new(2024, 9, 7); private static readonly DateOnly SmeltingAsOf = new(2023, 11, 5); + private static readonly DateOnly CctvAsOf = new(2025, 11, 12); private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { @@ -83,10 +84,14 @@ internal static int Main(string[] args) var smeltingSource = new OfflineSmeltingSource( Path.Combine(rustplusDir, "rustlabsSmeltingData.json")); + var cctvSource = new OfflineCctvSource(Path.Combine(rustplusDir, "cctv.json")); + var raidTargets = durabilitySource.LoadRaidTargets(names); Console.WriteLine($"Loaded {raidTargets.Count} raid targets"); var smelters = smeltingSource.LoadSmelters(names); Console.WriteLine($"Loaded {smelters.Count} smelters"); + var cctvMonuments = cctvSource.LoadMonuments(); + Console.WriteLine($"Loaded {cctvMonuments.Count} cctv monuments"); var nameIds = new HashSet(names.Keys); @@ -120,15 +125,16 @@ internal static int Main(string[] args) .ToList(); var dataset = new ItemDataset( - 4, + 5, new DatasetSources(NamesAsOf, RecycleAsOf, CraftAsOf, ResearchAsOf, DecayAsOf, UpkeepAsOf, DurabilityAsOf, - SmeltingAsOf), + SmeltingAsOf, CctvAsOf), items, raidTargets, - smelters); + smelters, + cctvMonuments); var validationOptions = new ValidationOptions(MinItemCount: minItems, MinRaidTargetCount: 300, - MinSmelterCount: 8); + MinSmelterCount: 8, MinCctvCount: 8); var errors = DatasetValidator.Validate(dataset, validationOptions); if (errors.Count > 0) { diff --git a/tools/RustPlusBot.ItemData.Generator/README.md b/tools/RustPlusBot.ItemData.Generator/README.md index 4c37dfcb..497efe7e 100644 --- a/tools/RustPlusBot.ItemData.Generator/README.md +++ b/tools/RustPlusBot.ItemData.Generator/README.md @@ -2,7 +2,7 @@ A maintainer CLI that regenerates the embedded Rust item dataset (`src/RustPlusBot.Features.ItemData/Data/item-data.json`) consumed by the bot's -`/item`, `/recycle`, `/craft`, `/research`, `/decay`, `/upkeep`, `/durability`, and `/smelt` calculators. +`/item`, `/recycle`, `/craft`, `/research`, `/decay`, `/upkeep`, `/durability`, `/smelt`, and `/cctv` calculators. This tool is **not** part of the running bot — it is referenced by nothing in the application graph. It exists so the bundled item data can be refreshed when @@ -39,6 +39,7 @@ garbage** if the upstream shape drifts. | `rustlabsUpkeepData.json` | upkeep cost (resource + quantity range) | | `rustlabsDurabilityData.json` | raid cost (explosives only — trimmed) | | `rustlabsSmeltingData.json` | smelting/cooking conversions (per smelter) | + | `cctv.json` | monument CCTV camera codes | 2. Projects them into our own typed schema (`ItemDataset` / `ItemRecord` / …, defined in `RustPlusBot.Features.ItemData`), keyed by item id, @@ -92,7 +93,7 @@ tests assert known items resolve correctly. The source dates stamped into the dataset are currently hard-coded constants in [`Program.cs`](Program.cs) (`NamesAsOf`, `RecycleAsOf`, `CraftAsOf`, -`ResearchAsOf`, `DecayAsOf`, `UpkeepAsOf`, `DurabilityAsOf`, `SmeltingAsOf`). +`ResearchAsOf`, `DecayAsOf`, `UpkeepAsOf`, `DurabilityAsOf`, `SmeltingAsOf`, `CctvAsOf`). Update them when you refresh from newer upstream data. ## Notes @@ -112,6 +113,9 @@ Update them when you refresh from newer upstream data. resolved by name like raid targets. Its provenance (`SmeltingAsOf`, 2023-11-05) lags the other rustlabs sources because the upstream file has not changed since then. +- CCTV data (`cctv.json`, ~2 KB) is keyed by **monument name** and projected into a dedicated + `Cctv` table (`OfflineCctvSource`), resolved by name. Codes are un-escaped (`\*` → `*`) during the + transform. Its provenance (`CctvAsOf`, 2025-11-12) is the upstream last-change date for the file. ## Attribution diff --git a/tools/RustPlusBot.ItemData.Generator/Sources/ICctvSource.cs b/tools/RustPlusBot.ItemData.Generator/Sources/ICctvSource.cs new file mode 100644 index 00000000..5fc8752c --- /dev/null +++ b/tools/RustPlusBot.ItemData.Generator/Sources/ICctvSource.cs @@ -0,0 +1,11 @@ +using RustPlusBot.Features.ItemData.Data; + +namespace RustPlusBot.ItemData.Generator.Sources; + +/// Provides monument CCTV camera codes from the rustplusplus static data. +internal interface ICctvSource +{ + /// Loads every monument with at least one camera code. + /// The monuments, in source order, with un-escaped codes. + IReadOnlyList LoadMonuments(); +} diff --git a/tools/RustPlusBot.ItemData.Generator/Sources/OfflineCctvSource.cs b/tools/RustPlusBot.ItemData.Generator/Sources/OfflineCctvSource.cs new file mode 100644 index 00000000..9c866d14 --- /dev/null +++ b/tools/RustPlusBot.ItemData.Generator/Sources/OfflineCctvSource.cs @@ -0,0 +1,54 @@ +using System.Text.Json; +using RustPlusBot.Features.ItemData.Data; + +namespace RustPlusBot.ItemData.Generator.Sources; + +/// Reads monument CCTV codes from the offline cctv.json file, keyed by monument name. +/// Path to the cctv.json file. +internal sealed class OfflineCctvSource(string CctvFilePath) : ICctvSource +{ + /// + public IReadOnlyList LoadMonuments() + { + using var stream = File.OpenRead(CctvFilePath); + using var doc = JsonDocument.Parse(stream); + + var monuments = new List(); + foreach (var prop in doc.RootElement.EnumerateObject()) + { + var codes = ReadCodes(prop.Value); + if (codes.Count == 0) + { + continue; // monument with no codes — drop + } + + var dynamic = prop.Value.TryGetProperty("dynamic", out var d) && d.ValueKind == JsonValueKind.True; + monuments.Add(new CctvMonument(prop.Name, codes, dynamic)); + } + + return monuments; + } + + private static List ReadCodes(JsonElement entry) + { + var codes = new List(); + if (!entry.TryGetProperty("codes", out var arr) || arr.ValueKind != JsonValueKind.Array) + { + return codes; + } + + foreach (var element in arr.EnumerateArray()) + { + if (element.ValueKind == JsonValueKind.String && element.GetString() is { Length: > 0 } raw) + { + codes.Add(Unescape(raw)); + } + } + + return codes; + } + + /// Strips the markdown asterisk-escape used in cctv.json (COMPOUND\*\*…COMPOUND**…). + /// The raw camera code string from the JSON. + private static string Unescape(string code) => code.Replace("\\*", "*", StringComparison.Ordinal); +} diff --git a/tools/RustPlusBot.ItemData.Generator/Validation/DatasetValidator.cs b/tools/RustPlusBot.ItemData.Generator/Validation/DatasetValidator.cs index 266d6209..2a77ff54 100644 --- a/tools/RustPlusBot.ItemData.Generator/Validation/DatasetValidator.cs +++ b/tools/RustPlusBot.ItemData.Generator/Validation/DatasetValidator.cs @@ -6,7 +6,12 @@ namespace RustPlusBot.ItemData.Generator.Validation; /// The minimum number of items the dataset must contain. /// The minimum number of raid targets the dataset must contain. /// The minimum number of smelters the dataset must contain. -internal sealed record ValidationOptions(int MinItemCount, int MinRaidTargetCount = 0, int MinSmelterCount = 0); +/// The minimum number of CCTV monuments the dataset must contain. +internal sealed record ValidationOptions( + int MinItemCount, + int MinRaidTargetCount = 0, + int MinSmelterCount = 0, + int MinCctvCount = 0); /// Validates an for structural integrity. internal static class DatasetValidator @@ -146,6 +151,30 @@ public static IReadOnlyList Validate(ItemDataset dataset, ValidationOpti } } + var cctv = dataset.Cctv ?? []; + if (cctv.Count < options.MinCctvCount) + { + errors.Add($"cctv monument count {cctv.Count} below minimum {options.MinCctvCount}"); + } + + foreach (var monument in cctv) + { + if (string.IsNullOrWhiteSpace(monument.Name)) + { + errors.Add("cctv monument has an empty name"); + } + + if (monument.Codes.Count == 0) + { + errors.Add($"cctv monument {monument.Name}: has no codes"); + } + + foreach (var code in monument.Codes.Where(string.IsNullOrWhiteSpace)) + { + errors.Add($"cctv monument {monument.Name}: has an empty code"); + } + } + return errors; } }