diff --git a/Directory.Packages.props b/Directory.Packages.props index 12c2b1e2..b0931dae 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,7 +13,8 @@ - + + @@ -22,10 +23,13 @@ + + + diff --git a/src/RustPlusBot.Features.Map/Assets/MapIcons.cs b/src/RustPlusBot.Features.Map/Assets/MapIcons.cs index eedd7d30..4111c26c 100644 --- a/src/RustPlusBot.Features.Map/Assets/MapIcons.cs +++ b/src/RustPlusBot.Features.Map/Assets/MapIcons.cs @@ -1,6 +1,5 @@ using System.Collections.Concurrent; using RustPlusBot.Abstractions.Connections; -using RustPlusBot.Abstractions.Events; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; @@ -8,8 +7,9 @@ namespace RustPlusBot.Features.Map.Assets; /// -/// Loads vendored icon assets (embedded PNGs) once and serves them by marker kind or monument token. +/// Loads vendored icon assets (embedded PNGs) once and serves them by marker kind. /// Cached images are immutable inputs the renderer draws from; never mutate them. +/// Monument and rig icons live in . /// /// /// MapIcons.Marker includes a TravellingVendor arm that returns the embedded vendor.png. @@ -36,33 +36,6 @@ public static class MapIcons /// The cached scaled icon, or null. public static Image? Marker(MarkerKind kind, int size) => Scaled(KeyFor(kind), size); - /// Gets the rig icon for a rig kind, or null when there is no icon for that kind. - /// Which rig. - /// Whether the rig is currently active (reserved; activation styling is applied by the renderer). - /// The cached rig icon image, or null. - /// - /// The parameter is reserved for future use: the renderer is responsible - /// for overlaying activation styling; this method always returns the base icon regardless of state. - /// -#pragma warning disable RCS1163, IDE0060 // Unused parameter — 'active' is part of the public API contract; activation styling is applied by the renderer, not here. - public static Image? Rig(RigKind kind, bool active) => kind switch - { - RigKind.Small => Load("oilrig"), - RigKind.Large => Load("largeoilrig"), - _ => null, - }; -#pragma warning restore RCS1163, IDE0060 - - /// Gets the rig icon scaled to fit a square box, or null when the kind has no icon. - /// Which rig. - /// Whether the rig is active (reserved; styling is applied by the renderer). - /// The box edge length in pixels. - /// The cached scaled icon, or null. -#pragma warning disable RCS1163, IDE0060 // 'active' is part of the API contract; styling is the renderer's job. - public static Image? Rig(RigKind kind, bool active, int size) => - Scaled(kind switch { RigKind.Small => "oilrig", RigKind.Large => "largeoilrig", _ => null }, size); -#pragma warning restore RCS1163, IDE0060 - /// Gets the travelling vendor icon. /// The cached vendor icon image, or null. public static Image? Vendor() => Load("vendor"); @@ -76,21 +49,6 @@ public static class MapIcons /// The cached scaled icon, or null. public static Image? Player(int size) => Scaled("player", size); - /// Gets the icon for a monument token, or null when the token is unmapped or the file is missing. - /// The Rust+ monument protobuf token. - /// The cached icon image, or null. - public static Image? Monument(string token) - { - var key = MonumentIconMap.IconKeyFor(token); - return key is null ? null : Load(key); - } - - /// Gets the monument icon scaled to fit a square box, or null for unmapped tokens. - /// The Rust+ monument protobuf token. - /// The box edge length in pixels. - /// The cached scaled icon, or null. - public static Image? Monument(string token, int size) => Scaled(MonumentIconMap.IconKeyFor(token), size); - private static string? KeyFor(MarkerKind kind) => kind switch { MarkerKind.CargoShip => "cargo", diff --git a/src/RustPlusBot.Features.Map/Assets/MonumentIconMap.cs b/src/RustPlusBot.Features.Map/Assets/MonumentIconMap.cs deleted file mode 100644 index 04842d3a..00000000 --- a/src/RustPlusBot.Features.Map/Assets/MonumentIconMap.cs +++ /dev/null @@ -1,49 +0,0 @@ -namespace RustPlusBot.Features.Map.Assets; - -/// Maps a Rust+ monument protobuf token to a vendored icon key (filename without extension). Unknown tokens return null and are skipped. -public static class MonumentIconMap -{ - private static readonly Dictionary Map = new(StringComparer.Ordinal) - { - ["AbandonedMilitaryBase"] = "militarybase", - ["airfield_display_name"] = "airfield", - ["arctic_base_a"] = "arcticresearch", - ["bandit_camp"] = "banditcamp", - ["dome_monument_name"] = "dome", - ["excavator"] = "excavator", - ["ferryterminal"] = "ferryterminal", - ["fishing_village_display_name"] = "fishingvillage", - ["large_fishing_village_display_name"] = "fishingvillagelarge", - ["gas_station"] = "gasstation", - ["harbor_display_name"] = "harbour", - ["harbor_2_display_name"] = "harbour2", - ["junkyard_display_name"] = "junkyard", - ["large_oil_rig"] = "largeoilrig", - ["oil_rig_small"] = "oilrig", - ["launchsite"] = "launchsite", - ["lighthouse_display_name"] = "lighthouse", - ["military_tunnels_display_name"] = "militarytunnel", - ["mining_outpost_display_name"] = "miningoutpost", - ["mining_quarry_hqm_display_name"] = "hqmquarry", - ["mining_quarry_stone_display_name"] = "stonequarry", - ["mining_quarry_sulfur_display_name"] = "sulfurquarry", - ["missile_silo_monument"] = "missilesilo", - ["outpost"] = "outpost", - ["power_plant_display_name"] = "powerplant", - ["satellite_dish_display_name"] = "satellitedish", - ["sewer_display_name"] = "sewerbranch", - ["stables_a"] = "stable", - ["stables_b"] = "stable", - ["supermarket"] = "supermarket", - ["swamp_c"] = "swamp", - ["train_yard_display_name"] = "trainyard", - ["train_tunnel_display_name"] = "traintunnel", - ["water_treatment_plant_display_name"] = "watertreatment", - }; - - /// Gets the icon key for a monument token, or null when unmapped. - /// The Rust+ monument protobuf token. - /// The icon key (filename without extension), or null. - public static string? IconKeyFor(string token) => - token is not null && Map.TryGetValue(token, out var key) ? key : null; -} diff --git a/src/RustPlusBot.Features.Map/Assets/MonumentIconSource.cs b/src/RustPlusBot.Features.Map/Assets/MonumentIconSource.cs new file mode 100644 index 00000000..a3170bda --- /dev/null +++ b/src/RustPlusBot.Features.Map/Assets/MonumentIconSource.cs @@ -0,0 +1,127 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; +using RustMapsApi.V4.Assets; +using RustMapsApi.V4.Models; +using RustPlusBot.Abstractions.Events; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SkiaSharp; +using Svg.Skia; + +namespace RustPlusBot.Features.Map.Assets; + +/// +/// Serves monument icons from the RustMaps asset package: resolves a Rust+ token to a +/// , rasterizes the SVG at the requested size, and caches the result +/// (including null misses). Rasterization runs exactly once per (type, size), even when concurrent +/// first requests race. Cached images are immutable inputs the renderer draws from; never mutate them. +/// Register as a singleton. +/// +/// The RustMaps monument asset source. +/// Logs tokens that resolve to no icon, once per token per process. +public sealed partial class MonumentIconSource(IMonumentAssetSource assets, ILogger logger) +{ + private readonly ConcurrentDictionary<(MonumentType Type, int Size), Lazy?>> _cache = new(); + private readonly ConcurrentDictionary _reported = new(StringComparer.Ordinal); + + /// Gets the icon for a monument token scaled to a square box, or null when no icon exists. + /// The Rust+ monument protobuf token (or prefab name). + /// The box edge length in pixels. + /// The cached icon, or null (reported once per token). + public Image? Monument(string token, int size) + { + var type = MonumentTokenMap.TypeFor(token); + if (type is null) + { + ReportOnce(token, type: null); + return null; + } + + return For(type.Value, token, size); + } + + /// Gets the rig icon scaled to a square box, or null for an unknown kind. + /// Which rig. + /// The box edge length in pixels. + /// The cached rig icon, or null. + public Image? Rig(RigKind kind, int size) => kind switch + { + RigKind.Small => For(MonumentType.OilrigSmall, "oil_rig_small", size), + RigKind.Large => For(MonumentType.OilrigLarge, "large_oil_rig", size), + _ => null, + }; + + private Image? For(MonumentType type, string token, int size) + { + // GetOrAdd's value factory may run more than once under a first-request race; the Lazy wrapper + // (ExecutionAndPublication) guarantees Rasterize itself runs exactly once per key, so no losing + // image is leaked and the rasterization-failure Warning fires at most once per (type, size). + var image = _cache + .GetOrAdd((type, size), key => new Lazy?>(() => Rasterize(key.Type, key.Size))) + .Value; + if (image is null) + { + ReportOnce(token, type); + } + + return image; + } + + private void ReportOnce(string token, MonumentType? type) + { + if (_reported.TryAdd(token, 0)) + { + LogNoIcon(token, type); + } + } + + private Image? Rasterize(MonumentType type, int size) + { + if (!assets.TryGetAsset(type, out var asset)) + { + return null; + } + + try + { + using var svg = new SKSvg(); + using (var stream = asset.OpenStream()) + { + if (svg.Load(stream) is null) + { + return null; + } + } + + var bounds = svg.Picture!.CullRect; + using var bitmap = new SKBitmap(new SKImageInfo(size, size, SKColorType.Rgba8888, SKAlphaType.Premul)); + using (var canvas = new SKCanvas(bitmap)) + { + canvas.Clear(SKColors.Transparent); + canvas.Scale(size / bounds.Width, size / bounds.Height); + canvas.Translate(-bounds.Left, -bounds.Top); + canvas.DrawPicture(svg.Picture); + } + + using var skImage = SKImage.FromBitmap(bitmap); + using var png = skImage.Encode(SKEncodedImageFormat.Png, 100); + using var ms = png.AsStream(); + return Image.Load(ms); + } +#pragma warning disable CA1031 // Broad catch: a bad SVG must degrade to "no icon", never break a render. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogRasterFailed(type, ex); + return null; + } + } + + [LoggerMessage(Level = LogLevel.Information, + Message = "No monument icon for token \"{Token}\" (mapped type: {Type}); skipped.")] + private partial void LogNoIcon(string token, MonumentType? type); + + [LoggerMessage(Level = LogLevel.Warning, + Message = "Rasterizing the {Type} icon failed; the monument renders without an icon.")] + private partial void LogRasterFailed(MonumentType type, Exception ex); +} diff --git a/src/RustPlusBot.Features.Map/Assets/MonumentTokenMap.cs b/src/RustPlusBot.Features.Map/Assets/MonumentTokenMap.cs new file mode 100644 index 00000000..5a9217e7 --- /dev/null +++ b/src/RustPlusBot.Features.Map/Assets/MonumentTokenMap.cs @@ -0,0 +1,85 @@ +using RustMapsApi.V4.Models; + +namespace RustPlusBot.Features.Map.Assets; + +/// +/// Maps a Rust+ monument protobuf token to the RustMaps whose icon +/// represents it. Unknown tokens return null; the caller decides how to report them. +/// +/// +/// Token list sourced from the Rust+ app protocol as catalogued by the previous render map plus the +/// rustplusplus and rustplus.py bot projects. Swamps and underwater labs have no fixed token — Rust+ +/// sends prefab names — so those two families match by prefix. Several +/// values share one asset (e.g. both harbors → Harbor); where the exact variant is unknowable from +/// the token, the pick is arbitrary but fixed. +/// +public static class MonumentTokenMap +{ + private static readonly Dictionary Map = new(StringComparer.Ordinal) + { + ["AbandonedMilitaryBase"] = MonumentType.MilitaryBaseA, + ["airfield_display_name"] = MonumentType.Airfield, + ["arctic_base_a"] = MonumentType.ArcticResearchBaseA, + ["arctic_base_b"] = MonumentType.ArcticResearchBaseA, + ["bandit_camp"] = MonumentType.BanditTown, + ["dome_monument_name"] = MonumentType.SphereTank, + ["excavator"] = MonumentType.Excavator, + ["ferryterminal"] = MonumentType.FerryTerminal1, + ["fishing_village_display_name"] = MonumentType.FishingVillageA, + ["large_fishing_village_display_name"] = MonumentType.FishingVillageB, + ["gas_station"] = MonumentType.Gasstation, + ["harbor_display_name"] = MonumentType.HarborLarge, + ["harbor_2_display_name"] = MonumentType.HarborSmall, + ["jungle_ziggurat"] = MonumentType.JungleZigguratA, + ["junkyard_display_name"] = MonumentType.Junkyard, + ["large_oil_rig"] = MonumentType.OilrigLarge, + ["oil_rig_small"] = MonumentType.OilrigSmall, + ["launchsite"] = MonumentType.LaunchSite, + ["lighthouse_display_name"] = MonumentType.Lighthouse, + ["military_tunnels_display_name"] = MonumentType.MilitaryTunnels, + ["mining_outpost_display_name"] = MonumentType.Warehouse, + ["mining_quarry_hqm_display_name"] = MonumentType.HqmQuarry, + ["mining_quarry_stone_display_name"] = MonumentType.StoneQuarry, + ["mining_quarry_sulfur_display_name"] = MonumentType.SulfurQuarry, + ["missile_silo_monument"] = MonumentType.NuclearMissileSilo, + ["outpost"] = MonumentType.Outpost, + ["power_plant_display_name"] = MonumentType.Powerplant, + ["radtown"] = MonumentType.Radtown, + ["satellite_dish_display_name"] = MonumentType.SatelliteDish, + ["sewer_display_name"] = MonumentType.SewerBranch, + ["stables_a"] = MonumentType.StablesA, + ["stables_b"] = MonumentType.StablesB, + ["supermarket"] = MonumentType.Supermarket, + ["train_tunnel_display_name"] = MonumentType.TunnelEntrance, + ["train_tunnel_link_display_name"] = MonumentType.TunnelEntranceTransition, + ["train_yard_display_name"] = MonumentType.Trainyard, + ["water_treatment_plant_display_name"] = MonumentType.WaterTreatment, + }; + + /// All exactly-mapped tokens (excludes the prefix families); exposed for drift-guard tests. + internal static IReadOnlyCollection KnownTokens => Map.Keys; + + /// Gets the RustMaps monument type for a Rust+ token, or null when unmapped. + /// The Rust+ monument protobuf token (or prefab name). + /// The monument type whose asset represents the token, or null. + public static MonumentType? TypeFor(string? token) + { + if (string.IsNullOrEmpty(token)) + { + return null; + } + + if (Map.TryGetValue(token, out var type)) + { + return type; + } + + // Rust+ sends prefab names (not fixed tokens) for these families. + if (token.StartsWith("swamp", StringComparison.Ordinal)) + { + return MonumentType.SwampC; + } + + return token.StartsWith("underwater_lab", StringComparison.Ordinal) ? MonumentType.UnderwaterA : null; + } +} diff --git a/src/RustPlusBot.Features.Map/Assets/icons/airfield.png b/src/RustPlusBot.Features.Map/Assets/icons/airfield.png deleted file mode 100644 index d89829a9..00000000 Binary files a/src/RustPlusBot.Features.Map/Assets/icons/airfield.png and /dev/null differ diff --git a/src/RustPlusBot.Features.Map/Assets/icons/arcticresearch.png b/src/RustPlusBot.Features.Map/Assets/icons/arcticresearch.png deleted file mode 100644 index 60c1e343..00000000 Binary files a/src/RustPlusBot.Features.Map/Assets/icons/arcticresearch.png and /dev/null differ diff --git a/src/RustPlusBot.Features.Map/Assets/icons/banditcamp.png b/src/RustPlusBot.Features.Map/Assets/icons/banditcamp.png deleted file mode 100644 index 9a02f53d..00000000 Binary files a/src/RustPlusBot.Features.Map/Assets/icons/banditcamp.png and /dev/null differ diff --git a/src/RustPlusBot.Features.Map/Assets/icons/dome.png b/src/RustPlusBot.Features.Map/Assets/icons/dome.png deleted file mode 100644 index c8141885..00000000 Binary files a/src/RustPlusBot.Features.Map/Assets/icons/dome.png and /dev/null differ diff --git a/src/RustPlusBot.Features.Map/Assets/icons/excavator.png b/src/RustPlusBot.Features.Map/Assets/icons/excavator.png deleted file mode 100644 index b2b58da2..00000000 Binary files a/src/RustPlusBot.Features.Map/Assets/icons/excavator.png and /dev/null differ diff --git a/src/RustPlusBot.Features.Map/Assets/icons/ferryterminal.png b/src/RustPlusBot.Features.Map/Assets/icons/ferryterminal.png deleted file mode 100644 index e2766a49..00000000 Binary files a/src/RustPlusBot.Features.Map/Assets/icons/ferryterminal.png and /dev/null differ diff --git a/src/RustPlusBot.Features.Map/Assets/icons/fishingvillage.png b/src/RustPlusBot.Features.Map/Assets/icons/fishingvillage.png deleted file mode 100644 index aa26b819..00000000 Binary files a/src/RustPlusBot.Features.Map/Assets/icons/fishingvillage.png and /dev/null differ diff --git a/src/RustPlusBot.Features.Map/Assets/icons/fishingvillagelarge.png b/src/RustPlusBot.Features.Map/Assets/icons/fishingvillagelarge.png deleted file mode 100644 index 5f40bbe9..00000000 Binary files a/src/RustPlusBot.Features.Map/Assets/icons/fishingvillagelarge.png and /dev/null differ diff --git a/src/RustPlusBot.Features.Map/Assets/icons/gasstation.png b/src/RustPlusBot.Features.Map/Assets/icons/gasstation.png deleted file mode 100644 index 2a5363ea..00000000 Binary files a/src/RustPlusBot.Features.Map/Assets/icons/gasstation.png and /dev/null differ diff --git a/src/RustPlusBot.Features.Map/Assets/icons/harbour.png b/src/RustPlusBot.Features.Map/Assets/icons/harbour.png deleted file mode 100644 index a625d7de..00000000 Binary files a/src/RustPlusBot.Features.Map/Assets/icons/harbour.png and /dev/null differ diff --git a/src/RustPlusBot.Features.Map/Assets/icons/harbour2.png b/src/RustPlusBot.Features.Map/Assets/icons/harbour2.png deleted file mode 100644 index 96c5cb0c..00000000 Binary files a/src/RustPlusBot.Features.Map/Assets/icons/harbour2.png and /dev/null differ diff --git a/src/RustPlusBot.Features.Map/Assets/icons/hqmquarry.png b/src/RustPlusBot.Features.Map/Assets/icons/hqmquarry.png deleted file mode 100644 index 001a2923..00000000 Binary files a/src/RustPlusBot.Features.Map/Assets/icons/hqmquarry.png and /dev/null differ diff --git a/src/RustPlusBot.Features.Map/Assets/icons/junkyard.png b/src/RustPlusBot.Features.Map/Assets/icons/junkyard.png deleted file mode 100644 index eb4aab2e..00000000 Binary files a/src/RustPlusBot.Features.Map/Assets/icons/junkyard.png and /dev/null differ diff --git a/src/RustPlusBot.Features.Map/Assets/icons/largeoilrig.png b/src/RustPlusBot.Features.Map/Assets/icons/largeoilrig.png deleted file mode 100644 index 637d5d19..00000000 Binary files a/src/RustPlusBot.Features.Map/Assets/icons/largeoilrig.png and /dev/null differ diff --git a/src/RustPlusBot.Features.Map/Assets/icons/launchsite.png b/src/RustPlusBot.Features.Map/Assets/icons/launchsite.png deleted file mode 100644 index c1b86a7c..00000000 Binary files a/src/RustPlusBot.Features.Map/Assets/icons/launchsite.png and /dev/null differ diff --git a/src/RustPlusBot.Features.Map/Assets/icons/lighthouse.png b/src/RustPlusBot.Features.Map/Assets/icons/lighthouse.png deleted file mode 100644 index fd715f53..00000000 Binary files a/src/RustPlusBot.Features.Map/Assets/icons/lighthouse.png and /dev/null differ diff --git a/src/RustPlusBot.Features.Map/Assets/icons/militarybase.png b/src/RustPlusBot.Features.Map/Assets/icons/militarybase.png deleted file mode 100644 index c6d31d78..00000000 Binary files a/src/RustPlusBot.Features.Map/Assets/icons/militarybase.png and /dev/null differ diff --git a/src/RustPlusBot.Features.Map/Assets/icons/militarytunnel.png b/src/RustPlusBot.Features.Map/Assets/icons/militarytunnel.png deleted file mode 100644 index d1b893c5..00000000 Binary files a/src/RustPlusBot.Features.Map/Assets/icons/militarytunnel.png and /dev/null differ diff --git a/src/RustPlusBot.Features.Map/Assets/icons/miningoutpost.png b/src/RustPlusBot.Features.Map/Assets/icons/miningoutpost.png deleted file mode 100644 index 96d2c92a..00000000 Binary files a/src/RustPlusBot.Features.Map/Assets/icons/miningoutpost.png and /dev/null differ diff --git a/src/RustPlusBot.Features.Map/Assets/icons/missilesilo.png b/src/RustPlusBot.Features.Map/Assets/icons/missilesilo.png deleted file mode 100644 index fc5cf690..00000000 Binary files a/src/RustPlusBot.Features.Map/Assets/icons/missilesilo.png and /dev/null differ diff --git a/src/RustPlusBot.Features.Map/Assets/icons/oilrig.png b/src/RustPlusBot.Features.Map/Assets/icons/oilrig.png deleted file mode 100644 index cab6c80d..00000000 Binary files a/src/RustPlusBot.Features.Map/Assets/icons/oilrig.png and /dev/null differ diff --git a/src/RustPlusBot.Features.Map/Assets/icons/outpost.png b/src/RustPlusBot.Features.Map/Assets/icons/outpost.png deleted file mode 100644 index 6c790550..00000000 Binary files a/src/RustPlusBot.Features.Map/Assets/icons/outpost.png and /dev/null differ diff --git a/src/RustPlusBot.Features.Map/Assets/icons/powerplant.png b/src/RustPlusBot.Features.Map/Assets/icons/powerplant.png deleted file mode 100644 index 7b5689eb..00000000 Binary files a/src/RustPlusBot.Features.Map/Assets/icons/powerplant.png and /dev/null differ diff --git a/src/RustPlusBot.Features.Map/Assets/icons/powerstation.png b/src/RustPlusBot.Features.Map/Assets/icons/powerstation.png deleted file mode 100644 index 0c4584bd..00000000 Binary files a/src/RustPlusBot.Features.Map/Assets/icons/powerstation.png and /dev/null differ diff --git a/src/RustPlusBot.Features.Map/Assets/icons/satellitedish.png b/src/RustPlusBot.Features.Map/Assets/icons/satellitedish.png deleted file mode 100644 index c5f8840a..00000000 Binary files a/src/RustPlusBot.Features.Map/Assets/icons/satellitedish.png and /dev/null differ diff --git a/src/RustPlusBot.Features.Map/Assets/icons/sewerbranch.png b/src/RustPlusBot.Features.Map/Assets/icons/sewerbranch.png deleted file mode 100644 index 29a9acf7..00000000 Binary files a/src/RustPlusBot.Features.Map/Assets/icons/sewerbranch.png and /dev/null differ diff --git a/src/RustPlusBot.Features.Map/Assets/icons/stable.png b/src/RustPlusBot.Features.Map/Assets/icons/stable.png deleted file mode 100644 index 78492f85..00000000 Binary files a/src/RustPlusBot.Features.Map/Assets/icons/stable.png and /dev/null differ diff --git a/src/RustPlusBot.Features.Map/Assets/icons/stonequarry.png b/src/RustPlusBot.Features.Map/Assets/icons/stonequarry.png deleted file mode 100644 index 75050dd0..00000000 Binary files a/src/RustPlusBot.Features.Map/Assets/icons/stonequarry.png and /dev/null differ diff --git a/src/RustPlusBot.Features.Map/Assets/icons/sulfurquarry.png b/src/RustPlusBot.Features.Map/Assets/icons/sulfurquarry.png deleted file mode 100644 index 7628dda4..00000000 Binary files a/src/RustPlusBot.Features.Map/Assets/icons/sulfurquarry.png and /dev/null differ diff --git a/src/RustPlusBot.Features.Map/Assets/icons/supermarket.png b/src/RustPlusBot.Features.Map/Assets/icons/supermarket.png deleted file mode 100644 index c51c291e..00000000 Binary files a/src/RustPlusBot.Features.Map/Assets/icons/supermarket.png and /dev/null differ diff --git a/src/RustPlusBot.Features.Map/Assets/icons/swamp.png b/src/RustPlusBot.Features.Map/Assets/icons/swamp.png deleted file mode 100644 index 4d3a6aa0..00000000 Binary files a/src/RustPlusBot.Features.Map/Assets/icons/swamp.png and /dev/null differ diff --git a/src/RustPlusBot.Features.Map/Assets/icons/traintunnel.png b/src/RustPlusBot.Features.Map/Assets/icons/traintunnel.png deleted file mode 100644 index cd81437f..00000000 Binary files a/src/RustPlusBot.Features.Map/Assets/icons/traintunnel.png and /dev/null differ diff --git a/src/RustPlusBot.Features.Map/Assets/icons/trainyard.png b/src/RustPlusBot.Features.Map/Assets/icons/trainyard.png deleted file mode 100644 index 70a50bd4..00000000 Binary files a/src/RustPlusBot.Features.Map/Assets/icons/trainyard.png and /dev/null differ diff --git a/src/RustPlusBot.Features.Map/Assets/icons/watertreatment.png b/src/RustPlusBot.Features.Map/Assets/icons/watertreatment.png deleted file mode 100644 index 4b366296..00000000 Binary files a/src/RustPlusBot.Features.Map/Assets/icons/watertreatment.png and /dev/null differ diff --git a/src/RustPlusBot.Features.Map/Assets/icons/waterwell.png b/src/RustPlusBot.Features.Map/Assets/icons/waterwell.png deleted file mode 100644 index 20c65aba..00000000 Binary files a/src/RustPlusBot.Features.Map/Assets/icons/waterwell.png and /dev/null differ diff --git a/src/RustPlusBot.Features.Map/MapServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Map/MapServiceCollectionExtensions.cs index 051cb325..0f3eac62 100644 --- a/src/RustPlusBot.Features.Map/MapServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Map/MapServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using RustPlusBot.Abstractions.Map; +using RustPlusBot.Features.Map.Assets; using RustPlusBot.Features.Map.Composing; using RustPlusBot.Features.Map.Hosting; using RustPlusBot.Features.Map.Posting; @@ -26,6 +27,8 @@ public static IServiceCollection AddMap(this IServiceCollection services, IConfi ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(configuration); + services.AddRustMapsAssets(); + services.AddSingleton(); services.AddSingleton(); // RustMaps is NOT a base-map source (the #map render draws its own layers on the Rust+ tile). diff --git a/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs b/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs index 95f396bc..56b5a03e 100644 --- a/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs +++ b/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs @@ -12,9 +12,10 @@ namespace RustPlusBot.Features.Map.Rendering; /// /// Renders the base map tile plus overlay layers to PNG bytes. -/// Stateless; safe to register and use as a singleton. +/// Stateless per render; safe to register and use as a singleton. /// -public sealed class MapRenderer +/// Serves monument and rig icons from the RustMaps asset package. +public sealed class MapRenderer(MonumentIconSource monumentIcons) { /// The square output edge length in pixels. public const int OutputSize = 1024; @@ -47,8 +48,6 @@ private static FontFamily LoadFamily() /// Which overlay layers to draw. /// Which grid convention to draw (in-game F1 map, or Rust+/RustMaps). /// PNG-encoded bytes of a square image with pixels on each side. - /// Kept as an instance method so the class can be registered as a DI singleton. -#pragma warning disable CA1822, S2325 // Kept as instance method for DI singleton registration public byte[] Render(byte[] baseJpeg, MapProjection projection, IReadOnlyList markers, @@ -57,7 +56,6 @@ public byte[] Render(byte[] baseJpeg, IReadOnlyList rigs, MapLayerSet layers, MapGridStyle gridStyle = MapGridStyle.InGame) -#pragma warning restore CA1822, S2325 { ArgumentNullException.ThrowIfNull(baseJpeg); ArgumentNullException.ThrowIfNull(projection); @@ -211,7 +209,7 @@ private static void DrawMarkers(Image image, IReadOnlyList image, IReadOnlyList monuments) + private void DrawMonuments(Image image, IReadOnlyList monuments) { // One Mutate for the whole layer (see DrawMarkers): monuments can be numerous, so a single // pipeline beats one Mutate per monument. @@ -219,7 +217,7 @@ private static void DrawMonuments(Image image, IReadOnlyList image, IReadOnlyList image, IReadOnlyList rigs) + private void DrawRigs(Image image, IReadOnlyList rigs) { image.Mutate(ctx => { foreach (var rig in rigs) { - var icon = MapIcons.Rig(rig.Kind, rig.Active, MapRenderStyle.MonumentIconSize); + var icon = monumentIcons.Rig(rig.Kind, MapRenderStyle.MonumentIconSize); if (icon is null) { continue; diff --git a/src/RustPlusBot.Features.Map/RustPlusBot.Features.Map.csproj b/src/RustPlusBot.Features.Map/RustPlusBot.Features.Map.csproj index 7e5c4a44..a5d3e019 100644 --- a/src/RustPlusBot.Features.Map/RustPlusBot.Features.Map.csproj +++ b/src/RustPlusBot.Features.Map/RustPlusBot.Features.Map.csproj @@ -22,8 +22,12 @@ + + + + diff --git a/src/RustPlusBot.Host/appsettings.json b/src/RustPlusBot.Host/appsettings.json index 38472106..4b7a3215 100644 --- a/src/RustPlusBot.Host/appsettings.json +++ b/src/RustPlusBot.Host/appsettings.json @@ -1,7 +1,10 @@ { "Serilog": { "MinimumLevel": { - "Default": "Warning" + "Default": "Warning", + "Override": { + "RustPlusBot.Features.Map.Assets.MonumentIconSource": "Information" + } }, "Enrich": ["FromLogContext"], "WriteTo": [ diff --git a/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs index ce54b7d8..20452f6b 100644 --- a/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs @@ -1,8 +1,11 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; +using RustMapsApi.V4.Assets; using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Events; using RustPlusBot.Features.Events.State; +using RustPlusBot.Features.Map.Assets; using RustPlusBot.Features.Map.Composing; using RustPlusBot.Features.Map.Rendering; using RustPlusBot.Persistence.Map; @@ -44,7 +47,8 @@ private static MapComposer Build( baseImage is null ? null : new BaseMapImage(baseImage, (int)Dims.Width, (int)Dims.Height, Dims.OceanMargin)); - return new MapComposer(new BaseMapCache([source]), events, rigs, query, new MapRenderer(), + return new MapComposer(new BaseMapCache([source]), events, rigs, query, + new MapRenderer(new MonumentIconSource(new MonumentAssetSource(), NullLogger.Instance)), ScopeFactory(settingsStore)); } diff --git a/tests/RustPlusBot.Features.Map.Tests/MapIconsTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapIconsTests.cs index 3c9c0e0e..fc9cf2c8 100644 --- a/tests/RustPlusBot.Features.Map.Tests/MapIconsTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/MapIconsTests.cs @@ -1,5 +1,4 @@ using RustPlusBot.Abstractions.Connections; -using RustPlusBot.Abstractions.Events; using RustPlusBot.Features.Map.Assets; namespace RustPlusBot.Features.Map.Tests; @@ -13,27 +12,6 @@ public sealed class MapIconsTests public void Marker_resolves_for_known_kinds(MarkerKind kind) => Assert.NotNull(MapIcons.Marker(kind)); - [Fact] - public void Monument_resolves_known_token() => - Assert.NotNull(MapIcons.Monument("launchsite")); - - [Fact] - public void Monument_returns_null_for_unknown_token() => - Assert.Null(MapIcons.Monument("definitely_not_a_monument")); - - [Fact] - public void MonumentIconMap_maps_rig_tokens_to_icons() - { - Assert.Equal("oilrig", MonumentIconMap.IconKeyFor("oil_rig_small")); - Assert.Equal("largeoilrig", MonumentIconMap.IconKeyFor("large_oil_rig")); - } - - [Theory] - [InlineData(RigKind.Small)] - [InlineData(RigKind.Large)] - public void Rig_resolves_for_known_kinds(RigKind kind) => - Assert.NotNull(MapIcons.Rig(kind, active: false)); - [Fact] public void Player_resolves_to_vendored_icon() => Assert.NotNull(MapIcons.Player()); @@ -48,17 +26,6 @@ public void Sized_marker_icon_fits_the_requested_box() Assert.True(icon.Width == 40 || icon.Height == 40); // aspect-preserving fit, longest edge = size } - [Fact] - public void Sized_monument_icon_is_scaled_down_from_native() - { - var native = MapIcons.Monument("oil_rig_small"); // 875x875 native - var sized = MapIcons.Monument("oil_rig_small", 30); - - Assert.NotNull(native); - Assert.NotNull(sized); - Assert.True(sized!.Width <= 30 && sized.Height <= 30); - } - [Fact] public void Sized_icons_are_cached_per_size() { diff --git a/tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs index 65852fd5..157d8bda 100644 --- a/tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs @@ -1,5 +1,8 @@ +using Microsoft.Extensions.Logging.Abstractions; +using RustMapsApi.V4.Assets; using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.Map.Assets; using RustPlusBot.Features.Map.Rendering; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; @@ -12,6 +15,9 @@ public sealed class MapRendererTests new(WorldSize: 4000, ImageWidth: 4000, ImageHeight: 4000, OceanMarginPx: 500, OutputSize: MapRenderer.OutputSize); + private static MapRenderer CreateRenderer() => + new(new MonumentIconSource(new MonumentAssetSource(), NullLogger.Instance)); + /// A 64x64 solid-green JPEG, generated once in-test so the renderer has a real base image to decode. private static byte[] BaseJpeg() { @@ -54,7 +60,7 @@ private static Rectangle ChangedPixelBounds(byte[] a, byte[] b) [Fact] public void Render_produces_a_png_of_the_output_size() { - var renderer = new MapRenderer(); + var renderer = CreateRenderer(); var bytes = renderer.Render(BaseJpeg(), Projection, markers: [], monuments: [], players: [], rigs: [], MapLayerSet.AllOn); @@ -67,7 +73,7 @@ public void Render_produces_a_png_of_the_output_size() [Fact] public void Render_with_a_marker_differs_from_render_without() { - var renderer = new MapRenderer(); + var renderer = CreateRenderer(); var jpeg = BaseJpeg(); var without = renderer.Render(jpeg, Projection, markers: [], monuments: [], players: [], rigs: [], @@ -83,7 +89,7 @@ public void Render_with_a_marker_differs_from_render_without() [Fact] public void Render_with_all_layers_produces_valid_png() { - var renderer = new MapRenderer(); + var renderer = CreateRenderer(); var markers = new[] { new MarkerPlacement(MarkerKind.CargoShip, 100, 100, null, []) @@ -110,7 +116,7 @@ public void Render_with_all_layers_produces_valid_png() [Fact] public void Monument_icon_is_drawn_scaled_not_native() { - var renderer = new MapRenderer(); + var renderer = CreateRenderer(); var projection = new MapProjection(4000, 2000, 2000, 100, MapRenderer.OutputSize); var baseJpeg = SolidJpeg(2000); var (px, py) = projection.ToPixel(2000f, 2000f); @@ -130,7 +136,7 @@ [new MonumentPlacement("oil_rig_small", px, py)], [], [], [Fact] public void Trail_draws_pixels_between_history_points() { - var renderer = new MapRenderer(); + var renderer = CreateRenderer(); var projection = new MapProjection(4000, 2000, 2000, 100, MapRenderer.OutputSize); var baseJpeg = SolidJpeg(2000); var (ax, ay) = projection.ToPixel(1000f, 2000f); @@ -155,7 +161,7 @@ public void Grid_style_shifts_the_rendered_rows() { // Rust+/RustMaps rows sit 100 world-units south of the in-game rows, so the two styles must // produce different grid pixels on the same base image. - var renderer = new MapRenderer(); + var renderer = CreateRenderer(); var projection = new MapProjection(1500, 2000, 2000, 100, MapRenderer.OutputSize); var baseJpeg = SolidJpeg(2000); var layers = new MapLayerSet(Grid: true, Markers: false, Monuments: false, Vendor: false, @@ -170,7 +176,7 @@ public void Grid_style_shifts_the_rendered_rows() [Fact] public void Rotation_changes_the_rendered_icon() { - var renderer = new MapRenderer(); + var renderer = CreateRenderer(); var projection = new MapProjection(4000, 2000, 2000, 100, MapRenderer.OutputSize); var baseJpeg = SolidJpeg(2000); var (px, py) = projection.ToPixel(2000f, 2000f); diff --git a/tests/RustPlusBot.Features.Map.Tests/MonumentIconSourceTests.cs b/tests/RustPlusBot.Features.Map.Tests/MonumentIconSourceTests.cs new file mode 100644 index 00000000..f7bbb904 --- /dev/null +++ b/tests/RustPlusBot.Features.Map.Tests/MonumentIconSourceTests.cs @@ -0,0 +1,148 @@ +using System.Reflection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using RustMapsApi.V4.Assets; +using RustMapsApi.V4.Models; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.Map.Assets; + +namespace RustPlusBot.Features.Map.Tests; + +public sealed class MonumentIconSourceTests +{ + private static MonumentIconSource CreateSource() => + new(new MonumentAssetSource(), NullLogger.Instance); + + [Fact] + public void Monument_rasterizes_known_token_at_requested_size() + { + var icon = CreateSource().Monument("launchsite", 30); + + Assert.NotNull(icon); + Assert.Equal(30, icon!.Width); + Assert.Equal(30, icon.Height); + } + + [Fact] + public void Monument_caches_per_type_and_size() + { + var source = CreateSource(); + + Assert.Same(source.Monument("launchsite", 30), source.Monument("launchsite", 30)); + Assert.NotSame(source.Monument("launchsite", 30), source.Monument("launchsite", 24)); + } + + [Fact] + public void Monument_returns_null_and_logs_once_for_unknown_token() + { + var logger = new RecordingLogger(); + var source = new MonumentIconSource(new MonumentAssetSource(), logger); + + Assert.Null(source.Monument("definitely_not_a_monument", 30)); + Assert.Null(source.Monument("definitely_not_a_monument", 30)); + Assert.Null(source.Monument("definitely_not_a_monument", 24)); + + Assert.Equal(1, logger.Count(LogLevel.Information)); + } + + [Theory] + [InlineData(RigKind.Small)] + [InlineData(RigKind.Large)] + public void Rig_resolves_for_known_kinds(RigKind kind) => + Assert.NotNull(CreateSource().Rig(kind, 30)); + + [Fact] + public void Every_known_token_rasterizes() + { + // SVG-engine smoke test across the whole mapping: every token must yield a real image. + // Includes two prefix-family samples (swamp_a, underwater_lab_d) alongside KnownTokens so a + // rasterization regression in those families is caught too, not just the exactly-mapped tokens. + var source = CreateSource(); + foreach (var token in MonumentTokenMap.KnownTokens.Concat(["swamp_a", "underwater_lab_d"])) + { + var icon = source.Monument(token, 30); + Assert.True(icon is not null, $"{token} produced no icon"); + + var hasVisiblePixel = false; + for (var y = 0; y < icon!.Height && !hasVisiblePixel; y++) + { + for (var x = 0; x < icon.Width; x++) + { + if (icon[x, y].A > 0) + { + hasVisiblePixel = true; + break; + } + } + } + + Assert.True(hasVisiblePixel, $"{token} produced a fully transparent (blank) icon"); + } + } + + [Fact] + public void Monument_caches_null_for_assetless_type_and_does_not_retry() + { + var assets = Substitute.For(); + assets.TryGetAsset(Arg.Any(), out Arg.Any()).Returns(false); + var logger = new RecordingLogger(); + var source = new MonumentIconSource(assets, logger); + + Assert.Null(source.Monument("launchsite", 30)); + Assert.Null(source.Monument("launchsite", 30)); + Assert.Null(source.Monument("launchsite", 30)); + + Assert.Equal(1, logger.Count(LogLevel.Information)); + assets.Received(1).TryGetAsset(Arg.Any(), out Arg.Any()); + } + + [Fact] + public void Monument_logs_warning_once_when_rasterization_fails() + { + var bogus = CreateBogusAsset(); + var assets = Substitute.For(); + assets.TryGetAsset(Arg.Any(), out Arg.Any()).Returns(call => + { + call[1] = bogus; + return true; + }); + var logger = new RecordingLogger(); + var source = new MonumentIconSource(assets, logger); + + Assert.Null(source.Monument("launchsite", 30)); + Assert.Null(source.Monument("launchsite", 30)); + + Assert.Equal(1, logger.Count(LogLevel.Warning)); + Assert.Equal(1, logger.Count(LogLevel.Information)); + } + + /// Builds a MonumentAsset whose OpenStream throws (no such embedded resource). The package's + /// ctor is internal with no InternalsVisibleTo grant, so reflection is the only seam; the ctor does + /// not validate its arguments, so a bogus asset name survives construction and fails on first read. + private static MonumentAsset CreateBogusAsset() + { + var ctor = typeof(MonumentAsset).GetConstructor( + BindingFlags.Instance | BindingFlags.NonPublic, [typeof(MonumentType), typeof(string)]); + Assert.NotNull(ctor); + return (MonumentAsset)ctor!.Invoke([MonumentType.LaunchSite, "Definitely_Not_An_Asset"]); + } + + private sealed class RecordingLogger : ILogger + { + private readonly List _entries = []; + + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) => _entries.Add(logLevel); + + public int Count(LogLevel level) => _entries.Count(l => l == level); + } +} diff --git a/tests/RustPlusBot.Features.Map.Tests/MonumentTokenMapTests.cs b/tests/RustPlusBot.Features.Map.Tests/MonumentTokenMapTests.cs new file mode 100644 index 00000000..4710a77b --- /dev/null +++ b/tests/RustPlusBot.Features.Map.Tests/MonumentTokenMapTests.cs @@ -0,0 +1,64 @@ +using RustMapsApi.V4.Assets; +using RustMapsApi.V4.Models; +using RustPlusBot.Features.Map.Assets; + +namespace RustPlusBot.Features.Map.Tests; + +public sealed class MonumentTokenMapTests +{ + [Theory] + [InlineData("airfield_display_name", MonumentType.Airfield)] + [InlineData("dome_monument_name", MonumentType.SphereTank)] + [InlineData("mining_outpost_display_name", MonumentType.Warehouse)] + [InlineData("bandit_camp", MonumentType.BanditTown)] + [InlineData("radtown", MonumentType.Radtown)] + [InlineData("jungle_ziggurat", MonumentType.JungleZigguratA)] + [InlineData("train_tunnel_display_name", MonumentType.TunnelEntrance)] + [InlineData("train_tunnel_link_display_name", MonumentType.TunnelEntranceTransition)] + [InlineData("arctic_base_b", MonumentType.ArcticResearchBaseA)] + [InlineData("stables_a", MonumentType.StablesA)] + public void TypeFor_maps_known_tokens(string token, MonumentType expected) => + Assert.Equal(expected, MonumentTokenMap.TypeFor(token)); + + [Fact] + public void TypeFor_maps_rig_tokens() + { + Assert.Equal(MonumentType.OilrigSmall, MonumentTokenMap.TypeFor("oil_rig_small")); + Assert.Equal(MonumentType.OilrigLarge, MonumentTokenMap.TypeFor("large_oil_rig")); + } + + [Theory] + [InlineData("swamp_a")] + [InlineData("swamp_b")] + [InlineData("swamp_c")] + public void TypeFor_matches_swamps_by_prefix(string token) => + Assert.Equal(MonumentType.SwampC, MonumentTokenMap.TypeFor(token)); + + [Theory] + [InlineData("underwater_lab")] + [InlineData("underwater_lab_d")] + public void TypeFor_matches_underwater_labs_by_prefix(string token) => + Assert.Equal(MonumentType.UnderwaterA, MonumentTokenMap.TypeFor(token)); + + [Theory] + [InlineData("definitely_not_a_monument")] + [InlineData("")] + [InlineData(null)] + public void TypeFor_returns_null_for_unknown_or_empty(string? token) => + Assert.Null(MonumentTokenMap.TypeFor(token)); + + [Fact] + public void Every_mapped_type_has_a_package_asset() + { + // Drift guard: fails loud if a RustMapsApi.Assets update drops art we depend on. + // Includes two prefix-family samples (swamp_a, underwater_lab_d) alongside KnownTokens so a + // dropped Swamp or Underwater_Lab asset fails loud too, not just the exactly-mapped tokens. + foreach (var token in MonumentTokenMap.KnownTokens.Concat(["swamp_a", "underwater_lab_d"])) + { + var type = MonumentTokenMap.TypeFor(token); + Assert.NotNull(type); + Assert.True(MonumentAssets.HasAsset(type.Value), + $"{token} → {type} has no asset in RustMapsApi.Assets"); + } + } +}