diff --git a/NOTICE b/NOTICE
index e5af93ff..a4b06d70 100644
--- a/NOTICE
+++ b/NOTICE
@@ -15,15 +15,15 @@ Liberation Sans (Regular)
The OFL permits embedding and redistribution in software products.
-------------------------------------------------------------------------------
-MAP MARKER / MONUMENT ICONS (future 2b-ii slice)
+MAP MARKER / MONUMENT ICONS
-------------------------------------------------------------------------------
-The current 2b slice draws all map markers programmatically as glyphs; NO
-third-party image assets are bundled.
+Map marker and monument icons under src/RustPlusBot.Features.Map/Assets/icons/
+originate from Rust (© Facepunch Studios) and are reused here under the same
+companion-app fair-use basis as the official Rust+ application. They were
+sourced via the rustplus-desktop project, which redistributes the game art;
+the icons are Facepunch's game assets, not original GPL-licensed work, and are
+not treated as GPL-licensed nor as encumbering this MIT-licensed project.
-When marker and monument icons are added in the 2b-ii slice, they will
-originate as Facepunch / Rust game art. They are used under the same
-companion-app norms that govern the official Rust+ app and established
-reference bots — NOT under the GPL of the third-party repositories that
-merely redistribute them. Attribution for those assets will be added to this
-file at that time.
+The player position icon (player.png) was sourced from the rustplusplus project
+on the same Facepunch-game-art fair-use basis described above.
diff --git a/src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs b/src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs
index 42016780..c1a75869 100644
--- a/src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs
+++ b/src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs
@@ -45,4 +45,14 @@ public interface IRustServerQuery
/// A cancellation token.
/// The map dimensions, or null when there is no live socket.
Task GetMapDimensionsAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken);
+
+ /// Gets the server's monuments, or an empty list when there is no live socket.
+ /// The owning guild snowflake.
+ /// The target server id.
+ /// A cancellation token.
+ /// The monuments, or an empty list when there is no live socket.
+ Task> GetMonumentsAsync(
+ ulong guildId,
+ Guid serverId,
+ CancellationToken cancellationToken);
}
diff --git a/src/RustPlusBot.Abstractions/Connections/MarkerKind.cs b/src/RustPlusBot.Abstractions/Connections/MarkerKind.cs
index e543020a..fa337fc7 100644
--- a/src/RustPlusBot.Abstractions/Connections/MarkerKind.cs
+++ b/src/RustPlusBot.Abstractions/Connections/MarkerKind.cs
@@ -17,4 +17,7 @@ public enum MarkerKind
/// A locked crate.
Crate = 4,
+
+ /// The travelling vendor.
+ TravellingVendor = 5,
}
diff --git a/src/RustPlusBot.Abstractions/Events/MapSettingsChangedEvent.cs b/src/RustPlusBot.Abstractions/Events/MapSettingsChangedEvent.cs
new file mode 100644
index 00000000..56f5bd88
--- /dev/null
+++ b/src/RustPlusBot.Abstractions/Events/MapSettingsChangedEvent.cs
@@ -0,0 +1,6 @@
+namespace RustPlusBot.Abstractions.Events;
+
+/// Published when a server's map layer settings change, so the map image repaints immediately.
+/// The owning guild snowflake.
+/// The target server id.
+public sealed record MapSettingsChangedEvent(ulong GuildId, Guid ServerId);
diff --git a/src/RustPlusBot.Domain/Map/ServerMapSettings.cs b/src/RustPlusBot.Domain/Map/ServerMapSettings.cs
new file mode 100644
index 00000000..841ded60
--- /dev/null
+++ b/src/RustPlusBot.Domain/Map/ServerMapSettings.cs
@@ -0,0 +1,29 @@
+namespace RustPlusBot.Domain.Map;
+
+/// Per-(guild, server) rendered-map layer settings; one row per server. Layers default on.
+public sealed class ServerMapSettings
+{
+ /// The owning guild snowflake.
+ public ulong GuildId { get; set; }
+
+ /// The server id (FK to RustServer; primary key, one row per server).
+ public Guid ServerId { get; set; }
+
+ /// Whether the grid layer is drawn.
+ public bool ShowGrid { get; set; } = true;
+
+ /// Whether live cargo/heli/chinook markers are drawn.
+ public bool ShowMarkers { get; set; } = true;
+
+ /// Whether monument icons are drawn.
+ public bool ShowMonuments { get; set; } = true;
+
+ /// Whether the travelling-vendor marker is drawn.
+ public bool ShowVendor { get; set; } = true;
+
+ /// Whether teammate position markers are drawn.
+ public bool ShowPlayers { get; set; } = true;
+
+ /// Whether oil rigs are styled by activation state.
+ public bool ShowRigs { get; set; } = true;
+}
diff --git a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs
index b02b5bdf..1cbf54c5 100644
--- a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs
+++ b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs
@@ -324,9 +324,10 @@ public async Task> GetMapMarkersAsync(
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(timeout);
// CONFIRMED (2.0.0-beta.1): GetMapMarkersAsync returns Task>.
- // MapMarkers has typed dictionaries CargoShipMarkers/PatrolHelicopterMarkers/Ch47Markers
+ // MapMarkers has typed dictionaries CargoShipMarkers/PatrolHelicopterMarkers/Ch47Markers/TravellingVendorMarkers
// (Dictionary); each marker exposes Nullable Id, Nullable X/Y.
// No flat list, no Type field, NO crate bucket (the game stopped sending crate markers) → core-3.
+ // Surfaced buckets: CargoShip, PatrolHelicopter, Chinook, TravellingVendor.
var response = await _rustPlus.GetMapMarkersAsync(timeoutCts.Token).WaitAsync(timeoutCts.Token)
.ConfigureAwait(false);
if (!response.IsSuccess || response.Data is null)
@@ -339,6 +340,7 @@ public async Task> GetMapMarkersAsync(
AddMarkers(markers, data.CargoShipMarkers, MarkerKind.CargoShip);
AddMarkers(markers, data.PatrolHelicopterMarkers, MarkerKind.PatrolHelicopter);
AddMarkers(markers, data.Ch47Markers, MarkerKind.Chinook);
+ AddMarkers(markers, data.TravellingVendorMarkers, MarkerKind.TravellingVendor);
return markers;
}
diff --git a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs
index f4d6d9de..7630ed6c 100644
--- a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs
+++ b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs
@@ -211,6 +211,21 @@ public async Task PromoteToLeaderAsync(
.ConfigureAwait(false);
}
+ ///
+ public async Task> GetMonumentsAsync(
+ ulong guildId,
+ Guid serverId,
+ CancellationToken cancellationToken)
+ {
+ if (!_liveSockets.TryGetValue((guildId, serverId), out var live))
+ {
+ return [];
+ }
+
+ return await live.Connection.GetMonumentsAsync(_options.HeartbeatTimeout, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
///
public async Task SendAsync(
ulong guildId,
diff --git a/src/RustPlusBot.Features.Map/Assets/MapIcons.cs b/src/RustPlusBot.Features.Map/Assets/MapIcons.cs
new file mode 100644
index 00000000..fa790f7d
--- /dev/null
+++ b/src/RustPlusBot.Features.Map/Assets/MapIcons.cs
@@ -0,0 +1,75 @@
+using System.Collections.Concurrent;
+using RustPlusBot.Abstractions.Events;
+using RustPlusBot.Features.Connections.Listening;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.PixelFormats;
+
+namespace RustPlusBot.Features.Map.Assets;
+
+///
+/// Loads vendored icon assets (embedded PNGs) once and serves them by marker kind or monument token.
+/// Cached images are immutable inputs the renderer draws from; never mutate them.
+///
+///
+/// MapIcons.Marker includes a TravellingVendor arm that returns the embedded vendor.png.
+/// is retained as a bridge accessor for callers that reference it directly.
+///
+public static class MapIcons
+{
+ private const string ResourcePrefix = "RustPlusBot.Features.Map.Assets.icons.";
+
+ private static readonly ConcurrentDictionary?> Cache = new(StringComparer.Ordinal);
+
+ /// Gets the icon for a marker kind, or null when there is no icon for that kind.
+ /// The marker kind.
+ /// The cached icon image, or null.
+ public static Image? Marker(MarkerKind kind) => kind switch
+ {
+ MarkerKind.CargoShip => Load("cargo"),
+ MarkerKind.PatrolHelicopter => Load("patrol"),
+ MarkerKind.Chinook => Load("ch47"),
+ MarkerKind.TravellingVendor => Load("vendor"),
+ _ => null,
+ };
+
+ /// 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 // 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
+
+ /// Gets the travelling vendor icon.
+ /// The cached vendor icon image, or null.
+ public static Image? Vendor() => Load("vendor");
+
+ /// Gets the player position icon, or null when the asset is missing.
+ /// The player icon, or null.
+ public static Image? Player() => Load("player");
+
+ /// 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);
+ }
+
+ private static Image? Load(string key) => Cache.GetOrAdd(key, static k =>
+ {
+ var asm = typeof(MapIcons).Assembly;
+ using var stream = asm.GetManifestResourceStream(ResourcePrefix + k + ".png");
+ return stream is null ? null : Image.Load(stream);
+ });
+}
diff --git a/src/RustPlusBot.Features.Map/Assets/MarkerGlyphs.cs b/src/RustPlusBot.Features.Map/Assets/MarkerGlyphs.cs
deleted file mode 100644
index f900ca42..00000000
--- a/src/RustPlusBot.Features.Map/Assets/MarkerGlyphs.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-using RustPlusBot.Features.Connections.Listening;
-using SixLabors.ImageSharp;
-
-namespace RustPlusBot.Features.Map.Assets;
-
-/// Maps a marker kind to a draw glyph (colour + single letter). The keyed registry the map renderer draws from; 2b-ii can swap this for image assets without changing the renderer.
-public static class MarkerGlyphs
-{
- /// Gets the glyph colour and letter for a marker kind.
- /// The marker kind.
- /// The colour and a one-character label.
- public static (Color Color, string Letter) For(MarkerKind kind) => kind switch
- {
- MarkerKind.CargoShip => (Color.DodgerBlue, "C"),
- MarkerKind.PatrolHelicopter => (Color.Red, "H"),
- MarkerKind.Chinook => (Color.Orange, "K"),
- _ => (Color.Gray, "?"),
- };
-}
diff --git a/src/RustPlusBot.Features.Map/Assets/MonumentIconMap.cs b/src/RustPlusBot.Features.Map/Assets/MonumentIconMap.cs
new file mode 100644
index 00000000..bb3ae1dc
--- /dev/null
+++ b/src/RustPlusBot.Features.Map/Assets/MonumentIconMap.cs
@@ -0,0 +1,49 @@
+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",
+ ["oilrig_1"] = "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/icons/airfield.png b/src/RustPlusBot.Features.Map/Assets/icons/airfield.png
new file mode 100644
index 00000000..d89829a9
Binary files /dev/null and b/src/RustPlusBot.Features.Map/Assets/icons/airfield.png differ
diff --git a/src/RustPlusBot.Features.Map/Assets/icons/arcticresearch.png b/src/RustPlusBot.Features.Map/Assets/icons/arcticresearch.png
new file mode 100644
index 00000000..60c1e343
Binary files /dev/null and b/src/RustPlusBot.Features.Map/Assets/icons/arcticresearch.png differ
diff --git a/src/RustPlusBot.Features.Map/Assets/icons/banditcamp.png b/src/RustPlusBot.Features.Map/Assets/icons/banditcamp.png
new file mode 100644
index 00000000..9a02f53d
Binary files /dev/null and b/src/RustPlusBot.Features.Map/Assets/icons/banditcamp.png differ
diff --git a/src/RustPlusBot.Features.Map/Assets/icons/cargo.png b/src/RustPlusBot.Features.Map/Assets/icons/cargo.png
new file mode 100644
index 00000000..5fbfd50e
Binary files /dev/null and b/src/RustPlusBot.Features.Map/Assets/icons/cargo.png differ
diff --git a/src/RustPlusBot.Features.Map/Assets/icons/ch47.png b/src/RustPlusBot.Features.Map/Assets/icons/ch47.png
new file mode 100644
index 00000000..4af5d18b
Binary files /dev/null and b/src/RustPlusBot.Features.Map/Assets/icons/ch47.png differ
diff --git a/src/RustPlusBot.Features.Map/Assets/icons/dome.png b/src/RustPlusBot.Features.Map/Assets/icons/dome.png
new file mode 100644
index 00000000..c8141885
Binary files /dev/null and b/src/RustPlusBot.Features.Map/Assets/icons/dome.png differ
diff --git a/src/RustPlusBot.Features.Map/Assets/icons/excavator.png b/src/RustPlusBot.Features.Map/Assets/icons/excavator.png
new file mode 100644
index 00000000..b2b58da2
Binary files /dev/null and b/src/RustPlusBot.Features.Map/Assets/icons/excavator.png differ
diff --git a/src/RustPlusBot.Features.Map/Assets/icons/ferryterminal.png b/src/RustPlusBot.Features.Map/Assets/icons/ferryterminal.png
new file mode 100644
index 00000000..e2766a49
Binary files /dev/null and b/src/RustPlusBot.Features.Map/Assets/icons/ferryterminal.png differ
diff --git a/src/RustPlusBot.Features.Map/Assets/icons/fishingvillage.png b/src/RustPlusBot.Features.Map/Assets/icons/fishingvillage.png
new file mode 100644
index 00000000..aa26b819
Binary files /dev/null and b/src/RustPlusBot.Features.Map/Assets/icons/fishingvillage.png differ
diff --git a/src/RustPlusBot.Features.Map/Assets/icons/fishingvillagelarge.png b/src/RustPlusBot.Features.Map/Assets/icons/fishingvillagelarge.png
new file mode 100644
index 00000000..5f40bbe9
Binary files /dev/null and b/src/RustPlusBot.Features.Map/Assets/icons/fishingvillagelarge.png differ
diff --git a/src/RustPlusBot.Features.Map/Assets/icons/gasstation.png b/src/RustPlusBot.Features.Map/Assets/icons/gasstation.png
new file mode 100644
index 00000000..2a5363ea
Binary files /dev/null and b/src/RustPlusBot.Features.Map/Assets/icons/gasstation.png differ
diff --git a/src/RustPlusBot.Features.Map/Assets/icons/harbour.png b/src/RustPlusBot.Features.Map/Assets/icons/harbour.png
new file mode 100644
index 00000000..a625d7de
Binary files /dev/null and b/src/RustPlusBot.Features.Map/Assets/icons/harbour.png differ
diff --git a/src/RustPlusBot.Features.Map/Assets/icons/harbour2.png b/src/RustPlusBot.Features.Map/Assets/icons/harbour2.png
new file mode 100644
index 00000000..96c5cb0c
Binary files /dev/null and b/src/RustPlusBot.Features.Map/Assets/icons/harbour2.png differ
diff --git a/src/RustPlusBot.Features.Map/Assets/icons/hqmquarry.png b/src/RustPlusBot.Features.Map/Assets/icons/hqmquarry.png
new file mode 100644
index 00000000..001a2923
Binary files /dev/null and b/src/RustPlusBot.Features.Map/Assets/icons/hqmquarry.png differ
diff --git a/src/RustPlusBot.Features.Map/Assets/icons/junkyard.png b/src/RustPlusBot.Features.Map/Assets/icons/junkyard.png
new file mode 100644
index 00000000..eb4aab2e
Binary files /dev/null and b/src/RustPlusBot.Features.Map/Assets/icons/junkyard.png differ
diff --git a/src/RustPlusBot.Features.Map/Assets/icons/largeoilrig.png b/src/RustPlusBot.Features.Map/Assets/icons/largeoilrig.png
new file mode 100644
index 00000000..637d5d19
Binary files /dev/null and b/src/RustPlusBot.Features.Map/Assets/icons/largeoilrig.png differ
diff --git a/src/RustPlusBot.Features.Map/Assets/icons/launchsite.png b/src/RustPlusBot.Features.Map/Assets/icons/launchsite.png
new file mode 100644
index 00000000..c1b86a7c
Binary files /dev/null and b/src/RustPlusBot.Features.Map/Assets/icons/launchsite.png differ
diff --git a/src/RustPlusBot.Features.Map/Assets/icons/lighthouse.png b/src/RustPlusBot.Features.Map/Assets/icons/lighthouse.png
new file mode 100644
index 00000000..fd715f53
Binary files /dev/null and b/src/RustPlusBot.Features.Map/Assets/icons/lighthouse.png differ
diff --git a/src/RustPlusBot.Features.Map/Assets/icons/militarybase.png b/src/RustPlusBot.Features.Map/Assets/icons/militarybase.png
new file mode 100644
index 00000000..c6d31d78
Binary files /dev/null and b/src/RustPlusBot.Features.Map/Assets/icons/militarybase.png differ
diff --git a/src/RustPlusBot.Features.Map/Assets/icons/militarytunnel.png b/src/RustPlusBot.Features.Map/Assets/icons/militarytunnel.png
new file mode 100644
index 00000000..d1b893c5
Binary files /dev/null and b/src/RustPlusBot.Features.Map/Assets/icons/militarytunnel.png differ
diff --git a/src/RustPlusBot.Features.Map/Assets/icons/miningoutpost.png b/src/RustPlusBot.Features.Map/Assets/icons/miningoutpost.png
new file mode 100644
index 00000000..96d2c92a
Binary files /dev/null and b/src/RustPlusBot.Features.Map/Assets/icons/miningoutpost.png differ
diff --git a/src/RustPlusBot.Features.Map/Assets/icons/missilesilo.png b/src/RustPlusBot.Features.Map/Assets/icons/missilesilo.png
new file mode 100644
index 00000000..fc5cf690
Binary files /dev/null and b/src/RustPlusBot.Features.Map/Assets/icons/missilesilo.png differ
diff --git a/src/RustPlusBot.Features.Map/Assets/icons/oilrig.png b/src/RustPlusBot.Features.Map/Assets/icons/oilrig.png
new file mode 100644
index 00000000..cab6c80d
Binary files /dev/null and b/src/RustPlusBot.Features.Map/Assets/icons/oilrig.png differ
diff --git a/src/RustPlusBot.Features.Map/Assets/icons/outpost.png b/src/RustPlusBot.Features.Map/Assets/icons/outpost.png
new file mode 100644
index 00000000..6c790550
Binary files /dev/null and b/src/RustPlusBot.Features.Map/Assets/icons/outpost.png differ
diff --git a/src/RustPlusBot.Features.Map/Assets/icons/patrol.png b/src/RustPlusBot.Features.Map/Assets/icons/patrol.png
new file mode 100644
index 00000000..9db1f49e
Binary files /dev/null and b/src/RustPlusBot.Features.Map/Assets/icons/patrol.png differ
diff --git a/src/RustPlusBot.Features.Map/Assets/icons/player.png b/src/RustPlusBot.Features.Map/Assets/icons/player.png
new file mode 100644
index 00000000..5c6c8939
Binary files /dev/null and b/src/RustPlusBot.Features.Map/Assets/icons/player.png differ
diff --git a/src/RustPlusBot.Features.Map/Assets/icons/powerplant.png b/src/RustPlusBot.Features.Map/Assets/icons/powerplant.png
new file mode 100644
index 00000000..7b5689eb
Binary files /dev/null and b/src/RustPlusBot.Features.Map/Assets/icons/powerplant.png differ
diff --git a/src/RustPlusBot.Features.Map/Assets/icons/powerstation.png b/src/RustPlusBot.Features.Map/Assets/icons/powerstation.png
new file mode 100644
index 00000000..0c4584bd
Binary files /dev/null and b/src/RustPlusBot.Features.Map/Assets/icons/powerstation.png differ
diff --git a/src/RustPlusBot.Features.Map/Assets/icons/satellitedish.png b/src/RustPlusBot.Features.Map/Assets/icons/satellitedish.png
new file mode 100644
index 00000000..c5f8840a
Binary files /dev/null and b/src/RustPlusBot.Features.Map/Assets/icons/satellitedish.png differ
diff --git a/src/RustPlusBot.Features.Map/Assets/icons/sewerbranch.png b/src/RustPlusBot.Features.Map/Assets/icons/sewerbranch.png
new file mode 100644
index 00000000..29a9acf7
Binary files /dev/null and b/src/RustPlusBot.Features.Map/Assets/icons/sewerbranch.png differ
diff --git a/src/RustPlusBot.Features.Map/Assets/icons/stable.png b/src/RustPlusBot.Features.Map/Assets/icons/stable.png
new file mode 100644
index 00000000..78492f85
Binary files /dev/null and b/src/RustPlusBot.Features.Map/Assets/icons/stable.png differ
diff --git a/src/RustPlusBot.Features.Map/Assets/icons/stonequarry.png b/src/RustPlusBot.Features.Map/Assets/icons/stonequarry.png
new file mode 100644
index 00000000..75050dd0
Binary files /dev/null and b/src/RustPlusBot.Features.Map/Assets/icons/stonequarry.png differ
diff --git a/src/RustPlusBot.Features.Map/Assets/icons/sulfurquarry.png b/src/RustPlusBot.Features.Map/Assets/icons/sulfurquarry.png
new file mode 100644
index 00000000..7628dda4
Binary files /dev/null and b/src/RustPlusBot.Features.Map/Assets/icons/sulfurquarry.png differ
diff --git a/src/RustPlusBot.Features.Map/Assets/icons/supermarket.png b/src/RustPlusBot.Features.Map/Assets/icons/supermarket.png
new file mode 100644
index 00000000..c51c291e
Binary files /dev/null and b/src/RustPlusBot.Features.Map/Assets/icons/supermarket.png differ
diff --git a/src/RustPlusBot.Features.Map/Assets/icons/swamp.png b/src/RustPlusBot.Features.Map/Assets/icons/swamp.png
new file mode 100644
index 00000000..4d3a6aa0
Binary files /dev/null and b/src/RustPlusBot.Features.Map/Assets/icons/swamp.png differ
diff --git a/src/RustPlusBot.Features.Map/Assets/icons/traintunnel.png b/src/RustPlusBot.Features.Map/Assets/icons/traintunnel.png
new file mode 100644
index 00000000..cd81437f
Binary files /dev/null and b/src/RustPlusBot.Features.Map/Assets/icons/traintunnel.png differ
diff --git a/src/RustPlusBot.Features.Map/Assets/icons/trainyard.png b/src/RustPlusBot.Features.Map/Assets/icons/trainyard.png
new file mode 100644
index 00000000..70a50bd4
Binary files /dev/null and b/src/RustPlusBot.Features.Map/Assets/icons/trainyard.png differ
diff --git a/src/RustPlusBot.Features.Map/Assets/icons/vendor.png b/src/RustPlusBot.Features.Map/Assets/icons/vendor.png
new file mode 100644
index 00000000..1556ad97
Binary files /dev/null and b/src/RustPlusBot.Features.Map/Assets/icons/vendor.png differ
diff --git a/src/RustPlusBot.Features.Map/Assets/icons/watertreatment.png b/src/RustPlusBot.Features.Map/Assets/icons/watertreatment.png
new file mode 100644
index 00000000..4b366296
Binary files /dev/null and b/src/RustPlusBot.Features.Map/Assets/icons/watertreatment.png differ
diff --git a/src/RustPlusBot.Features.Map/Assets/icons/waterwell.png b/src/RustPlusBot.Features.Map/Assets/icons/waterwell.png
new file mode 100644
index 00000000..20c65aba
Binary files /dev/null and b/src/RustPlusBot.Features.Map/Assets/icons/waterwell.png differ
diff --git a/src/RustPlusBot.Features.Map/Composing/MapComposer.cs b/src/RustPlusBot.Features.Map/Composing/MapComposer.cs
index a87ff01e..621b9f36 100644
--- a/src/RustPlusBot.Features.Map/Composing/MapComposer.cs
+++ b/src/RustPlusBot.Features.Map/Composing/MapComposer.cs
@@ -1,20 +1,29 @@
+using Microsoft.Extensions.DependencyInjection;
+using RustPlusBot.Abstractions.Events;
using RustPlusBot.Features.Connections.Listening;
using RustPlusBot.Features.Events.State;
using RustPlusBot.Features.Map.Rendering;
+using RustPlusBot.Persistence.Map;
namespace RustPlusBot.Features.Map.Composing;
-/// Gathers the cached base map + live markers and renders the map PNG.
+/// Gathers the cached base map, per-server settings, and live layer data, then renders the map PNG.
/// The base-map cache.
/// Live marker state.
-/// Live query seam (supplies the static map dimensions).
+/// Inferred oil-rig state.
+/// Live query seam (dimensions, monuments, team).
/// The image renderer.
-public sealed class MapComposer(BaseMapCache cache, IEventState events, IRustServerQuery query, MapRenderer renderer)
+/// Opens a scope to read the scoped settings store.
+public sealed class MapComposer(
+ BaseMapCache cache,
+ IEventState events,
+ IRigState rigs,
+ IRustServerQuery query,
+ MapRenderer renderer,
+ IServiceScopeFactory scopeFactory)
{
- private static readonly MarkerKind[] DrawnKinds =
- [
- MarkerKind.CargoShip, MarkerKind.PatrolHelicopter, MarkerKind.Chinook,
- ];
+ private static readonly MarkerKind[] LiveMarkerKinds =
+ [MarkerKind.CargoShip, MarkerKind.PatrolHelicopter, MarkerKind.Chinook];
/// Composes the map PNG for a server, or null when no base map is available yet.
/// The owning guild snowflake.
@@ -29,25 +38,142 @@ public sealed class MapComposer(BaseMapCache cache, IEventState events, IRustSer
return null;
}
+ // The settings store is scoped (EF context); this composer is a singleton, so we open a scope per
+ // call to resolve it (mirroring MapHostedService.OnConnectionStatusAsync) — avoids a captive dependency.
+ MapLayerSettings settings;
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ var store = scope.ServiceProvider.GetRequiredService();
+ settings = await store.GetAsync(guildId, serverId, cancellationToken).ConfigureAwait(false);
+ }
+
+ var layers = new MapLayerSet(settings.Grid, settings.Markers, settings.Monuments,
+ settings.Vendor, settings.Players, settings.Rigs);
+
// Dimensions come from the map itself (not from a marker), so the grid renders even when no
// markers are present — e.g. on a freshly-connected or low-activity server.
var dims = await query.GetMapDimensionsAsync(guildId, serverId, cancellationToken).ConfigureAwait(false);
if (dims is null)
{
- // Dimensions unavailable: render the base tile only (grid/markers both need world→pixel).
- return renderer.Render(baseImage, new MapDimensions(0, 0, 0), markers: [],
- new MapLayerSet(Grid: false, Markers: false, Monuments: false, Vendor: false, Rigs: false));
+ // Dimensions unavailable: render the base tile only (every overlay needs world→pixel).
+ return renderer.Render(baseImage, new MapDimensions(0, 0, 0), markers: [], monuments: [], players: [],
+ rigs: [],
+ new MapLayerSet(Grid: false, Markers: false, Monuments: false, Vendor: false, Players: false,
+ Rigs: false));
+ }
+
+ var markers = GatherMarkers(guildId, serverId, dims, layers);
+
+ // Monuments feed both the monuments layer and the rig-styling layer; fetch them once when either is on.
+ IReadOnlyList serverMonuments = [];
+ if (layers.Monuments || layers.Rigs)
+ {
+ serverMonuments = await query.GetMonumentsAsync(guildId, serverId, cancellationToken).ConfigureAwait(false);
+ }
+
+ var monuments = GatherMonuments(serverMonuments, dims, layers);
+ var players = await GatherPlayersAsync(guildId, serverId, dims, layers, cancellationToken)
+ .ConfigureAwait(false);
+ var rigPlacements = GatherRigs(guildId, serverId, serverMonuments, dims, layers);
+
+ return renderer.Render(baseImage, dims, markers, monuments, players, rigPlacements, layers);
+ }
+
+ private List GatherMarkers(ulong guildId, Guid serverId, MapDimensions dims, MapLayerSet layers)
+ {
+ var markers = new List();
+ if (layers.Markers)
+ {
+ foreach (var kind in LiveMarkerKinds)
+ {
+ foreach (var m in events.GetActiveMarkers(guildId, serverId, kind))
+ {
+ var (px, py) = WorldToPixel.ToPixel(m.X, m.Y, dims, MapRenderer.OutputSize);
+ markers.Add(new MarkerPlacement(kind, px, py));
+ }
+ }
}
- var placements = DrawnKinds
- .SelectMany(kind => events.GetActiveMarkers(guildId, serverId, kind))
- .Select(m =>
+ if (layers.Vendor)
+ {
+ foreach (var m in events.GetActiveMarkers(guildId, serverId, MarkerKind.TravellingVendor))
{
var (px, py) = WorldToPixel.ToPixel(m.X, m.Y, dims, MapRenderer.OutputSize);
- return new MarkerPlacement(m.Kind, px, py);
- })
- .ToList();
+ markers.Add(new MarkerPlacement(MarkerKind.TravellingVendor, px, py));
+ }
+ }
+
+ return markers;
+ }
+
+ private static List GatherMonuments(
+ IReadOnlyList serverMonuments,
+ MapDimensions dims,
+ MapLayerSet layers)
+ {
+ var monuments = new List();
+ if (layers.Monuments)
+ {
+ foreach (var mon in serverMonuments)
+ {
+ var (px, py) = WorldToPixel.ToPixel(mon.X, mon.Y, dims, MapRenderer.OutputSize);
+ monuments.Add(new MonumentPlacement(mon.Token, px, py));
+ }
+ }
+
+ return monuments;
+ }
+
+ private async Task> GatherPlayersAsync(
+ ulong guildId,
+ Guid serverId,
+ MapDimensions dims,
+ MapLayerSet layers,
+ CancellationToken cancellationToken)
+ {
+ var players = new List();
+ if (layers.Players)
+ {
+ var team = await query.GetTeamInfoAsync(guildId, serverId, cancellationToken).ConfigureAwait(false);
+ foreach (var member in team?.Members ?? [])
+ {
+ var (px, py) = WorldToPixel.ToPixel(member.X, member.Y, dims, MapRenderer.OutputSize);
+ players.Add(new PlayerPlacement(member.Name, px, py, member.IsAlive, member.IsOnline));
+ }
+ }
+
+ return players;
+ }
+
+ private List GatherRigs(
+ ulong guildId,
+ Guid serverId,
+ IReadOnlyList serverMonuments,
+ MapDimensions dims,
+ MapLayerSet layers)
+ {
+ var rigPlacements = new List();
+ if (layers.Rigs)
+ {
+ // Reuse the monument positions for the two rig tokens; query rig state for activation styling.
+ foreach (var mon in serverMonuments)
+ {
+ RigKind? kind = mon.Token switch
+ {
+ "oilrig_1" => RigKind.Small,
+ "large_oil_rig" => RigKind.Large,
+ _ => null,
+ };
+ if (kind is { } k)
+ {
+ var state = rigs.Get(guildId, serverId, k);
+ var (px, py) = WorldToPixel.ToPixel(mon.X, mon.Y, dims, MapRenderer.OutputSize);
+ rigPlacements.Add(new RigPlacement(k, px, py, state.Status == RigStatus.Active));
+ }
+ }
+ }
- return renderer.Render(baseImage, dims, placements, MapLayerSet.Default2b);
+ return rigPlacements;
}
}
diff --git a/src/RustPlusBot.Features.Map/Hosting/MapHostedService.cs b/src/RustPlusBot.Features.Map/Hosting/MapHostedService.cs
index 186d39a2..dab0d77a 100644
--- a/src/RustPlusBot.Features.Map/Hosting/MapHostedService.cs
+++ b/src/RustPlusBot.Features.Map/Hosting/MapHostedService.cs
@@ -44,6 +44,7 @@ internal sealed partial class MapHostedService(
private readonly CancellationTokenSource _cts = new();
private readonly MapRefreshThrottle _throttle = new(clock);
private Task? _markerLoop;
+ private Task? _settingsLoop;
private Task? _statusLoop;
private Task? _tickLoop;
@@ -54,6 +55,7 @@ internal sealed partial class MapHostedService(
public Task StartAsync(CancellationToken cancellationToken)
{
_markerLoop = Task.Run(() => ConsumeMarkerEventsAsync(_cts.Token), CancellationToken.None);
+ _settingsLoop = Task.Run(() => ConsumeSettingsEventsAsync(_cts.Token), CancellationToken.None);
_statusLoop = Task.Run(() => ConsumeConnectionStatusEventsAsync(_cts.Token), CancellationToken.None);
_tickLoop = Task.Run(() => RunPeriodicRefreshAsync(_cts.Token), CancellationToken.None);
return Task.CompletedTask;
@@ -65,7 +67,7 @@ public async Task StopAsync(CancellationToken cancellationToken)
await _cts.CancelAsync().ConfigureAwait(false);
foreach (var loop in new[]
{
- _markerLoop, _statusLoop, _tickLoop
+ _markerLoop, _settingsLoop, _statusLoop, _tickLoop
}.Where(t => t is not null))
{
try
@@ -103,6 +105,36 @@ private async Task ConsumeMarkerEventsAsync(CancellationToken cancellationToken)
}
}
+ /// Drains from the bus and triggers an immediate repaint.
+ ///
+ /// Throttle caveat: the immediate toggle repaint passes through the same
+ /// as the periodic tick. If a toggle lands inside a recent paint's throttle window, the immediate repaint
+ /// is skipped and the change shows on the next tick (≤ ).
+ /// This is intentional — matches 2b's coalescing design.
+ ///
+ /// A cancellation token.
+ private async Task ConsumeSettingsEventsAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ await foreach (var evt in eventBus.SubscribeAsync(cancellationToken)
+ .ConfigureAwait(false))
+ {
+ await RefreshAsync(evt.GuildId, evt.ServerId, cancellationToken).ConfigureAwait(false);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Shutting down.
+ }
+#pragma warning disable CA1031 // Broad catch: a faulting consumer must not crash the host.
+ catch (Exception ex)
+#pragma warning restore CA1031
+ {
+ LogSettingsLoopFaulted(logger, ex);
+ }
+ }
+
///
/// Repaints every connected server's #map on a steady interval. Marker ids are stable, so a moving
/// cargo ship / heli / chinook fires no ; this tick is what keeps
@@ -205,6 +237,9 @@ private async Task OnConnectionStatusAsync(ConnectionStatusChangedEvent evt, Can
[LoggerMessage(Level = LogLevel.Error, Message = "Map marker loop faulted.")]
private static partial void LogMarkerLoopFaulted(ILogger logger, Exception exception);
+ [LoggerMessage(Level = LogLevel.Error, Message = "Map settings loop faulted.")]
+ private static partial void LogSettingsLoopFaulted(ILogger logger, Exception exception);
+
[LoggerMessage(Level = LogLevel.Error, Message = "Map connection-status loop faulted.")]
private static partial void LogStatusLoopFaulted(ILogger logger, Exception exception);
diff --git a/src/RustPlusBot.Features.Map/Posting/DiscordMapChannelPoster.cs b/src/RustPlusBot.Features.Map/Posting/DiscordMapChannelPoster.cs
index fea5fb77..9af64031 100644
--- a/src/RustPlusBot.Features.Map/Posting/DiscordMapChannelPoster.cs
+++ b/src/RustPlusBot.Features.Map/Posting/DiscordMapChannelPoster.cs
@@ -67,7 +67,9 @@ private async Task DeletePriorBotMessagesAsync(ITextChannel channel, RequestOpti
{
var batch = await channel.GetMessagesAsync(RecentMessageScan, options: options).FlattenAsync()
.ConfigureAwait(false);
- foreach (var message in batch.Where(m => m.Author.Id == client.CurrentUser.Id))
+ // Delete only our prior IMAGE posts (they carry a file attachment); leave the persistent
+ // control message (text + toggle components, no attachment) for the workspace reconciler.
+ foreach (var message in batch.Where(m => m.Author.Id == client.CurrentUser.Id && m.Attachments.Count > 0))
{
await message.DeleteAsync(options).ConfigureAwait(false);
}
diff --git a/src/RustPlusBot.Features.Map/Rendering/MapLayerSet.cs b/src/RustPlusBot.Features.Map/Rendering/MapLayerSet.cs
index 774be366..192cb97f 100644
--- a/src/RustPlusBot.Features.Map/Rendering/MapLayerSet.cs
+++ b/src/RustPlusBot.Features.Map/Rendering/MapLayerSet.cs
@@ -1,14 +1,14 @@
namespace RustPlusBot.Features.Map.Rendering;
-/// Which overlay layers the renderer should draw. 2b uses ; 2b-ii feeds this from per-server settings.
-/// Draw the map grid lines (cell labels are a 2b-ii addition).
+/// Which overlay layers the renderer should draw.
+/// Draw the map grid lines.
/// Draw live cargo/heli/chinook markers.
-/// Draw monument icons (2b-ii).
-/// Draw the travelling-vendor marker (2b-ii).
-/// Style oil rigs by activation state (2b-ii).
-public sealed record MapLayerSet(bool Grid, bool Markers, bool Monuments, bool Vendor, bool Rigs)
+/// Draw monument icons.
+/// Draw the travelling-vendor marker.
+/// Draw teammate position markers.
+/// Style oil rigs by activation state.
+public sealed record MapLayerSet(bool Grid, bool Markers, bool Monuments, bool Vendor, bool Players, bool Rigs)
{
- /// The fixed layer set for subsystem 2b: grid + live markers on, the rest off.
- public static MapLayerSet Default2b { get; } =
- new(Grid: true, Markers: true, Monuments: false, Vendor: false, Rigs: false);
+ /// All layers enabled — the defaults-on render.
+ public static MapLayerSet AllOn { get; } = new(true, true, true, true, true, true);
}
diff --git a/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs b/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs
index 58c150a1..b3f83ecd 100644
--- a/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs
+++ b/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs
@@ -20,8 +20,10 @@ public sealed class MapRenderer
public const int OutputSize = 1024;
private const float GridDiameter = 146.25f;
- private const float MarkerRadius = 9f;
+ private const float PlayerRadius = 6f;
private const float OutlinePenWidth = 1f;
+ private const float ActiveRingWidth = 3f;
+ private const float PlayerLabelOffset = 12f;
private static readonly Font Font = LoadFont();
@@ -40,6 +42,9 @@ private static Font LoadFont()
/// The raw base-map JPEG bytes.
/// The map dimensions (world size + ocean margin).
/// Marker placements already projected to pixel coordinates.
+ /// Monument placements already projected to pixel coordinates.
+ /// Player placements already projected to pixel coordinates.
+ /// Oil-rig placements already projected to pixel coordinates.
/// Which overlay layers to draw.
/// 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.
@@ -47,12 +52,18 @@ private static Font LoadFont()
public byte[] Render(byte[] baseJpeg,
MapDimensions dims,
IReadOnlyList markers,
+ IReadOnlyList monuments,
+ IReadOnlyList players,
+ IReadOnlyList rigs,
MapLayerSet layers)
#pragma warning restore CA1822, S2325
{
ArgumentNullException.ThrowIfNull(baseJpeg);
ArgumentNullException.ThrowIfNull(dims);
ArgumentNullException.ThrowIfNull(markers);
+ ArgumentNullException.ThrowIfNull(monuments);
+ ArgumentNullException.ThrowIfNull(players);
+ ArgumentNullException.ThrowIfNull(rigs);
ArgumentNullException.ThrowIfNull(layers);
using var image = Image.Load(baseJpeg);
@@ -63,12 +74,24 @@ public byte[] Render(byte[] baseJpeg,
DrawGrid(image, dims);
}
+ if (layers.Monuments)
+ {
+ DrawMonuments(image, monuments);
+ }
+
if (layers.Markers)
{
- foreach (var marker in markers)
- {
- DrawMarker(image, marker);
- }
+ DrawMarkers(image, markers);
+ }
+
+ if (layers.Rigs)
+ {
+ DrawRigs(image, rigs);
+ }
+
+ if (layers.Players)
+ {
+ DrawPlayers(image, players);
}
using var ms = new MemoryStream();
@@ -99,23 +122,101 @@ private static void DrawGrid(Image image, MapDimensions dims)
});
}
- private static void DrawMarker(Image image, MarkerPlacement m)
+ private static void DrawMarkers(Image image, IReadOnlyList markers)
+ {
+ // One Mutate for the whole layer: each Mutate builds and runs a fresh processing pipeline,
+ // so batching all the layer's DrawImage calls into a single Mutate avoids per-item overhead.
+ image.Mutate(ctx =>
+ {
+ foreach (var marker in markers)
+ {
+ var icon = MapIcons.Marker(marker.Kind);
+ if (icon is not null)
+ {
+ ctx.DrawImage(icon, CenterAt(marker.PixelX, marker.PixelY, icon), 1f);
+ }
+ }
+ });
+ }
+
+ private static void DrawMonuments(Image image, IReadOnlyList monuments)
{
- var (color, letter) = MarkerGlyphs.For(m.Kind);
- var circle = new EllipsePolygon(m.PixelX, m.PixelY, MarkerRadius);
+ // One Mutate for the whole layer (see DrawMarkers): monuments can be numerous, so a single
+ // pipeline beats one Mutate per monument.
+ image.Mutate(ctx =>
+ {
+ foreach (var monument in monuments)
+ {
+ var icon = MapIcons.Monument(monument.Token);
+ if (icon is not null)
+ {
+ ctx.DrawImage(icon, CenterAt(monument.PixelX, monument.PixelY, icon), 1f);
+ }
+ }
+ });
+ }
+ private static void DrawRigs(Image image, IReadOnlyList rigs)
+ {
image.Mutate(ctx =>
{
- ctx.Fill(color, circle);
- ctx.Draw(Color.Black, OutlinePenWidth, circle);
+ foreach (var rig in rigs)
+ {
+ var icon = MapIcons.Rig(rig.Kind, rig.Active);
+ if (icon is null)
+ {
+ continue;
+ }
+
+ ctx.DrawImage(icon, CenterAt(rig.PixelX, rig.PixelY, icon), 1f);
+
+ if (rig.Active)
+ {
+ // Active rigs are in their combat window: ring them in red to flag the danger.
+ var radius = (Math.Max(icon.Width, icon.Height) / 2f) + ActiveRingWidth;
+ var ring = new EllipsePolygon(rig.PixelX, rig.PixelY, radius);
+ ctx.Draw(Color.Red, ActiveRingWidth, ring);
+ }
+ }
+ });
+ }
+
+ private static void DrawPlayers(Image image, IReadOnlyList players)
+ {
+ var icon = MapIcons.Player();
- var textOptions = new RichTextOptions(Font)
+ image.Mutate(ctx =>
+ {
+ foreach (var player in players)
{
- Origin = new PointF(m.PixelX, m.PixelY),
- HorizontalAlignment = HorizontalAlignment.Center,
- VerticalAlignment = VerticalAlignment.Center,
- };
- ctx.DrawText(textOptions, letter, Color.White);
+ var isActive = player is { IsAlive: true, IsOnline: true };
+
+ if (icon is not null)
+ {
+ ctx.DrawImage(icon, CenterAt(player.PixelX, player.PixelY, icon), 1f);
+ }
+ else
+ {
+ var dotColor = isActive ? Color.LimeGreen : Color.Gray;
+ var dot = new EllipsePolygon(player.PixelX, player.PixelY, PlayerRadius);
+ ctx.Fill(dotColor, dot);
+ ctx.Draw(Color.Black, OutlinePenWidth, dot);
+ }
+
+ var suffix = player.IsAlive ? " (offline)" : " (dead)";
+ var label = isActive ? player.Name : player.Name + suffix;
+ var labelColor = isActive ? Color.White : Color.Gray;
+ var textOptions = new RichTextOptions(Font)
+ {
+ Origin = new PointF(player.PixelX, player.PixelY + PlayerLabelOffset),
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Top,
+ };
+ ctx.DrawText(textOptions, label, labelColor);
+ }
});
}
+
+ private static Point CenterAt(float x, float y, Image icon) =>
+ new((int)(x - (icon.Width / 2f)), (int)(y - (icon.Height / 2f)));
}
diff --git a/src/RustPlusBot.Features.Map/Rendering/PlayerPlacement.cs b/src/RustPlusBot.Features.Map/Rendering/PlayerPlacement.cs
new file mode 100644
index 00000000..6a578f3a
--- /dev/null
+++ b/src/RustPlusBot.Features.Map/Rendering/PlayerPlacement.cs
@@ -0,0 +1,24 @@
+using RustPlusBot.Abstractions.Events;
+
+namespace RustPlusBot.Features.Map.Rendering;
+
+/// One teammate to draw, already projected to pixel coordinates.
+/// The player display name.
+/// Pixel X on the rendered tile.
+/// Pixel Y on the rendered tile.
+/// Whether the player is alive (dead players are styled differently).
+/// Whether the player is online.
+public sealed record PlayerPlacement(string Name, float PixelX, float PixelY, bool IsAlive, bool IsOnline);
+
+/// One monument to draw, already projected to pixel coordinates.
+/// The monument protobuf token (selects the icon).
+/// Pixel X on the rendered tile.
+/// Pixel Y on the rendered tile.
+public sealed record MonumentPlacement(string Token, float PixelX, float PixelY);
+
+/// One oil rig to draw with activation styling, already projected to pixel coordinates.
+/// Which rig.
+/// Pixel X on the rendered tile.
+/// Pixel Y on the rendered tile.
+/// Whether the rig is currently active (combat window).
+public sealed record RigPlacement(RigKind Kind, float PixelX, float PixelY, bool Active);
diff --git a/src/RustPlusBot.Features.Map/RustPlusBot.Features.Map.csproj b/src/RustPlusBot.Features.Map/RustPlusBot.Features.Map.csproj
index 486b7e1a..1ff8ad92 100644
--- a/src/RustPlusBot.Features.Map/RustPlusBot.Features.Map.csproj
+++ b/src/RustPlusBot.Features.Map/RustPlusBot.Features.Map.csproj
@@ -17,6 +17,7 @@
+
diff --git a/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs b/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs
index c3966cf8..699ffe63 100644
--- a/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs
+++ b/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs
@@ -45,6 +45,13 @@ internal sealed class LocalizationCatalog
["server.info.team.value"] = "{0}/{1} online · leader {2}",
["server.info.swap.placeholder"] = "Switch active player",
["server.info.none"] = "—",
+ ["map.control.header"] = "Map layers — toggle to show/hide:",
+ ["map.layer.grid"] = "Grid",
+ ["map.layer.markers"] = "Markers",
+ ["map.layer.monuments"] = "Monuments",
+ ["map.layer.vendor"] = "Vendor",
+ ["map.layer.players"] = "Players",
+ ["map.layer.rigs"] = "Rigs",
},
["fr"] = new Dictionary(StringComparer.Ordinal)
{
@@ -81,6 +88,13 @@ internal sealed class LocalizationCatalog
["server.info.team.value"] = "{0}/{1} en ligne · chef {2}",
["server.info.swap.placeholder"] = "Changer le joueur actif",
["server.info.none"] = "—",
+ ["map.control.header"] = "Calques de la carte — activer/désactiver :",
+ ["map.layer.grid"] = "Grille",
+ ["map.layer.markers"] = "Marqueurs",
+ ["map.layer.monuments"] = "Monuments",
+ ["map.layer.vendor"] = "Marchand",
+ ["map.layer.players"] = "Joueurs",
+ ["map.layer.rigs"] = "Plateformes",
},
},
};
diff --git a/src/RustPlusBot.Features.Workspace/Messages/MapControlMessageRenderer.cs b/src/RustPlusBot.Features.Workspace/Messages/MapControlMessageRenderer.cs
new file mode 100644
index 00000000..27c9070f
--- /dev/null
+++ b/src/RustPlusBot.Features.Workspace/Messages/MapControlMessageRenderer.cs
@@ -0,0 +1,57 @@
+using Discord;
+using RustPlusBot.Features.Workspace.Gateway;
+using RustPlusBot.Features.Workspace.Localization;
+using RustPlusBot.Features.Workspace.Registry;
+using RustPlusBot.Persistence.Map;
+
+namespace RustPlusBot.Features.Workspace.Messages;
+
+/// Renders the per-server #map control message: six ManageGuild layer-toggle buttons.
+/// Per-(guild, server) layer toggles.
+/// String resolution.
+internal sealed class MapControlMessageRenderer(
+ IMapSettingsStore mapSettings,
+ ILocalizer localizer) : IMessageRenderer
+{
+ ///
+ public string MessageKey => WorkspaceMessageKeys.ServerMap;
+
+ ///
+ public async ValueTask RenderAsync(MessageRenderContext context,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+ if (context.ServerId is not Guid serverId)
+ {
+ return new MessagePayload(null, null, null);
+ }
+
+ var settings = await mapSettings.GetAsync(context.GuildId, serverId, cancellationToken).ConfigureAwait(false);
+
+ var builder = new ComponentBuilder();
+ // Discord caps an action row at five buttons, so split the six toggles across two rows.
+ AddToggle(builder, MapLayer.Grid, "map.layer.grid", settings.Grid, serverId, context.Culture, row: 0);
+ AddToggle(builder, MapLayer.Markers, "map.layer.markers", settings.Markers, serverId, context.Culture, row: 0);
+ AddToggle(builder, MapLayer.Monuments, "map.layer.monuments", settings.Monuments, serverId, context.Culture,
+ row: 0);
+ AddToggle(builder, MapLayer.Vendor, "map.layer.vendor", settings.Vendor, serverId, context.Culture, row: 1);
+ AddToggle(builder, MapLayer.Players, "map.layer.players", settings.Players, serverId, context.Culture, row: 1);
+ AddToggle(builder, MapLayer.Rigs, "map.layer.rigs", settings.Rigs, serverId, context.Culture, row: 1);
+
+ var header = localizer.Get("map.control.header", context.Culture);
+ return new MessagePayload(header, null, builder.Build());
+ }
+
+ private void AddToggle(ComponentBuilder builder,
+ MapLayer layer,
+ string labelKey,
+ bool enabled,
+ Guid serverId,
+ string culture,
+ int row) =>
+ builder.WithButton(
+ localizer.Get(labelKey, culture),
+ $"{WorkspaceComponentIds.MapTogglePrefix}{layer}:{serverId}",
+ enabled ? ButtonStyle.Success : ButtonStyle.Secondary,
+ row: row);
+}
diff --git a/src/RustPlusBot.Features.Workspace/Modules/MapComponentModule.cs b/src/RustPlusBot.Features.Workspace/Modules/MapComponentModule.cs
new file mode 100644
index 00000000..2d7ae6d3
--- /dev/null
+++ b/src/RustPlusBot.Features.Workspace/Modules/MapComponentModule.cs
@@ -0,0 +1,73 @@
+using Discord;
+using Discord.Interactions;
+using Microsoft.Extensions.DependencyInjection;
+using RustPlusBot.Abstractions.Events;
+using RustPlusBot.Features.Workspace.Reconciler;
+using RustPlusBot.Persistence.Map;
+using RustPlusBot.Persistence.Servers;
+
+namespace RustPlusBot.Features.Workspace.Modules;
+
+/// Handles the #map layer-toggle buttons (ManageGuild).
+/// Creates a short-lived DI scope per interaction.
+/// Publishes a settings-changed event to trigger an immediate repaint.
+public sealed class MapComponentModule(IServiceScopeFactory scopeFactory, IEventBus eventBus)
+ : InteractionModuleBase
+{
+ /// Flips one layer, re-renders the control message, and triggers a map repaint.
+ /// The "{layer}:{serverId}" tail captured from the custom id.
+ [ComponentInteraction(WorkspaceComponentIds.MapTogglePrefix + "*")]
+ [RequireUserPermission(GuildPermission.ManageGuild)]
+ public async Task ToggleAsync(string tail)
+ {
+ ArgumentNullException.ThrowIfNull(tail);
+ if (Context.Guild is null)
+ {
+ await RespondAsync("This control must be used in a server.", ephemeral: true).ConfigureAwait(false);
+ return;
+ }
+
+ var parts = tail.Split(':');
+ if (parts.Length != 2
+ || !Enum.TryParse(parts[0], ignoreCase: false, out var layer)
+ || !Guid.TryParse(parts[1], out var serverId))
+ {
+ await RespondAsync("That control wasn't valid.", ephemeral: true).ConfigureAwait(false);
+ return;
+ }
+
+ await DeferAsync(ephemeral: true).ConfigureAwait(false);
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ var servers = scope.ServiceProvider.GetRequiredService();
+ if (await servers.GetAsync(Context.Guild.Id, serverId).ConfigureAwait(false) is null)
+ {
+ await FollowupAsync("That server isn't available.", ephemeral: true).ConfigureAwait(false);
+ return;
+ }
+
+ var store = scope.ServiceProvider.GetRequiredService();
+ var current = await store.GetAsync(Context.Guild.Id, serverId).ConfigureAwait(false);
+ var newValue = !IsEnabled(current, layer);
+ await store.SetLayerAsync(Context.Guild.Id, serverId, layer, newValue).ConfigureAwait(false);
+
+ var reconciler = scope.ServiceProvider.GetRequiredService();
+ await reconciler.ReconcileServerAsync(Context.Guild.Id, serverId).ConfigureAwait(false);
+ }
+
+ await eventBus.PublishAsync(new MapSettingsChangedEvent(Context.Guild.Id, serverId)).ConfigureAwait(false);
+ await FollowupAsync("Updated map layers.", ephemeral: true).ConfigureAwait(false);
+ }
+
+ private static bool IsEnabled(MapLayerSettings settings, MapLayer layer) => layer switch
+ {
+ MapLayer.Grid => settings.Grid,
+ MapLayer.Markers => settings.Markers,
+ MapLayer.Monuments => settings.Monuments,
+ MapLayer.Vendor => settings.Vendor,
+ MapLayer.Players => settings.Players,
+ MapLayer.Rigs => settings.Rigs,
+ _ => true,
+ };
+}
diff --git a/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs b/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs
index e4cd5e5f..479321fe 100644
--- a/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs
+++ b/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs
@@ -22,5 +22,6 @@ public IEnumerable GetChannelSpecs() =>
public IEnumerable GetMessageSpecs() =>
[
new(WorkspaceScope.PerServer, WorkspaceMessageKeys.ServerInfo, WorkspaceChannelKeys.ServerInfo),
+ new(WorkspaceScope.PerServer, WorkspaceMessageKeys.ServerMap, WorkspaceChannelKeys.ServerMap),
];
}
diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceComponentIds.cs b/src/RustPlusBot.Features.Workspace/WorkspaceComponentIds.cs
index cfba54dd..8f00ffe7 100644
--- a/src/RustPlusBot.Features.Workspace/WorkspaceComponentIds.cs
+++ b/src/RustPlusBot.Features.Workspace/WorkspaceComponentIds.cs
@@ -17,4 +17,7 @@ public static class WorkspaceComponentIds
/// Prefix for the #info "remove server" button; the server id is appended. Handled by Connections.
public const string ServerInfoRemovePrefix = "workspace:info:remove:";
+
+ /// Prefix for a #map layer toggle button; "{layer}:{serverId}" is appended. Handled by Workspace.
+ public const string MapTogglePrefix = "workspace:map:toggle:";
}
diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs b/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs
index ca167d32..0c05d8b7 100644
--- a/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs
+++ b/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs
@@ -39,4 +39,7 @@ internal static class WorkspaceMessageKeys
/// Key for the per-server info message.
public const string ServerInfo = "server.info";
+
+ /// Key for the per-server map control message (layer toggles).
+ public const string ServerMap = "server.map";
}
diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs
index 80b80f7b..42deaf1f 100644
--- a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs
+++ b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs
@@ -43,6 +43,7 @@ public static IServiceCollection AddWorkspace(this IServiceCollection services)
// ServerInfoMessageRenderer requires IRustServerQuery, which is registered by AddConnections —
// the host must compose both AddWorkspace and AddConnections.
services.AddScoped();
+ services.AddScoped();
// Reconciler + teardown (scoped). Register the teardown service once and expose both interfaces
// off the same scoped instance, so resolving either does not create a second instance.
diff --git a/src/RustPlusBot.Persistence/BotDbContext.cs b/src/RustPlusBot.Persistence/BotDbContext.cs
index b6775ce4..6090e305 100644
--- a/src/RustPlusBot.Persistence/BotDbContext.cs
+++ b/src/RustPlusBot.Persistence/BotDbContext.cs
@@ -6,6 +6,7 @@
using RustPlusBot.Domain.Entities;
using RustPlusBot.Domain.Events;
using RustPlusBot.Domain.Guilds;
+using RustPlusBot.Domain.Map;
using RustPlusBot.Domain.Servers;
using RustPlusBot.Domain.Workspace;
using RustPlusBot.Persistence.Configurations;
@@ -34,6 +35,9 @@ public sealed class BotDbContext(DbContextOptions options) : Disco
/// Per-server command settings (trigger prefix and mute state).
public DbSet ServerCommandSettings => Set();
+ /// Per-(guild, server) rendered-map layer settings.
+ public DbSet ServerMapSettings => Set();
+
/// Per-guild settings.
public DbSet GuildSettings => Set();
@@ -64,6 +68,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
.ApplyConfiguration(new FcmRegistrationConfiguration())
.ApplyConfiguration(new ConnectionStateConfiguration())
.ApplyConfiguration(new ServerCommandSettingsConfiguration())
+ .ApplyConfiguration(new ServerMapSettingsConfiguration())
.ApplyConfiguration(new GuildSettingsConfiguration())
.ApplyConfiguration(new PairedEntityConfiguration())
.ApplyConfiguration(new EventSubscriptionConfiguration())
diff --git a/src/RustPlusBot.Persistence/Configurations/ServerMapSettingsConfiguration.cs b/src/RustPlusBot.Persistence/Configurations/ServerMapSettingsConfiguration.cs
new file mode 100644
index 00000000..f0d228ae
--- /dev/null
+++ b/src/RustPlusBot.Persistence/Configurations/ServerMapSettingsConfiguration.cs
@@ -0,0 +1,22 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using RustPlusBot.Domain.Map;
+using RustPlusBot.Domain.Servers;
+
+namespace RustPlusBot.Persistence.Configurations;
+
+internal sealed class ServerMapSettingsConfiguration : IEntityTypeConfiguration
+{
+ ///
+ public void Configure(EntityTypeBuilder builder)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ builder.HasKey(s => s.ServerId);
+
+ // Removing a RustServer cascades to its single map-settings row so no orphaned config lingers.
+ builder.HasOne()
+ .WithOne()
+ .HasForeignKey(s => s.ServerId)
+ .OnDelete(DeleteBehavior.Cascade);
+ }
+}
diff --git a/src/RustPlusBot.Persistence/Map/IMapSettingsStore.cs b/src/RustPlusBot.Persistence/Map/IMapSettingsStore.cs
new file mode 100644
index 00000000..47a4af17
--- /dev/null
+++ b/src/RustPlusBot.Persistence/Map/IMapSettingsStore.cs
@@ -0,0 +1,25 @@
+namespace RustPlusBot.Persistence.Map;
+
+/// Reads/writes per-(guild, server) map layer settings.
+public interface IMapSettingsStore
+{
+ /// Gets the layer toggles, returning all-on defaults when no row exists.
+ /// Owning Discord guild snowflake.
+ /// The Rust server id.
+ /// A cancellation token.
+ /// The resolved layer toggles.
+ Task GetAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default);
+
+ /// Sets one layer's enabled state, creating the row (all-on) if needed.
+ /// Owning Discord guild snowflake.
+ /// The Rust server id.
+ /// Which layer to change.
+ /// The new enabled state.
+ /// A cancellation token.
+ /// A task that completes when persisted.
+ Task SetLayerAsync(ulong guildId,
+ Guid serverId,
+ MapLayer layer,
+ bool enabled,
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/RustPlusBot.Persistence/Map/MapLayer.cs b/src/RustPlusBot.Persistence/Map/MapLayer.cs
new file mode 100644
index 00000000..c17f2092
--- /dev/null
+++ b/src/RustPlusBot.Persistence/Map/MapLayer.cs
@@ -0,0 +1,42 @@
+namespace RustPlusBot.Persistence.Map;
+
+/// A single toggleable map render layer.
+public enum MapLayer
+{
+ /// The grid lines layer.
+ Grid = 0,
+
+ /// The live cargo/heli/chinook markers layer.
+ Markers = 1,
+
+ /// The monument icons layer.
+ Monuments = 2,
+
+ /// The travelling-vendor layer.
+ Vendor = 3,
+
+ /// The teammate-positions layer.
+ Players = 4,
+
+ /// The oil-rig activation-styling layer.
+ Rigs = 5,
+}
+
+/// The resolved per-server layer toggles (all-on when no row is stored).
+/// Grid lines.
+/// Live cargo/heli/chinook markers.
+/// Monument icons.
+/// Travelling vendor.
+/// Teammate positions.
+/// Oil-rig activation styling.
+public sealed record MapLayerSettings(
+ bool Grid,
+ bool Markers,
+ bool Monuments,
+ bool Vendor,
+ bool Players,
+ bool Rigs)
+{
+ /// All layers enabled — the default when no settings row exists.
+ public static MapLayerSettings AllOn { get; } = new(true, true, true, true, true, true);
+}
diff --git a/src/RustPlusBot.Persistence/Map/MapSettingsStore.cs b/src/RustPlusBot.Persistence/Map/MapSettingsStore.cs
new file mode 100644
index 00000000..ca766684
--- /dev/null
+++ b/src/RustPlusBot.Persistence/Map/MapSettingsStore.cs
@@ -0,0 +1,57 @@
+using Microsoft.EntityFrameworkCore;
+using RustPlusBot.Domain.Map;
+
+namespace RustPlusBot.Persistence.Map;
+
+/// EF-backed .
+/// The bot database context.
+public sealed class MapSettingsStore(BotDbContext context) : IMapSettingsStore
+{
+ ///
+ public async Task GetAsync(ulong guildId,
+ Guid serverId,
+ CancellationToken cancellationToken = default)
+ {
+ var row = await context.ServerMapSettings
+ .SingleOrDefaultAsync(s => s.GuildId == guildId && s.ServerId == serverId, cancellationToken)
+ .ConfigureAwait(false);
+ return row is null
+ ? MapLayerSettings.AllOn
+ : new MapLayerSettings(row.ShowGrid, row.ShowMarkers, row.ShowMonuments, row.ShowVendor,
+ row.ShowPlayers, row.ShowRigs);
+ }
+
+ ///
+ public async Task SetLayerAsync(ulong guildId,
+ Guid serverId,
+ MapLayer layer,
+ bool enabled,
+ CancellationToken cancellationToken = default)
+ {
+ var existing = await context.ServerMapSettings
+ .SingleOrDefaultAsync(s => s.GuildId == guildId && s.ServerId == serverId, cancellationToken)
+ .ConfigureAwait(false);
+
+ var row = existing ?? new ServerMapSettings
+ {
+ GuildId = guildId, ServerId = serverId
+ };
+ switch (layer)
+ {
+ case MapLayer.Grid: row.ShowGrid = enabled; break;
+ case MapLayer.Markers: row.ShowMarkers = enabled; break;
+ case MapLayer.Monuments: row.ShowMonuments = enabled; break;
+ case MapLayer.Vendor: row.ShowVendor = enabled; break;
+ case MapLayer.Players: row.ShowPlayers = enabled; break;
+ case MapLayer.Rigs: row.ShowRigs = enabled; break;
+ default: throw new ArgumentOutOfRangeException(nameof(layer), layer, "Unknown map layer.");
+ }
+
+ if (existing is null)
+ {
+ context.ServerMapSettings.Add(row);
+ }
+
+ await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ }
+}
diff --git a/src/RustPlusBot.Persistence/Migrations/20260618164017_MapSettings.Designer.cs b/src/RustPlusBot.Persistence/Migrations/20260618164017_MapSettings.Designer.cs
new file mode 100644
index 00000000..8d2422cc
--- /dev/null
+++ b/src/RustPlusBot.Persistence/Migrations/20260618164017_MapSettings.Designer.cs
@@ -0,0 +1,544 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using RustPlusBot.Persistence;
+
+#nullable disable
+
+namespace RustPlusBot.Persistence.Migrations
+{
+ [DbContext(typeof(BotDbContext))]
+ [Migration("20260618164017_MapSettings")]
+ partial class MapSettings
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "10.0.9");
+
+ modelBuilder.Entity("Persistord.Core.Entities.ChannelEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("ParentId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Type")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GuildId");
+
+ b.HasIndex("ParentId");
+
+ b.ToTable("Channels");
+ });
+
+ modelBuilder.Entity("Persistord.Core.Entities.GuildEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("OwnerId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.ToTable("Guilds");
+ });
+
+ modelBuilder.Entity("Persistord.Core.Entities.MemberEntity", b =>
+ {
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("UserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("JoinedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Nickname")
+ .HasColumnType("TEXT");
+
+ b.HasKey("GuildId", "UserId");
+
+ b.ToTable("Members");
+ });
+
+ modelBuilder.Entity("Persistord.Core.Entities.RoleEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("INTEGER");
+
+ b.Property("Color")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Permissions")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GuildId");
+
+ b.ToTable("Roles");
+ });
+
+ modelBuilder.Entity("Persistord.Core.Entities.UserEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("INTEGER");
+
+ b.Property("GlobalName")
+ .HasColumnType("TEXT");
+
+ b.Property("Username")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.ToTable("Users");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Commands.ServerCommandSettings", b =>
+ {
+ b.Property("ServerId")
+ .HasColumnType("TEXT");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Muted")
+ .HasColumnType("INTEGER");
+
+ b.Property("Prefix")
+ .IsRequired()
+ .HasMaxLength(8)
+ .HasColumnType("TEXT");
+
+ b.HasKey("ServerId");
+
+ b.ToTable("ServerCommandSettings");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Connections.ConnectionState", b =>
+ {
+ b.Property("RustServerId")
+ .HasColumnType("TEXT");
+
+ b.Property("ActiveCredentialId")
+ .HasColumnType("TEXT");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("PlayerCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("Status")
+ .HasColumnType("INTEGER");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.HasKey("RustServerId");
+
+ b.ToTable("ConnectionStates");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Credentials.FcmRegistration", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("OwnerUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ProtectedFcmCredentials")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Status")
+ .HasColumnType("INTEGER");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GuildId", "OwnerUserId")
+ .IsUnique();
+
+ b.ToTable("FcmRegistrations");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Credentials.PlayerCredential", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("OwnerUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ProtectedPlayerToken")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("RustServerId")
+ .HasColumnType("TEXT");
+
+ b.Property("Status")
+ .HasColumnType("INTEGER");
+
+ b.Property("SteamId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RustServerId");
+
+ b.HasIndex("GuildId", "RustServerId", "OwnerUserId")
+ .IsUnique();
+
+ b.ToTable("PlayerCredentials");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Entities.PairedEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("EntityId")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Kind")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("RustServerId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GuildId", "RustServerId");
+
+ b.ToTable("PairedEntities");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Events.EventSubscription", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("EventKey")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("RustServerId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GuildId", "RustServerId");
+
+ b.ToTable("EventSubscriptions");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Guilds.GuildSettings", b =>
+ {
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Culture")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.HasKey("GuildId");
+
+ b.ToTable("GuildSettings");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Map.ServerMapSettings", b =>
+ {
+ b.Property("ServerId")
+ .HasColumnType("TEXT");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShowGrid")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShowMarkers")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShowMonuments")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShowPlayers")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShowRigs")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShowVendor")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ServerId");
+
+ b.ToTable("ServerMapSettings");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Servers.RustServer", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("AddedByUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Ip")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("Port")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GuildId");
+
+ b.HasIndex("GuildId", "Ip", "Port")
+ .IsUnique();
+
+ b.ToTable("RustServers");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedCategory", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("DiscordCategoryId")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("RustServerId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RustServerId");
+
+ b.HasIndex("GuildId", "RustServerId")
+ .IsUnique();
+
+ b.ToTable("ProvisionedCategories");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedChannel", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("ChannelKey")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("DiscordChannelId")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("RustServerId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RustServerId");
+
+ b.HasIndex("GuildId", "RustServerId", "ChannelKey")
+ .IsUnique();
+
+ b.ToTable("ProvisionedChannels");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedMessage", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("DiscordChannelId")
+ .HasColumnType("INTEGER");
+
+ b.Property("DiscordMessageId")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("MessageKey")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("RustServerId")
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RustServerId");
+
+ b.HasIndex("GuildId", "RustServerId", "MessageKey")
+ .IsUnique();
+
+ b.ToTable("ProvisionedMessages");
+ });
+
+ modelBuilder.Entity("Persistord.Core.Entities.ChannelEntity", b =>
+ {
+ b.HasOne("Persistord.Core.Entities.ChannelEntity", null)
+ .WithMany()
+ .HasForeignKey("ParentId")
+ .OnDelete(DeleteBehavior.Restrict);
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Commands.ServerCommandSettings", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithOne()
+ .HasForeignKey("RustPlusBot.Domain.Commands.ServerCommandSettings", "ServerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Connections.ConnectionState", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithOne()
+ .HasForeignKey("RustPlusBot.Domain.Connections.ConnectionState", "RustServerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Credentials.PlayerCredential", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithMany()
+ .HasForeignKey("RustServerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Map.ServerMapSettings", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithOne()
+ .HasForeignKey("RustPlusBot.Domain.Map.ServerMapSettings", "ServerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedCategory", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithMany()
+ .HasForeignKey("RustServerId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedChannel", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithMany()
+ .HasForeignKey("RustServerId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedMessage", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithMany()
+ .HasForeignKey("RustServerId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/RustPlusBot.Persistence/Migrations/20260618164017_MapSettings.cs b/src/RustPlusBot.Persistence/Migrations/20260618164017_MapSettings.cs
new file mode 100644
index 00000000..a16568f5
--- /dev/null
+++ b/src/RustPlusBot.Persistence/Migrations/20260618164017_MapSettings.cs
@@ -0,0 +1,46 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace RustPlusBot.Persistence.Migrations
+{
+ ///
+ public partial class MapSettings : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "ServerMapSettings",
+ columns: table => new
+ {
+ ServerId = table.Column(type: "TEXT", nullable: false),
+ GuildId = table.Column(type: "INTEGER", nullable: false),
+ ShowGrid = table.Column(type: "INTEGER", nullable: false),
+ ShowMarkers = table.Column(type: "INTEGER", nullable: false),
+ ShowMonuments = table.Column(type: "INTEGER", nullable: false),
+ ShowVendor = table.Column(type: "INTEGER", nullable: false),
+ ShowPlayers = table.Column(type: "INTEGER", nullable: false),
+ ShowRigs = table.Column(type: "INTEGER", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_ServerMapSettings", x => x.ServerId);
+ table.ForeignKey(
+ name: "FK_ServerMapSettings_RustServers_ServerId",
+ column: x => x.ServerId,
+ principalTable: "RustServers",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "ServerMapSettings");
+ }
+ }
+}
diff --git a/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs b/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs
index f864f3ec..73fe4780 100644
--- a/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs
+++ b/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs
@@ -302,6 +302,37 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.ToTable("GuildSettings");
});
+ modelBuilder.Entity("RustPlusBot.Domain.Map.ServerMapSettings", b =>
+ {
+ b.Property("ServerId")
+ .HasColumnType("TEXT");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShowGrid")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShowMarkers")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShowMonuments")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShowPlayers")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShowRigs")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShowVendor")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ServerId");
+
+ b.ToTable("ServerMapSettings");
+ });
+
modelBuilder.Entity("RustPlusBot.Domain.Servers.RustServer", b =>
{
b.Property("Id")
@@ -472,6 +503,15 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.IsRequired();
});
+ modelBuilder.Entity("RustPlusBot.Domain.Map.ServerMapSettings", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithOne()
+ .HasForeignKey("RustPlusBot.Domain.Map.ServerMapSettings", "ServerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedCategory", b =>
{
b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
diff --git a/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs b/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs
index d3972517..67506209 100644
--- a/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs
+++ b/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs
@@ -4,6 +4,7 @@
using RustPlusBot.Persistence.Commands;
using RustPlusBot.Persistence.Connections;
using RustPlusBot.Persistence.Credentials;
+using RustPlusBot.Persistence.Map;
using RustPlusBot.Persistence.Servers;
using RustPlusBot.Persistence.Workspace;
@@ -35,6 +36,7 @@ public static IServiceCollection AddBotPersistence(this IServiceCollection servi
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
return services;
}
diff --git a/tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs b/tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs
index 86058180..bbaf6413 100644
--- a/tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs
+++ b/tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs
@@ -244,6 +244,18 @@ public async Task PromoteToLeader_ReturnsFalse_WhenNoLiveSocket()
Assert.False(promoted);
}
+ [Fact]
+ public async Task GetMonumentsAsync_returns_empty_when_no_live_socket()
+ {
+ var source = new FakeRustSocketSource();
+ var (provider, supervisor) = CreateHarness(source);
+ await using var _ = provider;
+
+ var result = await supervisor.GetMonumentsAsync(10UL, Guid.NewGuid(), CancellationToken.None);
+
+ Assert.Empty(result);
+ }
+
private static async Task WaitUntilAsync(Func condition, CancellationToken ct)
{
while (!condition())
diff --git a/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs
index 7d827da6..f454840e 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 NSubstitute;
+using RustPlusBot.Abstractions.Events;
using RustPlusBot.Features.Connections.Listening;
using RustPlusBot.Features.Events.State;
using RustPlusBot.Features.Map.Composing;
using RustPlusBot.Features.Map.Rendering;
+using RustPlusBot.Persistence.Map;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using Xunit;
@@ -23,21 +26,56 @@ private static byte[] BaseJpeg()
return ms.ToArray();
}
- private static MapComposer Build(byte[]? baseImage, MapDimensions? dims, params ActiveMarker[] markers)
+ private static IServiceScopeFactory ScopeFactory(IMapSettingsStore settingsStore) =>
+ new ServiceCollection()
+ .AddScoped(_ => settingsStore)
+ .BuildServiceProvider()
+ .GetRequiredService();
+
+ private static MapComposer Build(
+ byte[]? baseImage,
+ MapDimensions? dims,
+ IRustServerQuery query,
+ IEventState events,
+ IRigState rigs,
+ IMapSettingsStore settingsStore)
{
- var query = Substitute.For();
query.GetMapImageAsync(Guild, Server, Arg.Any()).Returns(baseImage);
query.GetMapDimensionsAsync(Guild, Server, Arg.Any()).Returns(dims);
+ return new MapComposer(new BaseMapCache(query), events, rigs, query, new MapRenderer(),
+ ScopeFactory(settingsStore));
+ }
+
+ private static IRustServerQuery NewQuery() => Substitute.For();
+
+ private static IEventState NewEvents(params ActiveMarker[] markers)
+ {
var events = Substitute.For();
events.GetActiveMarkers(Guild, Server, Arg.Any())
.Returns(ci => markers.Where(m => m.Kind == (MarkerKind)ci[2]!).ToList());
- return new MapComposer(new BaseMapCache(query), events, query, new MapRenderer());
+ return events;
+ }
+
+ private static IRigState NewRigs()
+ {
+ var rigs = Substitute.For();
+ rigs.Get(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(new RigState(RigStatus.Online, null));
+ return rigs;
+ }
+
+ private static IMapSettingsStore NewSettings(MapLayerSettings settings)
+ {
+ var store = Substitute.For();
+ store.GetAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(settings);
+ return store;
}
[Fact]
- public async Task Returns_null_when_no_base_map_available()
+ public async Task ComposeAsync_returns_null_when_no_base_map()
{
- var composer = Build(baseImage: null, dims: Dims);
+ var composer = Build(baseImage: null, Dims, NewQuery(), NewEvents(), NewRigs(),
+ NewSettings(MapLayerSettings.AllOn));
var png = await composer.ComposeAsync(Guild, Server, CancellationToken.None);
@@ -48,7 +86,8 @@ public async Task Returns_null_when_no_base_map_available()
public async Task Renders_a_png_when_base_map_available()
{
var marker = new ActiveMarker(1, MarkerKind.CargoShip, 2000f, 2000f, Dims, DateTimeOffset.UtcNow);
- var composer = Build(BaseJpeg(), Dims, marker);
+ var composer = Build(BaseJpeg(), Dims, NewQuery(), NewEvents(marker), NewRigs(),
+ NewSettings(MapLayerSettings.AllOn));
var png = await composer.ComposeAsync(Guild, Server, CancellationToken.None);
@@ -58,33 +97,84 @@ public async Task Renders_a_png_when_base_map_available()
}
[Fact]
- public async Task Renders_the_grid_even_with_no_markers()
+ public async Task Renders_base_only_when_dimensions_unavailable()
{
- // Dimensions come from the query seam, not from a marker, so a connected server with no active
- // markers still renders the gridded map (not a bare base tile).
- var composer = Build(BaseJpeg(), Dims);
+ var composer = Build(BaseJpeg(), dims: null, NewQuery(),
+ NewEvents(new ActiveMarker(1, MarkerKind.CargoShip, 2000f, 2000f, Dims, DateTimeOffset.UtcNow)),
+ NewRigs(), NewSettings(MapLayerSettings.AllOn));
- var withoutGrid = new MapRenderer().Render(BaseJpeg(), Dims, markers: [],
- new MapLayerSet(Grid: false, Markers: false, Monuments: false, Vendor: false, Rigs: false));
var png = await composer.ComposeAsync(Guild, Server, CancellationToken.None);
Assert.NotNull(png);
using var result = Image.Load(png!);
Assert.Equal(MapRenderer.OutputSize, result.Width);
- // The grid was drawn: the gridded output differs from a base-only (grid-off) render.
- Assert.NotEqual(withoutGrid, png);
}
[Fact]
- public async Task Renders_base_only_when_dimensions_unavailable()
+ public async Task ComposeAsync_renders_only_enabled_layers()
{
- var composer = Build(BaseJpeg(), dims: null,
- new ActiveMarker(1, MarkerKind.CargoShip, 2000f, 2000f, Dims, DateTimeOffset.UtcNow));
+ // Monuments + Rigs off; Markers + Players on. The composer must NOT touch the monuments seam.
+ var query = NewQuery();
+ var team = new TeamInfoSnapshot(0,
+ [new TeamMemberSnapshot(1, "Ada", 2000f, 2000f, true, true, default, default)]);
+ query.GetTeamInfoAsync(Guild, Server, Arg.Any()).Returns(team);
+ var events = NewEvents(new ActiveMarker(1, MarkerKind.CargoShip, 2000f, 2000f, Dims, DateTimeOffset.UtcNow));
+ var settings = NewSettings(new MapLayerSettings(
+ Grid: true, Markers: true, Monuments: false, Vendor: true, Players: true, Rigs: false));
+
+ var composer = Build(BaseJpeg(), Dims, query, events, NewRigs(), settings);
var png = await composer.ComposeAsync(Guild, Server, CancellationToken.None);
Assert.NotNull(png);
- using var result = Image.Load(png!);
- Assert.Equal(MapRenderer.OutputSize, result.Width);
+ // Monuments + Rigs both disabled -> no monument round-trip at all.
+ await query.DidNotReceive().GetMonumentsAsync(Guild, Server, Arg.Any());
+ // Players enabled -> the team seam was consulted.
+ await query.Received().GetTeamInfoAsync(Guild, Server, Arg.Any());
+ }
+
+ [Fact]
+ public async Task ComposeAsync_uses_all_on_when_no_settings_row()
+ {
+ // A store with no row returns AllOn (its documented default) -> every gather path runs.
+ var query = NewQuery();
+ query.GetMonumentsAsync(Guild, Server, Arg.Any())
+ .Returns([new MonumentSnapshot("oilrig_1", 2000f, 2000f)]);
+ query.GetTeamInfoAsync(Guild, Server, Arg.Any())
+ .Returns(new TeamInfoSnapshot(0,
+ [new TeamMemberSnapshot(1, "Ada", 2000f, 2000f, true, true, default, default)]));
+ var events = NewEvents(new ActiveMarker(1, MarkerKind.CargoShip, 2000f, 2000f, Dims, DateTimeOffset.UtcNow));
+
+ var composer = Build(BaseJpeg(), Dims, query, events, NewRigs(), NewSettings(MapLayerSettings.AllOn));
+
+ var png = await composer.ComposeAsync(Guild, Server, CancellationToken.None);
+
+ Assert.NotNull(png);
+ await query.Received().GetMonumentsAsync(Guild, Server, Arg.Any());
+ await query.Received().GetTeamInfoAsync(Guild, Server, Arg.Any());
+ }
+
+ [Fact]
+ public async Task Renders_the_grid_even_with_no_markers()
+ {
+ // Regression guard: on a low-activity / just-connected server there are zero active markers.
+ // The composer must still render the grid (and produce a different image than with grid off),
+ // proving it does NOT early-return on an empty marker list.
+ var jpeg = BaseJpeg();
+
+ var composerOn = Build(jpeg, Dims, NewQuery(), NewEvents(), NewRigs(),
+ NewSettings(new MapLayerSettings(
+ Grid: true, Markers: true, Monuments: false, Vendor: false, Players: false, Rigs: false)));
+ var composerOff = Build(jpeg, Dims, NewQuery(), NewEvents(), NewRigs(),
+ NewSettings(new MapLayerSettings(
+ Grid: false, Markers: true, Monuments: false, Vendor: false, Players: false, Rigs: false)));
+
+ var pngOn = await composerOn.ComposeAsync(Guild, Server, CancellationToken.None);
+ var pngOff = await composerOff.ComposeAsync(Guild, Server, CancellationToken.None);
+
+ Assert.NotNull(pngOn);
+ Assert.NotNull(pngOff);
+ // The grid layer must have painted at least one pixel differently.
+ Assert.False(pngOn!.SequenceEqual(pngOff!), "Grid-on and grid-off renders must differ.");
}
}
diff --git a/tests/RustPlusBot.Features.Map.Tests/MapIconsTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapIconsTests.cs
new file mode 100644
index 00000000..893674eb
--- /dev/null
+++ b/tests/RustPlusBot.Features.Map.Tests/MapIconsTests.cs
@@ -0,0 +1,41 @@
+using RustPlusBot.Abstractions.Events;
+using RustPlusBot.Features.Connections.Listening;
+using RustPlusBot.Features.Map.Assets;
+using Xunit;
+
+namespace RustPlusBot.Features.Map.Tests;
+
+public sealed class MapIconsTests
+{
+ [Theory]
+ [InlineData(MarkerKind.CargoShip)]
+ [InlineData(MarkerKind.PatrolHelicopter)]
+ [InlineData(MarkerKind.Chinook)]
+ 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("oilrig_1"));
+ 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());
+}
diff --git a/tests/RustPlusBot.Features.Map.Tests/MapLayerSetTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapLayerSetTests.cs
index 94ada227..fc187a3e 100644
--- a/tests/RustPlusBot.Features.Map.Tests/MapLayerSetTests.cs
+++ b/tests/RustPlusBot.Features.Map.Tests/MapLayerSetTests.cs
@@ -6,14 +6,15 @@ namespace RustPlusBot.Features.Map.Tests;
public sealed class MapLayerSetTests
{
[Fact]
- public void Default2b_enables_grid_and_markers_only()
+ public void AllOn_enables_every_layer()
{
- var set = MapLayerSet.Default2b;
+ var set = MapLayerSet.AllOn;
Assert.True(set.Grid);
Assert.True(set.Markers);
- Assert.False(set.Monuments);
- Assert.False(set.Vendor);
- Assert.False(set.Rigs);
+ Assert.True(set.Monuments);
+ Assert.True(set.Vendor);
+ Assert.True(set.Players);
+ Assert.True(set.Rigs);
}
}
diff --git a/tests/RustPlusBot.Features.Map.Tests/MapRegistrationTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapRegistrationTests.cs
index 2b994bd0..004f5da3 100644
--- a/tests/RustPlusBot.Features.Map.Tests/MapRegistrationTests.cs
+++ b/tests/RustPlusBot.Features.Map.Tests/MapRegistrationTests.cs
@@ -4,6 +4,7 @@
using RustPlusBot.Features.Events.State;
using RustPlusBot.Features.Map.Composing;
using RustPlusBot.Features.Map.Rendering;
+using RustPlusBot.Persistence.Map;
namespace RustPlusBot.Features.Map.Tests;
@@ -35,6 +36,8 @@ private static ServiceProvider BuildProvider()
services.AddLogging();
services.AddSingleton(Substitute.For());
services.AddSingleton(Substitute.For());
+ services.AddSingleton(Substitute.For());
+ services.AddScoped(_ => Substitute.For());
services.AddMap();
return services.BuildServiceProvider(validateScopes: true);
diff --git a/tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs
index ad43f26d..c38f75f7 100644
--- a/tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs
+++ b/tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs
@@ -1,3 +1,4 @@
+using RustPlusBot.Abstractions.Events;
using RustPlusBot.Features.Connections.Listening;
using RustPlusBot.Features.Map.Rendering;
using SixLabors.ImageSharp;
@@ -23,7 +24,8 @@ public void Render_produces_a_png_of_the_output_size()
{
var renderer = new MapRenderer();
- var bytes = renderer.Render(BaseJpeg(), Dims, markers: [], MapLayerSet.Default2b);
+ var bytes = renderer.Render(BaseJpeg(), Dims, markers: [], monuments: [], players: [], rigs: [],
+ MapLayerSet.AllOn);
using var result = Image.Load(bytes);
Assert.Equal(MapRenderer.OutputSize, result.Width);
@@ -36,11 +38,40 @@ public void Render_with_a_marker_differs_from_render_without()
var renderer = new MapRenderer();
var jpeg = BaseJpeg();
- var without = renderer.Render(jpeg, Dims, markers: [], new MapLayerSet(false, true, false, false, false));
+ var without = renderer.Render(jpeg, Dims, markers: [], monuments: [], players: [], rigs: [],
+ new MapLayerSet(false, true, false, false, false, false));
var with = renderer.Render(jpeg, Dims,
markers: [new MarkerPlacement(MarkerKind.CargoShip, 512f, 512f)],
- new MapLayerSet(false, true, false, false, false));
+ monuments: [], players: [], rigs: [],
+ new MapLayerSet(false, true, false, false, false, false));
Assert.NotEqual(without, with); // The drawn marker changes the bytes.
}
+
+ [Fact]
+ public void Render_with_all_layers_produces_valid_png()
+ {
+ var renderer = new MapRenderer();
+ var markers = new[]
+ {
+ new MarkerPlacement(MarkerKind.CargoShip, 100, 100)
+ };
+ var monuments = new[]
+ {
+ new MonumentPlacement("launchsite", 200, 200)
+ };
+ var players = new[]
+ {
+ new PlayerPlacement("Alice", 300, 300, IsAlive: true, IsOnline: true)
+ };
+ var rigs = new[]
+ {
+ new RigPlacement(RigKind.Large, 400, 400, Active: true)
+ };
+
+ var png = renderer.Render(BaseJpeg(), Dims, markers, monuments, players, rigs, MapLayerSet.AllOn);
+
+ using var img = Image.Load(png); // throws if not a valid image
+ Assert.Equal(MapRenderer.OutputSize, img.Width);
+ }
}
diff --git a/tests/RustPlusBot.Features.Map.Tests/MarkerGlyphsTests.cs b/tests/RustPlusBot.Features.Map.Tests/MarkerGlyphsTests.cs
deleted file mode 100644
index 565ccece..00000000
--- a/tests/RustPlusBot.Features.Map.Tests/MarkerGlyphsTests.cs
+++ /dev/null
@@ -1,27 +0,0 @@
-using RustPlusBot.Features.Connections.Listening;
-using RustPlusBot.Features.Map.Assets;
-using SixLabors.ImageSharp;
-using Xunit;
-
-namespace RustPlusBot.Features.Map.Tests;
-
-public sealed class MarkerGlyphsTests
-{
- [Theory]
- [InlineData(MarkerKind.CargoShip, "C")]
- [InlineData(MarkerKind.PatrolHelicopter, "H")]
- [InlineData(MarkerKind.Chinook, "K")]
- public void Known_kinds_have_distinct_letters(MarkerKind kind, string expected)
- {
- var (_, letter) = MarkerGlyphs.For(kind);
- Assert.Equal(expected, letter);
- }
-
- [Fact]
- public void Unknown_kind_falls_back_to_question_mark()
- {
- var (color, letter) = MarkerGlyphs.For(MarkerKind.Other);
- Assert.Equal("?", letter);
- Assert.NotEqual(default, color);
- }
-}
diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Messages/MapControlMessageRendererTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Messages/MapControlMessageRendererTests.cs
new file mode 100644
index 00000000..87edc259
--- /dev/null
+++ b/tests/RustPlusBot.Features.Workspace.Tests/Messages/MapControlMessageRendererTests.cs
@@ -0,0 +1,83 @@
+using Discord;
+using NSubstitute;
+using RustPlusBot.Features.Workspace.Localization;
+using RustPlusBot.Features.Workspace.Messages;
+using RustPlusBot.Features.Workspace.Registry;
+using RustPlusBot.Persistence.Map;
+
+namespace RustPlusBot.Features.Workspace.Tests.Messages;
+
+public sealed class MapControlMessageRendererTests
+{
+ private static readonly Localizer Loc = new(LocalizationCatalog.Default);
+
+ [Fact]
+ public async Task Renders_six_toggle_buttons_reflecting_settings()
+ {
+ var serverId = Guid.NewGuid();
+ var settings = Substitute.For();
+ // Monuments off, the rest on.
+ settings.GetAsync(1, serverId, Arg.Any())
+ .Returns(new MapLayerSettings(true, true, false, true, true, true));
+ var renderer = new MapControlMessageRenderer(settings, Loc);
+
+ var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default);
+
+ Assert.NotNull(payload.Components);
+ var buttons = payload.Components!.Components.OfType()
+ .SelectMany(r => r.Components).OfType().ToList();
+ Assert.Equal(6, buttons.Count);
+
+ var monuments = buttons.Single(b =>
+ b.CustomId == $"workspace:map:toggle:{MapLayer.Monuments}:{serverId}");
+ Assert.Equal(ButtonStyle.Secondary, monuments.Style);
+
+ foreach (var other in buttons.Where(b => b.CustomId != monuments.CustomId))
+ {
+ Assert.Equal(ButtonStyle.Success, other.Style);
+ }
+ }
+
+ [Fact]
+ public async Task Renders_a_header_text()
+ {
+ var serverId = Guid.NewGuid();
+ var settings = Substitute.For();
+ settings.GetAsync(1, serverId, Arg.Any())
+ .Returns(MapLayerSettings.AllOn);
+ var renderer = new MapControlMessageRenderer(settings, Loc);
+
+ var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default);
+
+ Assert.Equal(Loc.Get("map.control.header", "en"), payload.Text);
+ }
+
+ [Fact]
+ public async Task NullServerId_RendersEmptyPayload()
+ {
+ var settings = Substitute.For();
+ var renderer = new MapControlMessageRenderer(settings, Loc);
+
+ var payload = await renderer.RenderAsync(new MessageRenderContext(1, null, "en"), default);
+
+ Assert.Null(payload.Text);
+ Assert.Null(payload.Components);
+ }
+
+ [Fact]
+ public async Task FrenchCulture_UsesFrenchGridLabel()
+ {
+ var serverId = Guid.NewGuid();
+ var settings = Substitute.For();
+ settings.GetAsync(1, serverId, Arg.Any())
+ .Returns(MapLayerSettings.AllOn);
+ var renderer = new MapControlMessageRenderer(settings, Loc);
+
+ var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "fr"), default);
+
+ var buttons = payload.Components!.Components.OfType()
+ .SelectMany(r => r.Components).OfType().ToList();
+ var gridLabel = Loc.Get("map.layer.grid", "fr");
+ Assert.Contains(buttons, b => b.Label == gridLabel);
+ }
+}
diff --git a/tests/RustPlusBot.Persistence.Tests/Map/MapSettingsStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/Map/MapSettingsStoreTests.cs
new file mode 100644
index 00000000..4aad7e5a
--- /dev/null
+++ b/tests/RustPlusBot.Persistence.Tests/Map/MapSettingsStoreTests.cs
@@ -0,0 +1,65 @@
+using RustPlusBot.Domain.Servers;
+using RustPlusBot.Persistence.Map;
+
+namespace RustPlusBot.Persistence.Tests.Map;
+
+public sealed class MapSettingsStoreTests
+{
+ private static RustServer SeedServer(BotDbContext context)
+ {
+ var server = new RustServer
+ {
+ GuildId = 1UL, Name = "S", Ip = "1.1.1.1", Port = 28015
+ };
+ context.RustServers.Add(server);
+ context.SaveChanges();
+ return server;
+ }
+
+ [Fact]
+ public async Task GetAsync_returns_all_on_when_no_row()
+ {
+ var (context, connection) = SqliteContextFixture.Create();
+ await using var _ = context;
+ await using var __ = connection;
+
+ var result = await new MapSettingsStore(context).GetAsync(1UL, Guid.NewGuid());
+
+ Assert.Equal(MapLayerSettings.AllOn, result);
+ }
+
+ [Fact]
+ public async Task SetLayerAsync_creates_row_and_disables_one_layer_only()
+ {
+ var (context, connection) = SqliteContextFixture.Create();
+ await using var _ = context;
+ await using var __ = connection;
+ var server = SeedServer(context);
+
+ await new MapSettingsStore(context).SetLayerAsync(1UL, server.Id, MapLayer.Monuments, enabled: false);
+ var result = await new MapSettingsStore(context).GetAsync(1UL, server.Id);
+
+ Assert.False(result.Monuments);
+ Assert.True(result.Grid);
+ Assert.True(result.Markers);
+ Assert.True(result.Vendor);
+ Assert.True(result.Players);
+ Assert.True(result.Rigs);
+ }
+
+ [Fact]
+ public async Task SetLayerAsync_updates_existing_row()
+ {
+ var (context, connection) = SqliteContextFixture.Create();
+ await using var _ = context;
+ await using var __ = connection;
+ var server = SeedServer(context);
+ var store = new MapSettingsStore(context);
+
+ await store.SetLayerAsync(1UL, server.Id, MapLayer.Players, enabled: false);
+ await store.SetLayerAsync(1UL, server.Id, MapLayer.Players, enabled: true);
+ var result = await store.GetAsync(1UL, server.Id);
+
+ Assert.True(result.Players);
+ }
+}
diff --git a/tests/RustPlusBot.Persistence.Tests/Map/ServerMapSettingsSchemaTests.cs b/tests/RustPlusBot.Persistence.Tests/Map/ServerMapSettingsSchemaTests.cs
new file mode 100644
index 00000000..4354647c
--- /dev/null
+++ b/tests/RustPlusBot.Persistence.Tests/Map/ServerMapSettingsSchemaTests.cs
@@ -0,0 +1,61 @@
+using Microsoft.EntityFrameworkCore;
+using RustPlusBot.Domain.Map;
+using RustPlusBot.Domain.Servers;
+
+namespace RustPlusBot.Persistence.Tests.Map;
+
+public sealed class ServerMapSettingsSchemaTests
+{
+ [Fact]
+ public async Task ServerMapSettings_persists_with_all_layers_on_by_default()
+ {
+ var (context, connection) = SqliteContextFixture.Create();
+ await using var _ = context;
+ await using var __ = connection;
+
+ var server = new RustServer
+ {
+ GuildId = 1UL, Name = "S", Ip = "1.1.1.1", Port = 28015
+ };
+ context.RustServers.Add(server);
+ await context.SaveChangesAsync();
+
+ context.ServerMapSettings.Add(new ServerMapSettings
+ {
+ ServerId = server.Id, GuildId = 1UL
+ });
+ await context.SaveChangesAsync();
+
+ var read = await context.ServerMapSettings.SingleAsync(s => s.ServerId == server.Id);
+ Assert.True(read.ShowGrid);
+ Assert.True(read.ShowMarkers);
+ Assert.True(read.ShowMonuments);
+ Assert.True(read.ShowVendor);
+ Assert.True(read.ShowPlayers);
+ Assert.True(read.ShowRigs);
+ }
+
+ [Fact]
+ public async Task RemovingServer_CascadeDeletesItsMapSettings()
+ {
+ var (context, connection) = SqliteContextFixture.Create();
+ await using var _ = context;
+ await using var __ = connection;
+
+ var server = new RustServer
+ {
+ GuildId = 1UL, Name = "S", Ip = "1.1.1.1", Port = 28015
+ };
+ context.RustServers.Add(server);
+ context.ServerMapSettings.Add(new ServerMapSettings
+ {
+ ServerId = server.Id, GuildId = 1UL
+ });
+ await context.SaveChangesAsync();
+
+ context.RustServers.Remove(server);
+ await context.SaveChangesAsync();
+
+ Assert.Empty(await context.ServerMapSettings.ToListAsync());
+ }
+}