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");
+ }
+ }
+}