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