From 85ce4bfbd6207e2ab0d5830815cdeafa57300ff7 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 7 Jul 2026 22:05:00 +0200 Subject: [PATCH 01/40] feat(map): plumb WorldSize through MapDimensions + GetWorldAsync seam --- .../Connections/IRustServerQuery.cs | 7 ++++ .../Connections/MapDimensions.cs | 11 ++--- .../Connections/WorldSnapshot.cs | 6 +++ .../Listening/IRustServerConnection.cs | 6 +++ .../Listening/RustPlusSocketSource.cs | 40 ++++++++++++++++++- .../Supervisor/ConnectionSupervisor.cs | 12 ++++++ .../Composing/MapComposer.cs | 2 +- .../Connections/MapMarkerSnapshotTests.cs | 2 +- .../Events/RigStateChangedEventTests.cs | 2 +- .../Handlers/EventHandlersTests.cs | 5 ++- .../ConnectionSupervisorTests.cs | 4 +- .../Fakes/FakeRustSocketSource.cs | 8 +++- .../Classifying/MarkerEventClassifierTests.cs | 2 +- .../Formatting/GridReferenceTests.cs | 4 +- .../Rendering/EventEmbedRendererTests.cs | 2 +- .../State/EventStateStoreTests.cs | 2 +- .../MapComposerTests.cs | 2 +- .../MapRendererTests.cs | 2 +- .../WorldToPixelTests.cs | 2 +- .../Hosting/PlayersHostedServiceTests.cs | 4 +- .../PlayerEventEndToEndTests.cs | 2 +- .../PlayerEventRelayTests.cs | 2 +- .../PlayerEventRendererTests.cs | 2 +- 23 files changed, 104 insertions(+), 27 deletions(-) create mode 100644 src/RustPlusBot.Abstractions/Connections/WorldSnapshot.cs diff --git a/src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs b/src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs index bd4b8cb9..c5e2d603 100644 --- a/src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs +++ b/src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs @@ -46,6 +46,13 @@ public interface IRustServerQuery /// The map dimensions, or null when there is no live socket. Task GetMapDimensionsAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken); + /// Gets the world size and seed of a connected server, or null when unavailable. + /// The owning guild snowflake. + /// The target server id. + /// A cancellation token. + /// The world snapshot, or null. + Task GetWorldAsync(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. diff --git a/src/RustPlusBot.Abstractions/Connections/MapDimensions.cs b/src/RustPlusBot.Abstractions/Connections/MapDimensions.cs index 1cc4e4d0..f25de7dc 100644 --- a/src/RustPlusBot.Abstractions/Connections/MapDimensions.cs +++ b/src/RustPlusBot.Abstractions/Connections/MapDimensions.cs @@ -1,7 +1,8 @@ namespace RustPlusBot.Abstractions.Connections; -/// The static-per-wipe map size needed to convert world coordinates to a grid reference. -/// Map image width. -/// Map image height. -/// The ocean margin around the playable area. -public sealed record MapDimensions(uint Width, uint Height, int OceanMargin); +/// Dimensions of the server-rendered map tile plus the world size. +/// Width of the base map image, in pixels. +/// Height of the base map image, in pixels. +/// Ocean border baked into the base map image, in pixels. +/// Size of the playable world, in game units (from server info). +public sealed record MapDimensions(uint Width, uint Height, int OceanMargin, uint WorldSize); diff --git a/src/RustPlusBot.Abstractions/Connections/WorldSnapshot.cs b/src/RustPlusBot.Abstractions/Connections/WorldSnapshot.cs new file mode 100644 index 00000000..57f0636c --- /dev/null +++ b/src/RustPlusBot.Abstractions/Connections/WorldSnapshot.cs @@ -0,0 +1,6 @@ +namespace RustPlusBot.Abstractions.Connections; + +/// The world identity of a connected server, used to resolve external map imagery. +/// Size of the playable world, in game units. +/// Procedural map generation seed. +public sealed record WorldSnapshot(uint WorldSize, uint Seed); diff --git a/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs b/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs index d775e8f4..dac5adbb 100644 --- a/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs +++ b/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs @@ -106,6 +106,12 @@ Task> GetMapMarkersAsync(TimeSpan timeout, /// The map dimensions, or null on failure/timeout. Task GetMapDimensionsAsync(TimeSpan timeout, CancellationToken cancellationToken = default); + /// Gets the world size and seed from server info, or null when unavailable. + /// The per-call timeout. + /// A cancellation token. + /// The world snapshot, or null. + Task GetWorldAsync(TimeSpan timeout, CancellationToken cancellationToken = default); + /// Gets the map monuments (for locating oil rigs). Throws on failure. /// How long to wait for the response. /// A cancellation token. diff --git a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs index 96386d00..cc9ac399 100644 --- a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs +++ b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs @@ -81,6 +81,9 @@ public Task> GetMapMarkersAsync(TimeSpan timeou CancellationToken cancellationToken = default) => Task.FromResult(null); + public Task GetWorldAsync(TimeSpan timeout, CancellationToken cancellationToken = default) => + Task.FromResult(null); + public Task> GetMonumentsAsync(TimeSpan timeout, CancellationToken cancellationToken = default) => Task.FromResult>([]); @@ -545,7 +548,42 @@ public async Task> GetMapMarkersAsync( return null; } - return new MapDimensions(width, height, margin); + var infoResponse = await _rustPlus.GetInfoAsync(timeoutCts.Token).WaitAsync(timeoutCts.Token) + .ConfigureAwait(false); + if (!infoResponse.IsSuccess || infoResponse.Data?.MapSize is not { } worldSize) + { + return null; + } + + return new MapDimensions(width, height, margin, worldSize); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return null; + } +#pragma warning disable CA1031 // Broad catch: any map-query failure maps to null; never surface a token/secret. + catch (Exception ex) when (!cancellationToken.IsCancellationRequested) +#pragma warning restore CA1031 + { + LogQueryFailed(_logger, ex); + return null; + } + } + + public async Task GetWorldAsync(TimeSpan timeout, CancellationToken cancellationToken = default) + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(timeout); + try + { + var response = await _rustPlus.GetInfoAsync(timeoutCts.Token).WaitAsync(timeoutCts.Token) + .ConfigureAwait(false); + if (!response.IsSuccess || response.Data is not { MapSize: { } size, Seed: { } seed }) + { + return null; + } + + return new WorldSnapshot(size, seed); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { diff --git a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs index 901053d1..b4ab1447 100644 --- a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs +++ b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs @@ -245,6 +245,18 @@ public async Task PromoteToLeaderAsync( .ConfigureAwait(false); } + /// + public async Task GetWorldAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) + { + if (!_liveSockets.TryGetValue((guildId, serverId), out var live)) + { + return null; + } + + return await live.Connection.GetWorldAsync(_options.HeartbeatTimeout, cancellationToken) + .ConfigureAwait(false); + } + /// public async Task> GetMonumentsAsync( ulong guildId, diff --git a/src/RustPlusBot.Features.Map/Composing/MapComposer.cs b/src/RustPlusBot.Features.Map/Composing/MapComposer.cs index a69ec25d..eafb2bcd 100644 --- a/src/RustPlusBot.Features.Map/Composing/MapComposer.cs +++ b/src/RustPlusBot.Features.Map/Composing/MapComposer.cs @@ -57,7 +57,7 @@ public sealed class MapComposer( if (dims is null) { // Dimensions unavailable: render the base tile only (every overlay needs world→pixel). - return renderer.Render(baseImage, new MapDimensions(0, 0, 0), markers: [], monuments: [], players: [], + return renderer.Render(baseImage, new MapDimensions(0, 0, 0, 0), markers: [], monuments: [], players: [], rigs: [], new MapLayerSet(Grid: false, Markers: false, Monuments: false, Vendor: false, Players: false, Rigs: false)); diff --git a/tests/RustPlusBot.Abstractions.Tests/Connections/MapMarkerSnapshotTests.cs b/tests/RustPlusBot.Abstractions.Tests/Connections/MapMarkerSnapshotTests.cs index 7b280b22..899488d3 100644 --- a/tests/RustPlusBot.Abstractions.Tests/Connections/MapMarkerSnapshotTests.cs +++ b/tests/RustPlusBot.Abstractions.Tests/Connections/MapMarkerSnapshotTests.cs @@ -19,7 +19,7 @@ public void Marker_carries_kind_and_coordinates() [Fact] public void Dimensions_carry_size_and_margin() { - var dims = new MapDimensions(4000u, 4000u, 500); + var dims = new MapDimensions(4000u, 4000u, 500, WorldSize: 4000u); Assert.Equal(4000u, dims.Width); Assert.Equal(500, dims.OceanMargin); diff --git a/tests/RustPlusBot.Abstractions.Tests/Events/RigStateChangedEventTests.cs b/tests/RustPlusBot.Abstractions.Tests/Events/RigStateChangedEventTests.cs index 2a828816..c5df766e 100644 --- a/tests/RustPlusBot.Abstractions.Tests/Events/RigStateChangedEventTests.cs +++ b/tests/RustPlusBot.Abstractions.Tests/Events/RigStateChangedEventTests.cs @@ -9,7 +9,7 @@ public sealed class RigStateChangedEventTests public void Carries_rig_kind_event_kind_position_and_dimensions() { var server = Guid.NewGuid(); - var dims = new MapDimensions(4000u, 4000u, 500); + var dims = new MapDimensions(4000u, 4000u, 500, WorldSize: 4000u); var evt = new RigStateChangedEvent(7UL, server, RigKind.Large, RigEventKind.CrateLootable, 1f, 2f, dims); diff --git a/tests/RustPlusBot.Features.Commands.Tests/Handlers/EventHandlersTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Handlers/EventHandlersTests.cs index e19ae1e4..468d0845 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/Handlers/EventHandlersTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/Handlers/EventHandlersTests.cs @@ -29,7 +29,7 @@ public async Task Cargo_with_active_marker_reports_grid() { var (clock, loc) = Deps(); var state = Substitute.For(); - var dims = new MapDimensions(4000u, 4000u, 500); + var dims = new MapDimensions(4000u, 4000u, 500, WorldSize: 4000u); state.GetActiveMarkers(Guild, Server, MarkerKind.CargoShip).Returns( [new ActiveMarker(1, MarkerKind.CargoShip, 10f, 3990f, dims, Now.AddMinutes(-5))]); var reply = await new CargoCommandHandler(state, loc, clock).ExecuteAsync(Ctx(), CancellationToken.None); @@ -60,7 +60,8 @@ public async Task Events_lists_recent_or_reports_none() await new EventsCommandHandler(state, loc).ExecuteAsync(Ctx(), CancellationToken.None)); state.GetRecentEvents(Guild, Server).Returns( - [new RustMapEvent(MapEventKind.CargoEntered, 10f, 3990f, new MapDimensions(4000u, 4000u, 500), Now)]); + [new RustMapEvent(MapEventKind.CargoEntered, 10f, 3990f, + new MapDimensions(4000u, 4000u, 500, WorldSize: 4000u), Now)]); var reply = await new EventsCommandHandler(state, loc).ExecuteAsync(Ctx(), CancellationToken.None); Assert.Contains("Recent:", reply, StringComparison.Ordinal); } diff --git a/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs b/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs index 22f622ac..82e5daad 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs @@ -402,8 +402,8 @@ public async Task Marker_added_on_a_later_poll_publishes_changed_event() } }, CancellationToken.None); - // FakeConnection default DimensionsResult is new(4000u, 4000u, 500); assert those exact values. - var expectedDims = new MapDimensions(4000u, 4000u, 500); + // FakeConnection default DimensionsResult is new(4000u, 4000u, 500, 4000u); assert those exact values. + var expectedDims = new MapDimensions(4000u, 4000u, 500, WorldSize: 4000u); // Script polls before EnsureConnectionAsync so the marker script is in the connection before // the poll loop can start — eliminates any setup race. diff --git a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs index e7929194..66658535 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs @@ -217,7 +217,10 @@ internal sealed class FakeConnection(SocketConnectOutcome outcome, FakeRustSocke public bool MarkersThrow { get; set; } /// The dimensions returned by . Defaults to a non-null snapshot. - public MapDimensions? DimensionsResult { get; set; } = new(4000u, 4000u, 500); + public MapDimensions? DimensionsResult { get; set; } = new(4000u, 4000u, 500, 4000u); + + /// The snapshot returned by . Defaults to null. + public WorldSnapshot? World { get; set; } /// The monuments returned by . Defaults to empty. public IReadOnlyList MonumentsResult { get; set; } = []; @@ -342,6 +345,9 @@ public Task> GetMapMarkersAsync(TimeSpan timeou CancellationToken cancellationToken = default) => Task.FromResult(DimensionsResult); + public Task GetWorldAsync(TimeSpan timeout, CancellationToken cancellationToken = default) => + Task.FromResult(World); + public Task> GetMonumentsAsync(TimeSpan timeout, CancellationToken cancellationToken = default) => Task.FromResult(MonumentsResult); diff --git a/tests/RustPlusBot.Features.Events.Tests/Classifying/MarkerEventClassifierTests.cs b/tests/RustPlusBot.Features.Events.Tests/Classifying/MarkerEventClassifierTests.cs index d7fdb74b..a3412ee3 100644 --- a/tests/RustPlusBot.Features.Events.Tests/Classifying/MarkerEventClassifierTests.cs +++ b/tests/RustPlusBot.Features.Events.Tests/Classifying/MarkerEventClassifierTests.cs @@ -21,7 +21,7 @@ private static MarkerEventClassifier Build() private static MapMarkersChangedEvent Evt( IReadOnlyList added, IReadOnlyList removed) => - new(1UL, Server, new MapDimensions(4000u, 4000u, 500), added, removed); + new(1UL, Server, new MapDimensions(4000u, 4000u, 500, WorldSize: 4000u), added, removed); [Fact] public void Cargo_added_is_CargoEntered() diff --git a/tests/RustPlusBot.Features.Events.Tests/Formatting/GridReferenceTests.cs b/tests/RustPlusBot.Features.Events.Tests/Formatting/GridReferenceTests.cs index 0b2bbd12..77b091dc 100644 --- a/tests/RustPlusBot.Features.Events.Tests/Formatting/GridReferenceTests.cs +++ b/tests/RustPlusBot.Features.Events.Tests/Formatting/GridReferenceTests.cs @@ -9,7 +9,7 @@ public sealed class GridReferenceTests public void Origin_is_top_left_cell() { // x near 0, y near top => column A, top row. - var dims = new MapDimensions(4000u, 4000u, 500); + var dims = new MapDimensions(4000u, 4000u, 500, WorldSize: 4000u); var grid = GridReference.From(10f, 3990f, dims); Assert.StartsWith("A", grid, StringComparison.Ordinal); @@ -28,7 +28,7 @@ public void Null_dimensions_fall_back_to_raw_coordinates() [InlineData(150f, 4000f, "B")] public void Column_letter_advances_every_cell(float x, float y, string expectedColumnPrefix) { - var dims = new MapDimensions(4000u, 4000u, 500); + var dims = new MapDimensions(4000u, 4000u, 500, WorldSize: 4000u); var grid = GridReference.From(x, y, dims); Assert.StartsWith(expectedColumnPrefix, grid, StringComparison.Ordinal); } diff --git a/tests/RustPlusBot.Features.Events.Tests/Rendering/EventEmbedRendererTests.cs b/tests/RustPlusBot.Features.Events.Tests/Rendering/EventEmbedRendererTests.cs index 00d8ccc8..5c4c41aa 100644 --- a/tests/RustPlusBot.Features.Events.Tests/Rendering/EventEmbedRendererTests.cs +++ b/tests/RustPlusBot.Features.Events.Tests/Rendering/EventEmbedRendererTests.cs @@ -15,7 +15,7 @@ private static EventEmbedRenderer Build() => [Fact] public void Cargo_entered_renders_english_with_grid() { - var dims = new MapDimensions(4000u, 4000u, 500); + var dims = new MapDimensions(4000u, 4000u, 500, WorldSize: 4000u); var embed = Build().Render(new RustMapEvent(MapEventKind.CargoEntered, 10f, 3990f, dims, Now), "en"); Assert.NotNull(embed.Description); diff --git a/tests/RustPlusBot.Features.Events.Tests/State/EventStateStoreTests.cs b/tests/RustPlusBot.Features.Events.Tests/State/EventStateStoreTests.cs index f12bc4be..d9c4262e 100644 --- a/tests/RustPlusBot.Features.Events.Tests/State/EventStateStoreTests.cs +++ b/tests/RustPlusBot.Features.Events.Tests/State/EventStateStoreTests.cs @@ -35,7 +35,7 @@ private static MapMarkersChangedEvent DeltaWithDims( public void Added_marker_becomes_active_and_carries_dimensions() { var store = Build(); - var dims = new MapDimensions(4000u, 4000u, 500); + var dims = new MapDimensions(4000u, 4000u, 500, WorldSize: 4000u); store.Apply( DeltaWithDims([new MapMarkerSnapshot(1, MarkerKind.CargoShip, 10f, 20f, null)], [], dims), [new RustMapEvent(MapEventKind.CargoEntered, 10f, 20f, dims, Now)]); diff --git a/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs index c6361841..9472ddf8 100644 --- a/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs @@ -15,7 +15,7 @@ public sealed class MapComposerTests { private const ulong Guild = 1UL; private static readonly Guid Server = Guid.NewGuid(); - private static readonly MapDimensions Dims = new(4000, 4000, 500); + private static readonly MapDimensions Dims = new(4000, 4000, 500, 4000); private static byte[] BaseJpeg() { diff --git a/tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs index c3d5c209..6397dc15 100644 --- a/tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs @@ -8,7 +8,7 @@ namespace RustPlusBot.Features.Map.Tests; public sealed class MapRendererTests { - private static readonly MapDimensions Dims = new(Width: 4000, Height: 4000, OceanMargin: 500); + private static readonly MapDimensions Dims = new(Width: 4000, Height: 4000, OceanMargin: 500, WorldSize: 4000); /// A 64x64 solid-green JPEG, generated once in-test so the renderer has a real base image to decode. private static byte[] BaseJpeg() diff --git a/tests/RustPlusBot.Features.Map.Tests/WorldToPixelTests.cs b/tests/RustPlusBot.Features.Map.Tests/WorldToPixelTests.cs index 368c6369..7ed3d893 100644 --- a/tests/RustPlusBot.Features.Map.Tests/WorldToPixelTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/WorldToPixelTests.cs @@ -5,7 +5,7 @@ namespace RustPlusBot.Features.Map.Tests; public sealed class WorldToPixelTests { - private static readonly MapDimensions Dims = new(Width: 4000, Height: 4000, OceanMargin: 500); + private static readonly MapDimensions Dims = new(Width: 4000, Height: 4000, OceanMargin: 500, WorldSize: 4000); [Fact] public void Origin_world_maps_to_bottom_left_inside_margin() diff --git a/tests/RustPlusBot.Features.Players.Tests/Hosting/PlayersHostedServiceTests.cs b/tests/RustPlusBot.Features.Players.Tests/Hosting/PlayersHostedServiceTests.cs index 02ec2391..fbd9b027 100644 --- a/tests/RustPlusBot.Features.Players.Tests/Hosting/PlayersHostedServiceTests.cs +++ b/tests/RustPlusBot.Features.Players.Tests/Hosting/PlayersHostedServiceTests.cs @@ -61,7 +61,7 @@ public async Task PlayerStateChangedEvent_routes_to_relay_and_sends_ingame_line( { await h.Bus.PublishAsync(new PlayerStateChangedEvent( 10UL, serverId, - new MapDimensions(3000, 3000, 0), + new MapDimensions(3000, 3000, 0, WorldSize: 3000), [new PlayerTransition(PlayerTransitionKind.Connect, 1UL, "Alice", null)])); await Task.Delay(20); } @@ -101,7 +101,7 @@ public async Task RelayLoop_faults_on_sender_exception_but_StopAsync_completes_c { await h.Bus.PublishAsync(new PlayerStateChangedEvent( 10UL, Guid.NewGuid(), - new MapDimensions(3000, 3000, 0), + new MapDimensions(3000, 3000, 0, WorldSize: 3000), [new PlayerTransition(PlayerTransitionKind.Connect, 1UL, "Bob", null)])); await Task.Delay(20); } diff --git a/tests/RustPlusBot.Features.Players.Tests/PlayerEventEndToEndTests.cs b/tests/RustPlusBot.Features.Players.Tests/PlayerEventEndToEndTests.cs index af2dba1d..16ff7851 100644 --- a/tests/RustPlusBot.Features.Players.Tests/PlayerEventEndToEndTests.cs +++ b/tests/RustPlusBot.Features.Players.Tests/PlayerEventEndToEndTests.cs @@ -11,7 +11,7 @@ public sealed class PlayerEventEndToEndTests public void All_transition_kinds_render_without_throwing() { var renderer = new PlayerEventRenderer(new ResxLocalizer()); - var dims = new MapDimensions(3000, 3000, 0); + var dims = new MapDimensions(3000, 3000, 0, WorldSize: 3000); foreach (var kind in Enum.GetValues()) { var loc = kind is PlayerTransitionKind.Death or PlayerTransitionKind.Respawn diff --git a/tests/RustPlusBot.Features.Players.Tests/PlayerEventRelayTests.cs b/tests/RustPlusBot.Features.Players.Tests/PlayerEventRelayTests.cs index 59e3c042..c9da0f0e 100644 --- a/tests/RustPlusBot.Features.Players.Tests/PlayerEventRelayTests.cs +++ b/tests/RustPlusBot.Features.Players.Tests/PlayerEventRelayTests.cs @@ -36,7 +36,7 @@ private static IServiceScopeFactory BuildScopeFactory(IWorkspaceStore workspace) } private static PlayerStateChangedEvent Evt(params PlayerTransition[] ts) - => new(1UL, Guid.NewGuid(), new MapDimensions(3000, 3000, 0), ts); + => new(1UL, Guid.NewGuid(), new MapDimensions(3000, 3000, 0, WorldSize: 3000), ts); [Fact] public async Task Sends_ingame_line_for_each_transition() diff --git a/tests/RustPlusBot.Features.Players.Tests/PlayerEventRendererTests.cs b/tests/RustPlusBot.Features.Players.Tests/PlayerEventRendererTests.cs index 1b8e3fdd..9f594fed 100644 --- a/tests/RustPlusBot.Features.Players.Tests/PlayerEventRendererTests.cs +++ b/tests/RustPlusBot.Features.Players.Tests/PlayerEventRendererTests.cs @@ -8,7 +8,7 @@ namespace RustPlusBot.Features.Players.Tests; public sealed class PlayerEventRendererTests { private static readonly PlayerEventRenderer Renderer = new(new ResxLocalizer()); - private static readonly MapDimensions Dims = new(3000, 3000, 0); + private static readonly MapDimensions Dims = new(3000, 3000, 0, 3000); [Fact] public void Connect_line_names_member_without_location() From 775052bf57e32bd2f343d56bd0c54540b13dbb88 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 7 Jul 2026 22:17:31 +0200 Subject: [PATCH 02/40] feat(map): shared MapGrid math; GridReference uses real world size --- .../Connections/MapGrid.cs | 49 +++++++++++++++++++ .../Formatting/GridReference.cs | 29 +---------- .../Connections/MapGridTests.cs | 42 ++++++++++++++++ .../Formatting/GridReferenceTests.cs | 8 +++ 4 files changed, 101 insertions(+), 27 deletions(-) create mode 100644 src/RustPlusBot.Abstractions/Connections/MapGrid.cs create mode 100644 tests/RustPlusBot.Abstractions.Tests/Connections/MapGridTests.cs diff --git a/src/RustPlusBot.Abstractions/Connections/MapGrid.cs b/src/RustPlusBot.Abstractions/Connections/MapGrid.cs new file mode 100644 index 00000000..22d03198 --- /dev/null +++ b/src/RustPlusBot.Abstractions/Connections/MapGrid.cs @@ -0,0 +1,49 @@ +using System.Globalization; +using System.Text; + +namespace RustPlusBot.Abstractions.Connections; + +/// +/// Rust map grid math shared by the map renderer and grid-reference formatting. +/// One cell is 146.25 game units (rustplusplus-compatible); rows are numbered from the top. +/// +public static class MapGrid +{ + /// Edge length of one grid cell, in game units. + public const float CellSize = 146.25f; + + /// Number of grid cells per axis, including a partial edge cell. + /// The world size in game units. + /// The cell count (at least 1). + public static int CellCount(uint worldSize) => Math.Max(1, (int)Math.Ceiling(worldSize / CellSize)); + + /// Formats a spreadsheet-style column label (0→A … 25→Z, 26→AA …). + /// The zero-based column index. + /// The column letters. + public static string ColumnLetters(int index) + { + var sb = new StringBuilder(); + var n = index; + do + { + sb.Insert(0, (char)('A' + (n % 26))); + n = (n / 26) - 1; + } while (n >= 0); + + return sb.ToString(); + } + + /// Formats the grid label ("D7") for a world coordinate. + /// World X (west→east), clamped into the world. + /// World Y (south→north), clamped into the world. + /// The world size in game units. + /// The grid label, rows numbered from the top. + public static string LabelFor(float x, float y, uint worldSize) + { + var cells = CellCount(worldSize); + var col = Math.Clamp((int)Math.Floor(Math.Clamp(x, 0f, worldSize - 1) / CellSize), 0, cells - 1); + var rowFromBottom = Math.Clamp((int)Math.Floor(Math.Clamp(y, 0f, worldSize - 1) / CellSize), 0, cells - 1); + var row = cells - rowFromBottom - 1; + return string.Create(CultureInfo.InvariantCulture, $"{ColumnLetters(col)}{row}"); + } +} diff --git a/src/RustPlusBot.Features.Events/Formatting/GridReference.cs b/src/RustPlusBot.Features.Events/Formatting/GridReference.cs index 59eeca52..12b8c058 100644 --- a/src/RustPlusBot.Features.Events/Formatting/GridReference.cs +++ b/src/RustPlusBot.Features.Events/Formatting/GridReference.cs @@ -1,5 +1,4 @@ using System.Globalization; -using System.Text; using RustPlusBot.Abstractions.Connections; namespace RustPlusBot.Features.Events.Formatting; @@ -7,8 +6,6 @@ namespace RustPlusBot.Features.Events.Formatting; /// Converts world coordinates to a Rust map grid reference (e.g. "D7"), rustplusplus-compatible. public static class GridReference { - private const float GridDiameter = 146.25f; - /// Formats a grid reference, or raw rounded coordinates when is null. /// World X coordinate. /// World Y coordinate. @@ -16,35 +13,13 @@ public static class GridReference /// A grid reference like "D7", or "(x, y)" when dimensions are unavailable. public static string From(float x, float y, MapDimensions? dims) { - if (dims is null) + if (dims is null || dims.WorldSize == 0) { return string.Create( CultureInfo.InvariantCulture, $"({Math.Round(x)}, {Math.Round(y)})"); } - // Columns run along X (Width); rows along Y (Height). Rust maps are square in practice, but - // derive each axis from its own dimension so the math is correct by construction, not by accident. - var col = (int)Math.Floor(Math.Clamp(x, 0f, dims.Width - 1) / GridDiameter); - var rowCount = (int)Math.Ceiling(dims.Height / GridDiameter); - // Rows are numbered from the TOP; world Y increases upward, so invert. - var rowFromBottom = (int)Math.Floor(Math.Clamp(y, 0f, dims.Height - 1) / GridDiameter); - var row = Math.Max(0, rowCount - rowFromBottom - 1); - - return string.Create(CultureInfo.InvariantCulture, $"{ColumnLetters(col)}{row}"); - } - - private static string ColumnLetters(int index) - { - // 0->A .. 25->Z, 26->AA .. (spreadsheet-style, matching rustplusplus past Z). - var sb = new StringBuilder(); - var n = index; - do - { - sb.Insert(0, (char)('A' + (n % 26))); - n = (n / 26) - 1; - } while (n >= 0); - - return sb.ToString(); + return MapGrid.LabelFor(x, y, dims.WorldSize); } } diff --git a/tests/RustPlusBot.Abstractions.Tests/Connections/MapGridTests.cs b/tests/RustPlusBot.Abstractions.Tests/Connections/MapGridTests.cs new file mode 100644 index 00000000..ff6f5de3 --- /dev/null +++ b/tests/RustPlusBot.Abstractions.Tests/Connections/MapGridTests.cs @@ -0,0 +1,42 @@ +using RustPlusBot.Abstractions.Connections; + +namespace RustPlusBot.Abstractions.Tests.Connections; + +public sealed class MapGridTests +{ + [Theory] + [InlineData(3000u, 21)] // 3000 / 146.25 = 20.51 -> 21 (partial edge cell counts) + [InlineData(3500u, 24)] // 23.93 -> 24 + [InlineData(4250u, 30)] // 29.06 -> 30 + [InlineData(4500u, 31)] // 30.77 -> 31 + public void CellCount_ceils_partial_edge_cells(uint worldSize, int expected) => + Assert.Equal(expected, MapGrid.CellCount(worldSize)); + + [Theory] + [InlineData(0, "A")] + [InlineData(25, "Z")] + [InlineData(26, "AA")] + [InlineData(27, "AB")] + public void ColumnLetters_is_spreadsheet_style(int index, string expected) => + Assert.Equal(expected, MapGrid.ColumnLetters(index)); + + [Fact] + public void LabelFor_origin_is_bottom_left_last_row() + { + // 4000 world -> 28 cells (27.35 ceil). World (0,0) = SW corner = column A, bottom row 27. + Assert.Equal("A27", MapGrid.LabelFor(0f, 0f, 4000u)); + } + + [Fact] + public void LabelFor_north_west_corner_is_A0() + { + Assert.Equal("A0", MapGrid.LabelFor(0f, 3999f, 4000u)); + } + + [Fact] + public void LabelFor_beyond_world_size_clamps_to_last_cell() + { + // Regression for the old GridReference bug: clamping against IMAGE pixels, not world units. + Assert.Equal("AB27", MapGrid.LabelFor(4500f, 0f, 4000u)); // col 27 = "AB" + } +} diff --git a/tests/RustPlusBot.Features.Events.Tests/Formatting/GridReferenceTests.cs b/tests/RustPlusBot.Features.Events.Tests/Formatting/GridReferenceTests.cs index 77b091dc..a00af917 100644 --- a/tests/RustPlusBot.Features.Events.Tests/Formatting/GridReferenceTests.cs +++ b/tests/RustPlusBot.Features.Events.Tests/Formatting/GridReferenceTests.cs @@ -5,6 +5,14 @@ namespace RustPlusBot.Features.Events.Tests.Formatting; public sealed class GridReferenceTests { + [Fact] + public void From_uses_world_size_not_image_pixels() + { + // Image is 2000px but the world is 4000 units: coords beyond 2000 must still resolve. + var dims = new MapDimensions(2000, 2000, 500, WorldSize: 4000); + Assert.Equal(GridReference.From(3900f, 3900f, dims), MapGrid.LabelFor(3900f, 3900f, 4000u)); + } + [Fact] public void Origin_is_top_left_cell() { From 1544d7e9a347d58c2d5839c760f0d6d0004d76b5 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 7 Jul 2026 22:24:29 +0200 Subject: [PATCH 03/40] feat(map): MapProjection canonical world-to-pixel transform Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Rendering/MapProjection.cs | 26 +++++++++ .../MapProjectionTests.cs | 56 +++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 src/RustPlusBot.Features.Map/Rendering/MapProjection.cs create mode 100644 tests/RustPlusBot.Features.Map.Tests/MapProjectionTests.cs diff --git a/src/RustPlusBot.Features.Map/Rendering/MapProjection.cs b/src/RustPlusBot.Features.Map/Rendering/MapProjection.cs new file mode 100644 index 00000000..5007631b --- /dev/null +++ b/src/RustPlusBot.Features.Map/Rendering/MapProjection.cs @@ -0,0 +1,26 @@ +namespace RustPlusBot.Features.Map.Rendering; + +/// +/// Projects world coordinates onto the rendered output image. +/// The canonical Rust+ transform: the playable world spans +/// [OceanMarginPx, ImageWidth - OceanMarginPx] on the base image, then the base image is +/// stretched to the square output. Matches the official app and rustplusplus. +/// +/// Size of the playable world, in game units. +/// Base map image width, in pixels. +/// Base map image height, in pixels. +/// Ocean border baked into the base image, in pixels per side. +/// Output image edge length, in pixels. +public sealed record MapProjection(uint WorldSize, int ImageWidth, int ImageHeight, int OceanMarginPx, int OutputSize) +{ + /// Converts a world (x, y) to an output pixel (x, y); origin top-left, Y down. + /// World X (west→east). + /// World Y (south→north). + /// The output-space pixel coordinate. + public (float X, float Y) ToPixel(float worldX, float worldY) + { + var imgX = (worldX * ((ImageWidth - (2f * OceanMarginPx)) / WorldSize)) + OceanMarginPx; + var imgY = ImageHeight - ((worldY * ((ImageHeight - (2f * OceanMarginPx)) / WorldSize)) + OceanMarginPx); + return (imgX * ((float)OutputSize / ImageWidth), imgY * ((float)OutputSize / ImageHeight)); + } +} diff --git a/tests/RustPlusBot.Features.Map.Tests/MapProjectionTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapProjectionTests.cs new file mode 100644 index 00000000..db9050a6 --- /dev/null +++ b/tests/RustPlusBot.Features.Map.Tests/MapProjectionTests.cs @@ -0,0 +1,56 @@ +using RustPlusBot.Features.Map.Rendering; + +namespace RustPlusBot.Features.Map.Tests; + +public sealed class MapProjectionTests +{ + [Fact] + public void Rust_plus_style_margin_projects_origin_inside_margin() + { + // 4000-unit world on a 2000px image with 100px margin, output 1000px. + // Playable spans [100, 1900]px on the image -> world(0,0) at image (100, 1900) -> output (50, 950). + var p = new MapProjection(WorldSize: 4000, ImageWidth: 2000, ImageHeight: 2000, OceanMarginPx: 100, OutputSize: 1000); + + var (x, y) = p.ToPixel(0f, 0f); + + Assert.Equal(50f, x, precision: 3); + Assert.Equal(950f, y, precision: 3); + } + + [Fact] + public void Center_of_world_is_center_of_output() + { + var p = new MapProjection(4000, 2000, 2000, 100, 1000); + + var (x, y) = p.ToPixel(2000f, 2000f); + + Assert.Equal(500f, x, precision: 3); + Assert.Equal(500f, y, precision: 3); + } + + [Fact] + public void Rustmaps_style_zero_margin_maps_world_to_full_image() + { + // RustMaps raw render: no ocean margin. World (0,0) -> output bottom-left corner. + var p = new MapProjection(3500, 1750, 1750, 0, 1024); + + var (x0, y0) = p.ToPixel(0f, 0f); + var (x1, y1) = p.ToPixel(3500f, 3500f); + + Assert.Equal(0f, x0, precision: 3); + Assert.Equal(1024f, y0, precision: 3); + Assert.Equal(1024f, x1, precision: 3); + Assert.Equal(0f, y1, precision: 3); + } + + [Fact] + public void North_is_visually_above_south() + { + var p = new MapProjection(4000, 2000, 2000, 100, 1000); + + var (_, southY) = p.ToPixel(2000f, 0f); + var (_, northY) = p.ToPixel(2000f, 4000f); + + Assert.True(northY < southY); + } +} From d8a56b6483315fe1cb0d43e8d9c78c34ef365e84 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 7 Jul 2026 22:31:13 +0200 Subject: [PATCH 04/40] feat(map): MapRenderStyle constants + size-scaled icon cache --- .../Assets/MapIcons.cs | 59 ++++++++++++++++--- .../Rendering/MapRenderStyle.cs | 44 ++++++++++++++ .../MapIconsTests.cs | 28 +++++++++ 3 files changed, 124 insertions(+), 7 deletions(-) create mode 100644 src/RustPlusBot.Features.Map/Rendering/MapRenderStyle.cs diff --git a/src/RustPlusBot.Features.Map/Assets/MapIcons.cs b/src/RustPlusBot.Features.Map/Assets/MapIcons.cs index fc58d913..eedd7d30 100644 --- a/src/RustPlusBot.Features.Map/Assets/MapIcons.cs +++ b/src/RustPlusBot.Features.Map/Assets/MapIcons.cs @@ -3,6 +3,7 @@ using RustPlusBot.Abstractions.Events; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; namespace RustPlusBot.Features.Map.Assets; @@ -23,14 +24,17 @@ public static class MapIcons /// 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 + public static Image? Marker(MarkerKind kind) { - MarkerKind.CargoShip => Load("cargo"), - MarkerKind.PatrolHelicopter => Load("patrol"), - MarkerKind.Chinook => Load("ch47"), - MarkerKind.TravellingVendor => Load("vendor"), - _ => null, - }; + var key = KeyFor(kind); + return key is null ? null : Load(key); + } + + /// Gets the marker icon scaled to fit a square box, or null when the kind has no icon. + /// The marker kind. + /// The box edge length in pixels; the longest icon edge is scaled to it. + /// The cached scaled icon, or null. + public static Image? Marker(MarkerKind kind, int size) => Scaled(KeyFor(kind), size); /// Gets the rig icon for a rig kind, or null when there is no icon for that kind. /// Which rig. @@ -49,6 +53,16 @@ public static class MapIcons }; #pragma warning restore RCS1163, IDE0060 + /// Gets the rig icon scaled to fit a square box, or null when the kind has no icon. + /// Which rig. + /// Whether the rig is active (reserved; styling is applied by the renderer). + /// The box edge length in pixels. + /// The cached scaled icon, or null. +#pragma warning disable RCS1163, IDE0060 // 'active' is part of the API contract; styling is the renderer's job. + public static Image? Rig(RigKind kind, bool active, int size) => + Scaled(kind switch { RigKind.Small => "oilrig", RigKind.Large => "largeoilrig", _ => null }, size); +#pragma warning restore RCS1163, IDE0060 + /// Gets the travelling vendor icon. /// The cached vendor icon image, or null. public static Image? Vendor() => Load("vendor"); @@ -57,6 +71,11 @@ public static class MapIcons /// The player icon, or null. public static Image? Player() => Load("player"); + /// Gets the player icon scaled to fit a square box, or null when the asset is missing. + /// The box edge length in pixels. + /// The cached scaled icon, or null. + public static Image? Player(int size) => Scaled("player", size); + /// Gets the icon for a monument token, or null when the token is unmapped or the file is missing. /// The Rust+ monument protobuf token. /// The cached icon image, or null. @@ -66,10 +85,36 @@ public static class MapIcons return key is null ? null : Load(key); } + /// Gets the monument icon scaled to fit a square box, or null for unmapped tokens. + /// The Rust+ monument protobuf token. + /// The box edge length in pixels. + /// The cached scaled icon, or null. + public static Image? Monument(string token, int size) => Scaled(MonumentIconMap.IconKeyFor(token), size); + + private static string? KeyFor(MarkerKind kind) => kind switch + { + MarkerKind.CargoShip => "cargo", + MarkerKind.PatrolHelicopter => "patrol", + MarkerKind.Chinook => "ch47", + MarkerKind.TravellingVendor => "vendor", + _ => null, + }; + 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); }); + + private static Image? Scaled(string? key, int size) => key is null + ? null + : Cache.GetOrAdd($"{key}@{size}", _ => + { + var native = Load(key); + return native?.Clone(ctx => ctx.Resize(new ResizeOptions + { + Mode = ResizeMode.Max, Size = new Size(size, size), + })); + }); } diff --git a/src/RustPlusBot.Features.Map/Rendering/MapRenderStyle.cs b/src/RustPlusBot.Features.Map/Rendering/MapRenderStyle.cs new file mode 100644 index 00000000..6a985cb7 --- /dev/null +++ b/src/RustPlusBot.Features.Map/Rendering/MapRenderStyle.cs @@ -0,0 +1,44 @@ +using RustPlusBot.Abstractions.Connections; +using SixLabors.ImageSharp; + +namespace RustPlusBot.Features.Map.Rendering; + +/// All on-map sizing and styling constants, expressed in pixels at the 1024px output. +public static class MapRenderStyle +{ + /// Monument icon box edge, in output pixels (~3% of the output edge). + public const int MonumentIconSize = 30; + + /// Default event-marker icon box edge, in output pixels. + public const int EventIconSize = 36; + + /// Cargo-ship icon box edge, in output pixels (slightly larger — it is a big target). + public const int CargoIconSize = 40; + + /// Player icon box edge, in output pixels. + public const int PlayerIconSize = 20; + + /// Trail polyline stroke width, in output pixels. + public const float TrailWidth = 2f; + + /// Grid cell label font size, in points. + public const float GridLabelFontSize = 10f; + + /// Gets the icon box edge for a marker kind. + /// The marker kind. + /// The box edge length in output pixels. + public static int MarkerIconSize(MarkerKind kind) => + kind == MarkerKind.CargoShip ? CargoIconSize : EventIconSize; + + /// Gets the trail color for a marker kind. + /// The marker kind. + /// The base (fully opaque) trail color. + public static Color TrailColor(MarkerKind kind) => kind switch + { + MarkerKind.CargoShip => Color.ParseHex("4FC3F7"), + MarkerKind.PatrolHelicopter => Color.ParseHex("EF5350"), + MarkerKind.Chinook => Color.ParseHex("FFB74D"), + MarkerKind.TravellingVendor => Color.ParseHex("81C784"), + _ => Color.White, + }; +} diff --git a/tests/RustPlusBot.Features.Map.Tests/MapIconsTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapIconsTests.cs index 81c2cc00..257d110c 100644 --- a/tests/RustPlusBot.Features.Map.Tests/MapIconsTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/MapIconsTests.cs @@ -37,4 +37,32 @@ public void Rig_resolves_for_known_kinds(RigKind kind) => [Fact] public void Player_resolves_to_vendored_icon() => Assert.NotNull(MapIcons.Player()); + + [Fact] + public void Sized_marker_icon_fits_the_requested_box() + { + var icon = MapIcons.Marker(MarkerKind.CargoShip, 40); + + Assert.NotNull(icon); + Assert.True(icon!.Width <= 40 && icon.Height <= 40); + Assert.True(icon.Width == 40 || icon.Height == 40); // aspect-preserving fit, longest edge = size + } + + [Fact] + public void Sized_monument_icon_is_scaled_down_from_native() + { + var native = MapIcons.Monument("oilrig_1"); // 875x875 native + var sized = MapIcons.Monument("oilrig_1", 30); + + Assert.NotNull(native); + Assert.NotNull(sized); + Assert.True(sized!.Width <= 30 && sized.Height <= 30); + } + + [Fact] + public void Sized_icons_are_cached_per_size() + { + Assert.Same(MapIcons.Player(20), MapIcons.Player(20)); + Assert.NotSame(MapIcons.Player(20), MapIcons.Player(24)); + } } From 78f4fd1cead6a2fc42de30108685be12c1f21b3d Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 7 Jul 2026 22:46:09 +0200 Subject: [PATCH 05/40] fix(map): correct projection, scaled icons, per-cell grid labels --- .../Composing/MapComposer.cs | 43 ++++++---- .../Rendering/MapRenderer.cs | 79 +++++++++++-------- .../Rendering/WorldToPixel.cs | 30 ------- .../MapComposerTests.cs | 2 +- .../MapRendererTests.cs | 62 +++++++++++++-- .../WorldToPixelTests.cs | 39 --------- 6 files changed, 132 insertions(+), 123 deletions(-) delete mode 100644 src/RustPlusBot.Features.Map/Rendering/WorldToPixel.cs delete mode 100644 tests/RustPlusBot.Features.Map.Tests/WorldToPixelTests.cs diff --git a/src/RustPlusBot.Features.Map/Composing/MapComposer.cs b/src/RustPlusBot.Features.Map/Composing/MapComposer.cs index eafb2bcd..60add57a 100644 --- a/src/RustPlusBot.Features.Map/Composing/MapComposer.cs +++ b/src/RustPlusBot.Features.Map/Composing/MapComposer.cs @@ -54,16 +54,21 @@ public sealed class MapComposer( // 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) + if (dims is null || dims.WorldSize == 0) { // Dimensions unavailable: render the base tile only (every overlay needs world→pixel). - return renderer.Render(baseImage, new MapDimensions(0, 0, 0, 0), markers: [], monuments: [], players: [], - rigs: [], + return renderer.Render(baseImage, new MapProjection(0, 1, 1, 0, MapRenderer.OutputSize), + 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); + // NOTE — Task 9 follow-up: image pixel dims come from MapDimensions for now, which is correct + // for the Rust+ JPEG. The base-map source chain will replace this with the fetched image's own dims. + var projection = new MapProjection(dims.WorldSize, (int)dims.Width, (int)dims.Height, dims.OceanMargin, + MapRenderer.OutputSize); + + var markers = GatherMarkers(guildId, serverId, projection, layers); // Monuments feed both the monuments layer and the rig-styling layer; fetch them once when either is on. IReadOnlyList serverMonuments = []; @@ -72,15 +77,19 @@ public sealed class MapComposer( serverMonuments = await query.GetMonumentsAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); } - var monuments = GatherMonuments(serverMonuments, dims, layers); - var players = await GatherPlayersAsync(guildId, serverId, dims, layers, cancellationToken) + var monuments = GatherMonuments(serverMonuments, projection, layers); + var players = await GatherPlayersAsync(guildId, serverId, projection, layers, cancellationToken) .ConfigureAwait(false); - var rigPlacements = GatherRigs(guildId, serverId, serverMonuments, dims, layers); + var rigPlacements = GatherRigs(guildId, serverId, serverMonuments, projection, layers); - return renderer.Render(baseImage, dims, markers, monuments, players, rigPlacements, layers); + return renderer.Render(baseImage, projection, markers, monuments, players, rigPlacements, layers); } - private List GatherMarkers(ulong guildId, Guid serverId, MapDimensions dims, MapLayerSet layers) + private List GatherMarkers( + ulong guildId, + Guid serverId, + MapProjection projection, + MapLayerSet layers) { var markers = new List(); if (layers.Markers) @@ -89,7 +98,7 @@ private List GatherMarkers(ulong guildId, Guid serverId, MapDim { foreach (var m in events.GetActiveMarkers(guildId, serverId, kind)) { - var (px, py) = WorldToPixel.ToPixel(m.X, m.Y, dims, MapRenderer.OutputSize); + var (px, py) = projection.ToPixel(m.X, m.Y); markers.Add(new MarkerPlacement(kind, px, py)); } } @@ -99,7 +108,7 @@ private List GatherMarkers(ulong guildId, Guid serverId, MapDim { foreach (var m in events.GetActiveMarkers(guildId, serverId, MarkerKind.TravellingVendor)) { - var (px, py) = WorldToPixel.ToPixel(m.X, m.Y, dims, MapRenderer.OutputSize); + var (px, py) = projection.ToPixel(m.X, m.Y); markers.Add(new MarkerPlacement(MarkerKind.TravellingVendor, px, py)); } } @@ -109,7 +118,7 @@ private List GatherMarkers(ulong guildId, Guid serverId, MapDim private static List GatherMonuments( IReadOnlyList serverMonuments, - MapDimensions dims, + MapProjection projection, MapLayerSet layers) { var monuments = new List(); @@ -117,7 +126,7 @@ private static List GatherMonuments( { foreach (var mon in serverMonuments) { - var (px, py) = WorldToPixel.ToPixel(mon.X, mon.Y, dims, MapRenderer.OutputSize); + var (px, py) = projection.ToPixel(mon.X, mon.Y); monuments.Add(new MonumentPlacement(mon.Token, px, py)); } } @@ -128,7 +137,7 @@ private static List GatherMonuments( private async Task> GatherPlayersAsync( ulong guildId, Guid serverId, - MapDimensions dims, + MapProjection projection, MapLayerSet layers, CancellationToken cancellationToken) { @@ -138,7 +147,7 @@ private async Task> GatherPlayersAsync( 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); + var (px, py) = projection.ToPixel(member.X, member.Y); players.Add(new PlayerPlacement(member.Name, px, py, member.IsAlive, member.IsOnline)); } } @@ -150,7 +159,7 @@ private List GatherRigs( ulong guildId, Guid serverId, IReadOnlyList serverMonuments, - MapDimensions dims, + MapProjection projection, MapLayerSet layers) { var rigPlacements = new List(); @@ -168,7 +177,7 @@ private List GatherRigs( if (kind is { } k) { var state = rigs.Get(guildId, serverId, k); - var (px, py) = WorldToPixel.ToPixel(mon.X, mon.Y, dims, MapRenderer.OutputSize); + var (px, py) = projection.ToPixel(mon.X, mon.Y); rigPlacements.Add(new RigPlacement(k, px, py, state.Status == RigStatus.Active)); } } diff --git a/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs b/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs index 1dd7d5eb..8dc2db8e 100644 --- a/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs +++ b/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs @@ -19,28 +19,27 @@ public sealed class MapRenderer /// The square output edge length in pixels. public const int OutputSize = 1024; - private const float GridDiameter = 146.25f; - 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(); + private static readonly FontFamily Family = LoadFamily(); + private static readonly Font Font = Family.CreateFont(12f); + private static readonly Font GridLabelFont = Family.CreateFont(MapRenderStyle.GridLabelFontSize); - private static Font LoadFont() + private static FontFamily LoadFamily() { var asm = typeof(MapRenderer).Assembly; using var stream = asm.GetManifestResourceStream("RustPlusBot.Features.Map.Assets.LiberationSans-Regular.ttf") ?? throw new InvalidOperationException( "Embedded map font 'RustPlusBot.Features.Map.Assets.LiberationSans-Regular.ttf' not found."); var collection = new FontCollection(); - var family = collection.Add(stream, CultureInfo.InvariantCulture); - return family.CreateFont(12f); + return collection.Add(stream, CultureInfo.InvariantCulture); } /// Renders the map tile plus the requested overlay layers to PNG bytes. /// The raw base-map JPEG bytes. - /// The map dimensions (world size + ocean margin). + /// The world-to-pixel projection (world size, base image dims, ocean margin). /// Marker placements already projected to pixel coordinates. /// Monument placements already projected to pixel coordinates. /// Player placements already projected to pixel coordinates. @@ -50,7 +49,7 @@ private static Font LoadFont() /// Kept as an instance method so the class can be registered as a DI singleton. #pragma warning disable CA1822, S2325 // Kept as instance method for DI singleton registration public byte[] Render(byte[] baseJpeg, - MapDimensions dims, + MapProjection projection, IReadOnlyList markers, IReadOnlyList monuments, IReadOnlyList players, @@ -59,7 +58,7 @@ public byte[] Render(byte[] baseJpeg, #pragma warning restore CA1822, S2325 { ArgumentNullException.ThrowIfNull(baseJpeg); - ArgumentNullException.ThrowIfNull(dims); + ArgumentNullException.ThrowIfNull(projection); ArgumentNullException.ThrowIfNull(markers); ArgumentNullException.ThrowIfNull(monuments); ArgumentNullException.ThrowIfNull(players); @@ -71,7 +70,7 @@ public byte[] Render(byte[] baseJpeg, if (layers.Grid) { - DrawGrid(image, dims); + DrawGrid(image, projection); } if (layers.Monuments) @@ -99,25 +98,46 @@ public byte[] Render(byte[] baseJpeg, return ms.ToArray(); } - private static void DrawGrid(Image image, MapDimensions dims) + private static void DrawGrid(Image image, MapProjection projection) { - var gridColor = Color.FromRgba(255, 255, 255, 80); + if (projection.WorldSize == 0) + { + return; + } + + var lineColor = Color.FromRgba(255, 255, 255, 80); + var labelColor = Color.FromRgba(255, 255, 255, 140); + var cells = MapGrid.CellCount(projection.WorldSize); + var worldSize = (float)projection.WorldSize; + var (left, top) = projection.ToPixel(0f, worldSize); + var (right, bottom) = projection.ToPixel(worldSize, 0f); image.Mutate(ctx => { - // Vertical lines step across the X axis (width); horizontal lines step across the Y axis - // (height). Driving each axis from its own dimension keeps the grid correct on a - // non-square map (Rust maps are square today, so Width == Height in practice). - for (var worldX = 0f; worldX <= dims.Width; worldX += GridDiameter) + for (var i = 0; i <= cells; i++) { - var (vx, _) = WorldToPixel.ToPixel(worldX, 0f, dims, OutputSize); - ctx.DrawLine(gridColor, OutlinePenWidth, new PointF(vx, 0), new PointF(vx, OutputSize)); + var boundary = Math.Min(i * MapGrid.CellSize, worldSize); + var (vx, _) = projection.ToPixel(boundary, 0f); + ctx.DrawLine(lineColor, OutlinePenWidth, new PointF(vx, top), new PointF(vx, bottom)); + var (_, hy) = projection.ToPixel(0f, boundary); + ctx.DrawLine(lineColor, OutlinePenWidth, new PointF(left, hy), new PointF(right, hy)); } - for (var worldY = 0f; worldY <= dims.Height; worldY += GridDiameter) + for (var col = 0; col < cells; col++) { - var (_, hy) = WorldToPixel.ToPixel(0f, worldY, dims, OutputSize); - ctx.DrawLine(gridColor, OutlinePenWidth, new PointF(0, hy), new PointF(OutputSize, hy)); + for (var row = 0; row < cells; row++) + { + // Label sits just inside each cell's top-left corner (official-app placement). + var worldX = col * MapGrid.CellSize; + var worldY = worldSize - (row * MapGrid.CellSize); + var (lx, ly) = projection.ToPixel(worldX, worldY); + var label = MapGrid.ColumnLetters(col) + row.ToString(CultureInfo.InvariantCulture); + ctx.DrawText(new RichTextOptions(GridLabelFont) + { + Origin = new PointF(lx + 2f, ly + 2f) + }, + label, labelColor); + } } }); } @@ -130,7 +150,7 @@ private static void DrawMarkers(Image image, IReadOnlyList image, IReadOnlyList image, IReadOnlyList ri { foreach (var rig in rigs) { - var icon = MapIcons.Rig(rig.Kind, rig.Active); + var icon = MapIcons.Rig(rig.Kind, rig.Active, MapRenderStyle.MonumentIconSize); if (icon is null) { continue; @@ -183,7 +203,7 @@ private static void DrawRigs(Image image, IReadOnlyList ri private static void DrawPlayers(Image image, IReadOnlyList players) { - var icon = MapIcons.Player(); + var icon = MapIcons.Player(MapRenderStyle.PlayerIconSize); image.Mutate(ctx => { @@ -197,17 +217,14 @@ private static void DrawPlayers(Image image, IReadOnlyList? icon) { - if (icon is not null) + if (icon is null) { - ctx.DrawImage(icon, CenterAt(player.PixelX, player.PixelY, icon), 1f); return; } var isActive = player is { IsAlive: true, IsOnline: true }; - 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); + // Dead/offline teammates render dimmed so status reads at a glance (label adds the suffix). + ctx.DrawImage(icon, CenterAt(player.PixelX, player.PixelY, icon), isActive ? 1f : 0.45f); } private static void DrawPlayerLabel(IImageProcessingContext ctx, PlayerPlacement player) diff --git a/src/RustPlusBot.Features.Map/Rendering/WorldToPixel.cs b/src/RustPlusBot.Features.Map/Rendering/WorldToPixel.cs deleted file mode 100644 index bcf38e98..00000000 --- a/src/RustPlusBot.Features.Map/Rendering/WorldToPixel.cs +++ /dev/null @@ -1,30 +0,0 @@ -using RustPlusBot.Abstractions.Connections; - -namespace RustPlusBot.Features.Map.Rendering; - -/// Maps world coordinates to pixel coordinates on a square rendered map tile. -public static class WorldToPixel -{ - /// Converts a world (x, y) to a pixel (x, y) on a square output of the given edge length. - /// World X (west→east), in [0, Width]. - /// World Y (south→north), in [0, Height]. - /// The map dimensions (width/height in game units + ocean margin). - /// The output image edge length in pixels. - /// The pixel coordinate (origin top-left, Y down). - public static (float X, float Y) ToPixel(float worldX, float worldY, MapDimensions dims, int outputSize) - { - ArgumentNullException.ThrowIfNull(dims); - - // The full tile adds the ocean margin on each side, so each axis spans - // [-margin, dimension+margin]. Each axis is scaled by its own dimension so the projection - // is correct even if a server ever reports a non-square map (Rust maps are square today). - var margin = dims.OceanMargin; - var perUnitX = outputSize / (dims.Width + (2f * margin)); - var perUnitY = outputSize / (dims.Height + (2f * margin)); - - var px = (worldX + margin) * perUnitX; - // Flip Y: world south (0) is the visual bottom (image y = outputSize), world north is the top. - var py = outputSize - ((worldY + margin) * perUnitY); - return (px, py); - } -} diff --git a/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs index 9472ddf8..08e1efe5 100644 --- a/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs @@ -15,7 +15,7 @@ public sealed class MapComposerTests { private const ulong Guild = 1UL; private static readonly Guid Server = Guid.NewGuid(); - private static readonly MapDimensions Dims = new(4000, 4000, 500, 4000); + private static readonly MapDimensions Dims = new(2000, 2000, 100, 4000); private static byte[] BaseJpeg() { diff --git a/tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs index 6397dc15..4ca7eac4 100644 --- a/tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs @@ -8,7 +8,9 @@ namespace RustPlusBot.Features.Map.Tests; public sealed class MapRendererTests { - private static readonly MapDimensions Dims = new(Width: 4000, Height: 4000, OceanMargin: 500, WorldSize: 4000); + private static readonly MapProjection Projection = + new(WorldSize: 4000, ImageWidth: 4000, ImageHeight: 4000, OceanMarginPx: 500, + OutputSize: MapRenderer.OutputSize); /// A 64x64 solid-green JPEG, generated once in-test so the renderer has a real base image to decode. private static byte[] BaseJpeg() @@ -19,12 +21,42 @@ private static byte[] BaseJpeg() return ms.ToArray(); } + private static byte[] SolidJpeg(int size) + { + using var img = new Image(size, size, new Rgba32(40, 90, 120)); + using var ms = new MemoryStream(); + img.SaveAsJpeg(ms); + return ms.ToArray(); + } + + private static Rectangle ChangedPixelBounds(byte[] a, byte[] b) + { + using var ia = Image.Load(a); + using var ib = Image.Load(b); + int minX = int.MaxValue, minY = int.MaxValue, maxX = -1, maxY = -1; + for (var y = 0; y < ia.Height; y++) + { + for (var x = 0; x < ia.Width; x++) + { + if (ia[x, y] != ib[x, y]) + { + minX = Math.Min(minX, x); + minY = Math.Min(minY, y); + maxX = Math.Max(maxX, x); + maxY = Math.Max(maxY, y); + } + } + } + + return maxX < 0 ? Rectangle.Empty : new Rectangle(minX, minY, maxX - minX + 1, maxY - minY + 1); + } + [Fact] public void Render_produces_a_png_of_the_output_size() { var renderer = new MapRenderer(); - var bytes = renderer.Render(BaseJpeg(), Dims, markers: [], monuments: [], players: [], rigs: [], + var bytes = renderer.Render(BaseJpeg(), Projection, markers: [], monuments: [], players: [], rigs: [], MapLayerSet.AllOn); using var result = Image.Load(bytes); @@ -38,9 +70,9 @@ public void Render_with_a_marker_differs_from_render_without() var renderer = new MapRenderer(); var jpeg = BaseJpeg(); - var without = renderer.Render(jpeg, Dims, markers: [], monuments: [], players: [], rigs: [], + var without = renderer.Render(jpeg, Projection, markers: [], monuments: [], players: [], rigs: [], new MapLayerSet(false, true, false, false, false, false)); - var with = renderer.Render(jpeg, Dims, + var with = renderer.Render(jpeg, Projection, markers: [new MarkerPlacement(MarkerKind.CargoShip, 512f, 512f)], monuments: [], players: [], rigs: [], new MapLayerSet(false, true, false, false, false, false)); @@ -69,9 +101,29 @@ public void Render_with_all_layers_produces_valid_png() new RigPlacement(RigKind.Large, 400, 400, Active: true) }; - var png = renderer.Render(BaseJpeg(), Dims, markers, monuments, players, rigs, MapLayerSet.AllOn); + var png = renderer.Render(BaseJpeg(), Projection, markers, monuments, players, rigs, MapLayerSet.AllOn); using var img = Image.Load(png); // throws if not a valid image Assert.Equal(MapRenderer.OutputSize, img.Width); } + + [Fact] + public void Monument_icon_is_drawn_scaled_not_native() + { + var renderer = new MapRenderer(); + var projection = new MapProjection(4000, 2000, 2000, 100, MapRenderer.OutputSize); + var baseJpeg = SolidJpeg(2000); + var (px, py) = projection.ToPixel(2000f, 2000f); + + var without = renderer.Render(baseJpeg, projection, [], [], [], [], + new MapLayerSet(false, false, false, false, false, false)); + var with = renderer.Render(baseJpeg, projection, [], + [new MonumentPlacement("oilrig_1", px, py)], [], [], + new MapLayerSet(false, false, true, false, false, false)); + + var bounds = ChangedPixelBounds(without, with); + Assert.True(bounds.Width <= MapRenderStyle.MonumentIconSize + 2, + $"changed area {bounds.Width}px wide — icon not scaled"); + Assert.True(bounds.Height <= MapRenderStyle.MonumentIconSize + 2); + } } diff --git a/tests/RustPlusBot.Features.Map.Tests/WorldToPixelTests.cs b/tests/RustPlusBot.Features.Map.Tests/WorldToPixelTests.cs deleted file mode 100644 index 7ed3d893..00000000 --- a/tests/RustPlusBot.Features.Map.Tests/WorldToPixelTests.cs +++ /dev/null @@ -1,39 +0,0 @@ -using RustPlusBot.Abstractions.Connections; -using RustPlusBot.Features.Map.Rendering; - -namespace RustPlusBot.Features.Map.Tests; - -public sealed class WorldToPixelTests -{ - private static readonly MapDimensions Dims = new(Width: 4000, Height: 4000, OceanMargin: 500, WorldSize: 4000); - - [Fact] - public void Origin_world_maps_to_bottom_left_inside_margin() - { - // World (0,0) is the SW corner of the playable area. Full tile spans [-500, 4500] = 5000 units. - // Pixel-per-unit at outputSize 1000 = 1000/5000 = 0.2. World x=0 -> (0 - (-500)) * 0.2 = 100. - // World y=0 is the bottom -> image y = outputSize - 100 = 900. - var (px, py) = WorldToPixel.ToPixel(0f, 0f, Dims, outputSize: 1000); - - Assert.Equal(100f, px, precision: 3); - Assert.Equal(900f, py, precision: 3); - } - - [Fact] - public void Center_world_maps_to_center_pixel() - { - var (px, py) = WorldToPixel.ToPixel(2000f, 2000f, Dims, outputSize: 1000); - - Assert.Equal(500f, px, precision: 3); - Assert.Equal(500f, py, precision: 3); - } - - [Fact] - public void North_edge_maps_higher_than_south_edge() - { - var (_, southY) = WorldToPixel.ToPixel(2000f, 0f, Dims, outputSize: 1000); - var (_, northY) = WorldToPixel.ToPixel(2000f, 4000f, Dims, outputSize: 1000); - - Assert.True(northY < southY); // North is visually higher = smaller image-Y. - } -} From 0ffcddb0ec3fd295f71e5188332f37b5d25b11a8 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Tue, 7 Jul 2026 23:02:41 +0200 Subject: [PATCH 06/40] feat(events): Moved bucket in marker deltas + nullable marker rotation --- .../Connections/MapMarkerSnapshot.cs | 11 +++- .../Events/MapMarkersChangedEvent.cs | 4 +- .../Listening/RustPlusSocketSource.cs | 2 + .../Supervisor/ConnectionSupervisor.cs | 22 ++++++-- .../ConnectionSupervisorTests.cs | 53 +++++++++++++++++++ .../Classifying/MarkerEventClassifierTests.cs | 2 +- .../Relaying/EventRelayTests.cs | 8 +-- .../State/EventStateStoreTests.cs | 4 +- 8 files changed, 94 insertions(+), 12 deletions(-) diff --git a/src/RustPlusBot.Abstractions/Connections/MapMarkerSnapshot.cs b/src/RustPlusBot.Abstractions/Connections/MapMarkerSnapshot.cs index 5c6cd5ca..da370ffd 100644 --- a/src/RustPlusBot.Abstractions/Connections/MapMarkerSnapshot.cs +++ b/src/RustPlusBot.Abstractions/Connections/MapMarkerSnapshot.cs @@ -1,9 +1,18 @@ namespace RustPlusBot.Abstractions.Connections; +// Rotation defaults to null so existing call sites stay valid and "no heading" is distinguishable +// from "heading north" once RustPlusApi beta.4 supplies values. /// One map marker observed in a GetMapMarkers poll. /// The stable marker id (used to diff polls). /// The classified marker kind. /// World X coordinate. /// World Y coordinate. /// The marker name, if any. -public sealed record MapMarkerSnapshot(ulong Id, MarkerKind Kind, float X, float Y, string? Name); +/// Heading in degrees as sent by the server, or null when unavailable. +public sealed record MapMarkerSnapshot( + ulong Id, + MarkerKind Kind, + float X, + float Y, + string? Name, + float? Rotation = null); diff --git a/src/RustPlusBot.Abstractions/Events/MapMarkersChangedEvent.cs b/src/RustPlusBot.Abstractions/Events/MapMarkersChangedEvent.cs index c71da8e9..2e657d86 100644 --- a/src/RustPlusBot.Abstractions/Events/MapMarkersChangedEvent.cs +++ b/src/RustPlusBot.Abstractions/Events/MapMarkersChangedEvent.cs @@ -8,9 +8,11 @@ namespace RustPlusBot.Abstractions.Events; /// Map dimensions for grid-reference rendering, or null if unavailable. /// Markers present now that were absent in the previous poll. /// Markers absent now that were present in the previous poll. +/// Markers present in both polls whose position or rotation changed. public sealed record MapMarkersChangedEvent( ulong GuildId, Guid ServerId, MapDimensions? Dimensions, IReadOnlyList Added, - IReadOnlyList Removed); + IReadOnlyList Removed, + IReadOnlyList Moved); diff --git a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs index cc9ac399..0e3d5b33 100644 --- a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs +++ b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs @@ -709,6 +709,8 @@ private static void AddMarkers( continue; } + // Rotation: RustPlusApi 2.0.0-beta.3 does not map AppMarker.rotation; wire it here once + // 2.0.0-beta.4 ships (see RustPlusApi docs/development/beta4-map-marker-rotation.md). into.Add(new MapMarkerSnapshot(id, kind, x, y, Name: null)); } } diff --git a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs index b4ab1447..40120889 100644 --- a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs +++ b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs @@ -671,12 +671,28 @@ private async Task PublishMarkerDeltaAsync( IReadOnlyList current, CancellationToken ct) { - var added = current.Where(c => previous.All(p => p.Id != c.Id)).ToList(); + var previousById = previous.ToDictionary(p => p.Id); + var added = new List(); + var moved = new List(); + foreach (var c in current) + { + if (!previousById.TryGetValue(c.Id, out var p)) + { + added.Add(c); + } +#pragma warning disable S1244 // Exact float compare is intentional: a stationary marker round-trips identical floats. + else if (c.X != p.X || c.Y != p.Y || !Nullable.Equals(c.Rotation, p.Rotation)) +#pragma warning restore S1244 + { + moved.Add(c); + } + } + var removed = previous.Where(p => current.All(c => c.Id != p.Id)).ToList(); - if (added.Count > 0 || removed.Count > 0) + if (added.Count > 0 || removed.Count > 0 || moved.Count > 0) { await eventBus.PublishAsync( - new MapMarkersChangedEvent(key.Guild, key.Server, dims, added, removed), ct) + new MapMarkersChangedEvent(key.Guild, key.Server, dims, added, removed, moved), ct) .ConfigureAwait(false); } } diff --git a/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs b/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs index 82e5daad..054478ad 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs @@ -435,6 +435,59 @@ public async Task Marker_added_on_a_later_poll_publishes_changed_event() } } + [Fact] + public async Task Marker_position_change_publishes_moved_bucket() + { + // Contract: a marker present in consecutive polls whose position changed lands in Moved + // (not Added/Removed), so the map can track cargo/heli movement. + // + // Script: + // Poll 1 → [Cargo id 7 @ (100, 100)] (baseline — no event) + // Poll 2 → [Cargo id 7 @ (150, 130)] (moved → one event) + var source = new FakeRustSocketSource(); + source.EnqueueConnect(SocketConnectOutcome.Connected); + source.EnqueueHeartbeat(HeartbeatResult.Ok(1)); + await using var h = CreateHarness(source); + var (serverId, _, _) = await SeedAsync(h.Provider); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var captured = new System.Collections.Concurrent.ConcurrentQueue(); + var subTask = Task.Run(async () => + { + await foreach (var e in h.Bus.SubscribeAsync(cts.Token)) + { + captured.Enqueue(e); + } + }, CancellationToken.None); + + source.EnqueueMarkers([new MapMarkerSnapshot(7UL, MarkerKind.CargoShip, 100f, 100f, "Cargo A")]); + source.EnqueueMarkers([new MapMarkerSnapshot(7UL, MarkerKind.CargoShip, 150f, 130f, "Cargo A")]); + + await h.Supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); + await WaitUntilAsync(() => !captured.IsEmpty, cts.Token); + + Assert.Single(captured); + Assert.True(captured.TryPeek(out var evt)); + Assert.NotNull(evt); + Assert.Empty(evt!.Added); + Assert.Empty(evt.Removed); + var moved = Assert.Single(evt.Moved); + Assert.Equal(7UL, moved.Id); + Assert.Equal(150f, moved.X); + Assert.Equal(130f, moved.Y); + + await h.Supervisor.StopAllAsync(); + await cts.CancelAsync(); + try + { + await subTask; + } + catch (OperationCanceledException) + { + /* expected */ + } + } + [Fact] public async Task Failed_marker_poll_retains_previous_snapshot() { diff --git a/tests/RustPlusBot.Features.Events.Tests/Classifying/MarkerEventClassifierTests.cs b/tests/RustPlusBot.Features.Events.Tests/Classifying/MarkerEventClassifierTests.cs index a3412ee3..153de6a1 100644 --- a/tests/RustPlusBot.Features.Events.Tests/Classifying/MarkerEventClassifierTests.cs +++ b/tests/RustPlusBot.Features.Events.Tests/Classifying/MarkerEventClassifierTests.cs @@ -21,7 +21,7 @@ private static MarkerEventClassifier Build() private static MapMarkersChangedEvent Evt( IReadOnlyList added, IReadOnlyList removed) => - new(1UL, Server, new MapDimensions(4000u, 4000u, 500, WorldSize: 4000u), added, removed); + new(1UL, Server, new MapDimensions(4000u, 4000u, 500, WorldSize: 4000u), added, removed, []); [Fact] public void Cargo_added_is_CargoEntered() diff --git a/tests/RustPlusBot.Features.Events.Tests/Relaying/EventRelayTests.cs b/tests/RustPlusBot.Features.Events.Tests/Relaying/EventRelayTests.cs index 02081439..60b1f539 100644 --- a/tests/RustPlusBot.Features.Events.Tests/Relaying/EventRelayTests.cs +++ b/tests/RustPlusBot.Features.Events.Tests/Relaying/EventRelayTests.cs @@ -66,7 +66,7 @@ public async Task Posts_one_embed_per_classified_event_and_updates_state() await relay.RelayAsync( new MapMarkersChangedEvent(Guild, Server, null, [new MapMarkerSnapshot(1, MarkerKind.CargoShip, 0f, 0f, null)], - []), + [], []), CancellationToken.None); await poster.Received(1).PostAsync(999UL, Arg.Any(), Arg.Any()); @@ -81,7 +81,7 @@ public async Task When_channel_missing_updates_state_but_does_not_post() await relay.RelayAsync( new MapMarkersChangedEvent(Guild, Server, null, [new MapMarkerSnapshot(1, MarkerKind.CargoShip, 0f, 0f, null)], - []), + [], []), CancellationToken.None); await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any()); @@ -98,7 +98,7 @@ public async Task Empty_classification_posts_nothing() await relay.RelayAsync( new MapMarkersChangedEvent(Guild, Server, null, [new MapMarkerSnapshot(2, MarkerKind.Other, 0f, 0f, null)], - []), + [], []), CancellationToken.None); await poster.DidNotReceive().PostAsync(Arg.Any(), Arg.Any(), Arg.Any()); @@ -112,7 +112,7 @@ public async Task Relay_broadcasts_each_event_in_game_in_addition_to_discord() await relay.RelayAsync( new MapMarkersChangedEvent(Guild, Server, null, [new MapMarkerSnapshot(1, MarkerKind.CargoShip, 0f, 0f, null)], - []), + [], []), CancellationToken.None); await poster.Received(1).PostAsync(999UL, Arg.Any(), Arg.Any()); diff --git a/tests/RustPlusBot.Features.Events.Tests/State/EventStateStoreTests.cs b/tests/RustPlusBot.Features.Events.Tests/State/EventStateStoreTests.cs index d9c4262e..fd58095b 100644 --- a/tests/RustPlusBot.Features.Events.Tests/State/EventStateStoreTests.cs +++ b/tests/RustPlusBot.Features.Events.Tests/State/EventStateStoreTests.cs @@ -23,13 +23,13 @@ private static EventStateStore Build() private static MapMarkersChangedEvent Delta( IReadOnlyList added, IReadOnlyList removed) => - new(Guild, Server, null, added, removed); + new(Guild, Server, null, added, removed, []); private static MapMarkersChangedEvent DeltaWithDims( IReadOnlyList added, IReadOnlyList removed, MapDimensions? dims) => - new(Guild, Server, dims, added, removed); + new(Guild, Server, dims, added, removed, []); [Fact] public void Added_marker_becomes_active_and_carries_dimensions() From 53966c319c8cec38c734221e3c8c540f42df611b Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 8 Jul 2026 17:31:34 +0200 Subject: [PATCH 07/40] feat(events): active markers track movement + 6-deep history ring --- .../State/ActiveMarker.cs | 6 ++- .../State/EventStateStore.cs | 26 ++++++++++- .../State/TrailPoint.cs | 6 +++ .../Handlers/EventHandlersTests.cs | 11 +++-- .../State/EventStateStoreTests.cs | 44 +++++++++++++++++++ .../MapComposerTests.cs | 12 +++-- 6 files changed, 96 insertions(+), 9 deletions(-) create mode 100644 src/RustPlusBot.Features.Events/State/TrailPoint.cs diff --git a/src/RustPlusBot.Features.Events/State/ActiveMarker.cs b/src/RustPlusBot.Features.Events/State/ActiveMarker.cs index 6e97f7f9..bea15704 100644 --- a/src/RustPlusBot.Features.Events/State/ActiveMarker.cs +++ b/src/RustPlusBot.Features.Events/State/ActiveMarker.cs @@ -9,10 +9,14 @@ namespace RustPlusBot.Features.Events.State; /// World Y coordinate. /// Map dimensions for grid rendering, or null. /// When the marker was first seen. +/// Recent positions, oldest first, newest (current) last; capped. +/// Heading in degrees as sent by the server, or null. public sealed record ActiveMarker( ulong Id, MarkerKind Kind, float X, float Y, MapDimensions? Dimensions, - DateTimeOffset SeenAtUtc); + DateTimeOffset SeenAtUtc, + IReadOnlyList History, + float? Rotation); diff --git a/src/RustPlusBot.Features.Events/State/EventStateStore.cs b/src/RustPlusBot.Features.Events/State/EventStateStore.cs index 162d5f3e..49eb083d 100644 --- a/src/RustPlusBot.Features.Events/State/EventStateStore.cs +++ b/src/RustPlusBot.Features.Events/State/EventStateStore.cs @@ -11,6 +11,7 @@ namespace RustPlusBot.Features.Events.State; internal sealed class EventStateStore(IClock clock) : IEventState { private const int RecentCapacity = 10; + private const int HistoryCapacity = 6; private readonly ConcurrentDictionary<(ulong Guild, Guid Server), ServerState> _byServer = new(); /// @@ -59,7 +60,8 @@ public void Apply(MapMarkersChangedEvent delta, IReadOnlyList even { foreach (var m in delta.Added) { - state.Active[m.Id] = new ActiveMarker(m.Id, m.Kind, m.X, m.Y, delta.Dimensions, now); + state.Active[m.Id] = new ActiveMarker(m.Id, m.Kind, m.X, m.Y, delta.Dimensions, now, + [new TrailPoint(m.X, m.Y)], m.Rotation); } foreach (var m in delta.Removed) @@ -67,6 +69,28 @@ public void Apply(MapMarkersChangedEvent delta, IReadOnlyList even state.Active.Remove(m.Id); } + foreach (var m in delta.Moved) + { + if (!state.Active.TryGetValue(m.Id, out var existing)) + { + continue; // moved-before-added can only happen after a Clear race; drop it + } + + var history = new List(existing.History) + { + new(m.X, m.Y) + }; + if (history.Count > HistoryCapacity) + { + history.RemoveAt(0); + } + + state.Active[m.Id] = existing with + { + X = m.X, Y = m.Y, Rotation = m.Rotation, History = history + }; + } + foreach (var e in events) { state.Recent.Insert(0, e); diff --git a/src/RustPlusBot.Features.Events/State/TrailPoint.cs b/src/RustPlusBot.Features.Events/State/TrailPoint.cs new file mode 100644 index 00000000..b9579224 --- /dev/null +++ b/src/RustPlusBot.Features.Events/State/TrailPoint.cs @@ -0,0 +1,6 @@ +namespace RustPlusBot.Features.Events.State; + +/// One historical marker position, in world coordinates. +/// World X coordinate. +/// World Y coordinate. +public sealed record TrailPoint(float X, float Y); diff --git a/tests/RustPlusBot.Features.Commands.Tests/Handlers/EventHandlersTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Handlers/EventHandlersTests.cs index 468d0845..1cd8d48d 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/Handlers/EventHandlersTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/Handlers/EventHandlersTests.cs @@ -31,7 +31,10 @@ public async Task Cargo_with_active_marker_reports_grid() var state = Substitute.For(); var dims = new MapDimensions(4000u, 4000u, 500, WorldSize: 4000u); state.GetActiveMarkers(Guild, Server, MarkerKind.CargoShip).Returns( - [new ActiveMarker(1, MarkerKind.CargoShip, 10f, 3990f, dims, Now.AddMinutes(-5))]); + [ + new ActiveMarker(1, MarkerKind.CargoShip, 10f, 3990f, dims, Now.AddMinutes(-5), + [new TrailPoint(10f, 3990f)], null) + ]); var reply = await new CargoCommandHandler(state, loc, clock).ExecuteAsync(Ctx(), CancellationToken.None); Assert.NotNull(reply); @@ -60,8 +63,10 @@ public async Task Events_lists_recent_or_reports_none() await new EventsCommandHandler(state, loc).ExecuteAsync(Ctx(), CancellationToken.None)); state.GetRecentEvents(Guild, Server).Returns( - [new RustMapEvent(MapEventKind.CargoEntered, 10f, 3990f, - new MapDimensions(4000u, 4000u, 500, WorldSize: 4000u), Now)]); + [ + new RustMapEvent(MapEventKind.CargoEntered, 10f, 3990f, + new MapDimensions(4000u, 4000u, 500, WorldSize: 4000u), Now) + ]); var reply = await new EventsCommandHandler(state, loc).ExecuteAsync(Ctx(), CancellationToken.None); Assert.Contains("Recent:", reply, StringComparison.Ordinal); } diff --git a/tests/RustPlusBot.Features.Events.Tests/State/EventStateStoreTests.cs b/tests/RustPlusBot.Features.Events.Tests/State/EventStateStoreTests.cs index fd58095b..a6175cf7 100644 --- a/tests/RustPlusBot.Features.Events.Tests/State/EventStateStoreTests.cs +++ b/tests/RustPlusBot.Features.Events.Tests/State/EventStateStoreTests.cs @@ -31,6 +31,9 @@ private static MapMarkersChangedEvent DeltaWithDims( MapDimensions? dims) => new(Guild, Server, dims, added, removed, []); + private static MapMarkersChangedEvent DeltaMoved(IReadOnlyList moved) => + new(Guild, Server, null, [], [], moved); + [Fact] public void Added_marker_becomes_active_and_carries_dimensions() { @@ -88,4 +91,45 @@ public void Clear_drops_active_and_recent_for_that_server() Assert.Empty(store.GetActiveMarkers(Guild, Server, MarkerKind.CargoShip)); Assert.Empty(store.GetRecentEvents(Guild, Server)); } + + [Fact] + public void Moved_marker_updates_position_and_rotation() + { + var store = Build(); + store.Apply(Delta([new MapMarkerSnapshot(1, MarkerKind.CargoShip, 10f, 20f, null)], []), []); + + store.Apply(DeltaMoved([new MapMarkerSnapshot(1, MarkerKind.CargoShip, 30f, 40f, null, Rotation: 90f)]), []); + + var active = Assert.Single(store.GetActiveMarkers(Guild, Server, MarkerKind.CargoShip)); + Assert.Equal(30f, active.X); + Assert.Equal(40f, active.Y); + Assert.Equal(90f, active.Rotation); + } + + [Fact] + public void History_ring_appends_and_caps_at_six() + { + var store = Build(); + store.Apply(Delta([new MapMarkerSnapshot(1, MarkerKind.CargoShip, 0f, 0f, null)], []), []); + + for (var i = 1; i <= 8; i++) + { + store.Apply(DeltaMoved([new MapMarkerSnapshot(1, MarkerKind.CargoShip, i * 10f, 0f, null)]), []); + } + + var active = Assert.Single(store.GetActiveMarkers(Guild, Server, MarkerKind.CargoShip)); + Assert.Equal(6, active.History.Count); + Assert.Equal(80f, active.History[^1].X); // newest last = current position + Assert.Equal(30f, active.History[0].X); // oldest surviving point + } + + [Fact] + public void Moved_for_unknown_id_is_ignored() + { + var store = Build(); + + store.Apply(DeltaMoved([new MapMarkerSnapshot(99, MarkerKind.CargoShip, 1f, 2f, null)]), []); + + Assert.Empty(store.GetActiveMarkers(Guild, Server, MarkerKind.CargoShip)); + } } diff --git a/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs index 08e1efe5..d6784a6c 100644 --- a/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs @@ -84,7 +84,8 @@ public async Task ComposeAsync_returns_null_when_no_base_map() [Fact] public async Task Renders_a_png_when_base_map_available() { - var marker = new ActiveMarker(1, MarkerKind.CargoShip, 2000f, 2000f, Dims, DateTimeOffset.UtcNow); + var marker = new ActiveMarker(1, MarkerKind.CargoShip, 2000f, 2000f, Dims, DateTimeOffset.UtcNow, + [new TrailPoint(2000f, 2000f)], null); var composer = Build(BaseJpeg(), Dims, NewQuery(), NewEvents(marker), NewRigs(), NewSettings(MapLayerSettings.AllOn)); @@ -99,7 +100,8 @@ public async Task Renders_a_png_when_base_map_available() public async Task Renders_base_only_when_dimensions_unavailable() { var composer = Build(BaseJpeg(), dims: null, NewQuery(), - NewEvents(new ActiveMarker(1, MarkerKind.CargoShip, 2000f, 2000f, Dims, DateTimeOffset.UtcNow)), + NewEvents(new ActiveMarker(1, MarkerKind.CargoShip, 2000f, 2000f, Dims, DateTimeOffset.UtcNow, + [new TrailPoint(2000f, 2000f)], null)), NewRigs(), NewSettings(MapLayerSettings.AllOn)); var png = await composer.ComposeAsync(Guild, Server, CancellationToken.None); @@ -117,7 +119,8 @@ public async Task ComposeAsync_renders_only_enabled_layers() 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 events = NewEvents(new ActiveMarker(1, MarkerKind.CargoShip, 2000f, 2000f, Dims, DateTimeOffset.UtcNow, + [new TrailPoint(2000f, 2000f)], null)); var settings = NewSettings(new MapLayerSettings( Grid: true, Markers: true, Monuments: false, Vendor: true, Players: true, Rigs: false)); @@ -142,7 +145,8 @@ public async Task ComposeAsync_uses_all_on_when_no_settings_row() 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 events = NewEvents(new ActiveMarker(1, MarkerKind.CargoShip, 2000f, 2000f, Dims, DateTimeOffset.UtcNow, + [new TrailPoint(2000f, 2000f)], null)); var composer = Build(BaseJpeg(), Dims, query, events, NewRigs(), NewSettings(MapLayerSettings.AllOn)); From a6ab8250624e2cfdb0f1609cf291c74f8ec95aa8 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 8 Jul 2026 17:41:32 +0200 Subject: [PATCH 08/40] feat(map): fading motion trails + rotation-aware marker drawing --- .../Composing/MapComposer.cs | 19 +++++++- .../Rendering/MapRenderer.cs | 40 ++++++++++++++++- .../Rendering/MarkerPlacement.cs | 12 ++++- .../MapRendererTests.cs | 44 ++++++++++++++++++- 4 files changed, 108 insertions(+), 7 deletions(-) diff --git a/src/RustPlusBot.Features.Map/Composing/MapComposer.cs b/src/RustPlusBot.Features.Map/Composing/MapComposer.cs index 60add57a..63080ce6 100644 --- a/src/RustPlusBot.Features.Map/Composing/MapComposer.cs +++ b/src/RustPlusBot.Features.Map/Composing/MapComposer.cs @@ -4,6 +4,7 @@ using RustPlusBot.Features.Events.State; using RustPlusBot.Features.Map.Rendering; using RustPlusBot.Persistence.Map; +using SixLabors.ImageSharp; namespace RustPlusBot.Features.Map.Composing; @@ -99,7 +100,8 @@ private List GatherMarkers( foreach (var m in events.GetActiveMarkers(guildId, serverId, kind)) { var (px, py) = projection.ToPixel(m.X, m.Y); - markers.Add(new MarkerPlacement(kind, px, py)); + var trail = ProjectTrail(m.History, projection); + markers.Add(new MarkerPlacement(kind, px, py, m.Rotation, trail)); } } } @@ -109,13 +111,26 @@ private List GatherMarkers( foreach (var m in events.GetActiveMarkers(guildId, serverId, MarkerKind.TravellingVendor)) { var (px, py) = projection.ToPixel(m.X, m.Y); - markers.Add(new MarkerPlacement(MarkerKind.TravellingVendor, px, py)); + var trail = ProjectTrail(m.History, projection); + markers.Add(new MarkerPlacement(MarkerKind.TravellingVendor, px, py, m.Rotation, trail)); } } return markers; } + private static List ProjectTrail(IReadOnlyList history, MapProjection projection) + { + var trail = new List(history.Count); + foreach (var h in history) + { + var (tx, ty) = projection.ToPixel(h.X, h.Y); + trail.Add(new PointF(tx, ty)); + } + + return trail; + } + private static List GatherMonuments( IReadOnlyList serverMonuments, MapProjection projection, diff --git a/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs b/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs index 8dc2db8e..910e8d3e 100644 --- a/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs +++ b/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs @@ -78,6 +78,11 @@ public byte[] Render(byte[] baseJpeg, DrawMonuments(image, monuments); } + if (layers.Markers || layers.Vendor) + { + DrawTrails(image, markers); + } + if (layers.Markers) { DrawMarkers(image, markers); @@ -142,6 +147,29 @@ private static void DrawGrid(Image image, MapProjection projection) }); } + private static void DrawTrails(Image image, IReadOnlyList markers) + { + image.Mutate(ctx => + { + foreach (var marker in markers) + { + if (marker.Trail.Count < 2) + { + continue; + } + + var baseColor = MapRenderStyle.TrailColor(marker.Kind); + for (var i = 1; i < marker.Trail.Count; i++) + { + // Fade from faint (oldest) to strong (newest) so travel direction reads instantly. + var alpha = 0.15f + (0.45f * i / (marker.Trail.Count - 1)); + ctx.DrawLine(baseColor.WithAlpha(alpha), MapRenderStyle.TrailWidth, + marker.Trail[i - 1], marker.Trail[i]); + } + } + }); + } + private static void DrawMarkers(Image image, IReadOnlyList markers) { // One Mutate for the whole layer: each Mutate builds and runs a fresh processing pipeline, @@ -151,7 +179,17 @@ private static void DrawMarkers(Image image, IReadOnlyList 0.01f) + { + using var rotated = icon.Clone(c => c.Rotate(-rotation)); + ctx.DrawImage(rotated, CenterAt(marker.PixelX, marker.PixelY, rotated), 1f); + } + else { ctx.DrawImage(icon, CenterAt(marker.PixelX, marker.PixelY, icon), 1f); } diff --git a/src/RustPlusBot.Features.Map/Rendering/MarkerPlacement.cs b/src/RustPlusBot.Features.Map/Rendering/MarkerPlacement.cs index f986f02a..da6b1e4e 100644 --- a/src/RustPlusBot.Features.Map/Rendering/MarkerPlacement.cs +++ b/src/RustPlusBot.Features.Map/Rendering/MarkerPlacement.cs @@ -1,9 +1,17 @@ using RustPlusBot.Abstractions.Connections; +using SixLabors.ImageSharp; namespace RustPlusBot.Features.Map.Rendering; /// One marker to draw, already projected to pixel coordinates. -/// The marker kind (selects the glyph). +/// The marker kind (selects the icon). /// Pixel X on the rendered tile. /// Pixel Y on the rendered tile. -public sealed record MarkerPlacement(MarkerKind Kind, float PixelX, float PixelY); +/// Heading in degrees as sent by the server, or null for no rotation. +/// Recent positions in output pixels, oldest first; empty for no trail. +public sealed record MarkerPlacement( + MarkerKind Kind, + float PixelX, + float PixelY, + float? Rotation, + IReadOnlyList Trail); diff --git a/tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs index 4ca7eac4..efb0bc02 100644 --- a/tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs @@ -73,7 +73,7 @@ public void Render_with_a_marker_differs_from_render_without() var without = renderer.Render(jpeg, Projection, markers: [], monuments: [], players: [], rigs: [], new MapLayerSet(false, true, false, false, false, false)); var with = renderer.Render(jpeg, Projection, - markers: [new MarkerPlacement(MarkerKind.CargoShip, 512f, 512f)], + markers: [new MarkerPlacement(MarkerKind.CargoShip, 512f, 512f, null, [])], monuments: [], players: [], rigs: [], new MapLayerSet(false, true, false, false, false, false)); @@ -86,7 +86,7 @@ public void Render_with_all_layers_produces_valid_png() var renderer = new MapRenderer(); var markers = new[] { - new MarkerPlacement(MarkerKind.CargoShip, 100, 100) + new MarkerPlacement(MarkerKind.CargoShip, 100, 100, null, []) }; var monuments = new[] { @@ -126,4 +126,44 @@ [new MonumentPlacement("oilrig_1", px, py)], [], [], $"changed area {bounds.Width}px wide — icon not scaled"); Assert.True(bounds.Height <= MapRenderStyle.MonumentIconSize + 2); } + + [Fact] + public void Trail_draws_pixels_between_history_points() + { + var renderer = new MapRenderer(); + var projection = new MapProjection(4000, 2000, 2000, 100, MapRenderer.OutputSize); + var baseJpeg = SolidJpeg(2000); + var (ax, ay) = projection.ToPixel(1000f, 2000f); + var (bx, by) = projection.ToPixel(2000f, 2000f); + var layers = new MapLayerSet(false, true, false, false, false, false); + + var without = renderer.Render(baseJpeg, projection, + [new MarkerPlacement(MarkerKind.CargoShip, bx, by, null, [])], [], [], [], layers); + var with = renderer.Render(baseJpeg, projection, + [ + new MarkerPlacement(MarkerKind.CargoShip, bx, by, null, + [new PointF(ax, ay), new PointF(bx, by)]) + ], [], [], [], layers); + + var bounds = ChangedPixelBounds(without, with); + // The trail spans from A to B — far wider than the icon alone. + Assert.True(bounds.Width > MapRenderStyle.CargoIconSize * 2, $"no trail drawn (width {bounds.Width}px)"); + } + + [Fact] + public void Rotation_changes_the_rendered_icon() + { + var renderer = new MapRenderer(); + var projection = new MapProjection(4000, 2000, 2000, 100, MapRenderer.OutputSize); + var baseJpeg = SolidJpeg(2000); + var (px, py) = projection.ToPixel(2000f, 2000f); + var layers = new MapLayerSet(false, true, false, false, false, false); + + var unrotated = renderer.Render(baseJpeg, projection, + [new MarkerPlacement(MarkerKind.CargoShip, px, py, null, [])], [], [], [], layers); + var rotated = renderer.Render(baseJpeg, projection, + [new MarkerPlacement(MarkerKind.CargoShip, px, py, 45f, [])], [], [], [], layers); + + Assert.False(unrotated.AsSpan().SequenceEqual(rotated)); + } } From b6ac80e77697475d78b982e32556184f3bce6c7d Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 8 Jul 2026 17:52:43 +0200 Subject: [PATCH 09/40] feat(map): base-map source chain with per-image projection metadata Generalize base-map fetch into a source chain (IBaseMapSource, tried in registration order) so a future RustMaps source can slot ahead of the Rust+ JPEG fallback. BaseMapImage carries the projection metadata of the specific fetched image (pixel width/height + ocean margin); BaseMapCache now caches BaseMapImage per (guild, server) instead of raw bytes, trying each source on a miss and not caching null. MapComposer builds its MapProjection from the fetched image's own metadata instead of MapDimensions, fulfilling the Task 5 interim note. --- .../Composing/BaseMapCache.cs | 27 +++--- .../Composing/BaseMapImage.cs | 8 ++ .../Composing/IBaseMapSource.cs | 12 +++ .../Composing/MapComposer.cs | 10 +-- .../Composing/RustPlusBaseMapSource.cs | 26 ++++++ .../MapServiceCollectionExtensions.cs | 1 + .../BaseMapCacheTests.cs | 83 ++++++++----------- .../MapComposerTests.cs | 13 ++- .../MapRegistrationTests.cs | 3 + .../RustPlusBaseMapSourceTests.cs | 56 +++++++++++++ 10 files changed, 172 insertions(+), 67 deletions(-) create mode 100644 src/RustPlusBot.Features.Map/Composing/BaseMapImage.cs create mode 100644 src/RustPlusBot.Features.Map/Composing/IBaseMapSource.cs create mode 100644 src/RustPlusBot.Features.Map/Composing/RustPlusBaseMapSource.cs create mode 100644 tests/RustPlusBot.Features.Map.Tests/RustPlusBaseMapSourceTests.cs diff --git a/src/RustPlusBot.Features.Map/Composing/BaseMapCache.cs b/src/RustPlusBot.Features.Map/Composing/BaseMapCache.cs index 967ef90c..df2a4c4d 100644 --- a/src/RustPlusBot.Features.Map/Composing/BaseMapCache.cs +++ b/src/RustPlusBot.Features.Map/Composing/BaseMapCache.cs @@ -1,33 +1,36 @@ using System.Collections.Concurrent; -using RustPlusBot.Abstractions.Connections; namespace RustPlusBot.Features.Map.Composing; -/// Caches the static-per-wipe base map image per (guild, server). Singleton so the cache survives across refreshes. -/// The live query seam used to fetch the base map on a cache miss. -public sealed class BaseMapCache(IRustServerQuery query) +/// Caches the static-per-wipe base map per (guild, server), trying sources in order on a miss. +/// Base-map sources in priority order (first hit wins). +public sealed class BaseMapCache(IEnumerable sources) { - private readonly ConcurrentDictionary<(ulong Guild, Guid Server), byte[]> _images = new(); + private readonly ConcurrentDictionary<(ulong Guild, Guid Server), BaseMapImage> _images = new(); - /// Gets the cached base map, fetching and caching it on a miss. Null results are not cached. + /// Gets the cached base map, fetching from the source chain on a miss. Null results are not cached. /// The owning guild snowflake. /// The target server id. /// A cancellation token. - /// The base-map JPEG bytes, or null if unavailable. - public async Task GetAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) + /// The base map image, or null if no source can provide one. + public async Task GetAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) { if (_images.TryGetValue((guildId, serverId), out var cached)) { return cached; } - var fetched = await query.GetMapImageAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); - if (fetched is not null) + foreach (var source in sources) { - _images[(guildId, serverId)] = fetched; + var fetched = await source.GetAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + if (fetched is not null) + { + _images[(guildId, serverId)] = fetched; + return fetched; + } } - return fetched; + return null; } /// Evicts the cached base map for a server (called on disconnect). diff --git a/src/RustPlusBot.Features.Map/Composing/BaseMapImage.cs b/src/RustPlusBot.Features.Map/Composing/BaseMapImage.cs new file mode 100644 index 00000000..abc9af28 --- /dev/null +++ b/src/RustPlusBot.Features.Map/Composing/BaseMapImage.cs @@ -0,0 +1,8 @@ +namespace RustPlusBot.Features.Map.Composing; + +/// A fetched base-map image plus the projection metadata of that specific image. +/// The encoded image bytes (JPEG or PNG). +/// Image width in pixels. +/// Image height in pixels. +/// Ocean border baked into this image, in pixels per side. +public sealed record BaseMapImage(byte[] Bytes, int PixelWidth, int PixelHeight, int OceanMarginPx); diff --git a/src/RustPlusBot.Features.Map/Composing/IBaseMapSource.cs b/src/RustPlusBot.Features.Map/Composing/IBaseMapSource.cs new file mode 100644 index 00000000..ee2c29ab --- /dev/null +++ b/src/RustPlusBot.Features.Map/Composing/IBaseMapSource.cs @@ -0,0 +1,12 @@ +namespace RustPlusBot.Features.Map.Composing; + +/// One provider of the static-per-wipe base map image. Sources are tried in registration order. +public interface IBaseMapSource +{ + /// Fetches the base map, or null when this source cannot provide one (falls through). + /// The owning guild snowflake. + /// The target server id. + /// A cancellation token. + /// The base map image, or null. + Task GetAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken); +} diff --git a/src/RustPlusBot.Features.Map/Composing/MapComposer.cs b/src/RustPlusBot.Features.Map/Composing/MapComposer.cs index 63080ce6..ec6ce610 100644 --- a/src/RustPlusBot.Features.Map/Composing/MapComposer.cs +++ b/src/RustPlusBot.Features.Map/Composing/MapComposer.cs @@ -58,16 +58,14 @@ public sealed class MapComposer( if (dims is null || dims.WorldSize == 0) { // Dimensions unavailable: render the base tile only (every overlay needs world→pixel). - return renderer.Render(baseImage, new MapProjection(0, 1, 1, 0, MapRenderer.OutputSize), + return renderer.Render(baseImage.Bytes, new MapProjection(0, 1, 1, 0, MapRenderer.OutputSize), markers: [], monuments: [], players: [], rigs: [], new MapLayerSet(Grid: false, Markers: false, Monuments: false, Vendor: false, Players: false, Rigs: false)); } - // NOTE — Task 9 follow-up: image pixel dims come from MapDimensions for now, which is correct - // for the Rust+ JPEG. The base-map source chain will replace this with the fetched image's own dims. - var projection = new MapProjection(dims.WorldSize, (int)dims.Width, (int)dims.Height, dims.OceanMargin, - MapRenderer.OutputSize); + var projection = new MapProjection(dims.WorldSize, baseImage.PixelWidth, baseImage.PixelHeight, + baseImage.OceanMarginPx, MapRenderer.OutputSize); var markers = GatherMarkers(guildId, serverId, projection, layers); @@ -83,7 +81,7 @@ public sealed class MapComposer( .ConfigureAwait(false); var rigPlacements = GatherRigs(guildId, serverId, serverMonuments, projection, layers); - return renderer.Render(baseImage, projection, markers, monuments, players, rigPlacements, layers); + return renderer.Render(baseImage.Bytes, projection, markers, monuments, players, rigPlacements, layers); } private List GatherMarkers( diff --git a/src/RustPlusBot.Features.Map/Composing/RustPlusBaseMapSource.cs b/src/RustPlusBot.Features.Map/Composing/RustPlusBaseMapSource.cs new file mode 100644 index 00000000..7cd9cbaa --- /dev/null +++ b/src/RustPlusBot.Features.Map/Composing/RustPlusBaseMapSource.cs @@ -0,0 +1,26 @@ +using RustPlusBot.Abstractions.Connections; + +namespace RustPlusBot.Features.Map.Composing; + +/// Fallback base-map source: the JPEG tile served by the Rust+ server itself. +/// The live query seam. +public sealed class RustPlusBaseMapSource(IRustServerQuery query) : IBaseMapSource +{ + /// + public async Task GetAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) + { + var bytes = await query.GetMapImageAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + if (bytes is null) + { + return null; + } + + var dims = await query.GetMapDimensionsAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + if (dims is null) + { + return null; + } + + return new BaseMapImage(bytes, (int)dims.Width, (int)dims.Height, dims.OceanMargin); + } +} diff --git a/src/RustPlusBot.Features.Map/MapServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Map/MapServiceCollectionExtensions.cs index ff7429b2..31ba466b 100644 --- a/src/RustPlusBot.Features.Map/MapServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Map/MapServiceCollectionExtensions.cs @@ -17,6 +17,7 @@ public static IServiceCollection AddMap(this IServiceCollection services) ArgumentNullException.ThrowIfNull(services); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/tests/RustPlusBot.Features.Map.Tests/BaseMapCacheTests.cs b/tests/RustPlusBot.Features.Map.Tests/BaseMapCacheTests.cs index d7d70daa..614d95fa 100644 --- a/tests/RustPlusBot.Features.Map.Tests/BaseMapCacheTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/BaseMapCacheTests.cs @@ -1,69 +1,58 @@ -using NSubstitute; -using RustPlusBot.Abstractions.Connections; using RustPlusBot.Features.Map.Composing; namespace RustPlusBot.Features.Map.Tests; public sealed class BaseMapCacheTests { - private const ulong Guild = 1UL; - private static readonly Guid Server = Guid.NewGuid(); - [Fact] - public async Task GetAsync_fetches_once_then_serves_from_cache() + public async Task First_source_wins_and_is_cached() { - var query = Substitute.For(); - query.GetMapImageAsync(Guild, Server, Arg.Any()) - .Returns( - [ - 9 - ]); - var cache = new BaseMapCache(query); - - var first = await cache.GetAsync(Guild, Server, CancellationToken.None); - var second = await cache.GetAsync(Guild, Server, CancellationToken.None); - - Assert.Equal("\t"u8.ToArray(), first); - Assert.Equal("\t"u8.ToArray(), second); - await query.Received(1).GetMapImageAsync(Guild, Server, Arg.Any()); + var preferred = new FakeSource(new BaseMapImage([1, 2], 100, 100, 0)); + var fallback = new FakeSource(new BaseMapImage([9, 9], 200, 200, 50)); + var cache = new BaseMapCache([preferred, fallback]); + var server = Guid.NewGuid(); + + var first = await cache.GetAsync(1, server, CancellationToken.None); + var second = await cache.GetAsync(1, server, CancellationToken.None); + + Assert.Equal(100, first!.PixelWidth); + Assert.Same(first, second); + Assert.Equal(1, preferred.Calls); // cached after the first hit + Assert.Equal(0, fallback.Calls); } [Fact] - public async Task GetAsync_does_not_cache_null_and_retries() + public async Task Falls_through_to_next_source_when_first_returns_null() { - var query = Substitute.For(); - query.GetMapImageAsync(Guild, Server, Arg.Any()) - .Returns((byte[]?)null, - [ - 7 - ]); - var cache = new BaseMapCache(query); + var preferred = new FakeSource(null); + var fallback = new FakeSource(new BaseMapImage([9], 200, 200, 50)); + var cache = new BaseMapCache([preferred, fallback]); - var first = await cache.GetAsync(Guild, Server, CancellationToken.None); - var second = await cache.GetAsync(Guild, Server, CancellationToken.None); + var result = await cache.GetAsync(1, Guid.NewGuid(), CancellationToken.None); - Assert.Null(first); - Assert.Equal(new byte[] - { - 7 - }, second); - await query.Received(2).GetMapImageAsync(Guild, Server, Arg.Any()); + Assert.Equal(200, result!.PixelWidth); } [Fact] - public async Task Clear_evicts_so_next_get_refetches() + public async Task All_null_is_not_cached_and_retries() { - var query = Substitute.For(); - query.GetMapImageAsync(Guild, Server, Arg.Any()).Returns( - [ - 1 - ]); - var cache = new BaseMapCache(query); + var source = new FakeSource(null); + var cache = new BaseMapCache([source]); + var server = Guid.NewGuid(); - await cache.GetAsync(Guild, Server, CancellationToken.None); - cache.Clear(Guild, Server); - await cache.GetAsync(Guild, Server, CancellationToken.None); + Assert.Null(await cache.GetAsync(1, server, CancellationToken.None)); + Assert.Null(await cache.GetAsync(1, server, CancellationToken.None)); + Assert.Equal(2, source.Calls); + } + + private sealed class FakeSource(BaseMapImage? result) : IBaseMapSource + { + public int Calls { get; private set; } - await query.Received(2).GetMapImageAsync(Guild, Server, Arg.Any()); + public Task GetAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) + { + Calls++; + return Task.FromResult(result); + } } } diff --git a/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs index d6784a6c..c40db722 100644 --- a/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs @@ -39,9 +39,12 @@ private static MapComposer Build( IRigState rigs, IMapSettingsStore settingsStore) { - 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(), + var source = new FakeSource( + baseImage is null + ? null + : new BaseMapImage(baseImage, (int)Dims.Width, (int)Dims.Height, Dims.OceanMargin)); + return new MapComposer(new BaseMapCache([source]), events, rigs, query, new MapRenderer(), ScopeFactory(settingsStore)); } @@ -180,4 +183,10 @@ public async Task Renders_the_grid_even_with_no_markers() // The grid layer must have painted at least one pixel differently. Assert.False(pngOn!.SequenceEqual(pngOff!), "Grid-on and grid-off renders must differ."); } + + private sealed class FakeSource(BaseMapImage? result) : IBaseMapSource + { + public Task GetAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) => + Task.FromResult(result); + } } diff --git a/tests/RustPlusBot.Features.Map.Tests/MapRegistrationTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapRegistrationTests.cs index e272320c..ef80de48 100644 --- a/tests/RustPlusBot.Features.Map.Tests/MapRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/MapRegistrationTests.cs @@ -18,6 +18,9 @@ public void AddMap_registers_renderer_cache_and_composer_as_singletons() var renderer = provider.GetRequiredService(); Assert.NotNull(renderer); + var source = provider.GetRequiredService(); + Assert.NotNull(source); + var cache = provider.GetRequiredService(); Assert.NotNull(cache); diff --git a/tests/RustPlusBot.Features.Map.Tests/RustPlusBaseMapSourceTests.cs b/tests/RustPlusBot.Features.Map.Tests/RustPlusBaseMapSourceTests.cs new file mode 100644 index 00000000..1a29a39d --- /dev/null +++ b/tests/RustPlusBot.Features.Map.Tests/RustPlusBaseMapSourceTests.cs @@ -0,0 +1,56 @@ +using NSubstitute; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Features.Map.Composing; + +namespace RustPlusBot.Features.Map.Tests; + +public sealed class RustPlusBaseMapSourceTests +{ + private const ulong Guild = 1UL; + private static readonly Guid Server = Guid.NewGuid(); + + [Fact] + public async Task GetAsync_returns_image_when_bytes_and_dimensions_present() + { + var query = Substitute.For(); + query.GetMapImageAsync(Guild, Server, Arg.Any()).Returns((byte[])[1, 2, 3]); + query.GetMapDimensionsAsync(Guild, Server, Arg.Any()) + .Returns(new MapDimensions(1000, 1000, 50, 2000)); + var source = new RustPlusBaseMapSource(query); + + var result = await source.GetAsync(Guild, Server, CancellationToken.None); + + Assert.NotNull(result); + Assert.Equal((byte[])[1, 2, 3], result!.Bytes); + Assert.Equal(1000, result.PixelWidth); + Assert.Equal(1000, result.PixelHeight); + Assert.Equal(50, result.OceanMarginPx); + } + + [Fact] + public async Task GetAsync_returns_null_when_image_bytes_missing() + { + var query = Substitute.For(); + query.GetMapImageAsync(Guild, Server, Arg.Any()).Returns((byte[]?)null); + query.GetMapDimensionsAsync(Guild, Server, Arg.Any()) + .Returns(new MapDimensions(1000, 1000, 50, 2000)); + var source = new RustPlusBaseMapSource(query); + + var result = await source.GetAsync(Guild, Server, CancellationToken.None); + + Assert.Null(result); + } + + [Fact] + public async Task GetAsync_returns_null_when_dimensions_missing() + { + var query = Substitute.For(); + query.GetMapImageAsync(Guild, Server, Arg.Any()).Returns((byte[])[1, 2, 3]); + query.GetMapDimensionsAsync(Guild, Server, Arg.Any()).Returns((MapDimensions?)null); + var source = new RustPlusBaseMapSource(query); + + var result = await source.GetAsync(Guild, Server, CancellationToken.None); + + Assert.Null(result); + } +} From a8cf34684a062e57d31fb5faf66933ea83f718a9 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 8 Jul 2026 17:57:44 +0200 Subject: [PATCH 10/40] test(map): restore BaseMapCache.Clear eviction coverage --- .../BaseMapCacheTests.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/RustPlusBot.Features.Map.Tests/BaseMapCacheTests.cs b/tests/RustPlusBot.Features.Map.Tests/BaseMapCacheTests.cs index 614d95fa..a2289b62 100644 --- a/tests/RustPlusBot.Features.Map.Tests/BaseMapCacheTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/BaseMapCacheTests.cs @@ -45,6 +45,23 @@ public async Task All_null_is_not_cached_and_retries() Assert.Equal(2, source.Calls); } + [Fact] + public async Task Clear_evicts_so_next_get_refetches() + { + var source = new FakeSource(new BaseMapImage([1], 100, 100, 0)); + var cache = new BaseMapCache([source]); + const ulong guild = 1UL; + var server = Guid.NewGuid(); + + await cache.GetAsync(guild, server, CancellationToken.None); + Assert.Equal(1, source.Calls); + + cache.Clear(guild, server); + await cache.GetAsync(guild, server, CancellationToken.None); + + Assert.Equal(2, source.Calls); + } + private sealed class FakeSource(BaseMapImage? result) : IBaseMapSource { public int Calls { get; private set; } From 68e32b4531ba13b026993cb790f0234fbc73de1d Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 8 Jul 2026 18:05:58 +0200 Subject: [PATCH 11/40] feat(map): wire marker rotation via RustPlusApi 2.0.0-beta.4 --- Directory.Packages.props | 4 ++-- .../Listening/RustPlusSocketSource.cs | 18 ++++++++++-------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 98d76429..7f3179f4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,8 +13,8 @@ - - + + diff --git a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs index 0e3d5b33..d5838f08 100644 --- a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs +++ b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs @@ -517,10 +517,13 @@ public async Task> GetMapMarkersAsync( var data = response.Data; var markers = new List(); - AddMarkers(markers, data.CargoShipMarkers, MarkerKind.CargoShip); - AddMarkers(markers, data.PatrolHelicopterMarkers, MarkerKind.PatrolHelicopter); - AddMarkers(markers, data.Ch47Markers, MarkerKind.Chinook); - AddMarkers(markers, data.TravellingVendorMarkers, MarkerKind.TravellingVendor); + // RustPlusApi 2.0.0-beta.4 declares Rotation independently on each concrete marker record + // (CargoShipMarker/PatrolHelicopterMarker/Ch47Marker/TravellingVendorMarker) rather than on + // the shared base Marker, so the selector is resolved per call site via type inference. + AddMarkers(markers, data.CargoShipMarkers, MarkerKind.CargoShip, m => m.Rotation); + AddMarkers(markers, data.PatrolHelicopterMarkers, MarkerKind.PatrolHelicopter, m => m.Rotation); + AddMarkers(markers, data.Ch47Markers, MarkerKind.Chinook, m => m.Rotation); + AddMarkers(markers, data.TravellingVendorMarkers, MarkerKind.TravellingVendor, m => m.Rotation); return markers; } @@ -697,7 +700,8 @@ .. info.Items.Select(i => private static void AddMarkers( List into, IReadOnlyDictionary source, - MarkerKind kind) + MarkerKind kind, + Func rotationSelector) where TMarker : RustPlusApi.Data.Markers.Marker { foreach (var (id, marker) in source) @@ -709,9 +713,7 @@ private static void AddMarkers( continue; } - // Rotation: RustPlusApi 2.0.0-beta.3 does not map AppMarker.rotation; wire it here once - // 2.0.0-beta.4 ships (see RustPlusApi docs/development/beta4-map-marker-rotation.md). - into.Add(new MapMarkerSnapshot(id, kind, x, y, Name: null)); + into.Add(new MapMarkerSnapshot(id, kind, x, y, Name: null, Rotation: rotationSelector(marker))); } } From c1dc6a965083a68ac7f3e15c69e4c3162d4a163b Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 8 Jul 2026 18:24:16 +0200 Subject: [PATCH 12/40] feat(map): RustMaps preferred base-map source behind optional API key; 30s refresh Adds RustMapsBaseMapSource ahead of the Rust+ JPEG fallback in the base-map source chain: fetches the RustMaps procedural render (clean terrain, no baked icons) by world size + seed, active only when Map:RustMaps:ApiKey is configured. Any failure (no key, unpublished map, HTTP error) falls through to the existing Rust+ source. Also bumps the default map refresh interval 45s -> 30s. Co-Authored-By: Claude Opus 4.8 (1M context) --- Directory.Packages.props | 1 + .../Composing/RustMapsBaseMapSource.cs | 74 +++++++++++++++ src/RustPlusBot.Features.Map/MapOptions.cs | 14 ++- .../MapServiceCollectionExtensions.cs | 17 +++- .../RustPlusBot.Features.Map.csproj | 1 + src/RustPlusBot.Host/Program.cs | 2 +- src/RustPlusBot.Host/appsettings.json | 5 +- .../MapProjectionTests.cs | 3 +- .../MapRegistrationTests.cs | 36 +++++++- .../RustMapsBaseMapSourceTests.cs | 91 +++++++++++++++++++ 10 files changed, 234 insertions(+), 10 deletions(-) create mode 100644 src/RustPlusBot.Features.Map/Composing/RustMapsBaseMapSource.cs create mode 100644 tests/RustPlusBot.Features.Map.Tests/RustMapsBaseMapSourceTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 7f3179f4..f07b5156 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,6 +13,7 @@ + diff --git a/src/RustPlusBot.Features.Map/Composing/RustMapsBaseMapSource.cs b/src/RustPlusBot.Features.Map/Composing/RustMapsBaseMapSource.cs new file mode 100644 index 00000000..b97b0c6d --- /dev/null +++ b/src/RustPlusBot.Features.Map/Composing/RustMapsBaseMapSource.cs @@ -0,0 +1,74 @@ +using Microsoft.Extensions.Logging; +using RustMapsApi.V4; +using RustPlusBot.Abstractions.Connections; +using SixLabors.ImageSharp; + +namespace RustPlusBot.Features.Map.Composing; + +/// +/// Preferred base-map source: the RustMaps procedural render (clean terrain, no baked icons), +/// resolved by the server's world size + seed. Any failure returns null so the chain falls +/// through to the Rust+ JPEG — custom/unpublished maps must still render. +/// +/// The RustMaps API client. +/// The live query seam (world size + seed). +/// Creates the image-download client. +/// Logs fall-through causes. +public sealed partial class RustMapsBaseMapSource( + IRustMapsClient client, + IRustServerQuery query, + IHttpClientFactory httpClientFactory, + ILogger logger) : IBaseMapSource +{ + /// Named HTTP client used to download the rendered image. + public const string HttpClientName = "RustMapsImages"; + + /// + public async Task GetAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) + { + try + { + var world = await query.GetWorldAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + if (world is null) + { + return null; + } + + var result = await client.GetMapBySeedAndSizeAsync( + (int)world.WorldSize, (int)world.Seed, staging: false, cancellationToken) + .ConfigureAwait(false); + if (!result.IsSuccess || result.Data?.RawImageUrl is not { } imageUrl) + { + LogRustMapsUnavailable(logger, (int)world.WorldSize, (int)world.Seed, result.StatusCode); + return null; + } + + var http = httpClientFactory.CreateClient(HttpClientName); + var bytes = await http.GetByteArrayAsync(new Uri(imageUrl), cancellationToken).ConfigureAwait(false); + var info = Image.Identify(bytes); + // RustMaps raw renders span the playable world edge-to-edge (no ocean border). + // VERIFY on the first live fetch via tools/RustPlusBot.MapParity; adjust here if wrong. + return new BaseMapImage(bytes, info.Width, info.Height, OceanMarginPx: 0); + } + catch (OperationCanceledException) + { + throw; + } +#pragma warning disable CA1031 // Broad catch: any RustMaps failure falls through to the Rust+ JPEG source. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogRustMapsFailed(logger, ex); + return null; + } + } + + [LoggerMessage(Level = LogLevel.Information, + Message = + "RustMaps has no render for size {Size} seed {Seed} (status {StatusCode}); falling back to the Rust+ map tile.")] + private static partial void LogRustMapsUnavailable(ILogger logger, int size, int seed, int statusCode); + + [LoggerMessage(Level = LogLevel.Warning, + Message = "RustMaps base-map fetch failed; falling back to the Rust+ map tile.")] + private static partial void LogRustMapsFailed(ILogger logger, Exception exception); +} diff --git a/src/RustPlusBot.Features.Map/MapOptions.cs b/src/RustPlusBot.Features.Map/MapOptions.cs index 9a10bc35..6368ad4d 100644 --- a/src/RustPlusBot.Features.Map/MapOptions.cs +++ b/src/RustPlusBot.Features.Map/MapOptions.cs @@ -3,6 +3,16 @@ namespace RustPlusBot.Features.Map; /// Map feature configuration, bound from the "Map" config section. public sealed class MapOptions { - /// Minimum time between #map image re-renders per server (coalesces rapid marker changes). Default 45s. - public TimeSpan MapRefreshInterval { get; set; } = TimeSpan.FromSeconds(45); + /// Minimum time between #map image re-renders per server (coalesces rapid marker changes). Default 30s. + public TimeSpan MapRefreshInterval { get; set; } = TimeSpan.FromSeconds(30); + + /// RustMaps integration settings. + public RustMapsOptions RustMaps { get; set; } = new(); +} + +/// RustMaps API settings; the integration is inactive when no key is configured. +public sealed class RustMapsOptions +{ + /// The RustMaps API key, or null/empty to disable the RustMaps base-map source. + public string? ApiKey { get; set; } } diff --git a/src/RustPlusBot.Features.Map/MapServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Map/MapServiceCollectionExtensions.cs index 31ba466b..7d7c327e 100644 --- a/src/RustPlusBot.Features.Map/MapServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Map/MapServiceCollectionExtensions.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using RustPlusBot.Features.Map.Composing; using RustPlusBot.Features.Map.Hosting; @@ -9,14 +10,26 @@ namespace RustPlusBot.Features.Map; /// DI registration for the map-render feature. public static class MapServiceCollectionExtensions { - /// Registers the renderer, base-map cache, composer, poster, pipeline bundle, and hosted service. + /// Registers the renderer, base-map source chain, composer, poster, pipeline bundle, and hosted service. /// The service collection to add to. + /// The host configuration (reads Map:RustMaps:ApiKey). /// The same service collection, for chaining. - public static IServiceCollection AddMap(this IServiceCollection services) + public static IServiceCollection AddMap(this IServiceCollection services, IConfiguration configuration) { ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); services.AddSingleton(); + + // Source order defines priority: RustMaps first (when a key is configured), Rust+ JPEG fallback. + var rustMapsKey = configuration["Map:RustMaps:ApiKey"]; + if (!string.IsNullOrWhiteSpace(rustMapsKey)) + { + services.AddRustMapsClientV4(o => o.ApiKey = rustMapsKey); + services.AddHttpClient(RustMapsBaseMapSource.HttpClientName); + services.AddSingleton(); + } + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/RustPlusBot.Features.Map/RustPlusBot.Features.Map.csproj b/src/RustPlusBot.Features.Map/RustPlusBot.Features.Map.csproj index 1ff8ad92..7e5c4a44 100644 --- a/src/RustPlusBot.Features.Map/RustPlusBot.Features.Map.csproj +++ b/src/RustPlusBot.Features.Map/RustPlusBot.Features.Map.csproj @@ -21,6 +21,7 @@ + diff --git a/src/RustPlusBot.Host/Program.cs b/src/RustPlusBot.Host/Program.cs index ceabbc0b..05eabfb6 100644 --- a/src/RustPlusBot.Host/Program.cs +++ b/src/RustPlusBot.Host/Program.cs @@ -81,7 +81,7 @@ .Bind(builder.Configuration.GetSection("Map")) .Validate(static o => o.MapRefreshInterval > TimeSpan.Zero, "Map:MapRefreshInterval must be positive.") .ValidateOnStart(); -builder.Services.AddMap(); +builder.Services.AddMap(builder.Configuration); builder.Services.AddSwitches(); builder.Services.AddAlarms(); builder.Services.AddStorageMonitors(); diff --git a/src/RustPlusBot.Host/appsettings.json b/src/RustPlusBot.Host/appsettings.json index 3c7327e4..4f1938b0 100644 --- a/src/RustPlusBot.Host/appsettings.json +++ b/src/RustPlusBot.Host/appsettings.json @@ -54,6 +54,9 @@ "Cooldown": "00:00:04" }, "Map": { - "MapRefreshInterval": "00:00:45" + "MapRefreshInterval": "00:00:30", + "RustMaps": { + "ApiKey": "" + } } } diff --git a/tests/RustPlusBot.Features.Map.Tests/MapProjectionTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapProjectionTests.cs index db9050a6..b92f6f08 100644 --- a/tests/RustPlusBot.Features.Map.Tests/MapProjectionTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/MapProjectionTests.cs @@ -9,7 +9,8 @@ public void Rust_plus_style_margin_projects_origin_inside_margin() { // 4000-unit world on a 2000px image with 100px margin, output 1000px. // Playable spans [100, 1900]px on the image -> world(0,0) at image (100, 1900) -> output (50, 950). - var p = new MapProjection(WorldSize: 4000, ImageWidth: 2000, ImageHeight: 2000, OceanMarginPx: 100, OutputSize: 1000); + var p = new MapProjection(WorldSize: 4000, ImageWidth: 2000, ImageHeight: 2000, OceanMarginPx: 100, + OutputSize: 1000); var (x, y) = p.ToPixel(0f, 0f); diff --git a/tests/RustPlusBot.Features.Map.Tests/MapRegistrationTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapRegistrationTests.cs index ef80de48..7198cdcd 100644 --- a/tests/RustPlusBot.Features.Map.Tests/MapRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/MapRegistrationTests.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using NSubstitute; using RustPlusBot.Abstractions.Connections; @@ -13,7 +14,7 @@ public sealed class MapRegistrationTests [Fact] public void AddMap_registers_renderer_cache_and_composer_as_singletons() { - using var provider = BuildProvider(); + using var provider = BuildProvider(EmptyConfiguration()); var renderer = provider.GetRequiredService(); Assert.NotNull(renderer); @@ -33,7 +34,36 @@ public void AddMap_registers_renderer_cache_and_composer_as_singletons() Assert.Same(composer, provider.GetRequiredService()); } - private static ServiceProvider BuildProvider() + [Fact] + public void AddMap_with_RustMaps_key_registers_RustMaps_source_first() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection([new KeyValuePair("Map:RustMaps:ApiKey", "test-key")]) + .Build(); + using var provider = BuildProvider(configuration); + + var sources = provider.GetServices().ToList(); + + Assert.Equal(2, sources.Count); + Assert.IsType(sources[0]); + Assert.IsType(sources[1]); + } + + [Fact] + public void AddMap_without_RustMaps_key_registers_only_the_RustPlus_source() + { + using var provider = BuildProvider(EmptyConfiguration()); + + var sources = provider.GetServices().ToList(); + + var source = Assert.Single(sources); + Assert.IsType(source); + } + + private static IConfiguration EmptyConfiguration() => + new ConfigurationBuilder().Build(); + + private static ServiceProvider BuildProvider(IConfiguration configuration) { var services = new ServiceCollection(); services.AddLogging(); @@ -41,7 +71,7 @@ private static ServiceProvider BuildProvider() services.AddSingleton(Substitute.For()); services.AddSingleton(Substitute.For()); services.AddScoped(_ => Substitute.For()); - services.AddMap(); + services.AddMap(configuration); return services.BuildServiceProvider(validateScopes: true); } diff --git a/tests/RustPlusBot.Features.Map.Tests/RustMapsBaseMapSourceTests.cs b/tests/RustPlusBot.Features.Map.Tests/RustMapsBaseMapSourceTests.cs new file mode 100644 index 00000000..50ebe471 --- /dev/null +++ b/tests/RustPlusBot.Features.Map.Tests/RustMapsBaseMapSourceTests.cs @@ -0,0 +1,91 @@ +using System.Net; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using RustMapsApi.Results; +using RustMapsApi.V4; +using RustMapsApi.V4.Models; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Features.Map.Composing; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; + +namespace RustPlusBot.Features.Map.Tests; + +public sealed class RustMapsBaseMapSourceTests +{ + private const ulong Guild = 1UL; + private static readonly Guid Server = Guid.NewGuid(); + + private static byte[] Png(int size) + { + using var img = new Image(size, size); + using var ms = new MemoryStream(); + img.SaveAsPng(ms); + return ms.ToArray(); + } + + private static IRustServerQuery QueryWithWorld(WorldSnapshot? world) + { + var query = Substitute.For(); + query.GetWorldAsync(Guild, Server, Arg.Any()).Returns(world); + return query; + } + + [Fact] + public async Task Happy_path_downloads_raw_image_and_measures_dims() + { + var client = Substitute.For(); + client.GetMapBySeedAndSizeAsync(3500, 1234, false, Arg.Any()) + .Returns(Result.Success(new MapInfo + { + RawImageUrl = "https://img.example/raw.png" + }, 200)); + var source = new RustMapsBaseMapSource(client, QueryWithWorld(new WorldSnapshot(3500, 1234)), + new StubFactory(new StubHandler(HttpStatusCode.OK, Png(1750))), NullLogger.Instance); + + var result = await source.GetAsync(Guild, Server, CancellationToken.None); + + Assert.NotNull(result); + Assert.Equal(1750, result!.PixelWidth); + Assert.Equal(0, result.OceanMarginPx); + } + + [Fact] + public async Task Map_not_found_returns_null() + { + var client = Substitute.For(); + client.GetMapBySeedAndSizeAsync(3500, 1234, false, Arg.Any()) + .Returns(Result.Failure( + new RustMapsError(RustMapsErrorKind.NotFound, "not generated", RawBody: null, RetryAfter: null), 404)); + var source = new RustMapsBaseMapSource(client, QueryWithWorld(new WorldSnapshot(3500, 1234)), + new StubFactory(new StubHandler(HttpStatusCode.OK, Png(16))), NullLogger.Instance); + + Assert.Null(await source.GetAsync(Guild, Server, CancellationToken.None)); + } + + [Fact] + public async Task Missing_world_info_returns_null() + { + var client = Substitute.For(); + var source = new RustMapsBaseMapSource(client, QueryWithWorld(null), + new StubFactory(new StubHandler(HttpStatusCode.OK, Png(16))), NullLogger.Instance); + + Assert.Null(await source.GetAsync(Guild, Server, CancellationToken.None)); + await client.DidNotReceiveWithAnyArgs().GetMapBySeedAndSizeAsync(0, 0, false, CancellationToken.None); + } + + private sealed class StubHandler(HttpStatusCode status, byte[] body) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) => + Task.FromResult(new HttpResponseMessage(status) + { + Content = new ByteArrayContent(body) + }); + } + + private sealed class StubFactory(HttpMessageHandler handler) : IHttpClientFactory + { + public HttpClient CreateClient(string name) => new(handler, disposeHandler: false); + } +} From 15c11f245fe3da5268625d83b7d71850f5d720ff Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 8 Jul 2026 18:40:24 +0200 Subject: [PATCH 13/40] feat(map): RustMaps parity harness + skippable ground-truth test Dev-only CLI (tools/RustPlusBot.MapParity) fetches a RustMaps render + monument list, projects each monument through our MapProjection, and draws crosshairs for eyeball diffing against RustMaps' own icon placement. RustMapsParityTests reads a committed fixture and asserts pixel-cell == world-cell for every monument; skips until the user generates and commits a fixture with their API key. Co-Authored-By: Claude Opus 4.8 (1M context) --- RustPlusBot.slnx | 1 + .../RustMapsParityTests.cs | 54 +++++++++++++ .../RustPlusBot.Features.Map.Tests.csproj | 5 ++ tools/RustPlusBot.MapParity/Program.cs | 76 +++++++++++++++++++ tools/RustPlusBot.MapParity/README.md | 24 ++++++ .../RustPlusBot.MapParity.csproj | 17 +++++ 6 files changed, 177 insertions(+) create mode 100644 tests/RustPlusBot.Features.Map.Tests/RustMapsParityTests.cs create mode 100644 tools/RustPlusBot.MapParity/Program.cs create mode 100644 tools/RustPlusBot.MapParity/README.md create mode 100644 tools/RustPlusBot.MapParity/RustPlusBot.MapParity.csproj diff --git a/RustPlusBot.slnx b/RustPlusBot.slnx index 40f51b35..fea52606 100644 --- a/RustPlusBot.slnx +++ b/RustPlusBot.slnx @@ -21,6 +21,7 @@ + diff --git a/tests/RustPlusBot.Features.Map.Tests/RustMapsParityTests.cs b/tests/RustPlusBot.Features.Map.Tests/RustMapsParityTests.cs new file mode 100644 index 00000000..97876d2f --- /dev/null +++ b/tests/RustPlusBot.Features.Map.Tests/RustMapsParityTests.cs @@ -0,0 +1,54 @@ +using System.Text.Json; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Features.Map.Rendering; + +namespace RustPlusBot.Features.Map.Tests; + +/// +/// Ground-truth parity: monuments from a committed RustMaps response, projected through our +/// MapProjection, must land in the grid cells RustMaps world coordinates imply. Skips when no +/// fixture has been generated yet (tools/RustPlusBot.MapParity produces one). +/// +public sealed class RustMapsParityTests +{ + private static readonly string FixtureDir = + Path.Combine(AppContext.BaseDirectory, "Fixtures"); + + [SkippableFact] + public void Monument_world_coords_project_into_consistent_grid_cells() + { + var fixture = Directory.Exists(FixtureDir) + ? Directory.EnumerateFiles(FixtureDir, "rustmaps-*.json").FirstOrDefault() + : null; + Skip.If(fixture is null, "No RustMaps fixture committed yet (run tools/RustPlusBot.MapParity)."); + + using var doc = JsonDocument.Parse(File.ReadAllText(fixture)); + var root = doc.RootElement; + var size = (uint)root.GetProperty("size").GetInt32(); + var projection = new MapProjection(size, 2048, 2048, 0, 2048); + + foreach (var monument in root.GetProperty("monuments").EnumerateArray()) + { + if (!monument.TryGetProperty("coordinates", out var coords) || coords.ValueKind == JsonValueKind.Null) + { + continue; + } + + var x = coords.GetProperty("x").GetSingle(); + var y = coords.GetProperty("y").GetSingle(); + var (px, py) = projection.ToPixel(x, y); + + // Independent re-derivation: the cell computed from the projected PIXEL must equal the + // cell computed from the WORLD coordinate. Catches any scale/offset/Y-flip regression. + var cells = MapGrid.CellCount(size); + var cellPx = MapGrid.CellSize * (2048f / size); + var colFromPixel = Math.Clamp((int)(px / cellPx), 0, cells - 1); + var rowFromPixel = Math.Clamp((int)(py / cellPx), 0, cells - 1); + var colFromWorld = Math.Clamp((int)(Math.Clamp(x, 0f, size - 1) / MapGrid.CellSize), 0, cells - 1); + var rowFromWorld = + cells - 1 - Math.Clamp((int)(Math.Clamp(y, 0f, size - 1) / MapGrid.CellSize), 0, cells - 1); + Assert.Equal(colFromWorld, colFromPixel); + Assert.Equal(rowFromWorld, rowFromPixel); + } + } +} diff --git a/tests/RustPlusBot.Features.Map.Tests/RustPlusBot.Features.Map.Tests.csproj b/tests/RustPlusBot.Features.Map.Tests/RustPlusBot.Features.Map.Tests.csproj index bdb8045f..aeeeea83 100644 --- a/tests/RustPlusBot.Features.Map.Tests/RustPlusBot.Features.Map.Tests.csproj +++ b/tests/RustPlusBot.Features.Map.Tests/RustPlusBot.Features.Map.Tests.csproj @@ -7,6 +7,7 @@ + @@ -18,4 +19,8 @@ + + + + diff --git a/tools/RustPlusBot.MapParity/Program.cs b/tools/RustPlusBot.MapParity/Program.cs new file mode 100644 index 00000000..4a373c04 --- /dev/null +++ b/tools/RustPlusBot.MapParity/Program.cs @@ -0,0 +1,76 @@ +using System.Globalization; +using System.Text.Json; +using RustMapsApi.V4; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Features.Map.Rendering; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Drawing.Processing; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; + +if (args.Length < 4) +{ + await Console.Error.WriteLineAsync("Usage: map-parity ").ConfigureAwait(false); + return 1; +} + +var size = int.Parse(args[0], CultureInfo.InvariantCulture); +var seed = int.Parse(args[1], CultureInfo.InvariantCulture); +var apiKey = args[2]; +var outDir = Directory.CreateDirectory(args[3]).FullName; + +#pragma warning disable S1075 // Dev-only CLI tool: the RustMaps API root is fixed, not app configuration. +using var http = new HttpClient +{ + BaseAddress = new Uri("https://api.rustmaps.com") +}; +#pragma warning restore S1075 +http.DefaultRequestHeaders.Add("X-API-Key", apiKey); +var client = new RustMapsClient(http); // verified: RustMapsClient has a single HttpClient primary ctor + +var result = await client.GetMapBySeedAndSizeAsync(size, seed, staging: false).ConfigureAwait(false); +if (!result.IsSuccess || result.Data is not { } map) +{ + await Console.Error.WriteLineAsync( + $"RustMaps lookup failed (status {result.StatusCode}): {result.Error?.Message}") + .ConfigureAwait(false); + return 2; +} + +await Console.Out.WriteLineAsync( + $"Map {map.Id}: ImageUrl={map.ImageUrl} RawImageUrl={map.RawImageUrl} monuments={map.TotalMonuments}") + .ConfigureAwait(false); +var imageBytes = await http.GetByteArrayAsync(new Uri(map.ImageUrl!)).ConfigureAwait(false); +await File.WriteAllBytesAsync(Path.Combine(outDir, "rustmaps-render.png"), imageBytes).ConfigureAwait(false); +await File.WriteAllTextAsync(Path.Combine(outDir, "monuments.json"), + JsonSerializer.Serialize(map, new JsonSerializerOptions + { + WriteIndented = true + })) + .ConfigureAwait(false); + +// Project every monument through OUR MapProjection onto THEIR render and drop crosshairs. +// If our math is right, every crosshair lands on the matching RustMaps monument icon. +using var overlay = Image.Load(imageBytes); +var projection = + new MapProjection((uint)size, overlay.Width, overlay.Height, OceanMarginPx: 0, OutputSize: overlay.Width); +overlay.Mutate(ctx => +{ + foreach (var monument in map.Monuments ?? []) + { + if (monument.Coordinates is not { } c) + { + continue; + } + + var (px, py) = projection.ToPixel(c.X, c.Y); + ctx.DrawLine(Color.Magenta, 2f, new PointF(px - 12, py), new PointF(px + 12, py)); + ctx.DrawLine(Color.Magenta, 2f, new PointF(px, py - 12), new PointF(px, py + 12)); + Console.WriteLine( + $"{monument.Type,-30} world=({c.X,6},{c.Y,6}) grid={MapGrid.LabelFor(c.X, c.Y, (uint)size)} px=({px:F0},{py:F0})"); + } +}); +await overlay.SaveAsPngAsync(Path.Combine(outDir, "overlay.png")).ConfigureAwait(false); +await Console.Out.WriteLineAsync($"Wrote {outDir}/overlay.png — crosshairs must sit on the RustMaps monument markers.") + .ConfigureAwait(false); +return 0; diff --git a/tools/RustPlusBot.MapParity/README.md b/tools/RustPlusBot.MapParity/README.md new file mode 100644 index 00000000..f8c1a05d --- /dev/null +++ b/tools/RustPlusBot.MapParity/README.md @@ -0,0 +1,24 @@ +# RustPlusBot.MapParity + +Dev-only manual tool that proves our `MapProjection` math agrees with RustMaps' own +ground-truth monument placement. It fetches a map from the RustMaps v4 API (rendered +`ImageUrl`, which has RustMaps' own monument icons baked in, plus the monument list), +projects every monument's world coordinate through our `MapProjection`, and draws a +magenta crosshair at the resulting pixel on a copy of RustMaps' render. If our +transform is correct, every crosshair lands squarely on the matching RustMaps monument +icon — an eyeball diff, not an assertion. + +## Usage + +```bash +dotnet run --project tools/RustPlusBot.MapParity -- +``` + +This writes `/rustmaps-render.png` (the untouched RustMaps render), +`/overlay.png` (the render with crosshairs — inspect this), and +`/monuments.json` (the serialized RustMaps `MapInfo` response, including the +monument list consumed by the parity test). + +Once the crosshairs check out, commit `monuments.json` to +`tests/RustPlusBot.Features.Map.Tests/Fixtures/rustmaps--.json` so +`RustMapsParityTests` picks it up (it skips automatically when no fixture exists). diff --git a/tools/RustPlusBot.MapParity/RustPlusBot.MapParity.csproj b/tools/RustPlusBot.MapParity/RustPlusBot.MapParity.csproj new file mode 100644 index 00000000..cd5fe347 --- /dev/null +++ b/tools/RustPlusBot.MapParity/RustPlusBot.MapParity.csproj @@ -0,0 +1,17 @@ + + + + Exe + + + + + + + + + + + + + From e1cdd6d6978ebdf16ea749a9dfd63eb526d3287d Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 8 Jul 2026 19:08:44 +0200 Subject: [PATCH 14/40] fix(map): RustMaps timeout fall-through + doc/config cleanup from branch review --- .../Events/MapMarkersChangedEvent.cs | 2 +- .../Composing/RustMapsBaseMapSource.cs | 2 +- .../Hosting/MapHostedService.cs | 13 ++--- src/RustPlusBot.Features.Map/MapOptions.cs | 10 ---- .../MapComposerTests.cs | 29 +++++++++++ .../RustMapsBaseMapSourceTests.cs | 48 +++++++++++++++++++ 6 files changed, 86 insertions(+), 18 deletions(-) diff --git a/src/RustPlusBot.Abstractions/Events/MapMarkersChangedEvent.cs b/src/RustPlusBot.Abstractions/Events/MapMarkersChangedEvent.cs index 2e657d86..5fef18d5 100644 --- a/src/RustPlusBot.Abstractions/Events/MapMarkersChangedEvent.cs +++ b/src/RustPlusBot.Abstractions/Events/MapMarkersChangedEvent.cs @@ -2,7 +2,7 @@ namespace RustPlusBot.Abstractions.Events; -/// Published when a marker poll detects markers that appeared or disappeared since the previous poll. +/// Published when a marker poll detects markers that appeared, disappeared, or moved since the previous poll. /// The owning guild snowflake. /// The target server id. /// Map dimensions for grid-reference rendering, or null if unavailable. diff --git a/src/RustPlusBot.Features.Map/Composing/RustMapsBaseMapSource.cs b/src/RustPlusBot.Features.Map/Composing/RustMapsBaseMapSource.cs index b97b0c6d..a468e979 100644 --- a/src/RustPlusBot.Features.Map/Composing/RustMapsBaseMapSource.cs +++ b/src/RustPlusBot.Features.Map/Composing/RustMapsBaseMapSource.cs @@ -50,7 +50,7 @@ public sealed partial class RustMapsBaseMapSource( // VERIFY on the first live fetch via tools/RustPlusBot.MapParity; adjust here if wrong. return new BaseMapImage(bytes, info.Width, info.Height, OceanMarginPx: 0); } - catch (OperationCanceledException) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } diff --git a/src/RustPlusBot.Features.Map/Hosting/MapHostedService.cs b/src/RustPlusBot.Features.Map/Hosting/MapHostedService.cs index c779dad3..41c0c251 100644 --- a/src/RustPlusBot.Features.Map/Hosting/MapHostedService.cs +++ b/src/RustPlusBot.Features.Map/Hosting/MapHostedService.cs @@ -25,9 +25,10 @@ internal sealed record MapPipeline( IMapChannelPoster Poster); /// -/// Keeps the #map image current: re-renders on marker changes, on a steady interval (so moving -/// markers track even though their ids are stable), and on connect; clears the base-map cache on -/// disconnect. All refreshes pass through a per-server throttle so the surfaces never double-post. +/// Keeps the #map image current: re-renders on marker changes (including moved markers), on a +/// steady interval (a backstop in case a delta is missed), and on connect; clears the base-map +/// cache on disconnect. All refreshes pass through a per-server throttle so the surfaces never +/// double-post. /// /// The in-process event bus. /// Bundles the rendering-pipeline collaborators. @@ -146,9 +147,9 @@ private async Task ConsumeSettingsEventsAsync(CancellationToken cancellationToke } /// - /// 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 - /// their positions current and posts the first image after connect. + /// Repaints every connected server's #map on a steady interval. A moving cargo ship / heli / chinook + /// now fires a "Moved" delta that drives its own refresh; this + /// tick is a backstop for any missed delta and posts the first image after connect. /// /// A cancellation token. /// A task that completes when the loop stops. diff --git a/src/RustPlusBot.Features.Map/MapOptions.cs b/src/RustPlusBot.Features.Map/MapOptions.cs index 6368ad4d..684195c9 100644 --- a/src/RustPlusBot.Features.Map/MapOptions.cs +++ b/src/RustPlusBot.Features.Map/MapOptions.cs @@ -5,14 +5,4 @@ public sealed class MapOptions { /// Minimum time between #map image re-renders per server (coalesces rapid marker changes). Default 30s. public TimeSpan MapRefreshInterval { get; set; } = TimeSpan.FromSeconds(30); - - /// RustMaps integration settings. - public RustMapsOptions RustMaps { get; set; } = new(); -} - -/// RustMaps API settings; the integration is inactive when no key is configured. -public sealed class RustMapsOptions -{ - /// The RustMaps API key, or null/empty to disable the RustMaps base-map source. - public string? ApiKey { get; set; } } diff --git a/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs index c40db722..b8a56ddf 100644 --- a/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs @@ -184,6 +184,35 @@ public async Task Renders_the_grid_even_with_no_markers() Assert.False(pngOn!.SequenceEqual(pngOff!), "Grid-on and grid-off renders must differ."); } + [Fact] + public async Task ComposeAsync_forwards_vendor_marker_history_into_rendered_trail() + { + // Regression guard for the Task-8 ProjectTrail refactor: GatherMarkers's Vendor branch must + // forward each marker's History ring through to the rendered trail, not just the current point. + var jpeg = BaseJpeg(); + var vendorWithTrail = new ActiveMarker(1, MarkerKind.TravellingVendor, 2000f, 2000f, Dims, + DateTimeOffset.UtcNow, [new TrailPoint(1200f, 1200f), new TrailPoint(2000f, 2000f)], null); + var vendorNoTrail = new ActiveMarker(1, MarkerKind.TravellingVendor, 2000f, 2000f, Dims, + DateTimeOffset.UtcNow, [new TrailPoint(2000f, 2000f)], null); + var layers = new MapLayerSettings( + Grid: false, Markers: false, Monuments: false, Vendor: true, Players: false, Rigs: false); + + var composerWithTrail = + Build(jpeg, Dims, NewQuery(), NewEvents(vendorWithTrail), NewRigs(), NewSettings(layers)); + var composerNoTrail = + Build(jpeg, Dims, NewQuery(), NewEvents(vendorNoTrail), NewRigs(), NewSettings(layers)); + + var pngWithTrail = await composerWithTrail.ComposeAsync(Guild, Server, CancellationToken.None); + var pngNoTrail = await composerNoTrail.ComposeAsync(Guild, Server, CancellationToken.None); + + Assert.NotNull(pngWithTrail); + Assert.NotNull(pngNoTrail); + // MapRenderer.DrawTrails only paints when Trail.Count >= 2, so a 2-point History must render + // differently from a 1-point History — proving the history ring made it through to the trail. + Assert.False(pngWithTrail!.SequenceEqual(pngNoTrail!), + "Vendor trail with 2-point history must render differently than a 1-point history."); + } + private sealed class FakeSource(BaseMapImage? result) : IBaseMapSource { public Task GetAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) => diff --git a/tests/RustPlusBot.Features.Map.Tests/RustMapsBaseMapSourceTests.cs b/tests/RustPlusBot.Features.Map.Tests/RustMapsBaseMapSourceTests.cs index 50ebe471..9293199a 100644 --- a/tests/RustPlusBot.Features.Map.Tests/RustMapsBaseMapSourceTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/RustMapsBaseMapSourceTests.cs @@ -74,6 +74,45 @@ public async Task Missing_world_info_returns_null() await client.DidNotReceiveWithAnyArgs().GetMapBySeedAndSizeAsync(0, 0, false, CancellationToken.None); } + [Fact] + public async Task Http_self_timeout_falls_through_to_null_when_caller_did_not_cancel() + { + var client = Substitute.For(); + client.GetMapBySeedAndSizeAsync(3500, 1234, false, Arg.Any()) + .Returns(Result.Success(new MapInfo + { + RawImageUrl = "https://img.example/raw.png" + }, 200)); + var source = new RustMapsBaseMapSource(client, QueryWithWorld(new WorldSnapshot(3500, 1234)), + new StubFactory(new ThrowingHandler(new TaskCanceledException("HTTP client self-timeout"))), + NullLogger.Instance); + + // The caller's own token is NOT cancelled: this is HttpClient's own timeout firing + // (TaskCanceledException is a subclass of OperationCanceledException), which must fall + // through like any other failure — not propagate and kill the marker-refresh loop. + var result = await source.GetAsync(Guild, Server, CancellationToken.None); + + Assert.Null(result); + } + + [Fact] + public async Task Genuine_caller_cancellation_still_propagates() + { + var client = Substitute.For(); + client.GetMapBySeedAndSizeAsync(3500, 1234, false, Arg.Any()) + .Returns(Result.Success(new MapInfo + { + RawImageUrl = "https://img.example/raw.png" + }, 200)); + var source = new RustMapsBaseMapSource(client, QueryWithWorld(new WorldSnapshot(3500, 1234)), + new StubFactory(new StubHandler(HttpStatusCode.OK, Png(16))), NullLogger.Instance); + + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + await Assert.ThrowsAnyAsync(() => source.GetAsync(Guild, Server, cts.Token)); + } + private sealed class StubHandler(HttpStatusCode status, byte[] body) : HttpMessageHandler { protected override Task SendAsync(HttpRequestMessage request, @@ -84,6 +123,15 @@ protected override Task SendAsync(HttpRequestMessage reques }); } + /// Simulates the HTTP client's own timeout: throws regardless of the caller's token state. + /// The exception every send throws. + private sealed class ThrowingHandler(Exception exception) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) => + Task.FromException(exception); + } + private sealed class StubFactory(HttpMessageHandler handler) : IHttpClientFactory { public HttpClient CreateClient(string name) => new(handler, disposeHandler: false); From b9c37cf4920842d70b36d991a9bbb295dc80819c Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 8 Jul 2026 19:38:49 +0200 Subject: [PATCH 15/40] fix(map): correct grid to whole 146.25 cells (rustplusplus getCorrectedMapSize) Snap the world size to a whole multiple of 146.25 before any grid math (MapGrid.CorrectedWorldSize, ported from rustplusplus getCorrectedMapSize), so the north-edge row and east-edge column are no longer a smaller partial cell. CellCount and LabelFor route through the corrected size; DrawGrid's line/label boundaries do too, with a small epsilon nudge on the label Y probe so it agrees with LabelFor's row binning (see grid-fix-report.md for the trace). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Connections/MapGrid.cs | 25 +++++++++++++++---- .../Rendering/MapRenderer.cs | 21 ++++++++++++---- .../Connections/MapGridTests.cs | 24 ++++++++++++------ 3 files changed, 52 insertions(+), 18 deletions(-) diff --git a/src/RustPlusBot.Abstractions/Connections/MapGrid.cs b/src/RustPlusBot.Abstractions/Connections/MapGrid.cs index 22d03198..2a1eb057 100644 --- a/src/RustPlusBot.Abstractions/Connections/MapGrid.cs +++ b/src/RustPlusBot.Abstractions/Connections/MapGrid.cs @@ -12,10 +12,24 @@ public static class MapGrid /// Edge length of one grid cell, in game units. public const float CellSize = 146.25f; - /// Number of grid cells per axis, including a partial edge cell. + /// + /// Snaps the world size to a whole number of grid cells (rustplusplus getCorrectedMapSize): + /// round down when the remainder is less than 120 game units, else round up. This removes the + /// partial edge cell so every grid cell is exactly . + /// + /// The world size in game units. + /// The corrected world size, an exact multiple of . + public static float CorrectedWorldSize(uint worldSize) + { + var remainder = worldSize % CellSize; + return remainder < 120f ? worldSize - remainder : worldSize + (CellSize - remainder); + } + + /// Number of whole grid cells per axis, after snapping the world size (see ). /// The world size in game units. /// The cell count (at least 1). - public static int CellCount(uint worldSize) => Math.Max(1, (int)Math.Ceiling(worldSize / CellSize)); + public static int CellCount(uint worldSize) => + Math.Max(1, (int)Math.Round(CorrectedWorldSize(worldSize) / CellSize)); /// Formats a spreadsheet-style column label (0→A … 25→Z, 26→AA …). /// The zero-based column index. @@ -40,9 +54,10 @@ public static string ColumnLetters(int index) /// The grid label, rows numbered from the top. public static string LabelFor(float x, float y, uint worldSize) { - var cells = CellCount(worldSize); - var col = Math.Clamp((int)Math.Floor(Math.Clamp(x, 0f, worldSize - 1) / CellSize), 0, cells - 1); - var rowFromBottom = Math.Clamp((int)Math.Floor(Math.Clamp(y, 0f, worldSize - 1) / CellSize), 0, cells - 1); + var corrected = CorrectedWorldSize(worldSize); + var cells = Math.Max(1, (int)Math.Round(corrected / CellSize)); + var col = Math.Clamp((int)Math.Floor(Math.Clamp(x, 0f, corrected - 1f) / CellSize), 0, cells - 1); + var rowFromBottom = Math.Clamp((int)Math.Floor(Math.Clamp(y, 0f, corrected - 1f) / CellSize), 0, cells - 1); var row = cells - rowFromBottom - 1; return string.Create(CultureInfo.InvariantCulture, $"{ColumnLetters(col)}{row}"); } diff --git a/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs b/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs index 910e8d3e..129e0402 100644 --- a/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs +++ b/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs @@ -23,6 +23,15 @@ public sealed class MapRenderer private const float ActiveRingWidth = 3f; private const float PlayerLabelOffset = 12f; + /// + /// A row's north (top) edge sits exactly on a bin boundary now that + /// the grid extent is snapped to a whole multiple of ; floor()-based + /// binning assigns that exact boundary to the row above. Nudging the probe south by this many game + /// units (a fraction of a pixel at map scale) keeps it inside the intended row so the label agrees + /// with . + /// + private const float LabelRowEpsilon = 1f; + private static readonly FontFamily Family = LoadFamily(); private static readonly Font Font = Family.CreateFont(12f); private static readonly Font GridLabelFont = Family.CreateFont(MapRenderStyle.GridLabelFontSize); @@ -112,16 +121,18 @@ private static void DrawGrid(Image image, MapProjection projection) var lineColor = Color.FromRgba(255, 255, 255, 80); var labelColor = Color.FromRgba(255, 255, 255, 140); + var correctedSize = MapGrid.CorrectedWorldSize(projection.WorldSize); var cells = MapGrid.CellCount(projection.WorldSize); - var worldSize = (float)projection.WorldSize; - var (left, top) = projection.ToPixel(0f, worldSize); - var (right, bottom) = projection.ToPixel(worldSize, 0f); + var (left, top) = projection.ToPixel(0f, correctedSize); + var (right, bottom) = projection.ToPixel(correctedSize, 0f); image.Mutate(ctx => { for (var i = 0; i <= cells; i++) { - var boundary = Math.Min(i * MapGrid.CellSize, worldSize); + // correctedSize is an exact multiple of MapGrid.CellSize, so i * CellSize reaches the + // edge exactly at i == cells — no clamp needed, and no partial final cell. + var boundary = i * MapGrid.CellSize; var (vx, _) = projection.ToPixel(boundary, 0f); ctx.DrawLine(lineColor, OutlinePenWidth, new PointF(vx, top), new PointF(vx, bottom)); var (_, hy) = projection.ToPixel(0f, boundary); @@ -134,7 +145,7 @@ private static void DrawGrid(Image image, MapProjection projection) { // Label sits just inside each cell's top-left corner (official-app placement). var worldX = col * MapGrid.CellSize; - var worldY = worldSize - (row * MapGrid.CellSize); + var worldY = correctedSize - (row * MapGrid.CellSize) - LabelRowEpsilon; var (lx, ly) = projection.ToPixel(worldX, worldY); var label = MapGrid.ColumnLetters(col) + row.ToString(CultureInfo.InvariantCulture); ctx.DrawText(new RichTextOptions(GridLabelFont) diff --git a/tests/RustPlusBot.Abstractions.Tests/Connections/MapGridTests.cs b/tests/RustPlusBot.Abstractions.Tests/Connections/MapGridTests.cs index ff6f5de3..ff945467 100644 --- a/tests/RustPlusBot.Abstractions.Tests/Connections/MapGridTests.cs +++ b/tests/RustPlusBot.Abstractions.Tests/Connections/MapGridTests.cs @@ -5,13 +5,19 @@ namespace RustPlusBot.Abstractions.Tests.Connections; public sealed class MapGridTests { [Theory] - [InlineData(3000u, 21)] // 3000 / 146.25 = 20.51 -> 21 (partial edge cell counts) - [InlineData(3500u, 24)] // 23.93 -> 24 - [InlineData(4250u, 30)] // 29.06 -> 30 - [InlineData(4500u, 31)] // 30.77 -> 31 - public void CellCount_ceils_partial_edge_cells(uint worldSize, int expected) => + [InlineData(3000u, 20)] // remainder 3000 % 146.25 = 75 < 120 -> round down to 2925 = 20 * 146.25 + [InlineData(3500u, 24)] // remainder 3500 % 146.25 = 136.25 >= 120 -> round up to 3510 = 24 * 146.25 + [InlineData(4250u, 29)] // remainder 4250 % 146.25 = 8.75 < 120 -> round down to 4241.25 = 29 * 146.25 + [InlineData(4500u, 30)] // remainder 4500 % 146.25 = 112.5 < 120 -> round down to 4387.5 = 30 * 146.25 + public void CellCount_snaps_to_whole_cells(uint worldSize, int expected) => Assert.Equal(expected, MapGrid.CellCount(worldSize)); + [Theory] + [InlineData(3000u, 2925f)] // round-down case: remainder 75 < 120 + [InlineData(3500u, 3510f)] // round-up case: remainder 136.25 >= 120 + public void CorrectedWorldSize_snaps_to_a_whole_multiple_of_cell_size(uint worldSize, float expected) => + Assert.Equal(expected, MapGrid.CorrectedWorldSize(worldSize)); + [Theory] [InlineData(0, "A")] [InlineData(25, "Z")] @@ -23,8 +29,9 @@ public void ColumnLetters_is_spreadsheet_style(int index, string expected) => [Fact] public void LabelFor_origin_is_bottom_left_last_row() { - // 4000 world -> 28 cells (27.35 ceil). World (0,0) = SW corner = column A, bottom row 27. - Assert.Equal("A27", MapGrid.LabelFor(0f, 0f, 4000u)); + // 4000 world: remainder 4000 % 146.25 = 51.25 < 120 -> corrected 3948.75 -> 27 cells. + // World (0,0) = SW corner = column A, bottom row (cells - 1 = 26). + Assert.Equal("A26", MapGrid.LabelFor(0f, 0f, 4000u)); } [Fact] @@ -37,6 +44,7 @@ public void LabelFor_north_west_corner_is_A0() public void LabelFor_beyond_world_size_clamps_to_last_cell() { // Regression for the old GridReference bug: clamping against IMAGE pixels, not world units. - Assert.Equal("AB27", MapGrid.LabelFor(4500f, 0f, 4000u)); // col 27 = "AB" + // 4000 world -> 27 cells (see LabelFor_origin_is_bottom_left_last_row); last column index 26 = "AA". + Assert.Equal("AA26", MapGrid.LabelFor(4500f, 0f, 4000u)); } } From b18fb59d9e66a6f82a4e246d3f00f60f86e37e30 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 8 Jul 2026 21:29:58 +0200 Subject: [PATCH 16/40] =?UTF-8?q?chore(deps):=20bump=20RustMapsApi=201.0.0?= =?UTF-8?q?-beta.1=20=E2=86=92=20beta.2=20(tolerant=20MonumentType=20enum)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index f07b5156..12c2b1e2 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,7 +13,7 @@ - + From de3065bcceaa4a0de59b3302d77c6aaad8d2e669 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 8 Jul 2026 22:18:31 +0200 Subject: [PATCH 17/40] revert(map): drop RustMaps as a base-map source; keep client for #info --- .../Composing/RustMapsBaseMapSource.cs | 74 ---------- .../MapServiceCollectionExtensions.cs | 5 +- .../MapRegistrationTests.cs | 27 ++-- .../RustMapsBaseMapSourceTests.cs | 139 ------------------ 4 files changed, 12 insertions(+), 233 deletions(-) delete mode 100644 src/RustPlusBot.Features.Map/Composing/RustMapsBaseMapSource.cs delete mode 100644 tests/RustPlusBot.Features.Map.Tests/RustMapsBaseMapSourceTests.cs diff --git a/src/RustPlusBot.Features.Map/Composing/RustMapsBaseMapSource.cs b/src/RustPlusBot.Features.Map/Composing/RustMapsBaseMapSource.cs deleted file mode 100644 index a468e979..00000000 --- a/src/RustPlusBot.Features.Map/Composing/RustMapsBaseMapSource.cs +++ /dev/null @@ -1,74 +0,0 @@ -using Microsoft.Extensions.Logging; -using RustMapsApi.V4; -using RustPlusBot.Abstractions.Connections; -using SixLabors.ImageSharp; - -namespace RustPlusBot.Features.Map.Composing; - -/// -/// Preferred base-map source: the RustMaps procedural render (clean terrain, no baked icons), -/// resolved by the server's world size + seed. Any failure returns null so the chain falls -/// through to the Rust+ JPEG — custom/unpublished maps must still render. -/// -/// The RustMaps API client. -/// The live query seam (world size + seed). -/// Creates the image-download client. -/// Logs fall-through causes. -public sealed partial class RustMapsBaseMapSource( - IRustMapsClient client, - IRustServerQuery query, - IHttpClientFactory httpClientFactory, - ILogger logger) : IBaseMapSource -{ - /// Named HTTP client used to download the rendered image. - public const string HttpClientName = "RustMapsImages"; - - /// - public async Task GetAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) - { - try - { - var world = await query.GetWorldAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); - if (world is null) - { - return null; - } - - var result = await client.GetMapBySeedAndSizeAsync( - (int)world.WorldSize, (int)world.Seed, staging: false, cancellationToken) - .ConfigureAwait(false); - if (!result.IsSuccess || result.Data?.RawImageUrl is not { } imageUrl) - { - LogRustMapsUnavailable(logger, (int)world.WorldSize, (int)world.Seed, result.StatusCode); - return null; - } - - var http = httpClientFactory.CreateClient(HttpClientName); - var bytes = await http.GetByteArrayAsync(new Uri(imageUrl), cancellationToken).ConfigureAwait(false); - var info = Image.Identify(bytes); - // RustMaps raw renders span the playable world edge-to-edge (no ocean border). - // VERIFY on the first live fetch via tools/RustPlusBot.MapParity; adjust here if wrong. - return new BaseMapImage(bytes, info.Width, info.Height, OceanMarginPx: 0); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } -#pragma warning disable CA1031 // Broad catch: any RustMaps failure falls through to the Rust+ JPEG source. - catch (Exception ex) -#pragma warning restore CA1031 - { - LogRustMapsFailed(logger, ex); - return null; - } - } - - [LoggerMessage(Level = LogLevel.Information, - Message = - "RustMaps has no render for size {Size} seed {Seed} (status {StatusCode}); falling back to the Rust+ map tile.")] - private static partial void LogRustMapsUnavailable(ILogger logger, int size, int seed, int statusCode); - - [LoggerMessage(Level = LogLevel.Warning, - Message = "RustMaps base-map fetch failed; falling back to the Rust+ map tile.")] - private static partial void LogRustMapsFailed(ILogger logger, Exception exception); -} diff --git a/src/RustPlusBot.Features.Map/MapServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Map/MapServiceCollectionExtensions.cs index 7d7c327e..5e53e143 100644 --- a/src/RustPlusBot.Features.Map/MapServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Map/MapServiceCollectionExtensions.cs @@ -21,13 +21,12 @@ public static IServiceCollection AddMap(this IServiceCollection services, IConfi services.AddSingleton(); - // Source order defines priority: RustMaps first (when a key is configured), Rust+ JPEG fallback. + // RustMaps is NOT a base-map source (the #map render draws its own layers on the Rust+ tile). + // The client is kept for the #info static-map surface (Task 7 wires the coordinator/service/poster). var rustMapsKey = configuration["Map:RustMaps:ApiKey"]; if (!string.IsNullOrWhiteSpace(rustMapsKey)) { services.AddRustMapsClientV4(o => o.ApiKey = rustMapsKey); - services.AddHttpClient(RustMapsBaseMapSource.HttpClientName); - services.AddSingleton(); } services.AddSingleton(); diff --git a/tests/RustPlusBot.Features.Map.Tests/MapRegistrationTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapRegistrationTests.cs index 7198cdcd..0d09dabb 100644 --- a/tests/RustPlusBot.Features.Map.Tests/MapRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/MapRegistrationTests.cs @@ -35,28 +35,21 @@ public void AddMap_registers_renderer_cache_and_composer_as_singletons() } [Fact] - public void AddMap_with_RustMaps_key_registers_RustMaps_source_first() + public void AddMap_registers_only_the_RustPlus_base_source_without_key() { - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection([new KeyValuePair("Map:RustMaps:ApiKey", "test-key")]) - .Build(); - using var provider = BuildProvider(configuration); - - var sources = provider.GetServices().ToList(); - - Assert.Equal(2, sources.Count); - Assert.IsType(sources[0]); - Assert.IsType(sources[1]); + using var provider = BuildProvider(EmptyConfiguration()); + var source = Assert.Single(provider.GetServices()); + Assert.IsType(source); } [Fact] - public void AddMap_without_RustMaps_key_registers_only_the_RustPlus_source() + public void AddMap_with_RustMaps_key_still_uses_only_the_RustPlus_base_source() { - using var provider = BuildProvider(EmptyConfiguration()); - - var sources = provider.GetServices().ToList(); - - var source = Assert.Single(sources); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection([new KeyValuePair("Map:RustMaps:ApiKey", "test-key")]) + .Build(); + using var provider = BuildProvider(configuration); + var source = Assert.Single(provider.GetServices()); Assert.IsType(source); } diff --git a/tests/RustPlusBot.Features.Map.Tests/RustMapsBaseMapSourceTests.cs b/tests/RustPlusBot.Features.Map.Tests/RustMapsBaseMapSourceTests.cs deleted file mode 100644 index 9293199a..00000000 --- a/tests/RustPlusBot.Features.Map.Tests/RustMapsBaseMapSourceTests.cs +++ /dev/null @@ -1,139 +0,0 @@ -using System.Net; -using Microsoft.Extensions.Logging.Abstractions; -using NSubstitute; -using RustMapsApi.Results; -using RustMapsApi.V4; -using RustMapsApi.V4.Models; -using RustPlusBot.Abstractions.Connections; -using RustPlusBot.Features.Map.Composing; -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.PixelFormats; - -namespace RustPlusBot.Features.Map.Tests; - -public sealed class RustMapsBaseMapSourceTests -{ - private const ulong Guild = 1UL; - private static readonly Guid Server = Guid.NewGuid(); - - private static byte[] Png(int size) - { - using var img = new Image(size, size); - using var ms = new MemoryStream(); - img.SaveAsPng(ms); - return ms.ToArray(); - } - - private static IRustServerQuery QueryWithWorld(WorldSnapshot? world) - { - var query = Substitute.For(); - query.GetWorldAsync(Guild, Server, Arg.Any()).Returns(world); - return query; - } - - [Fact] - public async Task Happy_path_downloads_raw_image_and_measures_dims() - { - var client = Substitute.For(); - client.GetMapBySeedAndSizeAsync(3500, 1234, false, Arg.Any()) - .Returns(Result.Success(new MapInfo - { - RawImageUrl = "https://img.example/raw.png" - }, 200)); - var source = new RustMapsBaseMapSource(client, QueryWithWorld(new WorldSnapshot(3500, 1234)), - new StubFactory(new StubHandler(HttpStatusCode.OK, Png(1750))), NullLogger.Instance); - - var result = await source.GetAsync(Guild, Server, CancellationToken.None); - - Assert.NotNull(result); - Assert.Equal(1750, result!.PixelWidth); - Assert.Equal(0, result.OceanMarginPx); - } - - [Fact] - public async Task Map_not_found_returns_null() - { - var client = Substitute.For(); - client.GetMapBySeedAndSizeAsync(3500, 1234, false, Arg.Any()) - .Returns(Result.Failure( - new RustMapsError(RustMapsErrorKind.NotFound, "not generated", RawBody: null, RetryAfter: null), 404)); - var source = new RustMapsBaseMapSource(client, QueryWithWorld(new WorldSnapshot(3500, 1234)), - new StubFactory(new StubHandler(HttpStatusCode.OK, Png(16))), NullLogger.Instance); - - Assert.Null(await source.GetAsync(Guild, Server, CancellationToken.None)); - } - - [Fact] - public async Task Missing_world_info_returns_null() - { - var client = Substitute.For(); - var source = new RustMapsBaseMapSource(client, QueryWithWorld(null), - new StubFactory(new StubHandler(HttpStatusCode.OK, Png(16))), NullLogger.Instance); - - Assert.Null(await source.GetAsync(Guild, Server, CancellationToken.None)); - await client.DidNotReceiveWithAnyArgs().GetMapBySeedAndSizeAsync(0, 0, false, CancellationToken.None); - } - - [Fact] - public async Task Http_self_timeout_falls_through_to_null_when_caller_did_not_cancel() - { - var client = Substitute.For(); - client.GetMapBySeedAndSizeAsync(3500, 1234, false, Arg.Any()) - .Returns(Result.Success(new MapInfo - { - RawImageUrl = "https://img.example/raw.png" - }, 200)); - var source = new RustMapsBaseMapSource(client, QueryWithWorld(new WorldSnapshot(3500, 1234)), - new StubFactory(new ThrowingHandler(new TaskCanceledException("HTTP client self-timeout"))), - NullLogger.Instance); - - // The caller's own token is NOT cancelled: this is HttpClient's own timeout firing - // (TaskCanceledException is a subclass of OperationCanceledException), which must fall - // through like any other failure — not propagate and kill the marker-refresh loop. - var result = await source.GetAsync(Guild, Server, CancellationToken.None); - - Assert.Null(result); - } - - [Fact] - public async Task Genuine_caller_cancellation_still_propagates() - { - var client = Substitute.For(); - client.GetMapBySeedAndSizeAsync(3500, 1234, false, Arg.Any()) - .Returns(Result.Success(new MapInfo - { - RawImageUrl = "https://img.example/raw.png" - }, 200)); - var source = new RustMapsBaseMapSource(client, QueryWithWorld(new WorldSnapshot(3500, 1234)), - new StubFactory(new StubHandler(HttpStatusCode.OK, Png(16))), NullLogger.Instance); - - using var cts = new CancellationTokenSource(); - await cts.CancelAsync(); - - await Assert.ThrowsAnyAsync(() => source.GetAsync(Guild, Server, cts.Token)); - } - - private sealed class StubHandler(HttpStatusCode status, byte[] body) : HttpMessageHandler - { - protected override Task SendAsync(HttpRequestMessage request, - CancellationToken cancellationToken) => - Task.FromResult(new HttpResponseMessage(status) - { - Content = new ByteArrayContent(body) - }); - } - - /// Simulates the HTTP client's own timeout: throws regardless of the caller's token state. - /// The exception every send throws. - private sealed class ThrowingHandler(Exception exception) : HttpMessageHandler - { - protected override Task SendAsync(HttpRequestMessage request, - CancellationToken cancellationToken) => - Task.FromException(exception); - } - - private sealed class StubFactory(HttpMessageHandler handler) : IHttpClientFactory - { - public HttpClient CreateClient(string name) => new(handler, disposeHandler: false); - } -} From 99ff0c263f5b9aabeca20305d4286c6053d33bdb Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 8 Jul 2026 22:26:09 +0200 Subject: [PATCH 18/40] feat(map): MapComposer.ComposeStaticAsync (grid+monuments static render) Extracts a shared private ComposeWithLayersAsync(guild, server, MapLayerSet, ct) core from ComposeAsync; ComposeAsync now reads the per-server toggle settings then delegates to it, and the new ComposeStaticAsync calls it with a fixed grid+monuments-only layer set for the #info static fallback render. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Composing/MapComposer.cs | 35 +++++++++--- .../MapComposerTests.cs | 56 +++++++++++++++++++ 2 files changed, 84 insertions(+), 7 deletions(-) diff --git a/src/RustPlusBot.Features.Map/Composing/MapComposer.cs b/src/RustPlusBot.Features.Map/Composing/MapComposer.cs index ec6ce610..37e29a00 100644 --- a/src/RustPlusBot.Features.Map/Composing/MapComposer.cs +++ b/src/RustPlusBot.Features.Map/Composing/MapComposer.cs @@ -26,19 +26,18 @@ public sealed class MapComposer( 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 static #info layer set: terrain base + grid + monuments only (no dynamic overlays). + private static readonly MapLayerSet StaticLayers = + new(Grid: true, Markers: false, Monuments: true, Vendor: false, Players: false, Rigs: false); + + /// Composes the map PNG for a server using its saved layer toggles, or null when no base map + /// is available yet. /// The owning guild snowflake. /// The target server id. /// A cancellation token. /// PNG bytes, or null. public async Task ComposeAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) { - var baseImage = await cache.GetAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); - if (baseImage is null) - { - 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; @@ -51,6 +50,28 @@ public sealed class MapComposer( var layers = new MapLayerSet(settings.Grid, settings.Markers, settings.Monuments, settings.Vendor, settings.Players, settings.Rigs); + return await ComposeWithLayersAsync(guildId, serverId, layers, cancellationToken).ConfigureAwait(false); + } + + /// Composes a static #info map: terrain base + grid + monuments only, ignoring saved toggles. + /// The owning guild snowflake. + /// The target server id. + /// A cancellation token. + /// PNG bytes, or null when no base map is available. + public Task ComposeStaticAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) => + ComposeWithLayersAsync(guildId, serverId, StaticLayers, cancellationToken); + + private async Task ComposeWithLayersAsync( + ulong guildId, + Guid serverId, + MapLayerSet layers, + CancellationToken cancellationToken) + { + var baseImage = await cache.GetAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + if (baseImage is null) + { + return null; + } // 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. diff --git a/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs index b8a56ddf..f6eeebc0 100644 --- a/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs @@ -73,6 +73,32 @@ private static IMapSettingsStore NewSettings(MapLayerSettings settings) return store; } + /// Builds a composer wired for the static #info path: a valid base map + dimensions, with + /// caller-controlled dynamic state (events/team) and monument list. The settings store is AllOn but + /// irrelevant — ComposeStaticAsync never reads it. + /// Live marker state; defaults to an empty stub. + /// Team snapshot returned by the query seam; null when no team. + /// Monuments returned by the query seam; defaults to empty. + private static MapComposer BuildComposer( + IEventState? events = null, + TeamInfoSnapshot? team = null, + IReadOnlyList? monuments = null) + { + var query = NewQuery(); + query.GetTeamInfoAsync(Guild, Server, Arg.Any()).Returns(team); + query.GetMonumentsAsync(Guild, Server, Arg.Any()).Returns(monuments ?? []); + return Build(BaseJpeg(), Dims, query, events ?? NewEvents(), NewRigs(), NewSettings(MapLayerSettings.AllOn)); + } + + private static IEventState EventsWithCargo() => + NewEvents(new ActiveMarker(1, MarkerKind.CargoShip, 2000f, 2000f, Dims, DateTimeOffset.UtcNow, + [new TrailPoint(2000f, 2000f)], null)); + + private static IEventState EmptyEvents() => NewEvents(); + + private static TeamInfoSnapshot TeamWithPlayer() => + new(0, [new TeamMemberSnapshot(1, "Ada", 2000f, 2000f, true, true, default, default)]); + [Fact] public async Task ComposeAsync_returns_null_when_no_base_map() { @@ -213,6 +239,36 @@ public async Task ComposeAsync_forwards_vendor_marker_history_into_rendered_trai "Vendor trail with 2-point history must render differently than a 1-point history."); } + [Fact] + public async Task ComposeStaticAsync_ignores_markers_and_players() + { + // Static compose must be invariant to live marker/player state (dynamic layers are OFF), + // so the bytes are identical whether or not the stores hold markers/players. + var withDynamic = BuildComposer(events: EventsWithCargo(), team: TeamWithPlayer()); + var withoutDynamic = BuildComposer(events: EmptyEvents(), team: null); + + var a = await withDynamic.ComposeStaticAsync(Guild, Server, CancellationToken.None); + var b = await withoutDynamic.ComposeStaticAsync(Guild, Server, CancellationToken.None); + + Assert.NotNull(a); + Assert.NotNull(b); + Assert.True(a!.AsSpan().SequenceEqual(b)); + } + + [Fact] + public async Task ComposeStaticAsync_draws_monuments_over_the_base() + { + var composer = BuildComposer(monuments: [new MonumentSnapshot("launchsite", 2000f, 2000f)]); + var withMonument = await composer.ComposeStaticAsync(Guild, Server, CancellationToken.None); + + var bare = BuildComposer(monuments: []); + var withoutMonument = await bare.ComposeStaticAsync(Guild, Server, CancellationToken.None); + + Assert.NotNull(withMonument); + Assert.NotNull(withoutMonument); + Assert.False(withMonument!.AsSpan().SequenceEqual(withoutMonument!)); // monument icon changed pixels + } + private sealed class FakeSource(BaseMapImage? result) : IBaseMapSource { public Task GetAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) => From c00befbfbe00786a94d03769717b45c357218136 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 8 Jul 2026 22:38:40 +0200 Subject: [PATCH 19/40] feat(map): RustMaps generation coordinator (per size+seed state machine) --- .editorconfig | 1 + .../RustMaps/IRustMapsMapCoordinator.cs | 40 ++++++ .../RustMaps/RustMapsGenerationState.cs | 20 +++ .../RustMaps/RustMapsMapCoordinator.cs | 120 ++++++++++++++++++ .../RustMaps/RustMapsMapKey.cs | 6 + .../RustMaps/RustMapsMapSnapshot.cs | 7 + .../RustMaps/RustMapsReadyMap.cs | 6 + .../RustMaps/RustMapsMapCoordinatorTests.cs | 57 +++++++++ 8 files changed, 257 insertions(+) create mode 100644 src/RustPlusBot.Features.Map/RustMaps/IRustMapsMapCoordinator.cs create mode 100644 src/RustPlusBot.Features.Map/RustMaps/RustMapsGenerationState.cs create mode 100644 src/RustPlusBot.Features.Map/RustMaps/RustMapsMapCoordinator.cs create mode 100644 src/RustPlusBot.Features.Map/RustMaps/RustMapsMapKey.cs create mode 100644 src/RustPlusBot.Features.Map/RustMaps/RustMapsMapSnapshot.cs create mode 100644 src/RustPlusBot.Features.Map/RustMaps/RustMapsReadyMap.cs create mode 100644 tests/RustPlusBot.Features.Map.Tests/RustMaps/RustMapsMapCoordinatorTests.cs diff --git a/.editorconfig b/.editorconfig index b1b7658d..2897f41f 100644 --- a/.editorconfig +++ b/.editorconfig @@ -392,6 +392,7 @@ roslynator_compiler_diagnostic_fixes.enabled = true dotnet_diagnostic.CA1002.severity = none # Do not expose generic lists — API uses List intentionally for protobuf collections dotnet_diagnostic.CA1008.severity = none # Enums should have zero value dotnet_diagnostic.CA1031.severity = none # Do not catch general exception types (intentional for fail-safe CLI resilience) +dotnet_diagnostic.CA1054.severity = none # Uri parameters should not be strings — same call as CA1056 below; RustMaps URLs travel as plain strings end to end dotnet_diagnostic.CA1056.severity = none # Uri properties should not be strings dotnet_diagnostic.CA1303.severity = none # Do not pass literals as localized parameters (literal strings in console output — CLI tool not localized) dotnet_diagnostic.CA1308.severity = none # Normalize strings to uppercase diff --git a/src/RustPlusBot.Features.Map/RustMaps/IRustMapsMapCoordinator.cs b/src/RustPlusBot.Features.Map/RustMaps/IRustMapsMapCoordinator.cs new file mode 100644 index 00000000..6c3d9d70 --- /dev/null +++ b/src/RustPlusBot.Features.Map/RustMaps/IRustMapsMapCoordinator.cs @@ -0,0 +1,40 @@ +namespace RustPlusBot.Features.Map.RustMaps; + +/// Tracks RustMaps generation state per (size, seed) and the servers awaiting each map. Thread-safe singleton. +public interface IRustMapsMapCoordinator +{ + /// Registers a server's interest in a map key (idempotent; never revives a terminal state). + /// The (size, seed) map key. + /// The requesting guild's id. + /// The requesting server's id. + void Register(RustMapsMapKey key, ulong guildId, Guid serverId); + + /// Gets an immutable snapshot of a key (an Idle snapshot for an unseen key). + /// The (size, seed) map key. + RustMapsMapSnapshot Snapshot(RustMapsMapKey key); + + /// Moves a key to Generating with its map id; returns false if it was already terminal. + /// The (size, seed) map key. + /// The RustMaps map id, when already known. + bool TrySetGenerating(RustMapsMapKey key, string? mapId); + + /// Marks a key Ready and caches its image. + /// The (size, seed) map key. + /// The generated image and its RustMaps page link. + void SetReady(RustMapsMapKey key, RustMapsReadyMap ready); + + /// Marks a key Failed (no retry this wipe). + /// The (size, seed) map key. + void SetFailed(RustMapsMapKey key); + + /// Marks a key LimitReached (credits exhausted; no spend). + /// The (size, seed) map key. + void SetLimitReached(RustMapsMapKey key); + + /// Keys with at least one requester still in Idle or Generating. + IReadOnlyList PendingKeys(); + + /// The (guild, server) pairs awaiting a key. + /// The (size, seed) map key. + IReadOnlyList<(ulong Guild, Guid Server)> Requesters(RustMapsMapKey key); +} diff --git a/src/RustPlusBot.Features.Map/RustMaps/RustMapsGenerationState.cs b/src/RustPlusBot.Features.Map/RustMaps/RustMapsGenerationState.cs new file mode 100644 index 00000000..28ec17b4 --- /dev/null +++ b/src/RustPlusBot.Features.Map/RustMaps/RustMapsGenerationState.cs @@ -0,0 +1,20 @@ +namespace RustPlusBot.Features.Map.RustMaps; + +/// Lifecycle of a RustMaps map for one (size, seed). Ready/Failed/LimitReached are terminal for the wipe. +public enum RustMapsGenerationState +{ + /// Not yet checked/started. + Idle = 0, + + /// Generation requested; polling for completion. + Generating = 1, + + /// The RustMaps render is available and cached. + Ready = 2, + + /// Generation failed; no retry this wipe. + Failed = 3, + + /// Generation skipped because the RustMaps credit limit is reached. + LimitReached = 4, +} diff --git a/src/RustPlusBot.Features.Map/RustMaps/RustMapsMapCoordinator.cs b/src/RustPlusBot.Features.Map/RustMaps/RustMapsMapCoordinator.cs new file mode 100644 index 00000000..4aefd994 --- /dev/null +++ b/src/RustPlusBot.Features.Map/RustMaps/RustMapsMapCoordinator.cs @@ -0,0 +1,120 @@ +using System.Collections.Concurrent; + +namespace RustPlusBot.Features.Map.RustMaps; + +/// +public sealed class RustMapsMapCoordinator : IRustMapsMapCoordinator +{ + private readonly ConcurrentDictionary _byKey = new(); + + /// + public void Register(RustMapsMapKey key, ulong guildId, Guid serverId) + { + var entry = _byKey.GetOrAdd(key, static _ => new Entry()); + lock (entry.Gate) + { + entry.Requesters.Add((guildId, serverId)); + } + } + + /// + public RustMapsMapSnapshot Snapshot(RustMapsMapKey key) + { + if (!_byKey.TryGetValue(key, out var entry)) + { + return new RustMapsMapSnapshot(RustMapsGenerationState.Idle, null, null); + } + + lock (entry.Gate) + { + return new RustMapsMapSnapshot(entry.State, entry.MapId, entry.Ready); + } + } + + /// + public bool TrySetGenerating(RustMapsMapKey key, string? mapId) + { + var entry = _byKey.GetOrAdd(key, static _ => new Entry()); + lock (entry.Gate) + { + if (entry.State is RustMapsGenerationState.Ready or RustMapsGenerationState.Failed + or RustMapsGenerationState.LimitReached) + { + return false; + } + + entry.State = RustMapsGenerationState.Generating; + entry.MapId = mapId; + return true; + } + } + + /// + public void SetReady(RustMapsMapKey key, RustMapsReadyMap ready) + { + ArgumentNullException.ThrowIfNull(ready); + var entry = _byKey.GetOrAdd(key, static _ => new Entry()); + lock (entry.Gate) + { + entry.State = RustMapsGenerationState.Ready; + entry.Ready = ready; + } + } + + /// + public void SetFailed(RustMapsMapKey key) => SetTerminal(key, RustMapsGenerationState.Failed); + + /// + public void SetLimitReached(RustMapsMapKey key) => SetTerminal(key, RustMapsGenerationState.LimitReached); + + /// + public IReadOnlyList PendingKeys() + { + var pending = new List(); + foreach (var (key, entry) in _byKey) + { + lock (entry.Gate) + { + if (entry.Requesters.Count > 0 + && entry.State is RustMapsGenerationState.Idle or RustMapsGenerationState.Generating) + { + pending.Add(key); + } + } + } + + return pending; + } + + /// + public IReadOnlyList<(ulong Guild, Guid Server)> Requesters(RustMapsMapKey key) + { + if (!_byKey.TryGetValue(key, out var entry)) + { + return []; + } + + lock (entry.Gate) + { + return [.. entry.Requesters]; + } + } + + private void SetTerminal(RustMapsMapKey key, RustMapsGenerationState state) + { + var entry = _byKey.GetOrAdd(key, static _ => new Entry()); + lock (entry.Gate) + { + entry.State = state; + } + } + + private sealed class Entry + { + public object Gate { get; } = new(); + public RustMapsGenerationState State { get; set; } = RustMapsGenerationState.Idle; + public string? MapId { get; set; } + public RustMapsReadyMap? Ready { get; set; } + public HashSet<(ulong Guild, Guid Server)> Requesters { get; } = []; + } +} diff --git a/src/RustPlusBot.Features.Map/RustMaps/RustMapsMapKey.cs b/src/RustPlusBot.Features.Map/RustMaps/RustMapsMapKey.cs new file mode 100644 index 00000000..1acd884b --- /dev/null +++ b/src/RustPlusBot.Features.Map/RustMaps/RustMapsMapKey.cs @@ -0,0 +1,6 @@ +namespace RustPlusBot.Features.Map.RustMaps; + +/// Identity of a RustMaps procedural map, shared by every server on a wipe. +/// The world size. +/// The map seed. +public sealed record RustMapsMapKey(int Size, int Seed); diff --git a/src/RustPlusBot.Features.Map/RustMaps/RustMapsMapSnapshot.cs b/src/RustPlusBot.Features.Map/RustMaps/RustMapsMapSnapshot.cs new file mode 100644 index 00000000..f021f5bc --- /dev/null +++ b/src/RustPlusBot.Features.Map/RustMaps/RustMapsMapSnapshot.cs @@ -0,0 +1,7 @@ +namespace RustPlusBot.Features.Map.RustMaps; + +/// An immutable view of a map key's generation progress. +/// The current lifecycle state. +/// The RustMaps map id once assigned, else null. +/// The ready image when is . +public sealed record RustMapsMapSnapshot(RustMapsGenerationState State, string? MapId, RustMapsReadyMap? Ready); diff --git a/src/RustPlusBot.Features.Map/RustMaps/RustMapsReadyMap.cs b/src/RustPlusBot.Features.Map/RustMaps/RustMapsReadyMap.cs new file mode 100644 index 00000000..1af6e0b5 --- /dev/null +++ b/src/RustPlusBot.Features.Map/RustMaps/RustMapsReadyMap.cs @@ -0,0 +1,6 @@ +namespace RustPlusBot.Features.Map.RustMaps; + +/// A generated RustMaps map ready to post: the downloaded image plus its RustMaps page link. +/// The downloaded RustMaps render bytes. +/// The RustMaps page URL, when available. +public sealed record RustMapsReadyMap(byte[] ImageBytes, string? RustMapsUrl); diff --git a/tests/RustPlusBot.Features.Map.Tests/RustMaps/RustMapsMapCoordinatorTests.cs b/tests/RustPlusBot.Features.Map.Tests/RustMaps/RustMapsMapCoordinatorTests.cs new file mode 100644 index 00000000..e6f89fe0 --- /dev/null +++ b/tests/RustPlusBot.Features.Map.Tests/RustMaps/RustMapsMapCoordinatorTests.cs @@ -0,0 +1,57 @@ +using RustPlusBot.Features.Map.RustMaps; + +namespace RustPlusBot.Features.Map.Tests.RustMaps; + +public sealed class RustMapsMapCoordinatorTests +{ + private static readonly RustMapsMapKey Key = new(4000, 12345); + private static readonly Guid Server = Guid.NewGuid(); + + [Fact] + public void Unseen_key_snapshots_as_idle() + { + var c = new RustMapsMapCoordinator(); + Assert.Equal(RustMapsGenerationState.Idle, c.Snapshot(Key).State); + } + + [Fact] + public void Register_is_idempotent_and_accumulates_requesters() + { + var c = new RustMapsMapCoordinator(); + var s2 = Guid.NewGuid(); + c.Register(Key, 1UL, Server); + c.Register(Key, 1UL, Server); // same requester again + c.Register(Key, 1UL, s2); // second server, same (size,seed) + + Assert.Equal(2, c.Requesters(Key).Count); + Assert.Contains(Key, c.PendingKeys()); + } + + [Fact] + public void Lifecycle_transitions_and_ready_caches_the_image() + { + var c = new RustMapsMapCoordinator(); + c.Register(Key, 1UL, Server); + Assert.True(c.TrySetGenerating(Key, "map-1")); + Assert.Equal(RustMapsGenerationState.Generating, c.Snapshot(Key).State); + Assert.Equal("map-1", c.Snapshot(Key).MapId); + + c.SetReady(Key, new RustMapsReadyMap([1, 2, 3], "https://rustmaps/x")); + var snap = c.Snapshot(Key); + Assert.Equal(RustMapsGenerationState.Ready, snap.State); + Assert.Equal([1, 2, 3], snap.Ready!.ImageBytes); + Assert.Equal("https://rustmaps/x", snap.Ready.RustMapsUrl); + } + + [Fact] + public void Terminal_states_are_not_pending_and_are_not_reset_by_register() + { + var c = new RustMapsMapCoordinator(); + c.Register(Key, 1UL, Server); + c.SetFailed(Key); + c.Register(Key, 1UL, Server); // must not revive + + Assert.Equal(RustMapsGenerationState.Failed, c.Snapshot(Key).State); + Assert.DoesNotContain(Key, c.PendingKeys()); + } +} From df522876ee7cd9412e0745d90a1567c6507d22d1 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 8 Jul 2026 22:46:23 +0200 Subject: [PATCH 20/40] feat(map): #info channel locator + info-map attachment poster --- .../Posting/DiscordInfoMapPoster.cs | 81 +++++++++++ .../Posting/IInfoMapPoster.cs | 15 +++ .../Locating/IInfoChannelLocator.cs | 12 ++ .../Locating/InfoChannelLocator.cs | 10 ++ .../WorkspaceServiceCollectionExtensions.cs | 1 + .../Locating/InfoChannelLocatorTests.cs | 126 ++++++++++++++++++ 6 files changed, 245 insertions(+) create mode 100644 src/RustPlusBot.Features.Map/Posting/DiscordInfoMapPoster.cs create mode 100644 src/RustPlusBot.Features.Map/Posting/IInfoMapPoster.cs create mode 100644 src/RustPlusBot.Features.Workspace/Locating/IInfoChannelLocator.cs create mode 100644 src/RustPlusBot.Features.Workspace/Locating/InfoChannelLocator.cs create mode 100644 tests/RustPlusBot.Features.Workspace.Tests/Locating/InfoChannelLocatorTests.cs diff --git a/src/RustPlusBot.Features.Map/Posting/DiscordInfoMapPoster.cs b/src/RustPlusBot.Features.Map/Posting/DiscordInfoMapPoster.cs new file mode 100644 index 00000000..5802c44a --- /dev/null +++ b/src/RustPlusBot.Features.Map/Posting/DiscordInfoMapPoster.cs @@ -0,0 +1,81 @@ +using Discord; +using Discord.WebSocket; +using Microsoft.Extensions.Logging; + +namespace RustPlusBot.Features.Map.Posting; + +/// Posts the static map image to #info by deleting the bot's prior image post and reposting. Untested integration shim. +/// The Discord socket client. +/// The logger. +internal sealed partial class DiscordInfoMapPoster( + DiscordSocketClient client, + ILogger logger) : IInfoMapPoster +{ + private const int RecentMessageScan = 10; + + /// + public async Task PostAsync(ulong channelId, Embed embed, byte[] pngBytes, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(embed); + ArgumentNullException.ThrowIfNull(pngBytes); + try + { + var options = new RequestOptions { CancelToken = cancellationToken }; + if (await client.GetChannelAsync(channelId, options).ConfigureAwait(false) is not ITextChannel channel) + { + return; + } + + try + { + await DeletePriorBotMessagesAsync(channel, options).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } +#pragma warning disable CA1031 // Broad catch: a failed delete must not abort the repost (best-effort). + catch (Exception ex) +#pragma warning restore CA1031 + { + LogDeleteFailed(logger, ex, channelId); + } + + var stream = new MemoryStream(pngBytes); + await using (stream.ConfigureAwait(false)) + { + await channel.SendFileAsync(stream, "map.png", embed: embed, options: options, + allowedMentions: AllowedMentions.None).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + throw; + } +#pragma warning disable CA1031 // Broad catch: a Discord hiccup must not crash the info-map loop. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogPostFailed(logger, ex, channelId); + } + } + + private async Task DeletePriorBotMessagesAsync(ITextChannel channel, RequestOptions options) + { + var batch = await channel.GetMessagesAsync(RecentMessageScan, options: options).FlattenAsync() + .ConfigureAwait(false); + // Delete only our prior IMAGE posts (they carry a file attachment); leave the reconciled + // #info status embed (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); + } + } + + [LoggerMessage(Level = LogLevel.Warning, Message = "Posting the #info map image to channel {ChannelId} failed.")] + private static partial void LogPostFailed(ILogger logger, Exception exception, ulong channelId); + + [LoggerMessage(Level = LogLevel.Warning, + Message = "Deleting prior #info map messages in channel {ChannelId} failed; reposting anyway.")] + private static partial void LogDeleteFailed(ILogger logger, Exception exception, ulong channelId); +} diff --git a/src/RustPlusBot.Features.Map/Posting/IInfoMapPoster.cs b/src/RustPlusBot.Features.Map/Posting/IInfoMapPoster.cs new file mode 100644 index 00000000..09f97c26 --- /dev/null +++ b/src/RustPlusBot.Features.Map/Posting/IInfoMapPoster.cs @@ -0,0 +1,15 @@ +using Discord; + +namespace RustPlusBot.Features.Map.Posting; + +/// Posts the static map image + embed to #info, replacing the bot's prior image post. +internal interface IInfoMapPoster +{ + /// Deletes the bot's prior #info image post (if any) and posts the new embed + PNG. + /// The #info channel id. + /// The map embed (title, size/seed, RustMaps link). + /// The map image PNG. + /// A cancellation token. + /// A task that completes when the post is issued. + Task PostAsync(ulong channelId, Embed embed, byte[] pngBytes, CancellationToken cancellationToken); +} diff --git a/src/RustPlusBot.Features.Workspace/Locating/IInfoChannelLocator.cs b/src/RustPlusBot.Features.Workspace/Locating/IInfoChannelLocator.cs new file mode 100644 index 00000000..c9417c7d --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Locating/IInfoChannelLocator.cs @@ -0,0 +1,12 @@ +namespace RustPlusBot.Features.Workspace.Locating; + +/// Resolves the per-server #info channel id (for posting the static map image). +public interface IInfoChannelLocator +{ + /// Gets the #info Discord channel id for (guild, server), or null if not provisioned. + /// The guild snowflake. + /// The server id. + /// A cancellation token. + /// The channel id, or null. + Task GetChannelIdAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken); +} diff --git a/src/RustPlusBot.Features.Workspace/Locating/InfoChannelLocator.cs b/src/RustPlusBot.Features.Workspace/Locating/InfoChannelLocator.cs new file mode 100644 index 00000000..43a49eb2 --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Locating/InfoChannelLocator.cs @@ -0,0 +1,10 @@ +using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Abstractions.Time; + +namespace RustPlusBot.Features.Workspace.Locating; + +/// Resolves the #info channel id for a (guild, server). +/// Opens scopes for the scoped workspace store. +/// Drives the cache TTL. +internal sealed class InfoChannelLocator(IServiceScopeFactory scopeFactory, IClock clock) + : CachingChannelLocator(scopeFactory, clock, WorkspaceChannelKeys.ServerInfo), IInfoChannelLocator; diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs index bfaa09e5..de503f2f 100644 --- a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs @@ -63,6 +63,7 @@ public static IServiceCollection AddWorkspace(this IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Locating/InfoChannelLocatorTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Locating/InfoChannelLocatorTests.cs new file mode 100644 index 00000000..5b6bad8f --- /dev/null +++ b/tests/RustPlusBot.Features.Workspace.Tests/Locating/InfoChannelLocatorTests.cs @@ -0,0 +1,126 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Domain.Servers; +using RustPlusBot.Domain.Workspace; +using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Persistence; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Features.Workspace.Tests.Locating; + +public sealed class InfoChannelLocatorTests +{ + private static (InfoChannelLocator Locator, ServiceProvider Provider, string ConnectionString, IClock Clock) + CreateLocator() + { + var clock = Substitute.For(); + clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); + + var cs = $"DataSource=info-locator-{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; + var keepAlive = new SqliteConnection(cs); + keepAlive.Open(); + using (var seed = new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)) + { + seed.Database.Migrate(); + } + + var services = new ServiceCollection(); + services.AddSingleton(keepAlive); + services.AddSingleton(clock); + services.AddScoped(_ => new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)); + services.AddScoped(); + var provider = services.BuildServiceProvider(); + + var locator = new InfoChannelLocator(provider.GetRequiredService(), clock); + return (locator, provider, cs, clock); + } + + private static async Task SeedAsync(string connectionString) + { + await using var context = + new BotDbContext(new DbContextOptionsBuilder().UseSqlite(connectionString).Options); + + var server = new RustServer + { + GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015 + }; + context.RustServers.Add(server); + await context.SaveChangesAsync(); + + context.ProvisionedChannels.Add(new ProvisionedChannel + { + GuildId = 10UL, + RustServerId = server.Id, + ChannelKey = WorkspaceChannelKeys.ServerInfo, + DiscordChannelId = 777UL, + CreatedAt = DateTimeOffset.UnixEpoch, + }); + await context.SaveChangesAsync(); + + return server.Id; + } + + [Fact] + public async Task GetChannelIdAsync_returns_provisioned_info_channel() + { + var (locator, provider, cs, _) = CreateLocator(); + await using var _p = provider; + var serverId = await SeedAsync(cs); + + var channelId = await locator.GetChannelIdAsync(10UL, serverId, CancellationToken.None); + + Assert.Equal(777UL, channelId); + } + + [Fact] + public async Task GetChannelIdAsync_returns_null_when_not_provisioned() + { + var (locator, provider, _, _) = CreateLocator(); + await using var _p = provider; + + Assert.Null(await locator.GetChannelIdAsync(10UL, Guid.NewGuid(), CancellationToken.None)); + } + + [Fact] + public async Task Cache_refreshes_after_ttl_expires() + { + var (locator, provider, cs, clock) = CreateLocator(); + await using var _p = provider; + + // Cold load with empty DB — cache built at UnixEpoch, no rows. + var firstResult = await locator.GetChannelIdAsync(20UL, Guid.NewGuid(), CancellationToken.None); + Assert.Null(firstResult); + + // Insert a server + channel into the DB after the first load. + await using var insertCtx = + new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options); + var server = new RustServer + { + GuildId = 20UL, Name = "T", Ip = "2.2.2.2", Port = 28015 + }; + insertCtx.RustServers.Add(server); + await insertCtx.SaveChangesAsync(); + insertCtx.ProvisionedChannels.Add(new ProvisionedChannel + { + GuildId = 20UL, + RustServerId = server.Id, + ChannelKey = WorkspaceChannelKeys.ServerInfo, + DiscordChannelId = 998UL, + CreatedAt = DateTimeOffset.UnixEpoch, + }); + await insertCtx.SaveChangesAsync(); + + // Clock still at UnixEpoch — within the 30 s TTL, cache must NOT be reloaded. + var withinTtlResult = await locator.GetChannelIdAsync(20UL, server.Id, CancellationToken.None); + Assert.Null(withinTtlResult); + + // Advance the clock past the 30 s TTL — next call must rebuild the cache. + clock.UtcNow.Returns(DateTimeOffset.UnixEpoch + TimeSpan.FromSeconds(31)); + + var afterTtlResult = await locator.GetChannelIdAsync(20UL, server.Id, CancellationToken.None); + Assert.Equal(998UL, afterTtlResult); + } +} From 5219f59405a7d77d3d5be99aac52612a518d76e1 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 8 Jul 2026 22:58:56 +0200 Subject: [PATCH 21/40] feat(map): credit-safe RustMaps generation driver (limits fail-closed, one-shot) Advances one (size,seed) map key per tick: GET -> Ready if ImageUrl present, else pre-check GetLimitsAsync (fail closed on error) before a single CreateMapAsync, then poll GetMapById until ready and download the image. CreateMapAsync is guaranteed to fire at most once per key. Poll responses that aren't a clean success are null-tolerant so a transient miss retries next tick instead of tripping the key to Failed. --- .../RustMaps/RustMapsGenerationDriver.cs | 145 +++++++++++++++++ .../RustMaps/RustMapsGenerationDriverTests.cs | 147 ++++++++++++++++++ 2 files changed, 292 insertions(+) create mode 100644 src/RustPlusBot.Features.Map/RustMaps/RustMapsGenerationDriver.cs create mode 100644 tests/RustPlusBot.Features.Map.Tests/RustMaps/RustMapsGenerationDriverTests.cs diff --git a/src/RustPlusBot.Features.Map/RustMaps/RustMapsGenerationDriver.cs b/src/RustPlusBot.Features.Map/RustMaps/RustMapsGenerationDriver.cs new file mode 100644 index 00000000..14bbf8c7 --- /dev/null +++ b/src/RustPlusBot.Features.Map/RustMaps/RustMapsGenerationDriver.cs @@ -0,0 +1,145 @@ +using Microsoft.Extensions.Logging; +using RustMapsApi.Results; +using RustMapsApi.V4; +using RustMapsApi.V4.Models; +using RustMapsApi.V4.Requests; + +namespace RustPlusBot.Features.Map.RustMaps; + +/// +/// Advances one RustMaps map key's generation one step: GET → (limits-gated) CreateMap → poll → download. +/// Credit-safe: CreateMap only on a genuine NotFound with confirmed budget, once per key, no retry. +/// +/// The RustMaps API client. +/// The shared generation state. +/// Creates the image-download client. +/// The logger. +public sealed partial class RustMapsGenerationDriver( + IRustMapsClient client, + IRustMapsMapCoordinator coordinator, + IHttpClientFactory httpClientFactory, + ILogger logger) +{ + /// Named HTTP client used to download the RustMaps render. + public const string HttpClientName = "RustMapsImages"; + + /// Advances the key one step based on its current state. + /// The map key. + /// A cancellation token. + /// A task that completes when the step is done. + public async Task AdvanceAsync(RustMapsMapKey key, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(key); + try + { + var snapshot = coordinator.Snapshot(key); + switch (snapshot.State) + { + case RustMapsGenerationState.Idle: + await StartAsync(key, cancellationToken).ConfigureAwait(false); + break; + case RustMapsGenerationState.Generating: + await PollAsync(key, snapshot.MapId, cancellationToken).ConfigureAwait(false); + break; + // Ready / Failed / LimitReached — terminal; nothing to do. + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } +#pragma warning disable CA1031 // Broad catch: any RustMaps failure marks the key Failed; the #info fallback still renders. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogAdvanceFailed(logger, ex, key.Size, key.Seed); + coordinator.SetFailed(key); + } + } + + private async Task StartAsync(RustMapsMapKey key, CancellationToken cancellationToken) + { + var get = await client.GetMapBySeedAndSizeAsync(key.Size, key.Seed, staging: false, cancellationToken) + .ConfigureAwait(false); + if (get.IsSuccess && get.Data?.ImageUrl is not null) + { + await SetReadyAsync(key, get.Data, cancellationToken).ConfigureAwait(false); + return; + } + + if (get.Error?.Kind == RustMapsErrorKind.Queued) + { + // Already generating (elsewhere/earlier) — poll without spending. + coordinator.TrySetGenerating(key, mapId: null); + return; + } + + // Genuine miss → pre-check limits (fail closed) before spending a credit. + var limits = await client.GetLimitsAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + if (!limits.IsSuccess || limits.Data is null) + { + LogLimitsUnavailable(logger, key.Size, key.Seed); + coordinator.SetFailed(key); + return; + } + + if (IsExhausted(limits.Data.Concurrent) || IsExhausted(limits.Data.Monthly)) + { + LogLimitReached(logger, key.Size, key.Seed); + coordinator.SetLimitReached(key); + return; + } + + var create = await client.CreateMapAsync( + new MapGenerationRequest + { + Size = key.Size, Seed = key.Seed, Staging = false + }, cancellationToken) + .ConfigureAwait(false); + if (!create.IsSuccess) + { + LogCreateFailed(logger, key.Size, key.Seed, create.StatusCode); + coordinator.SetFailed(key); + return; + } + + coordinator.TrySetGenerating(key, create.Data?.MapId); + } + + private async Task PollAsync(RustMapsMapKey key, string? mapId, CancellationToken cancellationToken) + { + var get = mapId is { } id + ? await client.GetMapByIdAsync(id, cancellationToken).ConfigureAwait(false) + : await client.GetMapBySeedAndSizeAsync(key.Size, key.Seed, staging: false, cancellationToken) + .ConfigureAwait(false); + if (get is { IsSuccess: true, Data.ImageUrl: not null }) + { + await SetReadyAsync(key, get.Data, cancellationToken).ConfigureAwait(false); + } + // else: still generating (or a transient poll miss) — leave Generating, poll again next tick. + } + + private async Task SetReadyAsync(RustMapsMapKey key, MapInfo info, CancellationToken cancellationToken) + { + var http = httpClientFactory.CreateClient(HttpClientName); + var bytes = await http.GetByteArrayAsync(new Uri(info.ImageUrl!), cancellationToken).ConfigureAwait(false); + coordinator.SetReady(key, new RustMapsReadyMap(bytes, info.Url)); + } + + private static bool IsExhausted(MapGenerationStat? stat) => stat is { } s && s.Current >= s.Allowed; + + [LoggerMessage(Level = LogLevel.Warning, Message = "RustMaps advance failed for size {Size} seed {Seed}.")] + private static partial void LogAdvanceFailed(ILogger logger, Exception exception, int size, int seed); + + [LoggerMessage(Level = LogLevel.Warning, + Message = "RustMaps limits unavailable for size {Size} seed {Seed}; skipping generation (fail closed).")] + private static partial void LogLimitsUnavailable(ILogger logger, int size, int seed); + + [LoggerMessage(Level = LogLevel.Information, + Message = "RustMaps credit limit reached; skipping generation for size {Size} seed {Seed}.")] + private static partial void LogLimitReached(ILogger logger, int size, int seed); + + [LoggerMessage(Level = LogLevel.Warning, + Message = "RustMaps CreateMap failed for size {Size} seed {Seed} (status {StatusCode}).")] + private static partial void LogCreateFailed(ILogger logger, int size, int seed, int statusCode); +} diff --git a/tests/RustPlusBot.Features.Map.Tests/RustMaps/RustMapsGenerationDriverTests.cs b/tests/RustPlusBot.Features.Map.Tests/RustMaps/RustMapsGenerationDriverTests.cs new file mode 100644 index 00000000..fd392cf1 --- /dev/null +++ b/tests/RustPlusBot.Features.Map.Tests/RustMaps/RustMapsGenerationDriverTests.cs @@ -0,0 +1,147 @@ +using System.Net; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using RustMapsApi.Results; +using RustMapsApi.V4; +using RustMapsApi.V4.Models; +using RustMapsApi.V4.Requests; +using RustPlusBot.Features.Map.RustMaps; + +namespace RustPlusBot.Features.Map.Tests.RustMaps; + +public sealed class RustMapsGenerationDriverTests +{ + private static readonly RustMapsMapKey Key = new(4000, 12345); + private static readonly Guid Server = Guid.NewGuid(); + + private static RustMapsError Error(RustMapsErrorKind kind) => new(kind, null, null, null); + + private static (RustMapsGenerationDriver Driver, IRustMapsClient Client, RustMapsMapCoordinator Coord) Build( + HttpStatusCode imageStatus = HttpStatusCode.OK) + { + var client = Substitute.For(); + var coord = new RustMapsMapCoordinator(); + var factory = new StubFactory(new StubHandler(imageStatus, [7, 7, 7])); + var driver = new RustMapsGenerationDriver(client, coord, factory, + NullLogger.Instance); + return (driver, client, coord); + } + + [Fact] + public async Task Existing_map_goes_straight_to_ready_without_generating() + { + var (driver, client, coord) = Build(); + coord.Register(Key, 1UL, Server); + client.GetMapBySeedAndSizeAsync(4000, 12345, false, Arg.Any()) + .Returns(Result.Success( + new MapInfo + { + ImageUrl = "https://img/x.png", Url = "https://rustmaps/x" + }, 200)); + + await driver.AdvanceAsync(Key, CancellationToken.None); + + Assert.Equal(RustMapsGenerationState.Ready, coord.Snapshot(Key).State); + Assert.Equal([7, 7, 7], coord.Snapshot(Key).Ready!.ImageBytes); + await client.DidNotReceiveWithAnyArgs().CreateMapAsync(default!, default); + } + + [Fact] + public async Task NotFound_with_budget_generates_exactly_once_across_ticks() + { + var (driver, client, coord) = Build(); + coord.Register(Key, 1UL, Server); + client.GetMapBySeedAndSizeAsync(4000, 12345, false, Arg.Any()) + .Returns(Result.Failure(Error(RustMapsErrorKind.NotFound), 404)); + client.GetLimitsAsync(Arg.Any(), Arg.Any()) + .Returns(Result.Success( + new MapGenLimits + { + Concurrent = new(0, 5), Monthly = new(3, 100) + }, 200)); + client.CreateMapAsync(Arg.Any(), Arg.Any()) + .Returns(Result.Success(new MapGenerationStatus + { + MapId = "map-1" + }, 200)); + + await driver.AdvanceAsync(Key, CancellationToken.None); // Idle → Generating + await driver.AdvanceAsync(Key, CancellationToken.None); // Generating → poll (still not ready) + + Assert.Equal(RustMapsGenerationState.Generating, coord.Snapshot(Key).State); + await client.Received(1).CreateMapAsync( + Arg.Is(r => r.Size == 4000 && r.Seed == 12345 && !r.Staging), + Arg.Any()); + } + + [Fact] + public async Task Limit_reached_never_generates() + { + var (driver, client, coord) = Build(); + coord.Register(Key, 1UL, Server); + client.GetMapBySeedAndSizeAsync(4000, 12345, false, Arg.Any()) + .Returns(Result.Failure(Error(RustMapsErrorKind.NotFound), 404)); + client.GetLimitsAsync(Arg.Any(), Arg.Any()) + .Returns(Result.Success( + new MapGenLimits + { + Concurrent = new(5, 5), Monthly = new(3, 100) + }, 200)); // concurrent exhausted + + await driver.AdvanceAsync(Key, CancellationToken.None); + + Assert.Equal(RustMapsGenerationState.LimitReached, coord.Snapshot(Key).State); + await client.DidNotReceiveWithAnyArgs().CreateMapAsync(default!, default); + } + + [Fact] + public async Task Limits_call_failing_fails_closed_no_generate() + { + var (driver, client, coord) = Build(); + coord.Register(Key, 1UL, Server); + client.GetMapBySeedAndSizeAsync(4000, 12345, false, Arg.Any()) + .Returns(Result.Failure(Error(RustMapsErrorKind.NotFound), 404)); + client.GetLimitsAsync(Arg.Any(), Arg.Any()) + .Returns(Result.Failure(Error(RustMapsErrorKind.Transport), 503)); + + await driver.AdvanceAsync(Key, CancellationToken.None); + + Assert.Equal(RustMapsGenerationState.Failed, coord.Snapshot(Key).State); + await client.DidNotReceiveWithAnyArgs().CreateMapAsync(default!, default); + } + + [Fact] + public async Task Poll_reaches_ready_and_downloads_the_image() + { + var (driver, client, coord) = Build(); + coord.Register(Key, 1UL, Server); + coord.TrySetGenerating(Key, "map-1"); + client.GetMapByIdAsync("map-1", Arg.Any()) + .Returns(Result.Success( + new MapInfo + { + ImageUrl = "https://img/x.png", Url = "https://rustmaps/x" + }, 200)); + + await driver.AdvanceAsync(Key, CancellationToken.None); + + Assert.Equal(RustMapsGenerationState.Ready, coord.Snapshot(Key).State); + Assert.Equal([7, 7, 7], coord.Snapshot(Key).Ready!.ImageBytes); + } + + private sealed class StubHandler(HttpStatusCode status, byte[] body) : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) => + Task.FromResult(new HttpResponseMessage(status) + { + Content = new ByteArrayContent(body) + }); + } + + private sealed class StubFactory(HttpMessageHandler handler) : IHttpClientFactory + { + public HttpClient CreateClient(string name) => new(handler, disposeHandler: false); + } +} From a5e558eaa5c03b1bf437ab2153c85b5d10d20345 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Thu, 9 Jul 2026 17:54:51 +0200 Subject: [PATCH 22/40] test(map): explicit still-generating poll test + shared IsReady helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Poll_still_generating_stays_generating_without_spending, which stubs a real Queued/409 GetMapByIdAsync failure (not NSubstitute's default null) and asserts the key stays Generating with no CreateMapAsync call — pinning the PollAsync null-tolerance fix. Extract IsReady(Result?) and use it at both the StartAsync and PollAsync "is the GET ready" checks, replacing two divergent idioms with one null-tolerant predicate. No behavior change. --- .../RustMaps/RustMapsGenerationDriver.cs | 10 ++++++---- .../RustMaps/RustMapsGenerationDriverTests.cs | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/RustPlusBot.Features.Map/RustMaps/RustMapsGenerationDriver.cs b/src/RustPlusBot.Features.Map/RustMaps/RustMapsGenerationDriver.cs index 14bbf8c7..2cbd1078 100644 --- a/src/RustPlusBot.Features.Map/RustMaps/RustMapsGenerationDriver.cs +++ b/src/RustPlusBot.Features.Map/RustMaps/RustMapsGenerationDriver.cs @@ -61,9 +61,9 @@ private async Task StartAsync(RustMapsMapKey key, CancellationToken cancellation { var get = await client.GetMapBySeedAndSizeAsync(key.Size, key.Seed, staging: false, cancellationToken) .ConfigureAwait(false); - if (get.IsSuccess && get.Data?.ImageUrl is not null) + if (IsReady(get)) { - await SetReadyAsync(key, get.Data, cancellationToken).ConfigureAwait(false); + await SetReadyAsync(key, get.Data!, cancellationToken).ConfigureAwait(false); return; } @@ -112,9 +112,9 @@ private async Task PollAsync(RustMapsMapKey key, string? mapId, CancellationToke ? await client.GetMapByIdAsync(id, cancellationToken).ConfigureAwait(false) : await client.GetMapBySeedAndSizeAsync(key.Size, key.Seed, staging: false, cancellationToken) .ConfigureAwait(false); - if (get is { IsSuccess: true, Data.ImageUrl: not null }) + if (IsReady(get)) { - await SetReadyAsync(key, get.Data, cancellationToken).ConfigureAwait(false); + await SetReadyAsync(key, get.Data!, cancellationToken).ConfigureAwait(false); } // else: still generating (or a transient poll miss) — leave Generating, poll again next tick. } @@ -128,6 +128,8 @@ private async Task SetReadyAsync(RustMapsMapKey key, MapInfo info, CancellationT private static bool IsExhausted(MapGenerationStat? stat) => stat is { } s && s.Current >= s.Allowed; + private static bool IsReady(Result? get) => get is { IsSuccess: true, Data.ImageUrl: not null }; + [LoggerMessage(Level = LogLevel.Warning, Message = "RustMaps advance failed for size {Size} seed {Seed}.")] private static partial void LogAdvanceFailed(ILogger logger, Exception exception, int size, int seed); diff --git a/tests/RustPlusBot.Features.Map.Tests/RustMaps/RustMapsGenerationDriverTests.cs b/tests/RustPlusBot.Features.Map.Tests/RustMaps/RustMapsGenerationDriverTests.cs index fd392cf1..f55e6219 100644 --- a/tests/RustPlusBot.Features.Map.Tests/RustMaps/RustMapsGenerationDriverTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/RustMaps/RustMapsGenerationDriverTests.cs @@ -129,6 +129,21 @@ public async Task Poll_reaches_ready_and_downloads_the_image() Assert.Equal([7, 7, 7], coord.Snapshot(Key).Ready!.ImageBytes); } + [Fact] + public async Task Poll_still_generating_stays_generating_without_spending() + { + var (driver, client, coord) = Build(); + coord.Register(Key, 1UL, Server); + coord.TrySetGenerating(Key, "map-1"); + client.GetMapByIdAsync("map-1", Arg.Any()) + .Returns(Result.Failure(Error(RustMapsErrorKind.Queued), 409)); + + await driver.AdvanceAsync(Key, CancellationToken.None); + + Assert.Equal(RustMapsGenerationState.Generating, coord.Snapshot(Key).State); + await client.DidNotReceiveWithAnyArgs().CreateMapAsync(default!, default); + } + private sealed class StubHandler(HttpStatusCode status, byte[] body) : HttpMessageHandler { protected override Task SendAsync( From ad1851130515f364d53b7a25b6b3428d05ca6eed Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Thu, 9 Jul 2026 17:59:36 +0200 Subject: [PATCH 23/40] feat(map): RustMaps generation poll-interval option + config --- src/RustPlusBot.Features.Map/MapOptions.cs | 10 ++++++++++ src/RustPlusBot.Host/Program.cs | 2 ++ src/RustPlusBot.Host/appsettings.json | 3 ++- .../MapOptionsTests.cs | 13 +++++++++++++ 4 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 tests/RustPlusBot.Features.Map.Tests/MapOptionsTests.cs diff --git a/src/RustPlusBot.Features.Map/MapOptions.cs b/src/RustPlusBot.Features.Map/MapOptions.cs index 684195c9..816cc776 100644 --- a/src/RustPlusBot.Features.Map/MapOptions.cs +++ b/src/RustPlusBot.Features.Map/MapOptions.cs @@ -5,4 +5,14 @@ public sealed class MapOptions { /// Minimum time between #map image re-renders per server (coalesces rapid marker changes). Default 30s. public TimeSpan MapRefreshInterval { get; set; } = TimeSpan.FromSeconds(30); + + /// RustMaps integration settings. + public RustMapsOptions RustMaps { get; set; } = new(); +} + +/// RustMaps settings. The integration is inactive when no API key is configured. +public sealed class RustMapsOptions +{ + /// How often the background service checks generation progress. Default 20s. + public TimeSpan GenerationPollInterval { get; set; } = TimeSpan.FromSeconds(20); } diff --git a/src/RustPlusBot.Host/Program.cs b/src/RustPlusBot.Host/Program.cs index 05eabfb6..5195edf9 100644 --- a/src/RustPlusBot.Host/Program.cs +++ b/src/RustPlusBot.Host/Program.cs @@ -80,6 +80,8 @@ builder.Services.AddOptions() .Bind(builder.Configuration.GetSection("Map")) .Validate(static o => o.MapRefreshInterval > TimeSpan.Zero, "Map:MapRefreshInterval must be positive.") + .Validate(static o => o.RustMaps.GenerationPollInterval > TimeSpan.Zero, + "Map:RustMaps:GenerationPollInterval must be positive.") .ValidateOnStart(); builder.Services.AddMap(builder.Configuration); builder.Services.AddSwitches(); diff --git a/src/RustPlusBot.Host/appsettings.json b/src/RustPlusBot.Host/appsettings.json index 4f1938b0..38472106 100644 --- a/src/RustPlusBot.Host/appsettings.json +++ b/src/RustPlusBot.Host/appsettings.json @@ -56,7 +56,8 @@ "Map": { "MapRefreshInterval": "00:00:30", "RustMaps": { - "ApiKey": "" + "ApiKey": "", + "GenerationPollInterval": "00:00:20" } } } diff --git a/tests/RustPlusBot.Features.Map.Tests/MapOptionsTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapOptionsTests.cs new file mode 100644 index 00000000..62d91b47 --- /dev/null +++ b/tests/RustPlusBot.Features.Map.Tests/MapOptionsTests.cs @@ -0,0 +1,13 @@ +namespace RustPlusBot.Features.Map.Tests; + +public sealed class MapOptionsTests +{ + [Fact] + public void Defaults_are_sane() + { + var options = new MapOptions(); + Assert.Equal(TimeSpan.FromSeconds(30), options.MapRefreshInterval); + Assert.NotNull(options.RustMaps); + Assert.Equal(TimeSpan.FromSeconds(20), options.RustMaps.GenerationPollInterval); + } +} From 5868b8d56908346237c7c0aeb90f8d6dea5e7a3e Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Thu, 9 Jul 2026 18:18:36 +0200 Subject: [PATCH 24/40] feat(map): #info static RustMaps map + credit-safe auto-generate service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InfoMapHostedService owns the #info surface: tracks connected servers via ConnectionStatusChangedEvent, drives RustMapsGenerationDriver.AdvanceAsync strictly sequentially (one key at a time, single tick loop — never concurrent, since the driver's check-then-spend path is not atomic across awaits), and posts via IInfoMapPoster (RustMaps bytes when Ready, else the grid+monuments MapComposer.ComposeStaticAsync fallback). Posts only on state change (None→Fallback→Ready) to avoid per-tick repost churn. Registers the RustMaps coordinator/driver/poster/hosted-service in AddMap when Map:RustMaps:ApiKey is configured. Adds the map.info.* EN/FR strings and swept a pre-existing jb formatting drift in DiscordInfoMapPoster. --- .../Hosting/InfoMapHostedService.cs | 270 ++++++++++++++++++ .../MapServiceCollectionExtensions.cs | 14 +- .../Posting/DiscordInfoMapPoster.cs | 5 +- src/RustPlusBot.Localization/Strings.fr.resx | 12 + src/RustPlusBot.Localization/Strings.resx | 12 + .../Hosting/InfoMapServiceTests.cs | 148 ++++++++++ .../MapRegistrationTests.cs | 33 +++ .../StringsResourceParityTests.cs | 2 +- 8 files changed, 492 insertions(+), 4 deletions(-) create mode 100644 src/RustPlusBot.Features.Map/Hosting/InfoMapHostedService.cs create mode 100644 tests/RustPlusBot.Features.Map.Tests/Hosting/InfoMapServiceTests.cs diff --git a/src/RustPlusBot.Features.Map/Hosting/InfoMapHostedService.cs b/src/RustPlusBot.Features.Map/Hosting/InfoMapHostedService.cs new file mode 100644 index 00000000..72186243 --- /dev/null +++ b/src/RustPlusBot.Features.Map/Hosting/InfoMapHostedService.cs @@ -0,0 +1,270 @@ +using System.Collections.Concurrent; +using Discord; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Domain.Connections; +using RustPlusBot.Features.Map.Composing; +using RustPlusBot.Features.Map.Posting; +using RustPlusBot.Features.Map.RustMaps; +using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Localization; +using RustPlusBot.Persistence.Connections; +using RustPlusBot.Persistence.Workspace; + +namespace RustPlusBot.Features.Map.Hosting; + +/// Posts the static RustMaps map to #info: a bot-rendered grid+monuments fallback until the +/// RustMaps render is generated, then the RustMaps image. Credit-safe generation via the driver. +/// The in-process event bus. +/// The shared RustMaps generation state. +/// Advances one map key's generation one step per tick. +/// Renders the grid+monuments fallback image. +/// Posts the #info map image. +/// Resolves the #info Discord channel for a server. +/// Live query seam (world size/seed). +/// Localizes the #info embed strings. +/// Supplies the generation-poll interval. +/// Opens scopes to read connection state and guild culture. +/// The logger. +internal sealed partial class InfoMapHostedService( + IEventBus eventBus, + IRustMapsMapCoordinator coordinator, + RustMapsGenerationDriver driver, + MapComposer composer, + IInfoMapPoster poster, + IInfoChannelLocator locator, + IRustServerQuery query, + ILocalizer localizer, + IOptions options, + IServiceScopeFactory scopeFactory, + ILogger logger) : IHostedService, IDisposable +{ + // Not a real URI: Discord's attachment:// pseudo-scheme references the file uploaded alongside the embed. +#pragma warning disable S1075 + private const string AttachmentImageUrl = "attachment://map.png"; +#pragma warning restore S1075 + private readonly ConcurrentDictionary<(ulong Guild, Guid Server), byte> _connected = new(); + private readonly CancellationTokenSource _cts = new(); + + private readonly ConcurrentDictionary<(ulong Guild, Guid Server), Posted> _posted = new(); + private Task? _statusLoop; + private Task? _tickLoop; + + /// + public void Dispose() => _cts.Dispose(); + + /// + public Task StartAsync(CancellationToken cancellationToken) + { + _statusLoop = Task.Run(() => ConsumeConnectionStatusAsync(_cts.Token), CancellationToken.None); + _tickLoop = Task.Run(() => RunTickAsync(_cts.Token), CancellationToken.None); + return Task.CompletedTask; + } + + /// + public async Task StopAsync(CancellationToken cancellationToken) + { + await _cts.CancelAsync().ConfigureAwait(false); + foreach (var loop in new[] + { + _statusLoop, _tickLoop + }.Where(t => t is not null)) + { + try + { +#pragma warning disable VSTHRD003 // Our own loop tasks, joined on stop. + await loop!.ConfigureAwait(false); +#pragma warning restore VSTHRD003 + } + catch (OperationCanceledException) + { + // Expected on shutdown. + } + } + } + + /// Ensures #info shows the correct map image for the server's current generation state. + /// The guild snowflake. + /// The server id. + /// A cancellation token. + /// A task that completes when the check (and any post) is done. + public async Task EnsureInfoMapAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) + { + var world = await query.GetWorldAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + if (world is null) + { + return; + } + + var key = new RustMapsMapKey((int)world.WorldSize, (int)world.Seed); + coordinator.Register(key, guildId, serverId); + + var channelId = await locator.GetChannelIdAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + if (channelId is not { } id) + { + return; + } + + var snapshot = coordinator.Snapshot(key); + var mapKey = (guildId, serverId); + + if (snapshot is { State: RustMapsGenerationState.Ready, Ready: { } ready }) + { + if (_posted.TryGetValue(mapKey, out var v) && v == Posted.Ready) + { + return; + } + + var culture = await GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false); + await poster.PostAsync(id, BuildEmbed(key, ready.RustMapsUrl, culture), ready.ImageBytes, cancellationToken) + .ConfigureAwait(false); + _posted[mapKey] = Posted.Ready; + return; + } + + if (_posted.TryGetValue(mapKey, out var posted) && posted != Posted.None) + { + return; // fallback already up; nothing better to show yet. + } + + var png = await composer.ComposeStaticAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + if (png is null) + { + return; // base map not ready yet; retry next tick. + } + + var fallbackCulture = await GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false); + await poster.PostAsync(id, BuildEmbed(key, rustMapsUrl: null, fallbackCulture), png, cancellationToken) + .ConfigureAwait(false); + _posted[mapKey] = Posted.Fallback; + } + + private Embed BuildEmbed(RustMapsMapKey key, string? rustMapsUrl, string culture) + { + var builder = new EmbedBuilder() + .WithTitle(localizer.Get("map.info.title", culture)) + .WithColor(Color.DarkGreen) + .AddField(localizer.Get("map.info.size", culture), + key.Size.ToString(System.Globalization.CultureInfo.InvariantCulture), inline: true) + .AddField(localizer.Get("map.info.seed", culture), + key.Seed.ToString(System.Globalization.CultureInfo.InvariantCulture), inline: true) + .WithImageUrl(AttachmentImageUrl); + if (rustMapsUrl is not null) + { + builder.WithUrl(rustMapsUrl); + } + else + { + builder.WithFooter(localizer.Get("map.info.generating", culture)); + } + + return builder.Build(); + } + + private async Task GetCultureAsync(ulong guildId, CancellationToken cancellationToken) + { + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var store = scope.ServiceProvider.GetRequiredService(); + return await store.GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false); + } + } + + /// + /// Advances every pending RustMaps generation key strictly one-at-a-time (sequential, never + /// concurrent) — the driver's check-then-spend path is not atomic across awaits, so concurrent + /// calls for the same key could double-spend real RustMaps credits. Then repaints every connected + /// server's #info. + /// + /// A cancellation token. + private async Task RunTickAsync(CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + await Task.Delay(options.Value.RustMaps.GenerationPollInterval, cancellationToken) + .ConfigureAwait(false); + foreach (var key in coordinator.PendingKeys()) + { + await driver.AdvanceAsync(key, cancellationToken).ConfigureAwait(false); + } + + foreach (var (guild, server) in _connected.Keys) + { + await EnsureInfoMapAsync(guild, server, cancellationToken).ConfigureAwait(false); + } + } + } + catch (OperationCanceledException) + { + // Shutting down. + } +#pragma warning disable CA1031 // Broad catch: a faulting tick must not crash the host. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogTickFaulted(logger, ex); + } + } + + private async Task ConsumeConnectionStatusAsync(CancellationToken cancellationToken) + { + try + { + await foreach (var evt in eventBus.SubscribeAsync(cancellationToken) + .ConfigureAwait(false)) + { + await OnConnectionStatusAsync(evt, 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 + { + LogStatusFaulted(logger, ex); + } + } + + private async Task OnConnectionStatusAsync(ConnectionStatusChangedEvent evt, CancellationToken cancellationToken) + { + var key = (evt.GuildId, evt.ServerId); + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var store = scope.ServiceProvider.GetRequiredService(); + var state = await store.GetStateAsync(evt.GuildId, evt.ServerId, cancellationToken).ConfigureAwait(false); + if (state is null || state.Status != ConnectionStatus.Connected) + { + _connected.TryRemove(key, out _); + return; + } + + _connected[key] = 0; + } + + await EnsureInfoMapAsync(evt.GuildId, evt.ServerId, cancellationToken).ConfigureAwait(false); + } + + [LoggerMessage(Level = LogLevel.Error, Message = "Info-map tick loop faulted.")] + private static partial void LogTickFaulted(ILogger logger, Exception exception); + + [LoggerMessage(Level = LogLevel.Error, Message = "Info-map connection-status loop faulted.")] + private static partial void LogStatusFaulted(ILogger logger, Exception exception); + + private enum Posted + { + None = 0, + Fallback = 1, + Ready = 2, + } +} diff --git a/src/RustPlusBot.Features.Map/MapServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Map/MapServiceCollectionExtensions.cs index 5e53e143..51948b4f 100644 --- a/src/RustPlusBot.Features.Map/MapServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Map/MapServiceCollectionExtensions.cs @@ -4,13 +4,18 @@ using RustPlusBot.Features.Map.Hosting; using RustPlusBot.Features.Map.Posting; using RustPlusBot.Features.Map.Rendering; +using RustPlusBot.Features.Map.RustMaps; namespace RustPlusBot.Features.Map; /// DI registration for the map-render feature. public static class MapServiceCollectionExtensions { - /// Registers the renderer, base-map source chain, composer, poster, pipeline bundle, and hosted service. + /// + /// Registers the renderer, base-map source chain, composer, poster, pipeline bundle, and hosted service. + /// When a RustMaps API key is configured, also registers the credit-safe generation coordinator/driver + /// and the #info poster + hosted service that auto-generates and posts the static RustMaps render. + /// /// The service collection to add to. /// The host configuration (reads Map:RustMaps:ApiKey). /// The same service collection, for chaining. @@ -22,11 +27,16 @@ public static IServiceCollection AddMap(this IServiceCollection services, IConfi services.AddSingleton(); // RustMaps is NOT a base-map source (the #map render draws its own layers on the Rust+ tile). - // The client is kept for the #info static-map surface (Task 7 wires the coordinator/service/poster). + // The client drives the #info static-map surface: credit-safe generation + auto-post. var rustMapsKey = configuration["Map:RustMaps:ApiKey"]; if (!string.IsNullOrWhiteSpace(rustMapsKey)) { services.AddRustMapsClientV4(o => o.ApiKey = rustMapsKey); + services.AddHttpClient(RustMapsGenerationDriver.HttpClientName); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddHostedService(); } services.AddSingleton(); diff --git a/src/RustPlusBot.Features.Map/Posting/DiscordInfoMapPoster.cs b/src/RustPlusBot.Features.Map/Posting/DiscordInfoMapPoster.cs index 5802c44a..3ae99ce5 100644 --- a/src/RustPlusBot.Features.Map/Posting/DiscordInfoMapPoster.cs +++ b/src/RustPlusBot.Features.Map/Posting/DiscordInfoMapPoster.cs @@ -20,7 +20,10 @@ public async Task PostAsync(ulong channelId, Embed embed, byte[] pngBytes, Cance ArgumentNullException.ThrowIfNull(pngBytes); try { - var options = new RequestOptions { CancelToken = cancellationToken }; + var options = new RequestOptions + { + CancelToken = cancellationToken + }; if (await client.GetChannelAsync(channelId, options).ConfigureAwait(false) is not ITextChannel channel) { return; diff --git a/src/RustPlusBot.Localization/Strings.fr.resx b/src/RustPlusBot.Localization/Strings.fr.resx index 9a19fc77..d92e052d 100644 --- a/src/RustPlusBot.Localization/Strings.fr.resx +++ b/src/RustPlusBot.Localization/Strings.fr.resx @@ -540,6 +540,18 @@ Calques de la carte — activer/désactiver : + + Rendu RustMaps haute qualité en cours de génération… + + + Seed + + + Taille + + + Carte du serveur + Grille diff --git a/src/RustPlusBot.Localization/Strings.resx b/src/RustPlusBot.Localization/Strings.resx index a59d1ccb..6db1a714 100644 --- a/src/RustPlusBot.Localization/Strings.resx +++ b/src/RustPlusBot.Localization/Strings.resx @@ -540,6 +540,18 @@ Map layers — toggle to show/hide: + + Higher-quality RustMaps render generating… + + + Seed + + + Size + + + Server Map + Grid diff --git a/tests/RustPlusBot.Features.Map.Tests/Hosting/InfoMapServiceTests.cs b/tests/RustPlusBot.Features.Map.Tests/Hosting/InfoMapServiceTests.cs new file mode 100644 index 00000000..0e2b1273 --- /dev/null +++ b/tests/RustPlusBot.Features.Map.Tests/Hosting/InfoMapServiceTests.cs @@ -0,0 +1,148 @@ +using Discord; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; +using RustMapsApi.V4; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.Events.State; +using RustPlusBot.Features.Map.Composing; +using RustPlusBot.Features.Map.Hosting; +using RustPlusBot.Features.Map.Posting; +using RustPlusBot.Features.Map.Rendering; +using RustPlusBot.Features.Map.RustMaps; +using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Localization; +using RustPlusBot.Persistence.Workspace; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; + +namespace RustPlusBot.Features.Map.Tests.Hosting; + +public sealed class InfoMapServiceTests +{ + private const ulong Guild = 1UL; + private static readonly Guid Server = Guid.NewGuid(); + private static readonly RustMapsMapKey Key = new(4000, 12345); + + private static byte[] BaseJpeg() + { + using var img = new Image(64, 64, new Rgba32(0, 128, 0)); + using var ms = new MemoryStream(); + img.SaveAsJpeg(ms); + return ms.ToArray(); + } + + private static IServiceScopeFactory ScopeFactory(IWorkspaceStore workspaceStore) => + new ServiceCollection() + .AddScoped(_ => workspaceStore) + .BuildServiceProvider() + .GetRequiredService(); + + private static (InfoMapHostedService Service, RustMapsMapCoordinator Coordinator, IInfoMapPoster Poster, + IRustServerQuery Query) Build(ulong? channelId = 123UL) + { + var coordinator = new RustMapsMapCoordinator(); + + var query = Substitute.For(); + query.GetWorldAsync(Guild, Server, Arg.Any()) + .Returns(new WorldSnapshot((uint)Key.Size, (uint)Key.Seed)); + query.GetMapDimensionsAsync(Guild, Server, Arg.Any()) + .Returns(new MapDimensions(2000, 2000, 100, 4000)); + query.GetMonumentsAsync(Guild, Server, Arg.Any()).Returns([]); + + var locator = Substitute.For(); + locator.GetChannelIdAsync(Guild, Server, Arg.Any()).Returns(channelId); + + var poster = Substitute.For(); + + var workspaceStore = Substitute.For(); + workspaceStore.GetCultureAsync(Guild, Arg.Any()).Returns("en"); + var scopeFactory = ScopeFactory(workspaceStore); + + var baseSource = new FakeBaseMapSource(new BaseMapImage(BaseJpeg(), 2000, 2000, 100)); + var composer = new MapComposer( + new BaseMapCache([baseSource]), + Substitute.For(), + Substitute.For(), + query, + new MapRenderer(), + scopeFactory); + + var driver = new RustMapsGenerationDriver( + Substitute.For(), + coordinator, + Substitute.For(), + NullLogger.Instance); + + var service = new InfoMapHostedService( + Substitute.For(), + coordinator, + driver, + composer, + poster, + locator, + query, + new ResxLocalizer(), + Options.Create(new MapOptions()), + scopeFactory, + NullLogger.Instance); + + return (service, coordinator, poster, query); + } + + [Fact] + public async Task Ready_posts_the_RustMaps_bytes_once_and_a_repeat_call_is_a_no_op() + { + var (service, coordinator, poster, _) = Build(); + byte[] readyBytes = [9, 9, 9]; + coordinator.SetReady(Key, new RustMapsReadyMap(readyBytes, "https://rustmaps/x")); + + await service.EnsureInfoMapAsync(Guild, Server, CancellationToken.None); + await service.EnsureInfoMapAsync(Guild, Server, CancellationToken.None); + + await poster.Received(1).PostAsync(123UL, Arg.Any(), + Arg.Is(b => b.SequenceEqual(readyBytes)), Arg.Any()); + } + + [Fact] + public async Task Not_ready_posts_the_fallback_bytes_once_and_a_repeat_call_is_a_no_op() + { + var (service, coordinator, poster, _) = Build(); + + await service.EnsureInfoMapAsync(Guild, Server, CancellationToken.None); + await service.EnsureInfoMapAsync(Guild, Server, CancellationToken.None); + + // Idle for the whole test — no generation was ever advanced. + Assert.Equal(RustMapsGenerationState.Idle, coordinator.Snapshot(Key).State); + await poster.Received(1).PostAsync(123UL, Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task No_info_channel_never_posts() + { + var (service, _, poster, _) = Build(channelId: null); + + await service.EnsureInfoMapAsync(Guild, Server, CancellationToken.None); + + await poster.DidNotReceiveWithAnyArgs().PostAsync(default, default!, default!, default); + } + + [Fact] + public async Task World_unavailable_never_posts() + { + var (service, _, poster, query) = Build(); + query.GetWorldAsync(Guild, Server, Arg.Any()).Returns((WorldSnapshot?)null); + + await service.EnsureInfoMapAsync(Guild, Server, CancellationToken.None); + + await poster.DidNotReceiveWithAnyArgs().PostAsync(default, default!, default!, default); + } + + private sealed class FakeBaseMapSource(BaseMapImage image) : IBaseMapSource + { + public Task GetAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) => + Task.FromResult(image); + } +} diff --git a/tests/RustPlusBot.Features.Map.Tests/MapRegistrationTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapRegistrationTests.cs index 0d09dabb..26128a79 100644 --- a/tests/RustPlusBot.Features.Map.Tests/MapRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/MapRegistrationTests.cs @@ -4,7 +4,10 @@ using RustPlusBot.Abstractions.Connections; using RustPlusBot.Features.Events.State; using RustPlusBot.Features.Map.Composing; +using RustPlusBot.Features.Map.Hosting; +using RustPlusBot.Features.Map.Posting; using RustPlusBot.Features.Map.Rendering; +using RustPlusBot.Features.Map.RustMaps; using RustPlusBot.Persistence.Map; namespace RustPlusBot.Features.Map.Tests; @@ -53,6 +56,36 @@ public void AddMap_with_RustMaps_key_still_uses_only_the_RustPlus_base_source() Assert.IsType(source); } + [Fact] + public void AddMap_with_RustMaps_key_registers_the_generation_components() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); + services.AddScoped(_ => Substitute.For()); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection([new KeyValuePair("Map:RustMaps:ApiKey", "test-key")]) + .Build(); + services.AddMap(configuration); + + using var provider = services.BuildServiceProvider(validateScopes: true); + Assert.NotNull(provider.GetService()); + Assert.NotNull(provider.GetService()); + // Discord-dependent — assert registration without constructing DiscordSocketClient. + Assert.Contains(services, d => d.ServiceType == typeof(IInfoMapPoster)); + Assert.Contains(services, d => d.ImplementationType == typeof(InfoMapHostedService)); + } + + [Fact] + public void AddMap_without_RustMaps_key_registers_no_generation_components() + { + using var provider = BuildProvider(EmptyConfiguration()); + Assert.Null(provider.GetService()); + Assert.Null(provider.GetService()); + } + private static IConfiguration EmptyConfiguration() => new ConfigurationBuilder().Build(); diff --git a/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs b/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs index ec4396e7..ab6c1e1a 100644 --- a/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs +++ b/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs @@ -41,6 +41,6 @@ public void English_covers_every_french_key() [Fact] public void Catalog_has_expected_key_count() { - Assert.Equal(261, EnglishKeys().Count); + Assert.Equal(265, EnglishKeys().Count); } } From 8856b62974b3bf2ad6817b3c47f92e4edc882bcd Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Thu, 9 Jul 2026 18:45:08 +0200 Subject: [PATCH 25/40] fix(map): only spend a RustMaps credit on genuine NotFound + registered #info channel StartAsync fell through to GetLimits/CreateMap on any non-Queued GET result (Transport, RateLimited, Forbidden, Validation, Unknown), wrongly spending a credit on transient GET failures instead of only a genuine NotFound. Gate added right after the Queued check; non-NotFound now bails, leaving the key Idle so the free GET retries next tick. EnsureInfoMapAsync registered the (size,seed) key with the coordinator before confirming the #info channel exists, letting a connected server with no provisioned #info channel enroll a pending key the tick loop could still spend a credit generating. Channel resolution now happens before Register. --- .../Hosting/InfoMapHostedService.cs | 6 +++--- .../RustMaps/RustMapsGenerationDriver.cs | 7 +++++++ .../Hosting/InfoMapServiceTests.cs | 12 ++++++++++++ .../RustMaps/RustMapsGenerationDriverTests.cs | 15 +++++++++++++++ 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/RustPlusBot.Features.Map/Hosting/InfoMapHostedService.cs b/src/RustPlusBot.Features.Map/Hosting/InfoMapHostedService.cs index 72186243..b427e3a7 100644 --- a/src/RustPlusBot.Features.Map/Hosting/InfoMapHostedService.cs +++ b/src/RustPlusBot.Features.Map/Hosting/InfoMapHostedService.cs @@ -100,15 +100,15 @@ public async Task EnsureInfoMapAsync(ulong guildId, Guid serverId, CancellationT return; } - var key = new RustMapsMapKey((int)world.WorldSize, (int)world.Seed); - coordinator.Register(key, guildId, serverId); - var channelId = await locator.GetChannelIdAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); if (channelId is not { } id) { return; } + var key = new RustMapsMapKey((int)world.WorldSize, (int)world.Seed); + coordinator.Register(key, guildId, serverId); + var snapshot = coordinator.Snapshot(key); var mapKey = (guildId, serverId); diff --git a/src/RustPlusBot.Features.Map/RustMaps/RustMapsGenerationDriver.cs b/src/RustPlusBot.Features.Map/RustMaps/RustMapsGenerationDriver.cs index 2cbd1078..87580c24 100644 --- a/src/RustPlusBot.Features.Map/RustMaps/RustMapsGenerationDriver.cs +++ b/src/RustPlusBot.Features.Map/RustMaps/RustMapsGenerationDriver.cs @@ -74,6 +74,13 @@ private async Task StartAsync(RustMapsMapKey key, CancellationToken cancellation return; } + if (get.Error?.Kind != RustMapsErrorKind.NotFound) + { + // Transient/other GET error (or an unexpected non-ready success): do NOT spend a credit. + // Leave the key Idle so the free GET simply retries on the next tick. + return; + } + // Genuine miss → pre-check limits (fail closed) before spending a credit. var limits = await client.GetLimitsAsync(cancellationToken: cancellationToken).ConfigureAwait(false); if (!limits.IsSuccess || limits.Data is null) diff --git a/tests/RustPlusBot.Features.Map.Tests/Hosting/InfoMapServiceTests.cs b/tests/RustPlusBot.Features.Map.Tests/Hosting/InfoMapServiceTests.cs index 0e2b1273..78c1bec2 100644 --- a/tests/RustPlusBot.Features.Map.Tests/Hosting/InfoMapServiceTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/Hosting/InfoMapServiceTests.cs @@ -129,6 +129,18 @@ public async Task No_info_channel_never_posts() await poster.DidNotReceiveWithAnyArgs().PostAsync(default, default!, default!, default); } + [Fact] + public async Task No_info_channel_never_registers_the_key_with_the_coordinator() + { + var (service, coordinator, poster, _) = Build(channelId: null); + + await service.EnsureInfoMapAsync(Guild, Server, CancellationToken.None); + + Assert.Empty(coordinator.PendingKeys()); + Assert.Empty(coordinator.Requesters(Key)); + await poster.DidNotReceiveWithAnyArgs().PostAsync(default, default!, default!, default); + } + [Fact] public async Task World_unavailable_never_posts() { diff --git a/tests/RustPlusBot.Features.Map.Tests/RustMaps/RustMapsGenerationDriverTests.cs b/tests/RustPlusBot.Features.Map.Tests/RustMaps/RustMapsGenerationDriverTests.cs index f55e6219..034679bd 100644 --- a/tests/RustPlusBot.Features.Map.Tests/RustMaps/RustMapsGenerationDriverTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/RustMaps/RustMapsGenerationDriverTests.cs @@ -74,6 +74,21 @@ await client.Received(1).CreateMapAsync( Arg.Any()); } + [Fact] + public async Task Transient_get_error_that_is_not_NotFound_never_spends_a_credit() + { + var (driver, client, coord) = Build(); + coord.Register(Key, 1UL, Server); + client.GetMapBySeedAndSizeAsync(4000, 12345, false, Arg.Any()) + .Returns(Result.Failure(Error(RustMapsErrorKind.Transport), 503)); + + await driver.AdvanceAsync(Key, CancellationToken.None); + + Assert.Equal(RustMapsGenerationState.Idle, coord.Snapshot(Key).State); + await client.DidNotReceiveWithAnyArgs().GetLimitsAsync(default, default); + await client.DidNotReceiveWithAnyArgs().CreateMapAsync(default!, default); + } + [Fact] public async Task Limit_reached_never_generates() { From 23bba489e63e05c08e7716bdf4c4d4d7164c72de Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Thu, 9 Jul 2026 19:09:09 +0200 Subject: [PATCH 26/40] fix(map): serialize #info map posting per server (no duplicate messages) The connection-status loop and the periodic tick loop both call EnsureInfoMapAsync for the same server. On connect one composes+posts the fallback while the other detects the RustMaps render is ready and posts it; both delete-prior-image scans run before either message commits, so neither deletes the other and two #info map messages survive. Serialize EnsureInfoMapAsync with a per-server SemaphoreSlim so the fallback fully posts before the RustMaps post runs its delete. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Hosting/InfoMapHostedService.cs | 24 +++++++++++ .../Hosting/InfoMapServiceTests.cs | 42 ++++++++++++++++++- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/RustPlusBot.Features.Map/Hosting/InfoMapHostedService.cs b/src/RustPlusBot.Features.Map/Hosting/InfoMapHostedService.cs index b427e3a7..39a53ad8 100644 --- a/src/RustPlusBot.Features.Map/Hosting/InfoMapHostedService.cs +++ b/src/RustPlusBot.Features.Map/Hosting/InfoMapHostedService.cs @@ -50,6 +50,12 @@ internal sealed partial class InfoMapHostedService( private readonly ConcurrentDictionary<(ulong Guild, Guid Server), byte> _connected = new(); private readonly CancellationTokenSource _cts = new(); + /// + /// Serializes the two callers (connection-status loop + tick loop) per server so their + /// delete-then-repost cannot interleave and leave two #info map messages. + /// + private readonly ConcurrentDictionary<(ulong Guild, Guid Server), SemaphoreSlim> _gates = new(); + private readonly ConcurrentDictionary<(ulong Guild, Guid Server), Posted> _posted = new(); private Task? _statusLoop; private Task? _tickLoop; @@ -93,6 +99,24 @@ public async Task StopAsync(CancellationToken cancellationToken) /// A cancellation token. /// A task that completes when the check (and any post) is done. public async Task EnsureInfoMapAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) + { + // Both the connection-status loop and the periodic tick loop call this for the same server. + // Serialize per server: otherwise the fallback post and the RustMaps-ready post run their + // delete-prior-image scans concurrently, each misses the other's not-yet-committed message, + // and both survive — leaving two #info map messages. + var gate = _gates.GetOrAdd((guildId, serverId), static _ => new SemaphoreSlim(1, 1)); + await gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await EnsureInfoMapCoreAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + } + finally + { + gate.Release(); + } + } + + private async Task EnsureInfoMapCoreAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) { var world = await query.GetWorldAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); if (world is null) diff --git a/tests/RustPlusBot.Features.Map.Tests/Hosting/InfoMapServiceTests.cs b/tests/RustPlusBot.Features.Map.Tests/Hosting/InfoMapServiceTests.cs index 78c1bec2..c48302c6 100644 --- a/tests/RustPlusBot.Features.Map.Tests/Hosting/InfoMapServiceTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/Hosting/InfoMapServiceTests.cs @@ -41,7 +41,7 @@ private static IServiceScopeFactory ScopeFactory(IWorkspaceStore workspaceStore) .GetRequiredService(); private static (InfoMapHostedService Service, RustMapsMapCoordinator Coordinator, IInfoMapPoster Poster, - IRustServerQuery Query) Build(ulong? channelId = 123UL) + IRustServerQuery Query) Build(ulong? channelId = 123UL, IInfoMapPoster? poster = null) { var coordinator = new RustMapsMapCoordinator(); @@ -55,7 +55,7 @@ private static (InfoMapHostedService Service, RustMapsMapCoordinator Coordinator var locator = Substitute.For(); locator.GetChannelIdAsync(Guild, Server, Arg.Any()).Returns(channelId); - var poster = Substitute.For(); + poster ??= Substitute.For(); var workspaceStore = Substitute.For(); workspaceStore.GetCultureAsync(Guild, Arg.Any()).Returns("en"); @@ -152,6 +152,44 @@ public async Task World_unavailable_never_posts() await poster.DidNotReceiveWithAnyArgs().PostAsync(default, default!, default!, default); } + [Fact] + public async Task Concurrent_calls_for_the_same_server_are_serialized_and_post_once() + { + // The connection-status loop and the tick loop both call EnsureInfoMapAsync for the same + // server. Without per-server serialization their delete-then-repost interleaves and leaves + // TWO #info messages (the fallback and the RustMaps render each survive the other's delete + // scan). Serialized, the second caller sees the first's posted state and is a no-op. + var poster = new GatedPoster(); + var (service, coordinator, _, _) = Build(poster: poster); + coordinator.SetReady(Key, new RustMapsReadyMap([9, 9, 9], "https://rustmaps/x")); + + var first = service.EnsureInfoMapAsync(Guild, Server, CancellationToken.None); + var second = service.EnsureInfoMapAsync(Guild, Server, CancellationToken.None); + + await poster.Entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); // first call is inside PostAsync + await Task.Delay(100); // give the second call ample time to (try to) enter PostAsync + Assert.Equal(1, poster.Calls); // serialized: the second call is blocked, not inside PostAsync + + poster.Release.SetResult(); + await Task.WhenAll(first, second); + Assert.Equal(1, poster.Calls); // the second call saw the Ready state already posted → no-op + } + + private sealed class GatedPoster : IInfoMapPoster + { + private int _calls; + public TaskCompletionSource Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public int Calls => Volatile.Read(ref _calls); + + public async Task PostAsync(ulong channelId, Embed embed, byte[] pngBytes, CancellationToken cancellationToken) + { + Interlocked.Increment(ref _calls); + Entered.TrySetResult(); + await Release.Task.ConfigureAwait(false); + } + } + private sealed class FakeBaseMapSource(BaseMapImage image) : IBaseMapSource { public Task GetAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) => From 9a3a1148a2233e32f1aa77be3044b7e179db80a9 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Thu, 9 Jul 2026 19:26:47 +0200 Subject: [PATCH 27/40] fix(map): single edited-in-place #info map + use the iconned RustMaps render Two live-run fixes for the #info map: 1. Edit in place, never duplicate. The map is now one tracked message: the poster edits it (embed + attachment swapped via ModifyAsync) instead of delete+reposting, so the channel never shows two maps. On first post (or if the message was deleted / an edit fails) it sweeps stray prior posts and posts one fresh, tracking its id. Combined with the per-server serialization, the fallback->RustMaps transition is a clean in-place swap. 2. Download imageIconUrl (map_icons.png) instead of imageUrl (map_raw_normalized.png). Verified against the live API: imageUrl is plain terrain with no markers; imageIconUrl carries the monument icons. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Hosting/InfoMapHostedService.cs | 24 +++++- .../Posting/DiscordInfoMapPoster.cs | 78 +++++++++++++++++-- .../Posting/IInfoMapPoster.cs | 17 +++- .../RustMaps/RustMapsGenerationDriver.cs | 5 +- .../Hosting/InfoMapServiceTests.cs | 24 +++--- .../RustMaps/RustMapsGenerationDriverTests.cs | 41 ++++++++++ 6 files changed, 164 insertions(+), 25 deletions(-) diff --git a/src/RustPlusBot.Features.Map/Hosting/InfoMapHostedService.cs b/src/RustPlusBot.Features.Map/Hosting/InfoMapHostedService.cs index 39a53ad8..39beb36d 100644 --- a/src/RustPlusBot.Features.Map/Hosting/InfoMapHostedService.cs +++ b/src/RustPlusBot.Features.Map/Hosting/InfoMapHostedService.cs @@ -56,6 +56,9 @@ internal sealed partial class InfoMapHostedService( /// private readonly ConcurrentDictionary<(ulong Guild, Guid Server), SemaphoreSlim> _gates = new(); + /// The id of each server's single #info map message, so it is edited in place, never duplicated. + private readonly ConcurrentDictionary<(ulong Guild, Guid Server), ulong> _messageIds = new(); + private readonly ConcurrentDictionary<(ulong Guild, Guid Server), Posted> _posted = new(); private Task? _statusLoop; private Task? _tickLoop; @@ -144,8 +147,8 @@ private async Task EnsureInfoMapCoreAsync(ulong guildId, Guid serverId, Cancella } var culture = await GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false); - await poster.PostAsync(id, BuildEmbed(key, ready.RustMapsUrl, culture), ready.ImageBytes, cancellationToken) - .ConfigureAwait(false); + await UpsertAsync(mapKey, id, BuildEmbed(key, ready.RustMapsUrl, culture), ready.ImageBytes, + cancellationToken).ConfigureAwait(false); _posted[mapKey] = Posted.Ready; return; } @@ -162,11 +165,26 @@ await poster.PostAsync(id, BuildEmbed(key, ready.RustMapsUrl, culture), ready.Im } var fallbackCulture = await GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false); - await poster.PostAsync(id, BuildEmbed(key, rustMapsUrl: null, fallbackCulture), png, cancellationToken) + await UpsertAsync(mapKey, id, BuildEmbed(key, rustMapsUrl: null, fallbackCulture), png, cancellationToken) .ConfigureAwait(false); _posted[mapKey] = Posted.Fallback; } + private async Task UpsertAsync((ulong Guild, Guid Server) mapKey, + ulong channelId, + Embed embed, + byte[] pngBytes, + CancellationToken cancellationToken) + { + var existing = _messageIds.TryGetValue(mapKey, out var tracked) ? tracked : (ulong?)null; + var messageId = await poster.UpsertAsync(channelId, existing, embed, pngBytes, cancellationToken) + .ConfigureAwait(false); + if (messageId is { } id) + { + _messageIds[mapKey] = id; + } + } + private Embed BuildEmbed(RustMapsMapKey key, string? rustMapsUrl, string culture) { var builder = new EmbedBuilder() diff --git a/src/RustPlusBot.Features.Map/Posting/DiscordInfoMapPoster.cs b/src/RustPlusBot.Features.Map/Posting/DiscordInfoMapPoster.cs index 3ae99ce5..251ef903 100644 --- a/src/RustPlusBot.Features.Map/Posting/DiscordInfoMapPoster.cs +++ b/src/RustPlusBot.Features.Map/Posting/DiscordInfoMapPoster.cs @@ -4,7 +4,7 @@ namespace RustPlusBot.Features.Map.Posting; -/// Posts the static map image to #info by deleting the bot's prior image post and reposting. Untested integration shim. +/// Maintains the single #info map message, editing it in place so the channel never shows two. Untested integration shim. /// The Discord socket client. /// The logger. internal sealed partial class DiscordInfoMapPoster( @@ -14,21 +14,34 @@ internal sealed partial class DiscordInfoMapPoster( private const int RecentMessageScan = 10; /// - public async Task PostAsync(ulong channelId, Embed embed, byte[] pngBytes, CancellationToken cancellationToken) + public async Task UpsertAsync(ulong channelId, + ulong? existingMessageId, + Embed embed, + byte[] pngBytes, + CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(embed); ArgumentNullException.ThrowIfNull(pngBytes); + var options = new RequestOptions + { + CancelToken = cancellationToken + }; try { - var options = new RequestOptions - { - CancelToken = cancellationToken - }; if (await client.GetChannelAsync(channelId, options).ConfigureAwait(false) is not ITextChannel channel) { - return; + return existingMessageId; + } + + // Edit the one existing message in place — no delete+repost, so the channel never flashes two. + if (existingMessageId is { } id + && await TryEditAsync(channel, id, embed, pngBytes, options).ConfigureAwait(false)) + { + return id; } + // No live message to edit (first post, or it was deleted, or the edit failed): sweep any stray + // prior map posts, then post a single fresh message and track its id. try { await DeletePriorBotMessagesAsync(channel, options).ConfigureAwait(false); @@ -47,8 +60,9 @@ public async Task PostAsync(ulong channelId, Embed embed, byte[] pngBytes, Cance var stream = new MemoryStream(pngBytes); await using (stream.ConfigureAwait(false)) { - await channel.SendFileAsync(stream, "map.png", embed: embed, options: options, + var posted = await channel.SendFileAsync(stream, "map.png", embed: embed, options: options, allowedMentions: AllowedMentions.None).ConfigureAwait(false); + return posted.Id; } } catch (OperationCanceledException) @@ -60,6 +74,50 @@ await channel.SendFileAsync(stream, "map.png", embed: embed, options: options, #pragma warning restore CA1031 { LogPostFailed(logger, ex, channelId); + return existingMessageId; + } + } + + private async Task TryEditAsync(ITextChannel channel, + ulong messageId, + Embed embed, + byte[] pngBytes, + RequestOptions options) + { + try + { + if (await channel.GetMessageAsync(messageId, options: options).ConfigureAwait(false) + is not IUserMessage existing + || existing.Author.Id != client.CurrentUser.Id) + { + return false; // gone or not ours — fall back to a fresh post. + } + + var stream = new MemoryStream(pngBytes); + await using (stream.ConfigureAwait(false)) + { + await existing.ModifyAsync(m => + { + m.Embed = embed; + m.Attachments = new[] + { + new FileAttachment(stream, "map.png") + }; + }, options).ConfigureAwait(false); + } + + return true; + } + catch (OperationCanceledException) + { + throw; + } +#pragma warning disable CA1031 // Broad catch: an edit failure falls back to a fresh post below. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogEditFailed(logger, ex, channel.Id); + return false; } } @@ -78,6 +136,10 @@ private async Task DeletePriorBotMessagesAsync(ITextChannel channel, RequestOpti [LoggerMessage(Level = LogLevel.Warning, Message = "Posting the #info map image to channel {ChannelId} failed.")] private static partial void LogPostFailed(ILogger logger, Exception exception, ulong channelId); + [LoggerMessage(Level = LogLevel.Warning, + Message = "Editing the #info map message in channel {ChannelId} failed; reposting instead.")] + private static partial void LogEditFailed(ILogger logger, Exception exception, ulong channelId); + [LoggerMessage(Level = LogLevel.Warning, Message = "Deleting prior #info map messages in channel {ChannelId} failed; reposting anyway.")] private static partial void LogDeleteFailed(ILogger logger, Exception exception, ulong channelId); diff --git a/src/RustPlusBot.Features.Map/Posting/IInfoMapPoster.cs b/src/RustPlusBot.Features.Map/Posting/IInfoMapPoster.cs index 09f97c26..decf7610 100644 --- a/src/RustPlusBot.Features.Map/Posting/IInfoMapPoster.cs +++ b/src/RustPlusBot.Features.Map/Posting/IInfoMapPoster.cs @@ -2,14 +2,23 @@ namespace RustPlusBot.Features.Map.Posting; -/// Posts the static map image + embed to #info, replacing the bot's prior image post. +/// Maintains the single static #info map message, editing it in place so the channel never shows two. internal interface IInfoMapPoster { - /// Deletes the bot's prior #info image post (if any) and posts the new embed + PNG. + /// + /// Creates or updates the one #info map message. When is a live + /// bot message it is edited in place (embed + image swapped); otherwise any stray prior map posts are + /// removed and a fresh message is posted. Returns the id of the resulting message to track for next time. + /// /// The #info channel id. + /// The id of the previously-posted map message, or null on first post. /// The map embed (title, size/seed, RustMaps link). /// The map image PNG. /// A cancellation token. - /// A task that completes when the post is issued. - Task PostAsync(ulong channelId, Embed embed, byte[] pngBytes, CancellationToken cancellationToken); + /// The id of the current map message, or null if it could not be posted. + Task UpsertAsync(ulong channelId, + ulong? existingMessageId, + Embed embed, + byte[] pngBytes, + CancellationToken cancellationToken); } diff --git a/src/RustPlusBot.Features.Map/RustMaps/RustMapsGenerationDriver.cs b/src/RustPlusBot.Features.Map/RustMaps/RustMapsGenerationDriver.cs index 87580c24..f0371a12 100644 --- a/src/RustPlusBot.Features.Map/RustMaps/RustMapsGenerationDriver.cs +++ b/src/RustPlusBot.Features.Map/RustMaps/RustMapsGenerationDriver.cs @@ -129,7 +129,10 @@ private async Task PollAsync(RustMapsMapKey key, string? mapId, CancellationToke private async Task SetReadyAsync(RustMapsMapKey key, MapInfo info, CancellationToken cancellationToken) { var http = httpClientFactory.CreateClient(HttpClientName); - var bytes = await http.GetByteArrayAsync(new Uri(info.ImageUrl!), cancellationToken).ConfigureAwait(false); + // ImageIconUrl (map_icons.png) is the render WITH monument icon markers; ImageUrl + // (map_raw_normalized.png) is plain terrain with no markers. Prefer the iconned one. + var imageUrl = info.ImageIconUrl ?? info.ImageUrl; + var bytes = await http.GetByteArrayAsync(new Uri(imageUrl!), cancellationToken).ConfigureAwait(false); coordinator.SetReady(key, new RustMapsReadyMap(bytes, info.Url)); } diff --git a/tests/RustPlusBot.Features.Map.Tests/Hosting/InfoMapServiceTests.cs b/tests/RustPlusBot.Features.Map.Tests/Hosting/InfoMapServiceTests.cs index c48302c6..7d34f977 100644 --- a/tests/RustPlusBot.Features.Map.Tests/Hosting/InfoMapServiceTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/Hosting/InfoMapServiceTests.cs @@ -102,7 +102,7 @@ public async Task Ready_posts_the_RustMaps_bytes_once_and_a_repeat_call_is_a_no_ await service.EnsureInfoMapAsync(Guild, Server, CancellationToken.None); await service.EnsureInfoMapAsync(Guild, Server, CancellationToken.None); - await poster.Received(1).PostAsync(123UL, Arg.Any(), + await poster.Received(1).UpsertAsync(123UL, Arg.Any(), Arg.Any(), Arg.Is(b => b.SequenceEqual(readyBytes)), Arg.Any()); } @@ -116,7 +116,8 @@ public async Task Not_ready_posts_the_fallback_bytes_once_and_a_repeat_call_is_a // Idle for the whole test — no generation was ever advanced. Assert.Equal(RustMapsGenerationState.Idle, coordinator.Snapshot(Key).State); - await poster.Received(1).PostAsync(123UL, Arg.Any(), Arg.Any(), Arg.Any()); + await poster.Received(1) + .UpsertAsync(123UL, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] @@ -126,7 +127,7 @@ public async Task No_info_channel_never_posts() await service.EnsureInfoMapAsync(Guild, Server, CancellationToken.None); - await poster.DidNotReceiveWithAnyArgs().PostAsync(default, default!, default!, default); + await poster.DidNotReceiveWithAnyArgs().UpsertAsync(default, default, default!, default!, default); } [Fact] @@ -138,7 +139,7 @@ public async Task No_info_channel_never_registers_the_key_with_the_coordinator() Assert.Empty(coordinator.PendingKeys()); Assert.Empty(coordinator.Requesters(Key)); - await poster.DidNotReceiveWithAnyArgs().PostAsync(default, default!, default!, default); + await poster.DidNotReceiveWithAnyArgs().UpsertAsync(default, default, default!, default!, default); } [Fact] @@ -149,7 +150,7 @@ public async Task World_unavailable_never_posts() await service.EnsureInfoMapAsync(Guild, Server, CancellationToken.None); - await poster.DidNotReceiveWithAnyArgs().PostAsync(default, default!, default!, default); + await poster.DidNotReceiveWithAnyArgs().UpsertAsync(default, default, default!, default!, default); } [Fact] @@ -166,9 +167,9 @@ public async Task Concurrent_calls_for_the_same_server_are_serialized_and_post_o var first = service.EnsureInfoMapAsync(Guild, Server, CancellationToken.None); var second = service.EnsureInfoMapAsync(Guild, Server, CancellationToken.None); - await poster.Entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); // first call is inside PostAsync - await Task.Delay(100); // give the second call ample time to (try to) enter PostAsync - Assert.Equal(1, poster.Calls); // serialized: the second call is blocked, not inside PostAsync + await poster.Entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); // first call is inside UpsertAsync + await Task.Delay(100); // give the second call ample time to (try to) enter UpsertAsync + Assert.Equal(1, poster.Calls); // serialized: the second call is blocked, not inside UpsertAsync poster.Release.SetResult(); await Task.WhenAll(first, second); @@ -182,11 +183,16 @@ private sealed class GatedPoster : IInfoMapPoster public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public int Calls => Volatile.Read(ref _calls); - public async Task PostAsync(ulong channelId, Embed embed, byte[] pngBytes, CancellationToken cancellationToken) + public async Task UpsertAsync(ulong channelId, + ulong? existingMessageId, + Embed embed, + byte[] pngBytes, + CancellationToken cancellationToken) { Interlocked.Increment(ref _calls); Entered.TrySetResult(); await Release.Task.ConfigureAwait(false); + return 555UL; } } diff --git a/tests/RustPlusBot.Features.Map.Tests/RustMaps/RustMapsGenerationDriverTests.cs b/tests/RustPlusBot.Features.Map.Tests/RustMaps/RustMapsGenerationDriverTests.cs index 034679bd..c05f36d1 100644 --- a/tests/RustPlusBot.Features.Map.Tests/RustMaps/RustMapsGenerationDriverTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/RustMaps/RustMapsGenerationDriverTests.cs @@ -46,6 +46,31 @@ public async Task Existing_map_goes_straight_to_ready_without_generating() await client.DidNotReceiveWithAnyArgs().CreateMapAsync(default!, default); } + [Fact] + public async Task Ready_downloads_the_icon_render_not_the_plain_terrain_one() + { + // ImageIconUrl (map_icons.png) carries the monument markers; ImageUrl (map_raw_normalized.png) + // is plain terrain. The driver must download the iconned render. + var capture = new CapturingHandler(); + var client = Substitute.For(); + var coord = new RustMapsMapCoordinator(); + var driver = new RustMapsGenerationDriver(client, coord, new StubFactory(capture), + NullLogger.Instance); + coord.Register(Key, 1UL, Server); + client.GetMapBySeedAndSizeAsync(4000, 12345, false, Arg.Any()) + .Returns(Result.Success( + new MapInfo + { + ImageUrl = "https://img/plain.png", + ImageIconUrl = "https://img/icons.png", + Url = "https://rustmaps/x" + }, 200)); + + await driver.AdvanceAsync(Key, CancellationToken.None); + + Assert.Equal("https://img/icons.png", capture.LastUri?.ToString()); + } + [Fact] public async Task NotFound_with_budget_generates_exactly_once_across_ticks() { @@ -174,4 +199,20 @@ private sealed class StubFactory(HttpMessageHandler handler) : IHttpClientFactor { public HttpClient CreateClient(string name) => new(handler, disposeHandler: false); } + + private sealed class CapturingHandler : HttpMessageHandler + { + public Uri? LastUri { get; private set; } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + LastUri = request.RequestUri; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent([7, 7, 7]) + }); + } + } } From 701263c9d8244238a311645e18927dc1cb989824 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Thu, 9 Jul 2026 20:03:21 +0200 Subject: [PATCH 28/40] refactor(map): #info map is a reconciled top-of-channel message (persistent, iconned) Replaces the home-grown #info map poster (in-memory message-id tracking, new image posted on every restart) with a reconciled Workspace message: DB-persisted id via ProvisionedMessage, edited in place across restarts, declared first so it renders above the #info status embed. - Abstractions: IInfoMapReadModel/InfoMapView read seam + InfoMapReadyEvent, so Features.Workspace (which cannot reference Features.Map) can read RustMaps generation state and Features.Map can signal readiness across the boundary. - Features.Map: the coordinator now also implements IInfoMapReadModel (same singleton instance as IRustMapsMapCoordinator); the driver stores the RustMaps imageIconUrl directly instead of downloading bytes; InfoMapHostedService drops all posting/composing and just drives generation + publishes InfoMapReadyEvent once per (size, seed). Poster, #info channel locator, and the MapComposer byte fallback (ComposeStaticAsync) are deleted. - Features.Workspace: new ServerInfoMapMessageRenderer shows the RustMaps render via WithImageUrl (or a "preparing" footer) and always returns a non-empty embed so it claims the top slot on the first reconcile; WorkspaceHostedService gains an InfoMapReadyEvent consumer that reconciles the owning server. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Events/InfoMapReadyEvent.cs | 6 + .../Map/IInfoMapReadModel.cs | 11 + .../Map/InfoMapView.cs | 6 + .../Composing/MapComposer.cs | 12 - .../Hosting/InfoMapHostedService.cs | 227 ++++-------------- .../MapServiceCollectionExtensions.cs | 16 +- .../Posting/DiscordInfoMapPoster.cs | 146 ----------- .../Posting/IInfoMapPoster.cs | 24 -- .../RustMaps/RustMapsGenerationDriver.cs | 31 +-- .../RustMaps/RustMapsMapCoordinator.cs | 14 +- .../RustMaps/RustMapsReadyMap.cs | 6 +- .../Hosting/WorkspaceHostedService.cs | 32 ++- .../Locating/IInfoChannelLocator.cs | 12 - .../Locating/InfoChannelLocator.cs | 10 - .../Messages/ServerInfoMapMessageRenderer.cs | 72 ++++++ .../Specs/ServerWorkspaceSpecProvider.cs | 2 + .../WorkspaceKeys.cs | 3 + .../WorkspaceServiceCollectionExtensions.cs | 5 +- .../Hosting/InfoMapHostedServiceTests.cs | 146 +++++++++++ .../Hosting/InfoMapServiceTests.cs | 204 ---------------- .../MapComposerTests.cs | 56 ----- .../MapRegistrationTests.cs | 13 +- .../RustMaps/RustMapsGenerationDriverTests.cs | 58 +---- .../RustMaps/RustMapsMapCoordinatorTests.cs | 36 ++- .../Locating/InfoChannelLocatorTests.cs | 126 ---------- .../ServerInfoMapMessageRendererTests.cs | 126 ++++++++++ 26 files changed, 547 insertions(+), 853 deletions(-) create mode 100644 src/RustPlusBot.Abstractions/Events/InfoMapReadyEvent.cs create mode 100644 src/RustPlusBot.Abstractions/Map/IInfoMapReadModel.cs create mode 100644 src/RustPlusBot.Abstractions/Map/InfoMapView.cs delete mode 100644 src/RustPlusBot.Features.Map/Posting/DiscordInfoMapPoster.cs delete mode 100644 src/RustPlusBot.Features.Map/Posting/IInfoMapPoster.cs delete mode 100644 src/RustPlusBot.Features.Workspace/Locating/IInfoChannelLocator.cs delete mode 100644 src/RustPlusBot.Features.Workspace/Locating/InfoChannelLocator.cs create mode 100644 src/RustPlusBot.Features.Workspace/Messages/ServerInfoMapMessageRenderer.cs create mode 100644 tests/RustPlusBot.Features.Map.Tests/Hosting/InfoMapHostedServiceTests.cs delete mode 100644 tests/RustPlusBot.Features.Map.Tests/Hosting/InfoMapServiceTests.cs delete mode 100644 tests/RustPlusBot.Features.Workspace.Tests/Locating/InfoChannelLocatorTests.cs create mode 100644 tests/RustPlusBot.Features.Workspace.Tests/Messages/ServerInfoMapMessageRendererTests.cs diff --git a/src/RustPlusBot.Abstractions/Events/InfoMapReadyEvent.cs b/src/RustPlusBot.Abstractions/Events/InfoMapReadyEvent.cs new file mode 100644 index 00000000..c855e20d --- /dev/null +++ b/src/RustPlusBot.Abstractions/Events/InfoMapReadyEvent.cs @@ -0,0 +1,6 @@ +namespace RustPlusBot.Abstractions.Events; + +/// Published when a server's RustMaps #info map becomes ready, so its reconciled message repaints. +/// The owning guild snowflake. +/// The target server id. +public sealed record InfoMapReadyEvent(ulong GuildId, Guid ServerId); diff --git a/src/RustPlusBot.Abstractions/Map/IInfoMapReadModel.cs b/src/RustPlusBot.Abstractions/Map/IInfoMapReadModel.cs new file mode 100644 index 00000000..40d8e0e4 --- /dev/null +++ b/src/RustPlusBot.Abstractions/Map/IInfoMapReadModel.cs @@ -0,0 +1,11 @@ +namespace RustPlusBot.Abstractions.Map; + +/// Read seam over the RustMaps generation state, for the #info map renderer (cross-assembly). +public interface IInfoMapReadModel +{ + /// The ready RustMaps map for a (size, seed), or null when it is not generated/ready yet. + /// The world size. + /// The map generation seed. + /// The ready map view, or null. + InfoMapView? GetReady(int size, int seed); +} diff --git a/src/RustPlusBot.Abstractions/Map/InfoMapView.cs b/src/RustPlusBot.Abstractions/Map/InfoMapView.cs new file mode 100644 index 00000000..5636e63e --- /dev/null +++ b/src/RustPlusBot.Abstractions/Map/InfoMapView.cs @@ -0,0 +1,6 @@ +namespace RustPlusBot.Abstractions.Map; + +/// A ready RustMaps map for the #info map message: its hosted image URL plus its page link. +/// The RustMaps-hosted image URL (the monument-icon render). +/// The RustMaps page URL for the map, when available. +public sealed record InfoMapView(string ImageUrl, string? RustMapsPageUrl); diff --git a/src/RustPlusBot.Features.Map/Composing/MapComposer.cs b/src/RustPlusBot.Features.Map/Composing/MapComposer.cs index 37e29a00..28b46265 100644 --- a/src/RustPlusBot.Features.Map/Composing/MapComposer.cs +++ b/src/RustPlusBot.Features.Map/Composing/MapComposer.cs @@ -26,10 +26,6 @@ public sealed class MapComposer( private static readonly MarkerKind[] LiveMarkerKinds = [MarkerKind.CargoShip, MarkerKind.PatrolHelicopter, MarkerKind.Chinook]; - /// The static #info layer set: terrain base + grid + monuments only (no dynamic overlays). - private static readonly MapLayerSet StaticLayers = - new(Grid: true, Markers: false, Monuments: true, Vendor: false, Players: false, Rigs: false); - /// Composes the map PNG for a server using its saved layer toggles, or null when no base map /// is available yet. /// The owning guild snowflake. @@ -53,14 +49,6 @@ public sealed class MapComposer( return await ComposeWithLayersAsync(guildId, serverId, layers, cancellationToken).ConfigureAwait(false); } - /// Composes a static #info map: terrain base + grid + monuments only, ignoring saved toggles. - /// The owning guild snowflake. - /// The target server id. - /// A cancellation token. - /// PNG bytes, or null when no base map is available. - public Task ComposeStaticAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) => - ComposeWithLayersAsync(guildId, serverId, StaticLayers, cancellationToken); - private async Task ComposeWithLayersAsync( ulong guildId, Guid serverId, diff --git a/src/RustPlusBot.Features.Map/Hosting/InfoMapHostedService.cs b/src/RustPlusBot.Features.Map/Hosting/InfoMapHostedService.cs index 39beb36d..cd9036d4 100644 --- a/src/RustPlusBot.Features.Map/Hosting/InfoMapHostedService.cs +++ b/src/RustPlusBot.Features.Map/Hosting/InfoMapHostedService.cs @@ -1,5 +1,3 @@ -using System.Collections.Concurrent; -using Discord; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -7,59 +5,38 @@ using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Events; using RustPlusBot.Domain.Connections; -using RustPlusBot.Features.Map.Composing; -using RustPlusBot.Features.Map.Posting; using RustPlusBot.Features.Map.RustMaps; -using RustPlusBot.Features.Workspace.Locating; -using RustPlusBot.Localization; using RustPlusBot.Persistence.Connections; -using RustPlusBot.Persistence.Workspace; namespace RustPlusBot.Features.Map.Hosting; -/// Posts the static RustMaps map to #info: a bot-rendered grid+monuments fallback until the -/// RustMaps render is generated, then the RustMaps image. Credit-safe generation via the driver. +/// +/// Drives credit-safe RustMaps generation for the #info map: registers each connected server's (size, seed) +/// key with the coordinator, advances pending generations on a poll interval, and publishes +/// at most once per key so the workspace reconciler picks up the ready render. +/// No posting here — delivery is a reconciled Workspace message (ServerInfoMapMessageRenderer) that +/// reads the coordinator through IInfoMapReadModel. +/// /// The in-process event bus. /// The shared RustMaps generation state. /// Advances one map key's generation one step per tick. -/// Renders the grid+monuments fallback image. -/// Posts the #info map image. -/// Resolves the #info Discord channel for a server. /// Live query seam (world size/seed). -/// Localizes the #info embed strings. /// Supplies the generation-poll interval. -/// Opens scopes to read connection state and guild culture. +/// Opens scopes to read connection state. /// The logger. internal sealed partial class InfoMapHostedService( IEventBus eventBus, IRustMapsMapCoordinator coordinator, RustMapsGenerationDriver driver, - MapComposer composer, - IInfoMapPoster poster, - IInfoChannelLocator locator, IRustServerQuery query, - ILocalizer localizer, IOptions options, IServiceScopeFactory scopeFactory, ILogger logger) : IHostedService, IDisposable { - // Not a real URI: Discord's attachment:// pseudo-scheme references the file uploaded alongside the embed. -#pragma warning disable S1075 - private const string AttachmentImageUrl = "attachment://map.png"; -#pragma warning restore S1075 - private readonly ConcurrentDictionary<(ulong Guild, Guid Server), byte> _connected = new(); - private readonly CancellationTokenSource _cts = new(); - - /// - /// Serializes the two callers (connection-status loop + tick loop) per server so their - /// delete-then-repost cannot interleave and leave two #info map messages. - /// - private readonly ConcurrentDictionary<(ulong Guild, Guid Server), SemaphoreSlim> _gates = new(); - - /// The id of each server's single #info map message, so it is edited in place, never duplicated. - private readonly ConcurrentDictionary<(ulong Guild, Guid Server), ulong> _messageIds = new(); + /// Keys already announced via — each key is published at most once. + private readonly HashSet _announced = []; - private readonly ConcurrentDictionary<(ulong Guild, Guid Server), Posted> _posted = new(); + private readonly CancellationTokenSource _cts = new(); private Task? _statusLoop; private Task? _tickLoop; @@ -96,132 +73,11 @@ public async Task StopAsync(CancellationToken cancellationToken) } } - /// Ensures #info shows the correct map image for the server's current generation state. - /// The guild snowflake. - /// The server id. - /// A cancellation token. - /// A task that completes when the check (and any post) is done. - public async Task EnsureInfoMapAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) - { - // Both the connection-status loop and the periodic tick loop call this for the same server. - // Serialize per server: otherwise the fallback post and the RustMaps-ready post run their - // delete-prior-image scans concurrently, each misses the other's not-yet-committed message, - // and both survive — leaving two #info map messages. - var gate = _gates.GetOrAdd((guildId, serverId), static _ => new SemaphoreSlim(1, 1)); - await gate.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - await EnsureInfoMapCoreAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); - } - finally - { - gate.Release(); - } - } - - private async Task EnsureInfoMapCoreAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) - { - var world = await query.GetWorldAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); - if (world is null) - { - return; - } - - var channelId = await locator.GetChannelIdAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); - if (channelId is not { } id) - { - return; - } - - var key = new RustMapsMapKey((int)world.WorldSize, (int)world.Seed); - coordinator.Register(key, guildId, serverId); - - var snapshot = coordinator.Snapshot(key); - var mapKey = (guildId, serverId); - - if (snapshot is { State: RustMapsGenerationState.Ready, Ready: { } ready }) - { - if (_posted.TryGetValue(mapKey, out var v) && v == Posted.Ready) - { - return; - } - - var culture = await GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false); - await UpsertAsync(mapKey, id, BuildEmbed(key, ready.RustMapsUrl, culture), ready.ImageBytes, - cancellationToken).ConfigureAwait(false); - _posted[mapKey] = Posted.Ready; - return; - } - - if (_posted.TryGetValue(mapKey, out var posted) && posted != Posted.None) - { - return; // fallback already up; nothing better to show yet. - } - - var png = await composer.ComposeStaticAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); - if (png is null) - { - return; // base map not ready yet; retry next tick. - } - - var fallbackCulture = await GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false); - await UpsertAsync(mapKey, id, BuildEmbed(key, rustMapsUrl: null, fallbackCulture), png, cancellationToken) - .ConfigureAwait(false); - _posted[mapKey] = Posted.Fallback; - } - - private async Task UpsertAsync((ulong Guild, Guid Server) mapKey, - ulong channelId, - Embed embed, - byte[] pngBytes, - CancellationToken cancellationToken) - { - var existing = _messageIds.TryGetValue(mapKey, out var tracked) ? tracked : (ulong?)null; - var messageId = await poster.UpsertAsync(channelId, existing, embed, pngBytes, cancellationToken) - .ConfigureAwait(false); - if (messageId is { } id) - { - _messageIds[mapKey] = id; - } - } - - private Embed BuildEmbed(RustMapsMapKey key, string? rustMapsUrl, string culture) - { - var builder = new EmbedBuilder() - .WithTitle(localizer.Get("map.info.title", culture)) - .WithColor(Color.DarkGreen) - .AddField(localizer.Get("map.info.size", culture), - key.Size.ToString(System.Globalization.CultureInfo.InvariantCulture), inline: true) - .AddField(localizer.Get("map.info.seed", culture), - key.Seed.ToString(System.Globalization.CultureInfo.InvariantCulture), inline: true) - .WithImageUrl(AttachmentImageUrl); - if (rustMapsUrl is not null) - { - builder.WithUrl(rustMapsUrl); - } - else - { - builder.WithFooter(localizer.Get("map.info.generating", culture)); - } - - return builder.Build(); - } - - private async Task GetCultureAsync(ulong guildId, CancellationToken cancellationToken) - { - var scope = scopeFactory.CreateAsyncScope(); - await using (scope.ConfigureAwait(false)) - { - var store = scope.ServiceProvider.GetRequiredService(); - return await store.GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false); - } - } - /// /// Advances every pending RustMaps generation key strictly one-at-a-time (sequential, never /// concurrent) — the driver's check-then-spend path is not atomic across awaits, so concurrent - /// calls for the same key could double-spend real RustMaps credits. Then repaints every connected - /// server's #info. + /// calls for the same key could double-spend real RustMaps credits. Then announces any key that + /// just reached Ready to every one of its requesters. /// /// A cancellation token. private async Task RunTickAsync(CancellationToken cancellationToken) @@ -232,15 +88,13 @@ private async Task RunTickAsync(CancellationToken cancellationToken) { await Task.Delay(options.Value.RustMaps.GenerationPollInterval, cancellationToken) .ConfigureAwait(false); - foreach (var key in coordinator.PendingKeys()) + var pending = coordinator.PendingKeys(); + foreach (var key in pending) { await driver.AdvanceAsync(key, cancellationToken).ConfigureAwait(false); } - foreach (var (guild, server) in _connected.Keys) - { - await EnsureInfoMapAsync(guild, server, cancellationToken).ConfigureAwait(false); - } + await AnnounceReadyKeysAsync(pending, cancellationToken).ConfigureAwait(false); } } catch (OperationCanceledException) @@ -255,6 +109,24 @@ await Task.Delay(options.Value.RustMaps.GenerationPollInterval, cancellationToke } } + private async Task AnnounceReadyKeysAsync(IReadOnlyList keys, CancellationToken cancellationToken) + { + foreach (var key in keys) + { + if (_announced.Contains(key) || coordinator.Snapshot(key).State != RustMapsGenerationState.Ready) + { + continue; + } + + _announced.Add(key); + foreach (var (guild, server) in coordinator.Requesters(key)) + { + await eventBus.PublishAsync(new InfoMapReadyEvent(guild, server), cancellationToken) + .ConfigureAwait(false); + } + } + } + private async Task ConsumeConnectionStatusAsync(CancellationToken cancellationToken) { try @@ -279,22 +151,30 @@ private async Task ConsumeConnectionStatusAsync(CancellationToken cancellationTo private async Task OnConnectionStatusAsync(ConnectionStatusChangedEvent evt, CancellationToken cancellationToken) { - var key = (evt.GuildId, evt.ServerId); + // Re-derive current status from the store rather than trusting the event's IsConnected payload: the + // in-process bus is an unbounded queue, so by the time this loop dequeues the event the live status + // may already have moved on (mirrors MapHostedService.OnConnectionStatusAsync). + bool connected; var scope = scopeFactory.CreateAsyncScope(); await using (scope.ConfigureAwait(false)) { var store = scope.ServiceProvider.GetRequiredService(); var state = await store.GetStateAsync(evt.GuildId, evt.ServerId, cancellationToken).ConfigureAwait(false); - if (state is null || state.Status != ConnectionStatus.Connected) - { - _connected.TryRemove(key, out _); - return; - } + connected = state is { Status: ConnectionStatus.Connected }; + } - _connected[key] = 0; + if (!connected) + { + return; } - await EnsureInfoMapAsync(evt.GuildId, evt.ServerId, cancellationToken).ConfigureAwait(false); + var world = await query.GetWorldAsync(evt.GuildId, evt.ServerId, cancellationToken).ConfigureAwait(false); + if (world is null) + { + return; + } + + coordinator.Register(new RustMapsMapKey((int)world.WorldSize, (int)world.Seed), evt.GuildId, evt.ServerId); } [LoggerMessage(Level = LogLevel.Error, Message = "Info-map tick loop faulted.")] @@ -302,11 +182,4 @@ private async Task OnConnectionStatusAsync(ConnectionStatusChangedEvent evt, Can [LoggerMessage(Level = LogLevel.Error, Message = "Info-map connection-status loop faulted.")] private static partial void LogStatusFaulted(ILogger logger, Exception exception); - - private enum Posted - { - None = 0, - Fallback = 1, - Ready = 2, - } } diff --git a/src/RustPlusBot.Features.Map/MapServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Map/MapServiceCollectionExtensions.cs index 51948b4f..051cb325 100644 --- a/src/RustPlusBot.Features.Map/MapServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Map/MapServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Abstractions.Map; using RustPlusBot.Features.Map.Composing; using RustPlusBot.Features.Map.Hosting; using RustPlusBot.Features.Map.Posting; @@ -12,9 +13,10 @@ namespace RustPlusBot.Features.Map; public static class MapServiceCollectionExtensions { /// - /// Registers the renderer, base-map source chain, composer, poster, pipeline bundle, and hosted service. + /// Registers the renderer, base-map source chain, composer, pipeline bundle, and hosted service. /// When a RustMaps API key is configured, also registers the credit-safe generation coordinator/driver - /// and the #info poster + hosted service that auto-generates and posts the static RustMaps render. + /// and the hosted service that drives generation and publishes so the + /// reconciled #info map message (in Features.Workspace) shows the RustMaps render. /// /// The service collection to add to. /// The host configuration (reads Map:RustMaps:ApiKey). @@ -27,15 +29,17 @@ public static IServiceCollection AddMap(this IServiceCollection services, IConfi services.AddSingleton(); // RustMaps is NOT a base-map source (the #map render draws its own layers on the Rust+ tile). - // The client drives the #info static-map surface: credit-safe generation + auto-post. + // The client drives the #info static-map surface: credit-safe generation, no posting here. var rustMapsKey = configuration["Map:RustMaps:ApiKey"]; if (!string.IsNullOrWhiteSpace(rustMapsKey)) { services.AddRustMapsClientV4(o => o.ApiKey = rustMapsKey); - services.AddHttpClient(RustMapsGenerationDriver.HttpClientName); - services.AddSingleton(); + services.AddSingleton(); + // IRustMapsMapCoordinator and IInfoMapReadModel resolve to the SAME singleton instance, so the + // driver's writes are visible to the Workspace renderer's reads. + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); - services.AddSingleton(); services.AddHostedService(); } diff --git a/src/RustPlusBot.Features.Map/Posting/DiscordInfoMapPoster.cs b/src/RustPlusBot.Features.Map/Posting/DiscordInfoMapPoster.cs deleted file mode 100644 index 251ef903..00000000 --- a/src/RustPlusBot.Features.Map/Posting/DiscordInfoMapPoster.cs +++ /dev/null @@ -1,146 +0,0 @@ -using Discord; -using Discord.WebSocket; -using Microsoft.Extensions.Logging; - -namespace RustPlusBot.Features.Map.Posting; - -/// Maintains the single #info map message, editing it in place so the channel never shows two. Untested integration shim. -/// The Discord socket client. -/// The logger. -internal sealed partial class DiscordInfoMapPoster( - DiscordSocketClient client, - ILogger logger) : IInfoMapPoster -{ - private const int RecentMessageScan = 10; - - /// - public async Task UpsertAsync(ulong channelId, - ulong? existingMessageId, - Embed embed, - byte[] pngBytes, - CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(embed); - ArgumentNullException.ThrowIfNull(pngBytes); - var options = new RequestOptions - { - CancelToken = cancellationToken - }; - try - { - if (await client.GetChannelAsync(channelId, options).ConfigureAwait(false) is not ITextChannel channel) - { - return existingMessageId; - } - - // Edit the one existing message in place — no delete+repost, so the channel never flashes two. - if (existingMessageId is { } id - && await TryEditAsync(channel, id, embed, pngBytes, options).ConfigureAwait(false)) - { - return id; - } - - // No live message to edit (first post, or it was deleted, or the edit failed): sweep any stray - // prior map posts, then post a single fresh message and track its id. - try - { - await DeletePriorBotMessagesAsync(channel, options).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - throw; - } -#pragma warning disable CA1031 // Broad catch: a failed delete must not abort the repost (best-effort). - catch (Exception ex) -#pragma warning restore CA1031 - { - LogDeleteFailed(logger, ex, channelId); - } - - var stream = new MemoryStream(pngBytes); - await using (stream.ConfigureAwait(false)) - { - var posted = await channel.SendFileAsync(stream, "map.png", embed: embed, options: options, - allowedMentions: AllowedMentions.None).ConfigureAwait(false); - return posted.Id; - } - } - catch (OperationCanceledException) - { - throw; - } -#pragma warning disable CA1031 // Broad catch: a Discord hiccup must not crash the info-map loop. - catch (Exception ex) -#pragma warning restore CA1031 - { - LogPostFailed(logger, ex, channelId); - return existingMessageId; - } - } - - private async Task TryEditAsync(ITextChannel channel, - ulong messageId, - Embed embed, - byte[] pngBytes, - RequestOptions options) - { - try - { - if (await channel.GetMessageAsync(messageId, options: options).ConfigureAwait(false) - is not IUserMessage existing - || existing.Author.Id != client.CurrentUser.Id) - { - return false; // gone or not ours — fall back to a fresh post. - } - - var stream = new MemoryStream(pngBytes); - await using (stream.ConfigureAwait(false)) - { - await existing.ModifyAsync(m => - { - m.Embed = embed; - m.Attachments = new[] - { - new FileAttachment(stream, "map.png") - }; - }, options).ConfigureAwait(false); - } - - return true; - } - catch (OperationCanceledException) - { - throw; - } -#pragma warning disable CA1031 // Broad catch: an edit failure falls back to a fresh post below. - catch (Exception ex) -#pragma warning restore CA1031 - { - LogEditFailed(logger, ex, channel.Id); - return false; - } - } - - private async Task DeletePriorBotMessagesAsync(ITextChannel channel, RequestOptions options) - { - var batch = await channel.GetMessagesAsync(RecentMessageScan, options: options).FlattenAsync() - .ConfigureAwait(false); - // Delete only our prior IMAGE posts (they carry a file attachment); leave the reconciled - // #info status embed (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); - } - } - - [LoggerMessage(Level = LogLevel.Warning, Message = "Posting the #info map image to channel {ChannelId} failed.")] - private static partial void LogPostFailed(ILogger logger, Exception exception, ulong channelId); - - [LoggerMessage(Level = LogLevel.Warning, - Message = "Editing the #info map message in channel {ChannelId} failed; reposting instead.")] - private static partial void LogEditFailed(ILogger logger, Exception exception, ulong channelId); - - [LoggerMessage(Level = LogLevel.Warning, - Message = "Deleting prior #info map messages in channel {ChannelId} failed; reposting anyway.")] - private static partial void LogDeleteFailed(ILogger logger, Exception exception, ulong channelId); -} diff --git a/src/RustPlusBot.Features.Map/Posting/IInfoMapPoster.cs b/src/RustPlusBot.Features.Map/Posting/IInfoMapPoster.cs deleted file mode 100644 index decf7610..00000000 --- a/src/RustPlusBot.Features.Map/Posting/IInfoMapPoster.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Discord; - -namespace RustPlusBot.Features.Map.Posting; - -/// Maintains the single static #info map message, editing it in place so the channel never shows two. -internal interface IInfoMapPoster -{ - /// - /// Creates or updates the one #info map message. When is a live - /// bot message it is edited in place (embed + image swapped); otherwise any stray prior map posts are - /// removed and a fresh message is posted. Returns the id of the resulting message to track for next time. - /// - /// The #info channel id. - /// The id of the previously-posted map message, or null on first post. - /// The map embed (title, size/seed, RustMaps link). - /// The map image PNG. - /// A cancellation token. - /// The id of the current map message, or null if it could not be posted. - Task UpsertAsync(ulong channelId, - ulong? existingMessageId, - Embed embed, - byte[] pngBytes, - CancellationToken cancellationToken); -} diff --git a/src/RustPlusBot.Features.Map/RustMaps/RustMapsGenerationDriver.cs b/src/RustPlusBot.Features.Map/RustMaps/RustMapsGenerationDriver.cs index f0371a12..a6177943 100644 --- a/src/RustPlusBot.Features.Map/RustMaps/RustMapsGenerationDriver.cs +++ b/src/RustPlusBot.Features.Map/RustMaps/RustMapsGenerationDriver.cs @@ -7,22 +7,18 @@ namespace RustPlusBot.Features.Map.RustMaps; /// -/// Advances one RustMaps map key's generation one step: GET → (limits-gated) CreateMap → poll → download. -/// Credit-safe: CreateMap only on a genuine NotFound with confirmed budget, once per key, no retry. +/// Advances one RustMaps map key's generation one step: GET → (limits-gated) CreateMap → poll. Stores the +/// RustMaps-hosted image URL directly — no download. Credit-safe: CreateMap only on a genuine NotFound with +/// confirmed budget, once per key, no retry. /// /// The RustMaps API client. /// The shared generation state. -/// Creates the image-download client. /// The logger. public sealed partial class RustMapsGenerationDriver( IRustMapsClient client, IRustMapsMapCoordinator coordinator, - IHttpClientFactory httpClientFactory, ILogger logger) { - /// Named HTTP client used to download the RustMaps render. - public const string HttpClientName = "RustMapsImages"; - /// Advances the key one step based on its current state. /// The map key. /// A cancellation token. @@ -63,7 +59,7 @@ private async Task StartAsync(RustMapsMapKey key, CancellationToken cancellation .ConfigureAwait(false); if (IsReady(get)) { - await SetReadyAsync(key, get.Data!, cancellationToken).ConfigureAwait(false); + SetReady(key, get.Data!); return; } @@ -121,20 +117,19 @@ private async Task PollAsync(RustMapsMapKey key, string? mapId, CancellationToke .ConfigureAwait(false); if (IsReady(get)) { - await SetReadyAsync(key, get.Data!, cancellationToken).ConfigureAwait(false); + SetReady(key, get.Data!); } // else: still generating (or a transient poll miss) — leave Generating, poll again next tick. } - private async Task SetReadyAsync(RustMapsMapKey key, MapInfo info, CancellationToken cancellationToken) - { - var http = httpClientFactory.CreateClient(HttpClientName); - // ImageIconUrl (map_icons.png) is the render WITH monument icon markers; ImageUrl - // (map_raw_normalized.png) is plain terrain with no markers. Prefer the iconned one. - var imageUrl = info.ImageIconUrl ?? info.ImageUrl; - var bytes = await http.GetByteArrayAsync(new Uri(imageUrl!), cancellationToken).ConfigureAwait(false); - coordinator.SetReady(key, new RustMapsReadyMap(bytes, info.Url)); - } + /// + /// Stores the ready render's URL. ImageIconUrl (map_icons.png) is the render WITH monument icon + /// markers; ImageUrl (map_raw_normalized.png) is plain terrain with no markers. Prefer the iconned one. + /// + /// The map key. + /// The RustMaps map info. + private void SetReady(RustMapsMapKey key, MapInfo info) => + coordinator.SetReady(key, new RustMapsReadyMap(info.ImageIconUrl ?? info.ImageUrl!, info.Url)); private static bool IsExhausted(MapGenerationStat? stat) => stat is { } s && s.Current >= s.Allowed; diff --git a/src/RustPlusBot.Features.Map/RustMaps/RustMapsMapCoordinator.cs b/src/RustPlusBot.Features.Map/RustMaps/RustMapsMapCoordinator.cs index 4aefd994..e944d51f 100644 --- a/src/RustPlusBot.Features.Map/RustMaps/RustMapsMapCoordinator.cs +++ b/src/RustPlusBot.Features.Map/RustMaps/RustMapsMapCoordinator.cs @@ -1,12 +1,22 @@ using System.Collections.Concurrent; +using RustPlusBot.Abstractions.Map; namespace RustPlusBot.Features.Map.RustMaps; -/// -public sealed class RustMapsMapCoordinator : IRustMapsMapCoordinator +/// +public sealed class RustMapsMapCoordinator : IRustMapsMapCoordinator, IInfoMapReadModel { private readonly ConcurrentDictionary _byKey = new(); + /// + public InfoMapView? GetReady(int size, int seed) + { + var snap = Snapshot(new RustMapsMapKey(size, seed)); + return snap is { State: RustMapsGenerationState.Ready, Ready: { } r } + ? new InfoMapView(r.ImageUrl, r.RustMapsUrl) + : null; + } + /// public void Register(RustMapsMapKey key, ulong guildId, Guid serverId) { diff --git a/src/RustPlusBot.Features.Map/RustMaps/RustMapsReadyMap.cs b/src/RustPlusBot.Features.Map/RustMaps/RustMapsReadyMap.cs index 1af6e0b5..faa5f35a 100644 --- a/src/RustPlusBot.Features.Map/RustMaps/RustMapsReadyMap.cs +++ b/src/RustPlusBot.Features.Map/RustMaps/RustMapsReadyMap.cs @@ -1,6 +1,6 @@ namespace RustPlusBot.Features.Map.RustMaps; -/// A generated RustMaps map ready to post: the downloaded image plus its RustMaps page link. -/// The downloaded RustMaps render bytes. +/// A generated RustMaps map ready to reference: its hosted image URL plus its RustMaps page link. +/// The RustMaps-hosted image URL (the monument-icon render). /// The RustMaps page URL, when available. -public sealed record RustMapsReadyMap(byte[] ImageBytes, string? RustMapsUrl); +public sealed record RustMapsReadyMap(string ImageUrl, string? RustMapsUrl); diff --git a/src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs b/src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs index c29c2904..a1dfd29e 100644 --- a/src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs +++ b/src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs @@ -21,6 +21,7 @@ internal sealed class WorkspaceHostedService( { private readonly CancellationTokenSource _cts = new(); private Task? _connectionStatusLoop; + private Task? _infoMapReadyLoop; private Task? _serverCredentialsLoop; private Task? _serverRegisteredLoop; private bool _startupDone; @@ -36,6 +37,7 @@ public Task StartAsync(CancellationToken cancellationToken) _serverRegisteredLoop = Task.Run(() => ConsumeServerRegisteredAsync(_cts.Token), CancellationToken.None); _connectionStatusLoop = Task.Run(() => ConsumeConnectionStatusAsync(_cts.Token), CancellationToken.None); _serverCredentialsLoop = Task.Run(() => ConsumeServerCredentialsAsync(_cts.Token), CancellationToken.None); + _infoMapReadyLoop = Task.Run(() => ConsumeInfoMapReadyAsync(_cts.Token), CancellationToken.None); return Task.CompletedTask; } @@ -47,7 +49,7 @@ public async Task StopAsync(CancellationToken cancellationToken) await _cts.CancelAsync().ConfigureAwait(false); foreach (var loop in new[] { - _serverRegisteredLoop, _connectionStatusLoop, _serverCredentialsLoop + _serverRegisteredLoop, _connectionStatusLoop, _serverCredentialsLoop, _infoMapReadyLoop }) { if (loop is null) @@ -184,6 +186,34 @@ await reconciler.ReconcileServerAsync(changed.GuildId, changed.ServerId, cancell } } + private async Task ConsumeInfoMapReadyAsync(CancellationToken cancellationToken) + { + // If this loop faults (broad catch), the consumer exits permanently and the #info map message stops + // updating until the host restarts. Acceptable: the reconciler is idempotent and a restart heals. + try + { + await foreach (var ready in eventBus.SubscribeAsync(cancellationToken) + .ConfigureAwait(false)) + { + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var reconciler = scope.ServiceProvider.GetRequiredService(); + await reconciler.ReconcileServerAsync(ready.GuildId, ready.ServerId, cancellationToken) + .ConfigureAwait(false); + } + } + } + catch (OperationCanceledException) + { + // Shutting down. + } + catch (Exception ex) // Broad catch is intentional: a faulting consumer must not crash the host. + { + logger.LogError(ex, "InfoMapReady consumer faulted."); + } + } + private async Task ConsumeServerRegisteredAsync(CancellationToken cancellationToken) { // Subscription is registered when this loop first calls SubscribeAsync; the in-process bus does diff --git a/src/RustPlusBot.Features.Workspace/Locating/IInfoChannelLocator.cs b/src/RustPlusBot.Features.Workspace/Locating/IInfoChannelLocator.cs deleted file mode 100644 index c9417c7d..00000000 --- a/src/RustPlusBot.Features.Workspace/Locating/IInfoChannelLocator.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace RustPlusBot.Features.Workspace.Locating; - -/// Resolves the per-server #info channel id (for posting the static map image). -public interface IInfoChannelLocator -{ - /// Gets the #info Discord channel id for (guild, server), or null if not provisioned. - /// The guild snowflake. - /// The server id. - /// A cancellation token. - /// The channel id, or null. - Task GetChannelIdAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken); -} diff --git a/src/RustPlusBot.Features.Workspace/Locating/InfoChannelLocator.cs b/src/RustPlusBot.Features.Workspace/Locating/InfoChannelLocator.cs deleted file mode 100644 index 43a49eb2..00000000 --- a/src/RustPlusBot.Features.Workspace/Locating/InfoChannelLocator.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using RustPlusBot.Abstractions.Time; - -namespace RustPlusBot.Features.Workspace.Locating; - -/// Resolves the #info channel id for a (guild, server). -/// Opens scopes for the scoped workspace store. -/// Drives the cache TTL. -internal sealed class InfoChannelLocator(IServiceScopeFactory scopeFactory, IClock clock) - : CachingChannelLocator(scopeFactory, clock, WorkspaceChannelKeys.ServerInfo), IInfoChannelLocator; diff --git a/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMapMessageRenderer.cs b/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMapMessageRenderer.cs new file mode 100644 index 00000000..0b01e1ec --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMapMessageRenderer.cs @@ -0,0 +1,72 @@ +using System.Globalization; +using Discord; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Map; +using RustPlusBot.Features.Workspace.Gateway; +using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Localization; + +namespace RustPlusBot.Features.Workspace.Messages; + +/// +/// Renders the per-server #info map message: the RustMaps monument-icon render once generated, else a +/// "preparing" placeholder. Always returns a non-empty embed — the reconciler skips an empty payload, and +/// this message must claim the top-of-channel slot on the very first reconcile. +/// +/// Live query seam (world size/seed). +/// String resolution. +/// +/// Read seam over the RustMaps generation state. Null when no RustMaps API key is configured — the message +/// then always shows the "preparing" placeholder. +/// +internal sealed class ServerInfoMapMessageRenderer( + IRustServerQuery query, + ILocalizer localizer, + IInfoMapReadModel? readModel = null) : IMessageRenderer +{ + /// + public string MessageKey => WorkspaceMessageKeys.ServerInfoMap; + + /// + 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 world = await query.GetWorldAsync(context.GuildId, serverId, cancellationToken).ConfigureAwait(false); + + var embed = new EmbedBuilder() + .WithTitle(localizer.Get("map.info.title", context.Culture)) + .WithColor(Color.DarkGreen); + + InfoMapView? ready = null; + if (world is not null) + { + embed + .AddField(localizer.Get("map.info.size", context.Culture), + world.WorldSize.ToString(CultureInfo.InvariantCulture), inline: true) + .AddField(localizer.Get("map.info.seed", context.Culture), + world.Seed.ToString(CultureInfo.InvariantCulture), inline: true); + ready = readModel?.GetReady((int)world.WorldSize, (int)world.Seed); + } + + if (ready is not null) + { + embed.WithImageUrl(ready.ImageUrl); + if (ready.RustMapsPageUrl is not null) + { + embed.WithUrl(ready.RustMapsPageUrl); + } + } + else + { + embed.WithFooter(localizer.Get("map.info.generating", context.Culture)); + } + + return new MessagePayload(null, embed.Build(), null); + } +} diff --git a/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs b/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs index bf894ed1..ce92fafc 100644 --- a/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs +++ b/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs @@ -27,6 +27,8 @@ public IEnumerable GetChannelSpecs() => /// public IEnumerable GetMessageSpecs() => [ + // Declared FIRST so it posts above the status embed (within-channel order = declaration order). + new(WorkspaceScope.PerServer, WorkspaceMessageKeys.ServerInfoMap, WorkspaceChannelKeys.ServerInfo), new(WorkspaceScope.PerServer, WorkspaceMessageKeys.ServerInfo, WorkspaceChannelKeys.ServerInfo), new(WorkspaceScope.PerServer, WorkspaceMessageKeys.ServerMap, WorkspaceChannelKeys.ServerMap), ]; diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs b/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs index 7d9841c7..e950adfe 100644 --- a/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs +++ b/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs @@ -46,6 +46,9 @@ internal static class WorkspaceMessageKeys /// Key for the main settings message. public const string SettingsMain = "settings.main"; + /// Key for the per-server #info map message (reconciled RustMaps monument-icon render). + public const string ServerInfoMap = "server.info.map"; + /// Key for the per-server info message. public const string ServerInfo = "server.info"; diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs index de503f2f..afc1bdf3 100644 --- a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs @@ -42,6 +42,10 @@ 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(); + // ServerInfoMapMessageRenderer takes IInfoMapReadModel as an OPTIONAL dependency: it only resolves + // when Features.Map registered it (RustMaps API key present); otherwise the renderer shows the + // "preparing" placeholder forever. + services.AddScoped(); services.AddScoped(); // Reconciler + teardown (scoped). Register the teardown service once and expose both interfaces @@ -63,7 +67,6 @@ public static IServiceCollection AddWorkspace(this IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/tests/RustPlusBot.Features.Map.Tests/Hosting/InfoMapHostedServiceTests.cs b/tests/RustPlusBot.Features.Map.Tests/Hosting/InfoMapHostedServiceTests.cs new file mode 100644 index 00000000..be1098b5 --- /dev/null +++ b/tests/RustPlusBot.Features.Map.Tests/Hosting/InfoMapHostedServiceTests.cs @@ -0,0 +1,146 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; +using RustMapsApi.Results; +using RustMapsApi.V4; +using RustMapsApi.V4.Models; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Domain.Connections; +using RustPlusBot.Features.Map.Hosting; +using RustPlusBot.Features.Map.RustMaps; +using RustPlusBot.Persistence.Connections; + +namespace RustPlusBot.Features.Map.Tests.Hosting; + +public sealed class InfoMapHostedServiceTests +{ + private const ulong GuildA = 1UL; + private const ulong GuildB = 2UL; + private static readonly Guid ServerA = Guid.NewGuid(); + private static readonly Guid ServerB = Guid.NewGuid(); + private static readonly RustMapsMapKey Key = new(4000, 12345); + + private static IServiceScopeFactory ScopeFactory(IConnectionStore store) => + new ServiceCollection() + .AddScoped(_ => store) + .BuildServiceProvider() + .GetRequiredService(); + + private static IOptions ShortPollOptions() => Options.Create(new MapOptions + { + RustMaps = new RustMapsOptions + { + GenerationPollInterval = TimeSpan.FromMilliseconds(10) + } + }); + + [Fact] + public async Task Ready_key_publishes_InfoMapReadyEvent_once_per_requester() + { + var coordinator = new RustMapsMapCoordinator(); + coordinator.Register(Key, GuildA, ServerA); + coordinator.Register(Key, GuildB, ServerB); + + var client = Substitute.For(); + client.GetMapBySeedAndSizeAsync(Key.Size, Key.Seed, false, Arg.Any()) + .Returns(Result.Success( + new MapInfo + { + ImageUrl = "https://img/plain.png", + ImageIconUrl = "https://img/icons.png", + Url = "https://rustmaps/x" + }, 200)); + var driver = new RustMapsGenerationDriver(client, coordinator, NullLogger.Instance); + + var query = Substitute.For(); + var bus = new InMemoryEventBus(); + var received = new List(); + using var collectCts = new CancellationTokenSource(); + _ = Task.Run(async () => + { + await foreach (var evt in bus.SubscribeAsync(collectCts.Token)) + { + received.Add(evt); + } + }); + + var service = new InfoMapHostedService( + bus, coordinator, driver, query, ShortPollOptions(), + ScopeFactory(Substitute.For()), NullLogger.Instance); + + await service.StartAsync(CancellationToken.None); + try + { + var deadline = DateTimeOffset.UtcNow.AddSeconds(5); + while (DateTimeOffset.UtcNow < deadline && received.Count < 2) + { + await Task.Delay(20); + } + } + finally + { + await service.StopAsync(CancellationToken.None); + await collectCts.CancelAsync(); + } + + Assert.Equal(2, received.Count); + Assert.Contains(received, e => e.GuildId == GuildA && e.ServerId == ServerA); + Assert.Contains(received, e => e.GuildId == GuildB && e.ServerId == ServerB); + Assert.Equal(RustMapsGenerationState.Ready, coordinator.Snapshot(Key).State); + Assert.Equal("https://img/icons.png", coordinator.GetReady(Key.Size, Key.Seed)!.ImageUrl); + } + + [Fact] + public async Task Connect_registers_the_servers_world_key_with_the_coordinator() + { + // No tick should be needed for registration — the connection-status path resolves the world and + // registers immediately, independent of the (here, effectively-never-firing) generation poll. + var coordinator = new RustMapsMapCoordinator(); + var driver = new RustMapsGenerationDriver( + Substitute.For(), coordinator, NullLogger.Instance); + + var serverId = Guid.NewGuid(); + var query = Substitute.For(); + query.GetWorldAsync(GuildA, serverId, Arg.Any()) + .Returns(new WorldSnapshot((uint)Key.Size, (uint)Key.Seed)); + + var connectionStore = Substitute.For(); + connectionStore.GetStateAsync(GuildA, serverId, Arg.Any()) + .Returns(new ConnectionState + { + GuildId = GuildA, RustServerId = serverId, Status = ConnectionStatus.Connected + }); + + var bus = new InMemoryEventBus(); + var pollOptions = Options.Create(new MapOptions + { + RustMaps = new RustMapsOptions + { + GenerationPollInterval = TimeSpan.FromSeconds(30) // must not need to fire for this test to pass + } + }); + var service = new InfoMapHostedService( + bus, coordinator, driver, query, pollOptions, + ScopeFactory(connectionStore), NullLogger.Instance); + + await service.StartAsync(CancellationToken.None); + try + { + var deadline = DateTimeOffset.UtcNow.AddSeconds(5); + while (DateTimeOffset.UtcNow < deadline && coordinator.PendingKeys().Count == 0) + { + await bus.PublishAsync(new ConnectionStatusChangedEvent(GuildA, serverId, true, false)); + await Task.Delay(20); + } + } + finally + { + await service.StopAsync(CancellationToken.None); + } + + Assert.Contains(Key, coordinator.PendingKeys()); + Assert.Contains((GuildA, serverId), coordinator.Requesters(Key)); + } +} diff --git a/tests/RustPlusBot.Features.Map.Tests/Hosting/InfoMapServiceTests.cs b/tests/RustPlusBot.Features.Map.Tests/Hosting/InfoMapServiceTests.cs deleted file mode 100644 index 7d34f977..00000000 --- a/tests/RustPlusBot.Features.Map.Tests/Hosting/InfoMapServiceTests.cs +++ /dev/null @@ -1,204 +0,0 @@ -using Discord; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; -using NSubstitute; -using RustMapsApi.V4; -using RustPlusBot.Abstractions.Connections; -using RustPlusBot.Abstractions.Events; -using RustPlusBot.Features.Events.State; -using RustPlusBot.Features.Map.Composing; -using RustPlusBot.Features.Map.Hosting; -using RustPlusBot.Features.Map.Posting; -using RustPlusBot.Features.Map.Rendering; -using RustPlusBot.Features.Map.RustMaps; -using RustPlusBot.Features.Workspace.Locating; -using RustPlusBot.Localization; -using RustPlusBot.Persistence.Workspace; -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.PixelFormats; - -namespace RustPlusBot.Features.Map.Tests.Hosting; - -public sealed class InfoMapServiceTests -{ - private const ulong Guild = 1UL; - private static readonly Guid Server = Guid.NewGuid(); - private static readonly RustMapsMapKey Key = new(4000, 12345); - - private static byte[] BaseJpeg() - { - using var img = new Image(64, 64, new Rgba32(0, 128, 0)); - using var ms = new MemoryStream(); - img.SaveAsJpeg(ms); - return ms.ToArray(); - } - - private static IServiceScopeFactory ScopeFactory(IWorkspaceStore workspaceStore) => - new ServiceCollection() - .AddScoped(_ => workspaceStore) - .BuildServiceProvider() - .GetRequiredService(); - - private static (InfoMapHostedService Service, RustMapsMapCoordinator Coordinator, IInfoMapPoster Poster, - IRustServerQuery Query) Build(ulong? channelId = 123UL, IInfoMapPoster? poster = null) - { - var coordinator = new RustMapsMapCoordinator(); - - var query = Substitute.For(); - query.GetWorldAsync(Guild, Server, Arg.Any()) - .Returns(new WorldSnapshot((uint)Key.Size, (uint)Key.Seed)); - query.GetMapDimensionsAsync(Guild, Server, Arg.Any()) - .Returns(new MapDimensions(2000, 2000, 100, 4000)); - query.GetMonumentsAsync(Guild, Server, Arg.Any()).Returns([]); - - var locator = Substitute.For(); - locator.GetChannelIdAsync(Guild, Server, Arg.Any()).Returns(channelId); - - poster ??= Substitute.For(); - - var workspaceStore = Substitute.For(); - workspaceStore.GetCultureAsync(Guild, Arg.Any()).Returns("en"); - var scopeFactory = ScopeFactory(workspaceStore); - - var baseSource = new FakeBaseMapSource(new BaseMapImage(BaseJpeg(), 2000, 2000, 100)); - var composer = new MapComposer( - new BaseMapCache([baseSource]), - Substitute.For(), - Substitute.For(), - query, - new MapRenderer(), - scopeFactory); - - var driver = new RustMapsGenerationDriver( - Substitute.For(), - coordinator, - Substitute.For(), - NullLogger.Instance); - - var service = new InfoMapHostedService( - Substitute.For(), - coordinator, - driver, - composer, - poster, - locator, - query, - new ResxLocalizer(), - Options.Create(new MapOptions()), - scopeFactory, - NullLogger.Instance); - - return (service, coordinator, poster, query); - } - - [Fact] - public async Task Ready_posts_the_RustMaps_bytes_once_and_a_repeat_call_is_a_no_op() - { - var (service, coordinator, poster, _) = Build(); - byte[] readyBytes = [9, 9, 9]; - coordinator.SetReady(Key, new RustMapsReadyMap(readyBytes, "https://rustmaps/x")); - - await service.EnsureInfoMapAsync(Guild, Server, CancellationToken.None); - await service.EnsureInfoMapAsync(Guild, Server, CancellationToken.None); - - await poster.Received(1).UpsertAsync(123UL, Arg.Any(), Arg.Any(), - Arg.Is(b => b.SequenceEqual(readyBytes)), Arg.Any()); - } - - [Fact] - public async Task Not_ready_posts_the_fallback_bytes_once_and_a_repeat_call_is_a_no_op() - { - var (service, coordinator, poster, _) = Build(); - - await service.EnsureInfoMapAsync(Guild, Server, CancellationToken.None); - await service.EnsureInfoMapAsync(Guild, Server, CancellationToken.None); - - // Idle for the whole test — no generation was ever advanced. - Assert.Equal(RustMapsGenerationState.Idle, coordinator.Snapshot(Key).State); - await poster.Received(1) - .UpsertAsync(123UL, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task No_info_channel_never_posts() - { - var (service, _, poster, _) = Build(channelId: null); - - await service.EnsureInfoMapAsync(Guild, Server, CancellationToken.None); - - await poster.DidNotReceiveWithAnyArgs().UpsertAsync(default, default, default!, default!, default); - } - - [Fact] - public async Task No_info_channel_never_registers_the_key_with_the_coordinator() - { - var (service, coordinator, poster, _) = Build(channelId: null); - - await service.EnsureInfoMapAsync(Guild, Server, CancellationToken.None); - - Assert.Empty(coordinator.PendingKeys()); - Assert.Empty(coordinator.Requesters(Key)); - await poster.DidNotReceiveWithAnyArgs().UpsertAsync(default, default, default!, default!, default); - } - - [Fact] - public async Task World_unavailable_never_posts() - { - var (service, _, poster, query) = Build(); - query.GetWorldAsync(Guild, Server, Arg.Any()).Returns((WorldSnapshot?)null); - - await service.EnsureInfoMapAsync(Guild, Server, CancellationToken.None); - - await poster.DidNotReceiveWithAnyArgs().UpsertAsync(default, default, default!, default!, default); - } - - [Fact] - public async Task Concurrent_calls_for_the_same_server_are_serialized_and_post_once() - { - // The connection-status loop and the tick loop both call EnsureInfoMapAsync for the same - // server. Without per-server serialization their delete-then-repost interleaves and leaves - // TWO #info messages (the fallback and the RustMaps render each survive the other's delete - // scan). Serialized, the second caller sees the first's posted state and is a no-op. - var poster = new GatedPoster(); - var (service, coordinator, _, _) = Build(poster: poster); - coordinator.SetReady(Key, new RustMapsReadyMap([9, 9, 9], "https://rustmaps/x")); - - var first = service.EnsureInfoMapAsync(Guild, Server, CancellationToken.None); - var second = service.EnsureInfoMapAsync(Guild, Server, CancellationToken.None); - - await poster.Entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); // first call is inside UpsertAsync - await Task.Delay(100); // give the second call ample time to (try to) enter UpsertAsync - Assert.Equal(1, poster.Calls); // serialized: the second call is blocked, not inside UpsertAsync - - poster.Release.SetResult(); - await Task.WhenAll(first, second); - Assert.Equal(1, poster.Calls); // the second call saw the Ready state already posted → no-op - } - - private sealed class GatedPoster : IInfoMapPoster - { - private int _calls; - public TaskCompletionSource Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); - public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); - public int Calls => Volatile.Read(ref _calls); - - public async Task UpsertAsync(ulong channelId, - ulong? existingMessageId, - Embed embed, - byte[] pngBytes, - CancellationToken cancellationToken) - { - Interlocked.Increment(ref _calls); - Entered.TrySetResult(); - await Release.Task.ConfigureAwait(false); - return 555UL; - } - } - - private sealed class FakeBaseMapSource(BaseMapImage image) : IBaseMapSource - { - public Task GetAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) => - Task.FromResult(image); - } -} diff --git a/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs index f6eeebc0..b8a56ddf 100644 --- a/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs @@ -73,32 +73,6 @@ private static IMapSettingsStore NewSettings(MapLayerSettings settings) return store; } - /// Builds a composer wired for the static #info path: a valid base map + dimensions, with - /// caller-controlled dynamic state (events/team) and monument list. The settings store is AllOn but - /// irrelevant — ComposeStaticAsync never reads it. - /// Live marker state; defaults to an empty stub. - /// Team snapshot returned by the query seam; null when no team. - /// Monuments returned by the query seam; defaults to empty. - private static MapComposer BuildComposer( - IEventState? events = null, - TeamInfoSnapshot? team = null, - IReadOnlyList? monuments = null) - { - var query = NewQuery(); - query.GetTeamInfoAsync(Guild, Server, Arg.Any()).Returns(team); - query.GetMonumentsAsync(Guild, Server, Arg.Any()).Returns(monuments ?? []); - return Build(BaseJpeg(), Dims, query, events ?? NewEvents(), NewRigs(), NewSettings(MapLayerSettings.AllOn)); - } - - private static IEventState EventsWithCargo() => - NewEvents(new ActiveMarker(1, MarkerKind.CargoShip, 2000f, 2000f, Dims, DateTimeOffset.UtcNow, - [new TrailPoint(2000f, 2000f)], null)); - - private static IEventState EmptyEvents() => NewEvents(); - - private static TeamInfoSnapshot TeamWithPlayer() => - new(0, [new TeamMemberSnapshot(1, "Ada", 2000f, 2000f, true, true, default, default)]); - [Fact] public async Task ComposeAsync_returns_null_when_no_base_map() { @@ -239,36 +213,6 @@ public async Task ComposeAsync_forwards_vendor_marker_history_into_rendered_trai "Vendor trail with 2-point history must render differently than a 1-point history."); } - [Fact] - public async Task ComposeStaticAsync_ignores_markers_and_players() - { - // Static compose must be invariant to live marker/player state (dynamic layers are OFF), - // so the bytes are identical whether or not the stores hold markers/players. - var withDynamic = BuildComposer(events: EventsWithCargo(), team: TeamWithPlayer()); - var withoutDynamic = BuildComposer(events: EmptyEvents(), team: null); - - var a = await withDynamic.ComposeStaticAsync(Guild, Server, CancellationToken.None); - var b = await withoutDynamic.ComposeStaticAsync(Guild, Server, CancellationToken.None); - - Assert.NotNull(a); - Assert.NotNull(b); - Assert.True(a!.AsSpan().SequenceEqual(b)); - } - - [Fact] - public async Task ComposeStaticAsync_draws_monuments_over_the_base() - { - var composer = BuildComposer(monuments: [new MonumentSnapshot("launchsite", 2000f, 2000f)]); - var withMonument = await composer.ComposeStaticAsync(Guild, Server, CancellationToken.None); - - var bare = BuildComposer(monuments: []); - var withoutMonument = await bare.ComposeStaticAsync(Guild, Server, CancellationToken.None); - - Assert.NotNull(withMonument); - Assert.NotNull(withoutMonument); - Assert.False(withMonument!.AsSpan().SequenceEqual(withoutMonument!)); // monument icon changed pixels - } - private sealed class FakeSource(BaseMapImage? result) : IBaseMapSource { public Task GetAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken) => diff --git a/tests/RustPlusBot.Features.Map.Tests/MapRegistrationTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapRegistrationTests.cs index 26128a79..2639210d 100644 --- a/tests/RustPlusBot.Features.Map.Tests/MapRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/MapRegistrationTests.cs @@ -2,10 +2,10 @@ using Microsoft.Extensions.DependencyInjection; using NSubstitute; using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Map; using RustPlusBot.Features.Events.State; using RustPlusBot.Features.Map.Composing; using RustPlusBot.Features.Map.Hosting; -using RustPlusBot.Features.Map.Posting; using RustPlusBot.Features.Map.Rendering; using RustPlusBot.Features.Map.RustMaps; using RustPlusBot.Persistence.Map; @@ -71,10 +71,15 @@ public void AddMap_with_RustMaps_key_registers_the_generation_components() services.AddMap(configuration); using var provider = services.BuildServiceProvider(validateScopes: true); - Assert.NotNull(provider.GetService()); + var coordinator = provider.GetService(); + var readModel = provider.GetService(); + Assert.NotNull(coordinator); + Assert.NotNull(readModel); + // IRustMapsMapCoordinator and IInfoMapReadModel must resolve to the SAME singleton so the driver's + // writes are visible to the Workspace renderer's reads. + Assert.Same(coordinator, readModel); Assert.NotNull(provider.GetService()); // Discord-dependent — assert registration without constructing DiscordSocketClient. - Assert.Contains(services, d => d.ServiceType == typeof(IInfoMapPoster)); Assert.Contains(services, d => d.ImplementationType == typeof(InfoMapHostedService)); } @@ -83,7 +88,7 @@ public void AddMap_without_RustMaps_key_registers_no_generation_components() { using var provider = BuildProvider(EmptyConfiguration()); Assert.Null(provider.GetService()); - Assert.Null(provider.GetService()); + Assert.Null(provider.GetService()); } private static IConfiguration EmptyConfiguration() => diff --git a/tests/RustPlusBot.Features.Map.Tests/RustMaps/RustMapsGenerationDriverTests.cs b/tests/RustPlusBot.Features.Map.Tests/RustMaps/RustMapsGenerationDriverTests.cs index c05f36d1..518e5aa4 100644 --- a/tests/RustPlusBot.Features.Map.Tests/RustMaps/RustMapsGenerationDriverTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/RustMaps/RustMapsGenerationDriverTests.cs @@ -1,4 +1,3 @@ -using System.Net; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using RustMapsApi.Results; @@ -16,14 +15,11 @@ public sealed class RustMapsGenerationDriverTests private static RustMapsError Error(RustMapsErrorKind kind) => new(kind, null, null, null); - private static (RustMapsGenerationDriver Driver, IRustMapsClient Client, RustMapsMapCoordinator Coord) Build( - HttpStatusCode imageStatus = HttpStatusCode.OK) + private static (RustMapsGenerationDriver Driver, IRustMapsClient Client, RustMapsMapCoordinator Coord) Build() { var client = Substitute.For(); var coord = new RustMapsMapCoordinator(); - var factory = new StubFactory(new StubHandler(imageStatus, [7, 7, 7])); - var driver = new RustMapsGenerationDriver(client, coord, factory, - NullLogger.Instance); + var driver = new RustMapsGenerationDriver(client, coord, NullLogger.Instance); return (driver, client, coord); } @@ -42,20 +38,16 @@ public async Task Existing_map_goes_straight_to_ready_without_generating() await driver.AdvanceAsync(Key, CancellationToken.None); Assert.Equal(RustMapsGenerationState.Ready, coord.Snapshot(Key).State); - Assert.Equal([7, 7, 7], coord.Snapshot(Key).Ready!.ImageBytes); + Assert.Equal("https://img/x.png", coord.Snapshot(Key).Ready!.ImageUrl); await client.DidNotReceiveWithAnyArgs().CreateMapAsync(default!, default); } [Fact] - public async Task Ready_downloads_the_icon_render_not_the_plain_terrain_one() + public async Task Ready_prefers_the_icon_render_url_over_the_plain_terrain_one() { // ImageIconUrl (map_icons.png) carries the monument markers; ImageUrl (map_raw_normalized.png) - // is plain terrain. The driver must download the iconned render. - var capture = new CapturingHandler(); - var client = Substitute.For(); - var coord = new RustMapsMapCoordinator(); - var driver = new RustMapsGenerationDriver(client, coord, new StubFactory(capture), - NullLogger.Instance); + // is plain terrain. The driver must store the iconned render's URL. + var (driver, client, coord) = Build(); coord.Register(Key, 1UL, Server); client.GetMapBySeedAndSizeAsync(4000, 12345, false, Arg.Any()) .Returns(Result.Success( @@ -68,7 +60,7 @@ public async Task Ready_downloads_the_icon_render_not_the_plain_terrain_one() await driver.AdvanceAsync(Key, CancellationToken.None); - Assert.Equal("https://img/icons.png", capture.LastUri?.ToString()); + Assert.Equal("https://img/icons.png", coord.Snapshot(Key).Ready!.ImageUrl); } [Fact] @@ -151,7 +143,7 @@ public async Task Limits_call_failing_fails_closed_no_generate() } [Fact] - public async Task Poll_reaches_ready_and_downloads_the_image() + public async Task Poll_reaches_ready_and_stores_the_image_url() { var (driver, client, coord) = Build(); coord.Register(Key, 1UL, Server); @@ -166,7 +158,7 @@ public async Task Poll_reaches_ready_and_downloads_the_image() await driver.AdvanceAsync(Key, CancellationToken.None); Assert.Equal(RustMapsGenerationState.Ready, coord.Snapshot(Key).State); - Assert.Equal([7, 7, 7], coord.Snapshot(Key).Ready!.ImageBytes); + Assert.Equal("https://img/x.png", coord.Snapshot(Key).Ready!.ImageUrl); } [Fact] @@ -183,36 +175,4 @@ public async Task Poll_still_generating_stays_generating_without_spending() Assert.Equal(RustMapsGenerationState.Generating, coord.Snapshot(Key).State); await client.DidNotReceiveWithAnyArgs().CreateMapAsync(default!, default); } - - private sealed class StubHandler(HttpStatusCode status, byte[] body) : HttpMessageHandler - { - protected override Task SendAsync( - HttpRequestMessage request, - CancellationToken cancellationToken) => - Task.FromResult(new HttpResponseMessage(status) - { - Content = new ByteArrayContent(body) - }); - } - - private sealed class StubFactory(HttpMessageHandler handler) : IHttpClientFactory - { - public HttpClient CreateClient(string name) => new(handler, disposeHandler: false); - } - - private sealed class CapturingHandler : HttpMessageHandler - { - public Uri? LastUri { get; private set; } - - protected override Task SendAsync( - HttpRequestMessage request, - CancellationToken cancellationToken) - { - LastUri = request.RequestUri; - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new ByteArrayContent([7, 7, 7]) - }); - } - } } diff --git a/tests/RustPlusBot.Features.Map.Tests/RustMaps/RustMapsMapCoordinatorTests.cs b/tests/RustPlusBot.Features.Map.Tests/RustMaps/RustMapsMapCoordinatorTests.cs index e6f89fe0..d4b6237d 100644 --- a/tests/RustPlusBot.Features.Map.Tests/RustMaps/RustMapsMapCoordinatorTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/RustMaps/RustMapsMapCoordinatorTests.cs @@ -36,10 +36,10 @@ public void Lifecycle_transitions_and_ready_caches_the_image() Assert.Equal(RustMapsGenerationState.Generating, c.Snapshot(Key).State); Assert.Equal("map-1", c.Snapshot(Key).MapId); - c.SetReady(Key, new RustMapsReadyMap([1, 2, 3], "https://rustmaps/x")); + c.SetReady(Key, new RustMapsReadyMap("https://img/x.png", "https://rustmaps/x")); var snap = c.Snapshot(Key); Assert.Equal(RustMapsGenerationState.Ready, snap.State); - Assert.Equal([1, 2, 3], snap.Ready!.ImageBytes); + Assert.Equal("https://img/x.png", snap.Ready!.ImageUrl); Assert.Equal("https://rustmaps/x", snap.Ready.RustMapsUrl); } @@ -54,4 +54,36 @@ public void Terminal_states_are_not_pending_and_are_not_reset_by_register() Assert.Equal(RustMapsGenerationState.Failed, c.Snapshot(Key).State); Assert.DoesNotContain(Key, c.PendingKeys()); } + + [Fact] + public void GetReady_returns_the_view_when_ready() + { + var c = new RustMapsMapCoordinator(); + c.Register(Key, 1UL, Server); + c.SetReady(Key, new RustMapsReadyMap("https://img/x.png", "https://rustmaps/x")); + + var view = c.GetReady(Key.Size, Key.Seed); + + Assert.NotNull(view); + Assert.Equal("https://img/x.png", view!.ImageUrl); + Assert.Equal("https://rustmaps/x", view.RustMapsPageUrl); + } + + [Fact] + public void GetReady_returns_null_when_not_ready() + { + var c = new RustMapsMapCoordinator(); + c.Register(Key, 1UL, Server); + c.TrySetGenerating(Key, "map-1"); + + Assert.Null(c.GetReady(Key.Size, Key.Seed)); + } + + [Fact] + public void GetReady_returns_null_for_an_unseen_key() + { + var c = new RustMapsMapCoordinator(); + + Assert.Null(c.GetReady(Key.Size, Key.Seed)); + } } diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Locating/InfoChannelLocatorTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Locating/InfoChannelLocatorTests.cs deleted file mode 100644 index 5b6bad8f..00000000 --- a/tests/RustPlusBot.Features.Workspace.Tests/Locating/InfoChannelLocatorTests.cs +++ /dev/null @@ -1,126 +0,0 @@ -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using NSubstitute; -using RustPlusBot.Abstractions.Time; -using RustPlusBot.Domain.Servers; -using RustPlusBot.Domain.Workspace; -using RustPlusBot.Features.Workspace.Locating; -using RustPlusBot.Persistence; -using RustPlusBot.Persistence.Workspace; - -namespace RustPlusBot.Features.Workspace.Tests.Locating; - -public sealed class InfoChannelLocatorTests -{ - private static (InfoChannelLocator Locator, ServiceProvider Provider, string ConnectionString, IClock Clock) - CreateLocator() - { - var clock = Substitute.For(); - clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); - - var cs = $"DataSource=info-locator-{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; - var keepAlive = new SqliteConnection(cs); - keepAlive.Open(); - using (var seed = new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)) - { - seed.Database.Migrate(); - } - - var services = new ServiceCollection(); - services.AddSingleton(keepAlive); - services.AddSingleton(clock); - services.AddScoped(_ => new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options)); - services.AddScoped(); - var provider = services.BuildServiceProvider(); - - var locator = new InfoChannelLocator(provider.GetRequiredService(), clock); - return (locator, provider, cs, clock); - } - - private static async Task SeedAsync(string connectionString) - { - await using var context = - new BotDbContext(new DbContextOptionsBuilder().UseSqlite(connectionString).Options); - - var server = new RustServer - { - GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015 - }; - context.RustServers.Add(server); - await context.SaveChangesAsync(); - - context.ProvisionedChannels.Add(new ProvisionedChannel - { - GuildId = 10UL, - RustServerId = server.Id, - ChannelKey = WorkspaceChannelKeys.ServerInfo, - DiscordChannelId = 777UL, - CreatedAt = DateTimeOffset.UnixEpoch, - }); - await context.SaveChangesAsync(); - - return server.Id; - } - - [Fact] - public async Task GetChannelIdAsync_returns_provisioned_info_channel() - { - var (locator, provider, cs, _) = CreateLocator(); - await using var _p = provider; - var serverId = await SeedAsync(cs); - - var channelId = await locator.GetChannelIdAsync(10UL, serverId, CancellationToken.None); - - Assert.Equal(777UL, channelId); - } - - [Fact] - public async Task GetChannelIdAsync_returns_null_when_not_provisioned() - { - var (locator, provider, _, _) = CreateLocator(); - await using var _p = provider; - - Assert.Null(await locator.GetChannelIdAsync(10UL, Guid.NewGuid(), CancellationToken.None)); - } - - [Fact] - public async Task Cache_refreshes_after_ttl_expires() - { - var (locator, provider, cs, clock) = CreateLocator(); - await using var _p = provider; - - // Cold load with empty DB — cache built at UnixEpoch, no rows. - var firstResult = await locator.GetChannelIdAsync(20UL, Guid.NewGuid(), CancellationToken.None); - Assert.Null(firstResult); - - // Insert a server + channel into the DB after the first load. - await using var insertCtx = - new BotDbContext(new DbContextOptionsBuilder().UseSqlite(cs).Options); - var server = new RustServer - { - GuildId = 20UL, Name = "T", Ip = "2.2.2.2", Port = 28015 - }; - insertCtx.RustServers.Add(server); - await insertCtx.SaveChangesAsync(); - insertCtx.ProvisionedChannels.Add(new ProvisionedChannel - { - GuildId = 20UL, - RustServerId = server.Id, - ChannelKey = WorkspaceChannelKeys.ServerInfo, - DiscordChannelId = 998UL, - CreatedAt = DateTimeOffset.UnixEpoch, - }); - await insertCtx.SaveChangesAsync(); - - // Clock still at UnixEpoch — within the 30 s TTL, cache must NOT be reloaded. - var withinTtlResult = await locator.GetChannelIdAsync(20UL, server.Id, CancellationToken.None); - Assert.Null(withinTtlResult); - - // Advance the clock past the 30 s TTL — next call must rebuild the cache. - clock.UtcNow.Returns(DateTimeOffset.UnixEpoch + TimeSpan.FromSeconds(31)); - - var afterTtlResult = await locator.GetChannelIdAsync(20UL, server.Id, CancellationToken.None); - Assert.Equal(998UL, afterTtlResult); - } -} diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Messages/ServerInfoMapMessageRendererTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Messages/ServerInfoMapMessageRendererTests.cs new file mode 100644 index 00000000..d33fb61a --- /dev/null +++ b/tests/RustPlusBot.Features.Workspace.Tests/Messages/ServerInfoMapMessageRendererTests.cs @@ -0,0 +1,126 @@ +using NSubstitute; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Map; +using RustPlusBot.Features.Workspace.Messages; +using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Localization; + +namespace RustPlusBot.Features.Workspace.Tests.Messages; + +public sealed class ServerInfoMapMessageRendererTests +{ + private static readonly ResxLocalizer Loc = new(); + + private static IRustServerQuery QueryWithWorld(ulong guildId, Guid serverId, uint size = 4000, uint seed = 12345) + { + var query = Substitute.For(); + query.GetWorldAsync(guildId, serverId, Arg.Any()) + .Returns(new WorldSnapshot(size, seed)); + return query; + } + + [Fact] + public async Task NullServerId_RendersEmptyPayload() + { + var query = Substitute.For(); + var renderer = new ServerInfoMapMessageRenderer(query, Loc); + + var payload = await renderer.RenderAsync(new MessageRenderContext(1, null, "en"), default); + + Assert.Null(payload.Text); + Assert.Null(payload.Embed); + Assert.Null(payload.Components); + } + + [Fact] + public async Task Ready_view_sets_the_embed_image_and_url() + { + var serverId = Guid.NewGuid(); + var query = QueryWithWorld(1, serverId); + var readModel = Substitute.For(); + readModel.GetReady(4000, 12345).Returns(new InfoMapView("https://img/icons.png", "https://rustmaps/x")); + var renderer = new ServerInfoMapMessageRenderer(query, Loc, readModel); + + var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); + + Assert.NotNull(payload.Embed); + Assert.Equal("https://img/icons.png", payload.Embed!.Image!.Value.Url); + Assert.Equal("https://rustmaps/x", payload.Embed.Url); + } + + [Fact] + public async Task Ready_view_without_page_url_omits_the_embed_url() + { + var serverId = Guid.NewGuid(); + var query = QueryWithWorld(1, serverId); + var readModel = Substitute.For(); + readModel.GetReady(4000, 12345).Returns(new InfoMapView("https://img/icons.png", null)); + var renderer = new ServerInfoMapMessageRenderer(query, Loc, readModel); + + var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); + + Assert.Equal("https://img/icons.png", payload.Embed!.Image!.Value.Url); + Assert.Null(payload.Embed.Url); + } + + [Fact] + public async Task No_readModel_configured_still_returns_a_non_empty_embed_with_the_generating_footer() + { + // IInfoMapReadModel is only registered when a RustMaps API key is configured — the renderer must + // still be usable (and non-empty, so the reconciler doesn't skip it) with the optional dependency unset. + var serverId = Guid.NewGuid(); + var query = QueryWithWorld(1, serverId); + var renderer = new ServerInfoMapMessageRenderer(query, Loc); + + var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); + + Assert.NotNull(payload.Embed); + Assert.Null(payload.Embed!.Image); + Assert.Equal(Loc.Get("map.info.generating", "en"), payload.Embed.Footer!.Value.Text); + } + + [Fact] + public async Task Not_ready_yet_shows_the_generating_footer() + { + var serverId = Guid.NewGuid(); + var query = QueryWithWorld(1, serverId); + var readModel = Substitute.For(); + readModel.GetReady(4000, 12345).Returns((InfoMapView?)null); + var renderer = new ServerInfoMapMessageRenderer(query, Loc, readModel); + + var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); + + Assert.NotNull(payload.Embed); + Assert.Equal(Loc.Get("map.info.generating", "en"), payload.Embed!.Footer!.Value.Text); + } + + [Fact] + public async Task No_world_yet_still_returns_a_non_empty_embed_so_the_reconciler_does_not_skip_it() + { + // Ordering caveat: the reconciler skips an empty payload, which would let ServerInfo claim the + // top slot. This message must be non-empty even before the world snapshot is available. + var serverId = Guid.NewGuid(); + var query = Substitute.For(); + query.GetWorldAsync(1, serverId, Arg.Any()).Returns((WorldSnapshot?)null); + var renderer = new ServerInfoMapMessageRenderer(query, Loc); + + var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); + + Assert.NotNull(payload.Embed); + Assert.Empty(payload.Embed!.Fields); + } + + [Fact] + public async Task World_present_shows_size_and_seed_fields() + { + var serverId = Guid.NewGuid(); + var query = QueryWithWorld(1, serverId); + var renderer = new ServerInfoMapMessageRenderer(query, Loc); + + var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); + + var body = string.Concat(payload.Embed!.Fields.Select(f => f.Value)); + Assert.Contains("4000", body, StringComparison.Ordinal); + Assert.Contains("12345", body, StringComparison.Ordinal); + } +} From 4c2a1d9240e2b3b949262b82131008e570747f06 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Thu, 9 Jul 2026 20:30:15 +0200 Subject: [PATCH 29/40] fix(workspace): reconcile channel message order (map above the #info status embed) EnsureMessagesAsync only edited-in-place or posted-if-missing per spec, never reordering, so on an already-provisioned guild a newly-declared earlier message (server.info.map) posted after an existing later one (server.info) since Discord orders by creation time. Group specs by channel, detect a to-post message that precedes an already-live one, and delete+repost the trailing live messages so they re-materialize fresh, below it, in declaration order. In-order and single-message channels are unaffected (still edit-in-place). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Gateway/DiscordWorkspaceGateway.cs | 23 ++++ .../Gateway/IWorkspaceGateway.cs | 7 ++ .../Reconciler/WorkspaceReconciler.cs | 118 ++++++++++++------ .../Fakes/FakeWorkspaceGateway.cs | 16 +++ .../Reconciler/ReconcilerHarness.cs | 21 ++++ .../WorkspaceReconcilerMessageTests.cs | 92 ++++++++++++++ 6 files changed, 238 insertions(+), 39 deletions(-) diff --git a/src/RustPlusBot.Features.Workspace/Gateway/DiscordWorkspaceGateway.cs b/src/RustPlusBot.Features.Workspace/Gateway/DiscordWorkspaceGateway.cs index b9efd24b..e9ea89e9 100644 --- a/src/RustPlusBot.Features.Workspace/Gateway/DiscordWorkspaceGateway.cs +++ b/src/RustPlusBot.Features.Workspace/Gateway/DiscordWorkspaceGateway.cs @@ -1,4 +1,5 @@ using Discord; +using Discord.Net; using Discord.WebSocket; using RustPlusBot.Features.Workspace.Registry; @@ -138,6 +139,28 @@ await channel.ModifyMessageAsync(messageId, props => }).ConfigureAwait(false); } + /// + public async Task DeleteMessageAsync(ulong guildId, + ulong channelId, + ulong messageId, + CancellationToken cancellationToken) + { + var channel = client.GetGuild(guildId)?.GetTextChannel(channelId); + if (channel is null) + { + return; + } + + try + { + await channel.DeleteMessageAsync(messageId).ConfigureAwait(false); + } + catch (HttpException ex) when (ex.HttpCode == System.Net.HttpStatusCode.NotFound) + { + // Already gone: best-effort delete succeeds trivially. + } + } + /// public async Task DeleteChannelAsync(ulong guildId, ulong channelId, CancellationToken cancellationToken) { diff --git a/src/RustPlusBot.Features.Workspace/Gateway/IWorkspaceGateway.cs b/src/RustPlusBot.Features.Workspace/Gateway/IWorkspaceGateway.cs index add747be..2e8cce00 100644 --- a/src/RustPlusBot.Features.Workspace/Gateway/IWorkspaceGateway.cs +++ b/src/RustPlusBot.Features.Workspace/Gateway/IWorkspaceGateway.cs @@ -97,6 +97,13 @@ Task EditMessageAsync(ulong guildId, MessagePayload payload, CancellationToken cancellationToken); + /// Deletes a single message; a no-op if it is already gone. + /// The snowflake ID of the guild. + /// The snowflake ID of the channel containing the message. + /// The snowflake ID of the message to delete. + /// Token to cancel the operation. + Task DeleteMessageAsync(ulong guildId, ulong channelId, ulong messageId, CancellationToken cancellationToken); + /// Deletes a channel by snowflake (no-op if already gone). /// The snowflake ID of the guild. /// The snowflake ID of the channel to delete. diff --git a/src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs b/src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs index feab5810..4e842de0 100644 --- a/src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs +++ b/src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs @@ -235,60 +235,93 @@ private async Task EnsureMessagesAsync(ulong guildId, WorkspaceScope scope, CancellationToken cancellationToken) { - foreach (var spec in backends.Registry.GetMessageSpecs(scope)) - { - if (!channelIds.TryGetValue(spec.ChannelKey, out var channelId) || - !_renderers.TryGetValue(spec.Key, out var renderer)) - { - continue; - } + var specsByChannel = backends.Registry.GetMessageSpecs(scope) + .Where(s => channelIds.ContainsKey(s.ChannelKey) && _renderers.ContainsKey(s.Key)) + .GroupBy(s => s.ChannelKey, StringComparer.Ordinal); - var payload = await renderer - .RenderAsync(new MessageRenderContext(guildId, serverId, culture), cancellationToken) - .ConfigureAwait(false); + foreach (var group in specsByChannel) + { + var channelId = channelIds[group.Key]; + var items = new List(); - // A renderer with nothing to show (e.g. the source entity vanished mid-reconcile) returns an - // empty payload; Discord rejects a message with no content/embed/components, so skip it. - if (payload.Text is null && payload.Embed is null && payload.Components is null) + foreach (var spec in group) { - continue; - } + var payload = await _renderers[spec.Key] + .RenderAsync(new MessageRenderContext(guildId, serverId, culture), cancellationToken) + .ConfigureAwait(false); - var record = await backends.Store.GetMessageAsync(guildId, serverId, spec.Key, cancellationToken) - .ConfigureAwait(false); + // A renderer with nothing to show (e.g. the source entity vanished mid-reconcile) returns an + // empty payload; Discord rejects a message with no content/embed/components, so skip it. + var isEmpty = payload.Text is null && payload.Embed is null && payload.Components is null; + + ulong? liveId = null; + if (!isEmpty) + { + var record = await backends.Store.GetMessageAsync(guildId, serverId, spec.Key, cancellationToken) + .ConfigureAwait(false); + if (record is not null + && record.DiscordChannelId == channelId + && await backends.Gateway + .MessageExistsAsync(guildId, channelId, record.DiscordMessageId, cancellationToken) + .ConfigureAwait(false)) + { + liveId = record.DiscordMessageId; + } + } - var canEditInPlace = record is not null - && record.DiscordChannelId == channelId - && await backends.Gateway - .MessageExistsAsync(guildId, channelId, record.DiscordMessageId, cancellationToken) - .ConfigureAwait(false); + items.Add(new MessageItem(spec, payload, isEmpty, liveId)); + } - if (canEditInPlace) + // Discord orders messages by creation time. If an earlier-declared message still needs to be + // posted while a later-declared one is already live, the channel would render out of spec + // order. Delete the live messages after that first to-post one so they re-post fresh, below + // it, in declaration order. Live messages before it already sit in their correct earlier + // position and are left untouched. + var firstToPost = items.FindIndex(i => !i.IsEmpty && i.LiveId is null); + if (firstToPost >= 0) { - await backends.Gateway.EditMessageAsync(guildId, channelId, record!.DiscordMessageId, payload, - cancellationToken) - .ConfigureAwait(false); - await backends.Store.SaveMessageAsync( - new ProvisionedMessage + for (var k = firstToPost + 1; k < items.Count; k++) + { + if (items[k].LiveId is { } staleId) { - GuildId = guildId, - RustServerId = serverId, - MessageKey = spec.Key, - DiscordChannelId = channelId, - DiscordMessageId = record.DiscordMessageId - }, - cancellationToken).ConfigureAwait(false); + await backends.Gateway.DeleteMessageAsync(guildId, channelId, staleId, cancellationToken) + .ConfigureAwait(false); + items[k] = items[k] with + { + LiveId = null + }; + } + } } - else + + foreach (var item in items) { - var messageId = await backends.Gateway.PostMessageAsync(guildId, channelId, payload, cancellationToken) - .ConfigureAwait(false); + if (item.IsEmpty) + { + continue; + } + + ulong messageId; + if (item.LiveId is { } liveId) + { + await backends.Gateway + .EditMessageAsync(guildId, channelId, liveId, item.Payload, cancellationToken) + .ConfigureAwait(false); + messageId = liveId; + } + else + { + messageId = await backends.Gateway + .PostMessageAsync(guildId, channelId, item.Payload, cancellationToken) + .ConfigureAwait(false); + } + await backends.Store.SaveMessageAsync( new ProvisionedMessage { GuildId = guildId, RustServerId = serverId, - MessageKey = spec.Key, + MessageKey = item.Spec.Key, DiscordChannelId = channelId, DiscordMessageId = messageId }, @@ -296,4 +329,11 @@ await backends.Store.SaveMessageAsync( } } } + + /// A rendered message spec paired with its current live materialization, if any. + /// The declared message spec. + /// The freshly rendered content. + /// True if the renderer had nothing to show (skipped entirely). + /// The snowflake of the currently-live message, or null if it must be posted. + private sealed record MessageItem(MessageSpec Spec, MessagePayload Payload, bool IsEmpty, ulong? LiveId); } diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGateway.cs b/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGateway.cs index 5156cb1e..9b5f4cb8 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGateway.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGateway.cs @@ -9,7 +9,9 @@ internal sealed class FakeWorkspaceGateway : IWorkspaceGateway { private readonly ConcurrentDictionary _categories = new(); private readonly ConcurrentDictionary _channels = new(); + private readonly List _deletedMessageIds = []; private readonly ConcurrentDictionary _messages = new(); + private readonly List _postedPayloads = []; private ulong _nextId = 1000; public IReadOnlyList MissingPermissions { get; set; } = []; @@ -20,6 +22,12 @@ internal sealed class FakeWorkspaceGateway : IWorkspaceGateway public IReadOnlyCollection ChannelIds => [.. _channels.Keys]; public IReadOnlyCollection CategoryIds => [.. _categories.Keys]; + /// Snowflakes deleted via , in call order. + public IReadOnlyList DeletedMessageIds => _deletedMessageIds; + + /// Payloads posted via , in call order. + public IReadOnlyList PostedPayloads => _postedPayloads; + public bool CategoryExists(ulong guildId, ulong categoryId) => _categories.ContainsKey(categoryId); public Task FindCategoryAsync(ulong guildId, string name, CancellationToken cancellationToken) @@ -86,6 +94,7 @@ public Task PostMessageAsync(ulong guildId, var id = NextId(); _messages[id] = new Message(id, channelId, payload); PostedMessages++; + _postedPayloads.Add(payload); return Task.FromResult(id); } @@ -100,6 +109,13 @@ public Task EditMessageAsync(ulong guildId, return Task.CompletedTask; } + public Task DeleteMessageAsync(ulong guildId, ulong channelId, ulong messageId, CancellationToken cancellationToken) + { + _messages.TryRemove(messageId, out _); + _deletedMessageIds.Add(messageId); + return Task.CompletedTask; + } + public Task DeleteChannelAsync(ulong guildId, ulong channelId, CancellationToken cancellationToken) { _channels.TryRemove(channelId, out _); diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ReconcilerHarness.cs b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ReconcilerHarness.cs index 3450de35..6d639b2d 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ReconcilerHarness.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ReconcilerHarness.cs @@ -77,6 +77,13 @@ public ReconcilerBuilderReusing WithChannel(WorkspaceScope scope, string key, st return this; } + public ReconcilerBuilderReusing WithMessage(WorkspaceScope scope, string key, string channelKey, string text) + { + _messageProviders.Add(new ListMessageProvider([new MessageSpec(scope, key, channelKey)])); + _renderers.Add(new ListMessageRenderer(key, text)); + return this; + } + public WorkspaceReconciler Build() => new( new WorkspaceBackends(new WorkspaceRegistry(_channelProviders, _messageProviders), source.Gateway, source.Store), @@ -88,4 +95,18 @@ private sealed class ListChannelProvider(IEnumerable specs) : IChan { public IEnumerable GetChannelSpecs() => specs; } + + private sealed class ListMessageProvider(IEnumerable specs) : IMessageSpecProvider + { + public IEnumerable GetMessageSpecs() => specs; + } + + private sealed class ListMessageRenderer(string key, string text) : IMessageRenderer + { + public string MessageKey { get; } = key; + + public ValueTask + RenderAsync(MessageRenderContext context, CancellationToken cancellationToken) => + ValueTask.FromResult(new MessagePayload(text, null, null)); + } } diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerMessageTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerMessageTests.cs index 18f237f9..d1670bd1 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerMessageTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerMessageTests.cs @@ -1,3 +1,6 @@ +using NSubstitute; +using RustPlusBot.Domain.Servers; +using RustPlusBot.Features.Workspace.Reconciler; using RustPlusBot.Features.Workspace.Registry; namespace RustPlusBot.Features.Workspace.Tests.Reconciler; @@ -42,4 +45,93 @@ public async Task ChannelKeyRemovedFromRegistry_RetainsExistingRecord() var channels = await harness.Store.GetChannelsAsync(1, null); Assert.Contains(channels, c => c.ChannelKey == "settings"); } + + [Fact] + public async Task NewEarlierMessageBehindLiveOne_DeletesAndRepostsInDeclarationOrder() + { + // Simulates the #info map bug: "server.info" was already provisioned by a prior deploy, then + // "server.info.map" is declared BEFORE it. Without reorder-repair the map would post below the + // already-live status embed (Discord orders by creation time). + var serverId = Guid.NewGuid(); + var harness = new ReconcilerHarness() + .WithChannel(WorkspaceScope.PerServer, "info", "channel.info.name", 0) + .WithMessage(WorkspaceScope.PerServer, "server.info", "info", "info-text"); + harness.Servers.GetAsync(1, serverId, Arg.Any()) + .Returns(new RustServer + { + Id = serverId, + GuildId = 1, + Name = "Rustopia EU", + Ip = "1.1.1.1", + Port = 28015 + }); + var sut = harness.Build(); + await sut.ReconcileServerAsync(1, serverId); + + var originalInfoMessage = await harness.Store.GetMessageAsync(1, serverId, "server.info"); + Assert.NotNull(originalInfoMessage); + var originalInfoMessageId = originalInfoMessage!.DiscordMessageId; + Assert.Equal(1, harness.Gateway.PostedMessages); + Assert.Empty(harness.Gateway.DeletedMessageIds); + + // Next deploy: the registry now declares "server.info.map" BEFORE "server.info", but only the + // latter has a provisioned record (reusing the same store + gateway). + var sut2 = new ReconcilerBuilderReusing(harness) + .WithChannel(WorkspaceScope.PerServer, "info", "channel.info.name", 0) + .WithMessage(WorkspaceScope.PerServer, "server.info.map", "info", "map-text") + .WithMessage(WorkspaceScope.PerServer, "server.info", "info", "info-text") + .Build(); + + var result = await sut2.ReconcileServerAsync(1, serverId); + + Assert.Equal(ReconcileStatus.Provisioned, result.Status); + + // The pre-existing "server.info" message was deleted exactly once. + Assert.Single(harness.Gateway.DeletedMessageIds); + Assert.Equal(originalInfoMessageId, harness.Gateway.DeletedMessageIds[0]); + + // Both messages were freshly posted this run (the prior run already posted one), map before info. + Assert.Equal(3, harness.Gateway.PostedMessages); + var repostedThisRun = harness.Gateway.PostedPayloads.TakeLast(2).ToList(); + Assert.Equal("map-text", repostedThisRun[0].Text); + Assert.Equal("info-text", repostedThisRun[1].Text); + + // The persisted records now point at the freshly posted messages. + var mapRecord = await harness.Store.GetMessageAsync(1, serverId, "server.info.map"); + var infoRecord = await harness.Store.GetMessageAsync(1, serverId, "server.info"); + Assert.NotNull(mapRecord); + Assert.NotNull(infoRecord); + Assert.NotEqual(originalInfoMessageId, infoRecord!.DiscordMessageId); + } + + [Fact] + public async Task ChannelAlreadyInDeclarationOrder_EditsInPlace_NoDelete() + { + var serverId = Guid.NewGuid(); + var harness = new ReconcilerHarness() + .WithChannel(WorkspaceScope.PerServer, "info", "channel.info.name", 0) + .WithMessage(WorkspaceScope.PerServer, "server.info.map", "info", "map-text") + .WithMessage(WorkspaceScope.PerServer, "server.info", "info", "info-text"); + harness.Servers.GetAsync(1, serverId, Arg.Any()) + .Returns(new RustServer + { + Id = serverId, + GuildId = 1, + Name = "Rustopia EU", + Ip = "1.1.1.1", + Port = 28015 + }); + var sut = harness.Build(); + + await sut.ReconcileServerAsync(1, serverId); + Assert.Equal(2, harness.Gateway.PostedMessages); + Assert.Empty(harness.Gateway.DeletedMessageIds); + + var result = await sut.ReconcileServerAsync(1, serverId); + + Assert.Equal(ReconcileStatus.Provisioned, result.Status); + Assert.Empty(harness.Gateway.DeletedMessageIds); // already in order: no repair needed + Assert.Equal(2, harness.Gateway.PostedMessages); // no reposts + Assert.Equal(2, harness.Gateway.EditedMessages); // both edited in place + } } From 3fb282aa514a0529edf760f3bfb2ae872bb0419a Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Thu, 9 Jul 2026 21:02:02 +0200 Subject: [PATCH 30/40] fix(map): register #info map generation from the connection store each tick MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The RustMaps generation was registered only from ConnectionStatusChangedEvent, which the in-process bus delivers live-only (no replay). Because InfoMapHostedService is registered after Connections in Program.cs, the connection can publish the connect event before this service has subscribed, so it is missed — the (size,seed) key is never registered, PendingKeys() stays empty, generation never runs, and #info shows the 'preparing' placeholder forever. Register connected servers from IConnectionStore each tick (GetWorldAsync returns null for non-connected), independent of the event. Adds a regression test that generates with no connect event ever published. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Hosting/InfoMapHostedService.cs | 32 ++++++++++ .../Hosting/InfoMapHostedServiceTests.cs | 63 +++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/src/RustPlusBot.Features.Map/Hosting/InfoMapHostedService.cs b/src/RustPlusBot.Features.Map/Hosting/InfoMapHostedService.cs index cd9036d4..51e5990d 100644 --- a/src/RustPlusBot.Features.Map/Hosting/InfoMapHostedService.cs +++ b/src/RustPlusBot.Features.Map/Hosting/InfoMapHostedService.cs @@ -88,6 +88,12 @@ private async Task RunTickAsync(CancellationToken cancellationToken) { await Task.Delay(options.Value.RustMaps.GenerationPollInterval, cancellationToken) .ConfigureAwait(false); + + // Register from the source of truth every tick, not only from the live-only + // ConnectionStatusChangedEvent (which the connection may publish before this service has + // subscribed on startup, so it is missed and the server would otherwise never generate). + await RegisterConnectedServersAsync(cancellationToken).ConfigureAwait(false); + var pending = coordinator.PendingKeys(); foreach (var key in pending) { @@ -109,6 +115,32 @@ await Task.Delay(options.Value.RustMaps.GenerationPollInterval, cancellationToke } } + /// + /// Registers every currently-connected server's (size, seed) key with the coordinator. Idempotent, and + /// independent of the connection-status event — returns null + /// for a server that is not connected/queryable, so only live servers are registered. + /// + /// A cancellation token. + private async Task RegisterConnectedServersAsync(CancellationToken cancellationToken) + { + IReadOnlyList<(ulong Guild, Guid Server)> servers; + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var store = scope.ServiceProvider.GetRequiredService(); + servers = await store.ListConnectableServersAsync(cancellationToken).ConfigureAwait(false); + } + + foreach (var (guild, server) in servers) + { + var world = await query.GetWorldAsync(guild, server, cancellationToken).ConfigureAwait(false); + if (world is not null) + { + coordinator.Register(new RustMapsMapKey((int)world.WorldSize, (int)world.Seed), guild, server); + } + } + } + private async Task AnnounceReadyKeysAsync(IReadOnlyList keys, CancellationToken cancellationToken) { foreach (var key in keys) diff --git a/tests/RustPlusBot.Features.Map.Tests/Hosting/InfoMapHostedServiceTests.cs b/tests/RustPlusBot.Features.Map.Tests/Hosting/InfoMapHostedServiceTests.cs index be1098b5..cac67637 100644 --- a/tests/RustPlusBot.Features.Map.Tests/Hosting/InfoMapHostedServiceTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/Hosting/InfoMapHostedServiceTests.cs @@ -92,6 +92,69 @@ public async Task Ready_key_publishes_InfoMapReadyEvent_once_per_requester() Assert.Equal("https://img/icons.png", coordinator.GetReady(Key.Size, Key.Seed)!.ImageUrl); } + [Fact] + public async Task Tick_registers_and_generates_connected_servers_without_any_connect_event() + { + // The ConnectionStatusChangedEvent is live-only and can be missed on startup (the connection may + // publish it before this service subscribes). Registration must therefore come from the connection + // store each tick, so a connected server still generates even if the event was never seen. + var coordinator = new RustMapsMapCoordinator(); + var serverId = Guid.NewGuid(); + + var client = Substitute.For(); + client.GetMapBySeedAndSizeAsync(Key.Size, Key.Seed, false, Arg.Any()) + .Returns(Result.Success( + new MapInfo + { + ImageUrl = "https://img/plain.png", + ImageIconUrl = "https://img/icons.png", + Url = "https://rustmaps/x" + }, 200)); + var driver = new RustMapsGenerationDriver(client, coordinator, NullLogger.Instance); + + var query = Substitute.For(); + query.GetWorldAsync(GuildA, serverId, Arg.Any()) + .Returns(new WorldSnapshot((uint)Key.Size, (uint)Key.Seed)); + + var store = Substitute.For(); + IReadOnlyList<(ulong GuildId, Guid ServerId)> connectable = [(GuildA, serverId)]; + store.ListConnectableServersAsync(Arg.Any()).Returns(connectable); + + var bus = new InMemoryEventBus(); + var received = new List(); + using var collectCts = new CancellationTokenSource(); + _ = Task.Run(async () => + { + await foreach (var evt in bus.SubscribeAsync(collectCts.Token)) + { + received.Add(evt); + } + }); + + var service = new InfoMapHostedService( + bus, coordinator, driver, query, ShortPollOptions(), + ScopeFactory(store), NullLogger.Instance); + + await service.StartAsync(CancellationToken.None); + try + { + var deadline = DateTimeOffset.UtcNow.AddSeconds(5); + while (DateTimeOffset.UtcNow < deadline && received.Count < 1) + { + await Task.Delay(20); + } + } + finally + { + await service.StopAsync(CancellationToken.None); + await collectCts.CancelAsync(); + } + + // No ConnectionStatusChangedEvent was ever published — registration came from the store tick. + Assert.Contains(received, e => e.GuildId == GuildA && e.ServerId == serverId); + Assert.Equal(RustMapsGenerationState.Ready, coordinator.Snapshot(Key).State); + } + [Fact] public async Task Connect_registers_the_servers_world_key_with_the_coordinator() { From a2ea4fed019cce14e693e5d03c83a40a425badb6 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Thu, 9 Jul 2026 21:20:16 +0200 Subject: [PATCH 31/40] fix(map): stop #info map flickering to a placeholder on every boot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The RustMaps "ready" state lives only in memory (RustMapsMapCoordinator), so on every restart it is empty until generation re-completes ~20s later. The renderer previously returned a "preparing…" placeholder embed while not ready, which overwrote the already-posted image on each boot and swapped it back once generation finished — the visible flicker/refresh. Return an EMPTY payload until the render is ready instead. The reconciler skips empty payloads, so the existing map message (with its last image) is left untouched across restarts. Positioning above the status embed is still handled by the reconciler's channel-order repair when the map first becomes non-empty, so the message no longer needs to be non-empty on first reconcile. Removes the now-dead map.info.generating string (EN/FR) and updates the renderer + localization parity tests accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Messages/ServerInfoMapMessageRenderer.cs | 54 +++++++++---------- src/RustPlusBot.Localization/Strings.fr.resx | 3 -- src/RustPlusBot.Localization/Strings.resx | 3 -- .../ServerInfoMapMessageRendererTests.cs | 38 +++++++------ .../StringsResourceParityTests.cs | 2 +- 5 files changed, 49 insertions(+), 51 deletions(-) diff --git a/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMapMessageRenderer.cs b/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMapMessageRenderer.cs index 0b01e1ec..8658a3e4 100644 --- a/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMapMessageRenderer.cs +++ b/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMapMessageRenderer.cs @@ -9,21 +9,25 @@ namespace RustPlusBot.Features.Workspace.Messages; /// -/// Renders the per-server #info map message: the RustMaps monument-icon render once generated, else a -/// "preparing" placeholder. Always returns a non-empty embed — the reconciler skips an empty payload, and -/// this message must claim the top-of-channel slot on the very first reconcile. +/// Renders the per-server #info map message: the RustMaps monument-icon render once generated. Until the +/// render is ready it returns an EMPTY payload so the reconciler leaves any existing map message untouched +/// (preserving the last image instead of overwriting it with a placeholder on every boot). The message is +/// (re)positioned above the status embed by the reconciler's channel-order repair when it first becomes +/// non-empty, so it does not need to be non-empty on the very first reconcile. /// /// Live query seam (world size/seed). /// String resolution. /// /// Read seam over the RustMaps generation state. Null when no RustMaps API key is configured — the message -/// then always shows the "preparing" placeholder. +/// then never has a render to show, and the empty payload leaves the channel untouched. /// internal sealed class ServerInfoMapMessageRenderer( IRustServerQuery query, ILocalizer localizer, IInfoMapReadModel? readModel = null) : IMessageRenderer { + private static readonly MessagePayload Empty = new(null, null, null); + /// public string MessageKey => WorkspaceMessageKeys.ServerInfoMap; @@ -34,37 +38,31 @@ public async ValueTask RenderAsync(MessageRenderContext context, ArgumentNullException.ThrowIfNull(context); if (context.ServerId is not Guid serverId) { - return new MessagePayload(null, null, null); + return Empty; } var world = await query.GetWorldAsync(context.GuildId, serverId, cancellationToken).ConfigureAwait(false); - - var embed = new EmbedBuilder() - .WithTitle(localizer.Get("map.info.title", context.Culture)) - .WithColor(Color.DarkGreen); - - InfoMapView? ready = null; - if (world is not null) + var ready = world is not null + ? readModel?.GetReady((int)world.WorldSize, (int)world.Seed) + : null; + if (ready is null) { - embed - .AddField(localizer.Get("map.info.size", context.Culture), - world.WorldSize.ToString(CultureInfo.InvariantCulture), inline: true) - .AddField(localizer.Get("map.info.seed", context.Culture), - world.Seed.ToString(CultureInfo.InvariantCulture), inline: true); - ready = readModel?.GetReady((int)world.WorldSize, (int)world.Seed); + // Not generated yet: return nothing so the reconciler leaves any existing map message (with its + // last image) untouched, rather than overwriting it with a placeholder on every boot. + return Empty; } - if (ready is not null) - { - embed.WithImageUrl(ready.ImageUrl); - if (ready.RustMapsPageUrl is not null) - { - embed.WithUrl(ready.RustMapsPageUrl); - } - } - else + var embed = new EmbedBuilder() + .WithTitle(localizer.Get("map.info.title", context.Culture)) + .WithColor(Color.DarkGreen) + .AddField(localizer.Get("map.info.size", context.Culture), + world!.WorldSize.ToString(CultureInfo.InvariantCulture), inline: true) + .AddField(localizer.Get("map.info.seed", context.Culture), + world.Seed.ToString(CultureInfo.InvariantCulture), inline: true) + .WithImageUrl(ready.ImageUrl); + if (ready.RustMapsPageUrl is not null) { - embed.WithFooter(localizer.Get("map.info.generating", context.Culture)); + embed.WithUrl(ready.RustMapsPageUrl); } return new MessagePayload(null, embed.Build(), null); diff --git a/src/RustPlusBot.Localization/Strings.fr.resx b/src/RustPlusBot.Localization/Strings.fr.resx index d92e052d..fe40fab2 100644 --- a/src/RustPlusBot.Localization/Strings.fr.resx +++ b/src/RustPlusBot.Localization/Strings.fr.resx @@ -540,9 +540,6 @@ Calques de la carte — activer/désactiver : - - Rendu RustMaps haute qualité en cours de génération… - Seed diff --git a/src/RustPlusBot.Localization/Strings.resx b/src/RustPlusBot.Localization/Strings.resx index 6db1a714..f144111b 100644 --- a/src/RustPlusBot.Localization/Strings.resx +++ b/src/RustPlusBot.Localization/Strings.resx @@ -540,9 +540,6 @@ Map layers — toggle to show/hide: - - Higher-quality RustMaps render generating… - Seed diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Messages/ServerInfoMapMessageRendererTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Messages/ServerInfoMapMessageRendererTests.cs index d33fb61a..19fc7fdd 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Messages/ServerInfoMapMessageRendererTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Messages/ServerInfoMapMessageRendererTests.cs @@ -64,24 +64,27 @@ public async Task Ready_view_without_page_url_omits_the_embed_url() } [Fact] - public async Task No_readModel_configured_still_returns_a_non_empty_embed_with_the_generating_footer() + public async Task No_readModel_configured_renders_empty_payload() { - // IInfoMapReadModel is only registered when a RustMaps API key is configured — the renderer must - // still be usable (and non-empty, so the reconciler doesn't skip it) with the optional dependency unset. + // IInfoMapReadModel is only registered when a RustMaps API key is configured. With no render source + // there is nothing to show, so the renderer returns an empty payload and the reconciler leaves the + // channel untouched (rather than overwriting an existing map message with a placeholder). var serverId = Guid.NewGuid(); var query = QueryWithWorld(1, serverId); var renderer = new ServerInfoMapMessageRenderer(query, Loc); var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); - Assert.NotNull(payload.Embed); - Assert.Null(payload.Embed!.Image); - Assert.Equal(Loc.Get("map.info.generating", "en"), payload.Embed.Footer!.Value.Text); + Assert.Null(payload.Text); + Assert.Null(payload.Embed); + Assert.Null(payload.Components); } [Fact] - public async Task Not_ready_yet_shows_the_generating_footer() + public async Task Not_ready_yet_renders_empty_payload() { + // Until the RustMaps render is ready the renderer returns nothing, so the reconciler preserves any + // existing map message (with its last image) instead of flickering to a placeholder on every boot. var serverId = Guid.NewGuid(); var query = QueryWithWorld(1, serverId); var readModel = Substitute.For(); @@ -90,15 +93,15 @@ public async Task Not_ready_yet_shows_the_generating_footer() var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); - Assert.NotNull(payload.Embed); - Assert.Equal(Loc.Get("map.info.generating", "en"), payload.Embed!.Footer!.Value.Text); + Assert.Null(payload.Text); + Assert.Null(payload.Embed); + Assert.Null(payload.Components); } [Fact] - public async Task No_world_yet_still_returns_a_non_empty_embed_so_the_reconciler_does_not_skip_it() + public async Task No_world_yet_renders_empty_payload() { - // Ordering caveat: the reconciler skips an empty payload, which would let ServerInfo claim the - // top slot. This message must be non-empty even before the world snapshot is available. + // No world snapshot means the map key is unknown and nothing can be ready — empty payload, no post. var serverId = Guid.NewGuid(); var query = Substitute.For(); query.GetWorldAsync(1, serverId, Arg.Any()).Returns((WorldSnapshot?)null); @@ -106,16 +109,19 @@ public async Task No_world_yet_still_returns_a_non_empty_embed_so_the_reconciler var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); - Assert.NotNull(payload.Embed); - Assert.Empty(payload.Embed!.Fields); + Assert.Null(payload.Text); + Assert.Null(payload.Embed); + Assert.Null(payload.Components); } [Fact] - public async Task World_present_shows_size_and_seed_fields() + public async Task Ready_view_shows_size_and_seed_fields() { var serverId = Guid.NewGuid(); var query = QueryWithWorld(1, serverId); - var renderer = new ServerInfoMapMessageRenderer(query, Loc); + var readModel = Substitute.For(); + readModel.GetReady(4000, 12345).Returns(new InfoMapView("https://img/icons.png", null)); + var renderer = new ServerInfoMapMessageRenderer(query, Loc, readModel); var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); diff --git a/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs b/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs index ab6c1e1a..f4664c3a 100644 --- a/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs +++ b/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs @@ -41,6 +41,6 @@ public void English_covers_every_french_key() [Fact] public void Catalog_has_expected_key_count() { - Assert.Equal(265, EnglishKeys().Count); + Assert.Equal(264, EnglishKeys().Count); } } From f26485b54450abffe116d3b21bcbe71b9c859399 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Thu, 9 Jul 2026 21:52:15 +0200 Subject: [PATCH 32/40] =?UTF-8?q?fix(map):=20correct=20the=20small=20oil?= =?UTF-8?q?=20rig=20monument=20token=20(oilrig=5F1=20=E2=86=92=20oil=5Frig?= =?UTF-8?q?=5Fsmall)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Rust+ protobuf token for the small oil rig is `oil_rig_small` (confirmed against rustplusplus Map.js/MapMarkers.js and rustplus-desktop), not `oilrig_1`. With the wrong token the small rig never matched anywhere, so: its icon never drew, no RigPlacement was created, and its CH47-proximity activation was never detected. Fixes the token in all three production sites that switch on it — the icon map, the map-composer rig placement, and the connection-supervisor rig-state detection — plus the doc-comment examples and the tests that used it. The large oil rig token (`large_oil_rig`) was already correct. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Connections/MonumentSnapshot.cs | 2 +- src/RustPlusBot.Abstractions/Events/RigKind.cs | 2 +- .../Listening/RustPlusSocketSource.cs | 2 +- .../Supervisor/ConnectionSupervisor.cs | 2 +- src/RustPlusBot.Features.Map/Assets/MonumentIconMap.cs | 2 +- src/RustPlusBot.Features.Map/Composing/MapComposer.cs | 2 +- .../Events/RigStateChangedEventTests.cs | 4 ++-- .../ConnectionSupervisorTests.cs | 4 ++-- tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs | 2 +- tests/RustPlusBot.Features.Map.Tests/MapIconsTests.cs | 6 +++--- tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs | 2 +- 11 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/RustPlusBot.Abstractions/Connections/MonumentSnapshot.cs b/src/RustPlusBot.Abstractions/Connections/MonumentSnapshot.cs index c01fabd3..8308c63b 100644 --- a/src/RustPlusBot.Abstractions/Connections/MonumentSnapshot.cs +++ b/src/RustPlusBot.Abstractions/Connections/MonumentSnapshot.cs @@ -1,7 +1,7 @@ namespace RustPlusBot.Abstractions.Connections; /// One named monument observed in a GetMap response. -/// The monument token (e.g. oilrig_1, large_oil_rig). +/// The monument token (e.g. oil_rig_small, large_oil_rig). /// World X coordinate. /// World Y coordinate. public sealed record MonumentSnapshot(string Token, float X, float Y); diff --git a/src/RustPlusBot.Abstractions/Events/RigKind.cs b/src/RustPlusBot.Abstractions/Events/RigKind.cs index de640849..1320b8bf 100644 --- a/src/RustPlusBot.Abstractions/Events/RigKind.cs +++ b/src/RustPlusBot.Abstractions/Events/RigKind.cs @@ -3,7 +3,7 @@ namespace RustPlusBot.Abstractions.Events; /// Which oil rig a rig event refers to. public enum RigKind { - /// The small oil rig (monument token oilrig_1). + /// The small oil rig (monument token oil_rig_small). Small = 0, /// The large oil rig (monument token large_oil_rig). diff --git a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs index d5838f08..d61513fb 100644 --- a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs +++ b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs @@ -608,7 +608,7 @@ public async Task> GetMonumentsAsync( using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeoutCts.CancelAfter(timeout); // CONFIRMED (2.0.0-beta.1): GetMapAsync returns Task>. - // ServerMap.Monuments is List with Name (= protobuf token, e.g. "oilrig_1"), + // ServerMap.Monuments is List with Name (= protobuf token, e.g. "oil_rig_small"), // Nullable X/Y. We surface (token, x, y) and skip monuments with incomplete coordinates. var response = await _rustPlus.GetMapAsync(timeoutCts.Token).WaitAsync(timeoutCts.Token) .ConfigureAwait(false); diff --git a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs index 40120889..d5569cab 100644 --- a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs +++ b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs @@ -875,7 +875,7 @@ private async Task> GetRigPositionsAsync( { RigKind? kind = m.Token switch { - "oilrig_1" => RigKind.Small, + "oil_rig_small" => RigKind.Small, "large_oil_rig" => RigKind.Large, _ => null, }; diff --git a/src/RustPlusBot.Features.Map/Assets/MonumentIconMap.cs b/src/RustPlusBot.Features.Map/Assets/MonumentIconMap.cs index bb3ae1dc..04842d3a 100644 --- a/src/RustPlusBot.Features.Map/Assets/MonumentIconMap.cs +++ b/src/RustPlusBot.Features.Map/Assets/MonumentIconMap.cs @@ -19,7 +19,7 @@ public static class MonumentIconMap ["harbor_2_display_name"] = "harbour2", ["junkyard_display_name"] = "junkyard", ["large_oil_rig"] = "largeoilrig", - ["oilrig_1"] = "oilrig", + ["oil_rig_small"] = "oilrig", ["launchsite"] = "launchsite", ["lighthouse_display_name"] = "lighthouse", ["military_tunnels_display_name"] = "militarytunnel", diff --git a/src/RustPlusBot.Features.Map/Composing/MapComposer.cs b/src/RustPlusBot.Features.Map/Composing/MapComposer.cs index 28b46265..4ee7a427 100644 --- a/src/RustPlusBot.Features.Map/Composing/MapComposer.cs +++ b/src/RustPlusBot.Features.Map/Composing/MapComposer.cs @@ -192,7 +192,7 @@ private List GatherRigs( { RigKind? kind = mon.Token switch { - "oilrig_1" => RigKind.Small, + "oil_rig_small" => RigKind.Small, "large_oil_rig" => RigKind.Large, _ => null, }; diff --git a/tests/RustPlusBot.Abstractions.Tests/Events/RigStateChangedEventTests.cs b/tests/RustPlusBot.Abstractions.Tests/Events/RigStateChangedEventTests.cs index c5df766e..4dabec00 100644 --- a/tests/RustPlusBot.Abstractions.Tests/Events/RigStateChangedEventTests.cs +++ b/tests/RustPlusBot.Abstractions.Tests/Events/RigStateChangedEventTests.cs @@ -25,8 +25,8 @@ public void Carries_rig_kind_event_kind_position_and_dimensions() [Fact] public void Monument_snapshot_carries_token_and_position() { - var m = new MonumentSnapshot("oilrig_1", 10f, 20f); - Assert.Equal("oilrig_1", m.Token); + var m = new MonumentSnapshot("oil_rig_small", 10f, 20f); + Assert.Equal("oil_rig_small", m.Token); Assert.Equal(10f, m.X); Assert.Equal(20f, m.Y); } diff --git a/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs b/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs index 054478ad..74584a1d 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs @@ -557,7 +557,7 @@ public async Task Ch47_entering_rig_radius_publishes_activated_once_per_visit() var source = new FakeRustSocketSource(); source.EnqueueConnect(SocketConnectOutcome.Connected); source.EnqueueHeartbeat(HeartbeatResult.Ok(1)); - source.SetMonuments([new MonumentSnapshot("oilrig_1", 1000f, 1000f)]); + source.SetMonuments([new MonumentSnapshot("oil_rig_small", 1000f, 1000f)]); source.EnqueueMarkers([new MapMarkerSnapshot(1UL, MarkerKind.Chinook, 0f, 0f, null)]); // poll 1: far source.EnqueueMarkers([ new MapMarkerSnapshot(1UL, MarkerKind.Chinook, 1010f, 1010f, null) @@ -613,7 +613,7 @@ public async Task Ch47_far_from_rig_publishes_chinook_event_but_no_rig_event() var source = new FakeRustSocketSource(); source.EnqueueConnect(SocketConnectOutcome.Connected); source.EnqueueHeartbeat(HeartbeatResult.Ok(1)); - source.SetMonuments([new MonumentSnapshot("oilrig_1", 1000f, 1000f)]); + source.SetMonuments([new MonumentSnapshot("oil_rig_small", 1000f, 1000f)]); source.EnqueueMarkers([]); // poll 1: baseline source.EnqueueMarkers([new MapMarkerSnapshot(1UL, MarkerKind.Chinook, 0f, 0f, null)]); // poll 2: CH47 far source.EnqueueMarkers([]); // poll 3: gone diff --git a/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs index b8a56ddf..ce54b7d8 100644 --- a/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/MapComposerTests.cs @@ -144,7 +144,7 @@ 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)]); + .Returns([new MonumentSnapshot("oil_rig_small", 2000f, 2000f)]); query.GetTeamInfoAsync(Guild, Server, Arg.Any()) .Returns(new TeamInfoSnapshot(0, [new TeamMemberSnapshot(1, "Ada", 2000f, 2000f, true, true, default, default)])); diff --git a/tests/RustPlusBot.Features.Map.Tests/MapIconsTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapIconsTests.cs index 257d110c..3c9c0e0e 100644 --- a/tests/RustPlusBot.Features.Map.Tests/MapIconsTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/MapIconsTests.cs @@ -24,7 +24,7 @@ public void Monument_returns_null_for_unknown_token() => [Fact] public void MonumentIconMap_maps_rig_tokens_to_icons() { - Assert.Equal("oilrig", MonumentIconMap.IconKeyFor("oilrig_1")); + Assert.Equal("oilrig", MonumentIconMap.IconKeyFor("oil_rig_small")); Assert.Equal("largeoilrig", MonumentIconMap.IconKeyFor("large_oil_rig")); } @@ -51,8 +51,8 @@ public void Sized_marker_icon_fits_the_requested_box() [Fact] public void Sized_monument_icon_is_scaled_down_from_native() { - var native = MapIcons.Monument("oilrig_1"); // 875x875 native - var sized = MapIcons.Monument("oilrig_1", 30); + var native = MapIcons.Monument("oil_rig_small"); // 875x875 native + var sized = MapIcons.Monument("oil_rig_small", 30); Assert.NotNull(native); Assert.NotNull(sized); diff --git a/tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs index efb0bc02..c34c4836 100644 --- a/tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs @@ -118,7 +118,7 @@ public void Monument_icon_is_drawn_scaled_not_native() var without = renderer.Render(baseJpeg, projection, [], [], [], [], new MapLayerSet(false, false, false, false, false, false)); var with = renderer.Render(baseJpeg, projection, [], - [new MonumentPlacement("oilrig_1", px, py)], [], [], + [new MonumentPlacement("oil_rig_small", px, py)], [], [], new MapLayerSet(false, false, true, false, false, false)); var bounds = ChangedPixelBounds(without, with); From 507eef61a127facbb2f7fe757203b93384ff445e Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Thu, 9 Jul 2026 21:52:31 +0200 Subject: [PATCH 33/40] =?UTF-8?q?fix(map):=20centre=20grid=20cell=20labels?= =?UTF-8?q?=20(were=20top-left=20=E2=86=92=20"half=20a=20row=20too=20high"?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grid labels were anchored at each cell's top-left corner. Because text flows right and down from its origin, column letters read as roughly in-column (fine) but row numbers hugged the cell's top edge, appearing half a row too high versus the in-game map — which centres its labels. Probe the cell centre ((col+0.5, row+0.5) cells) and centre the text on it in both axes, matching the in-game grid. Drops the now-unneeded LabelRowEpsilon boundary nudge (centre placement is always safely inside the cell). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Rendering/MapRenderer.cs | 21 +++++++------------ 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs b/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs index 129e0402..c59efaa9 100644 --- a/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs +++ b/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs @@ -23,15 +23,6 @@ public sealed class MapRenderer private const float ActiveRingWidth = 3f; private const float PlayerLabelOffset = 12f; - /// - /// A row's north (top) edge sits exactly on a bin boundary now that - /// the grid extent is snapped to a whole multiple of ; floor()-based - /// binning assigns that exact boundary to the row above. Nudging the probe south by this many game - /// units (a fraction of a pixel at map scale) keeps it inside the intended row so the label agrees - /// with . - /// - private const float LabelRowEpsilon = 1f; - private static readonly FontFamily Family = LoadFamily(); private static readonly Font Font = Family.CreateFont(12f); private static readonly Font GridLabelFont = Family.CreateFont(MapRenderStyle.GridLabelFontSize); @@ -143,14 +134,18 @@ private static void DrawGrid(Image image, MapProjection projection) { for (var row = 0; row < cells; row++) { - // Label sits just inside each cell's top-left corner (official-app placement). - var worldX = col * MapGrid.CellSize; - var worldY = correctedSize - (row * MapGrid.CellSize) - LabelRowEpsilon; + // Label sits in the cell's centre (in-game map placement): probe the middle of the cell + // and centre the text on it, both axes. Anchoring at the top-left corner instead reads + // as "half a row too high" because the text hugs the cell's top edge. + var worldX = (col + 0.5f) * MapGrid.CellSize; + var worldY = correctedSize - ((row + 0.5f) * MapGrid.CellSize); var (lx, ly) = projection.ToPixel(worldX, worldY); var label = MapGrid.ColumnLetters(col) + row.ToString(CultureInfo.InvariantCulture); ctx.DrawText(new RichTextOptions(GridLabelFont) { - Origin = new PointF(lx + 2f, ly + 2f) + Origin = new PointF(lx, ly), + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, }, label, labelColor); } From 8122e2a9b44f3c6ac79d85bd95de0de03712ce6f Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Thu, 9 Jul 2026 22:12:45 +0200 Subject: [PATCH 34/40] revert(map): keep grid labels at the cell top-left corner Reverts the label-centring change (507eef6): the top-left corner placement was the intended one and should not have been touched. The real grid displacement is a separate, still-open issue tracked below. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Rendering/MapRenderer.cs | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs b/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs index c59efaa9..129e0402 100644 --- a/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs +++ b/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs @@ -23,6 +23,15 @@ public sealed class MapRenderer private const float ActiveRingWidth = 3f; private const float PlayerLabelOffset = 12f; + /// + /// A row's north (top) edge sits exactly on a bin boundary now that + /// the grid extent is snapped to a whole multiple of ; floor()-based + /// binning assigns that exact boundary to the row above. Nudging the probe south by this many game + /// units (a fraction of a pixel at map scale) keeps it inside the intended row so the label agrees + /// with . + /// + private const float LabelRowEpsilon = 1f; + private static readonly FontFamily Family = LoadFamily(); private static readonly Font Font = Family.CreateFont(12f); private static readonly Font GridLabelFont = Family.CreateFont(MapRenderStyle.GridLabelFontSize); @@ -134,18 +143,14 @@ private static void DrawGrid(Image image, MapProjection projection) { for (var row = 0; row < cells; row++) { - // Label sits in the cell's centre (in-game map placement): probe the middle of the cell - // and centre the text on it, both axes. Anchoring at the top-left corner instead reads - // as "half a row too high" because the text hugs the cell's top edge. - var worldX = (col + 0.5f) * MapGrid.CellSize; - var worldY = correctedSize - ((row + 0.5f) * MapGrid.CellSize); + // Label sits just inside each cell's top-left corner (official-app placement). + var worldX = col * MapGrid.CellSize; + var worldY = correctedSize - (row * MapGrid.CellSize) - LabelRowEpsilon; var (lx, ly) = projection.ToPixel(worldX, worldY); var label = MapGrid.ColumnLetters(col) + row.ToString(CultureInfo.InvariantCulture); ctx.DrawText(new RichTextOptions(GridLabelFont) { - Origin = new PointF(lx, ly), - HorizontalAlignment = HorizontalAlignment.Center, - VerticalAlignment = VerticalAlignment.Center, + Origin = new PointF(lx + 2f, ly + 2f) }, label, labelColor); } From 0326ff7cd8e5f2f45edf827ea4007b33ea79e957 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Thu, 9 Jul 2026 22:43:16 +0200 Subject: [PATCH 35/40] fix(map): render the partial edge grid cell so the grid matches RustMaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grid dropped a row and a column versus RustMaps and the in-game map: a 1500 world shows 11×11 (A–K, rows 0–10), but we drew 10×10 (A–J, 0–9). A prior change snapped the world size to a whole multiple of the 146.25 cell size (rustplusplus getCorrectedMapSize), which *deletes* the partial edge cell — the "displacement" the user reported. Rust, the Rust+ companion app and RustMaps all keep that partial edge cell as a (narrower) labelled cell. So: - MapGrid.CellCount is now ceil(worldSize / CellSize) — the whole world, partial edge cell included — and CorrectedWorldSize is removed. - MapGrid.LabelFor bins straight off the raw world size from the SW corner (the actual Rust grid formula), so grid references match the in-game grid. - MapRenderer.DrawGrid covers [0, worldSize], clamping the final boundary to worldSize so the last cell is the partial one; labels stay at the cell top-left corner. Drops the now-unneeded LabelRowEpsilon nudge. Verified against RustMaps 1500/1234: 11×11, and monuments land in the same cells RustMaps shows (small oil rig F5, lighthouse I3, substation I2). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Connections/MapGrid.cs | 43 +++++++++---------- .../Rendering/MapRenderer.cs | 29 +++++-------- .../Connections/MapGridTests.cs | 27 +++++------- 3 files changed, 42 insertions(+), 57 deletions(-) diff --git a/src/RustPlusBot.Abstractions/Connections/MapGrid.cs b/src/RustPlusBot.Abstractions/Connections/MapGrid.cs index 2a1eb057..aa051813 100644 --- a/src/RustPlusBot.Abstractions/Connections/MapGrid.cs +++ b/src/RustPlusBot.Abstractions/Connections/MapGrid.cs @@ -5,32 +5,30 @@ namespace RustPlusBot.Abstractions.Connections; /// /// Rust map grid math shared by the map renderer and grid-reference formatting. -/// One cell is 146.25 game units (rustplusplus-compatible); rows are numbered from the top. +/// One cell is 146.25 game units; the grid covers the whole world including the partial edge +/// cell (Rust / RustMaps / companion-app behaviour). Columns are lettered west→east from A; rows are +/// numbered north→south from 0. /// public static class MapGrid { - /// Edge length of one grid cell, in game units. + /// Edge length of one (whole) grid cell, in game units. public const float CellSize = 146.25f; /// - /// Snaps the world size to a whole number of grid cells (rustplusplus getCorrectedMapSize): - /// round down when the remainder is less than 120 game units, else round up. This removes the - /// partial edge cell so every grid cell is exactly . + /// Number of grid cells per axis, covering the whole world size — ceil(worldSize / CellSize). + /// The final cell along each axis is a partial (narrower) edge cell whenever the world size is not a + /// whole multiple of ; it is still a labelled cell, matching how Rust, the Rust+ + /// companion app and RustMaps draw the grid. (A 1500 world → 11 cells: A–K, rows 0–10.) /// /// The world size in game units. - /// The corrected world size, an exact multiple of . - public static float CorrectedWorldSize(uint worldSize) + /// The cell count (at least 1). + public static int CellCount(uint worldSize) { - var remainder = worldSize % CellSize; - return remainder < 120f ? worldSize - remainder : worldSize + (CellSize - remainder); + var full = (int)(worldSize / CellSize); + var hasPartial = worldSize - (full * CellSize) > 0.5f; + return Math.Max(1, hasPartial ? full + 1 : full); } - /// Number of whole grid cells per axis, after snapping the world size (see ). - /// The world size in game units. - /// The cell count (at least 1). - public static int CellCount(uint worldSize) => - Math.Max(1, (int)Math.Round(CorrectedWorldSize(worldSize) / CellSize)); - /// Formats a spreadsheet-style column label (0→A … 25→Z, 26→AA …). /// The zero-based column index. /// The column letters. @@ -48,17 +46,16 @@ public static string ColumnLetters(int index) } /// Formats the grid label ("D7") for a world coordinate. - /// World X (west→east), clamped into the world. - /// World Y (south→north), clamped into the world. + /// World X (west→east). + /// World Y (south→north). /// The world size in game units. - /// The grid label, rows numbered from the top. + /// The grid label, rows numbered from the top; out-of-world coordinates clamp to the edge cell. public static string LabelFor(float x, float y, uint worldSize) { - var corrected = CorrectedWorldSize(worldSize); - var cells = Math.Max(1, (int)Math.Round(corrected / CellSize)); - var col = Math.Clamp((int)Math.Floor(Math.Clamp(x, 0f, corrected - 1f) / CellSize), 0, cells - 1); - var rowFromBottom = Math.Clamp((int)Math.Floor(Math.Clamp(y, 0f, corrected - 1f) / CellSize), 0, cells - 1); - var row = cells - rowFromBottom - 1; + var cells = CellCount(worldSize); + var col = Math.Clamp((int)MathF.Floor(x / CellSize), 0, cells - 1); + var rowFromBottom = Math.Clamp((int)MathF.Floor(y / CellSize), 0, cells - 1); + var row = cells - 1 - rowFromBottom; return string.Create(CultureInfo.InvariantCulture, $"{ColumnLetters(col)}{row}"); } } diff --git a/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs b/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs index 129e0402..e32ae699 100644 --- a/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs +++ b/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs @@ -23,15 +23,6 @@ public sealed class MapRenderer private const float ActiveRingWidth = 3f; private const float PlayerLabelOffset = 12f; - /// - /// A row's north (top) edge sits exactly on a bin boundary now that - /// the grid extent is snapped to a whole multiple of ; floor()-based - /// binning assigns that exact boundary to the row above. Nudging the probe south by this many game - /// units (a fraction of a pixel at map scale) keeps it inside the intended row so the label agrees - /// with . - /// - private const float LabelRowEpsilon = 1f; - private static readonly FontFamily Family = LoadFamily(); private static readonly Font Font = Family.CreateFont(12f); private static readonly Font GridLabelFont = Family.CreateFont(MapRenderStyle.GridLabelFontSize); @@ -121,18 +112,19 @@ private static void DrawGrid(Image image, MapProjection projection) var lineColor = Color.FromRgba(255, 255, 255, 80); var labelColor = Color.FromRgba(255, 255, 255, 140); - var correctedSize = MapGrid.CorrectedWorldSize(projection.WorldSize); - var cells = MapGrid.CellCount(projection.WorldSize); - var (left, top) = projection.ToPixel(0f, correctedSize); - var (right, bottom) = projection.ToPixel(correctedSize, 0f); + var worldSize = projection.WorldSize; + var cells = MapGrid.CellCount(worldSize); + var (left, top) = projection.ToPixel(0f, worldSize); + var (right, bottom) = projection.ToPixel(worldSize, 0f); image.Mutate(ctx => { for (var i = 0; i <= cells; i++) { - // correctedSize is an exact multiple of MapGrid.CellSize, so i * CellSize reaches the - // edge exactly at i == cells — no clamp needed, and no partial final cell. - var boundary = i * MapGrid.CellSize; + // Cover the whole world including the partial edge cell: the final boundary is clamped to + // worldSize (Rust / RustMaps behaviour), so the last cell is narrower when the world size + // is not a whole multiple of MapGrid.CellSize. + var boundary = MathF.Min(i * MapGrid.CellSize, worldSize); var (vx, _) = projection.ToPixel(boundary, 0f); ctx.DrawLine(lineColor, OutlinePenWidth, new PointF(vx, top), new PointF(vx, bottom)); var (_, hy) = projection.ToPixel(0f, boundary); @@ -143,9 +135,10 @@ private static void DrawGrid(Image image, MapProjection projection) { for (var row = 0; row < cells; row++) { - // Label sits just inside each cell's top-left corner (official-app placement). + // Label sits just inside each cell's top-left corner (companion-app placement). Row 0 is + // the northernmost cell, so its top edge is the world's north edge (worldSize). var worldX = col * MapGrid.CellSize; - var worldY = correctedSize - (row * MapGrid.CellSize) - LabelRowEpsilon; + var worldY = MathF.Min((cells - row) * MapGrid.CellSize, worldSize); var (lx, ly) = projection.ToPixel(worldX, worldY); var label = MapGrid.ColumnLetters(col) + row.ToString(CultureInfo.InvariantCulture); ctx.DrawText(new RichTextOptions(GridLabelFont) diff --git a/tests/RustPlusBot.Abstractions.Tests/Connections/MapGridTests.cs b/tests/RustPlusBot.Abstractions.Tests/Connections/MapGridTests.cs index ff945467..336c77d4 100644 --- a/tests/RustPlusBot.Abstractions.Tests/Connections/MapGridTests.cs +++ b/tests/RustPlusBot.Abstractions.Tests/Connections/MapGridTests.cs @@ -5,19 +5,14 @@ namespace RustPlusBot.Abstractions.Tests.Connections; public sealed class MapGridTests { [Theory] - [InlineData(3000u, 20)] // remainder 3000 % 146.25 = 75 < 120 -> round down to 2925 = 20 * 146.25 - [InlineData(3500u, 24)] // remainder 3500 % 146.25 = 136.25 >= 120 -> round up to 3510 = 24 * 146.25 - [InlineData(4250u, 29)] // remainder 4250 % 146.25 = 8.75 < 120 -> round down to 4241.25 = 29 * 146.25 - [InlineData(4500u, 30)] // remainder 4500 % 146.25 = 112.5 < 120 -> round down to 4387.5 = 30 * 146.25 - public void CellCount_snaps_to_whole_cells(uint worldSize, int expected) => + [InlineData(1500u, 11)] // 1500 / 146.25 = 10.26 -> 10 whole + 1 partial edge cell = 11 (A-K, rows 0-10) + [InlineData(3000u, 21)] // 3000 / 146.25 = 20.51 -> 20 whole + 1 partial = 21 + [InlineData(3500u, 24)] // 3500 / 146.25 = 23.93 -> 23 whole + 1 partial = 24 + [InlineData(4250u, 30)] // 4250 / 146.25 = 29.06 -> 29 whole + 1 partial = 30 + [InlineData(4500u, 31)] // 4500 / 146.25 = 30.77 -> 30 whole + 1 partial = 31 + public void CellCount_covers_the_world_including_the_partial_edge_cell(uint worldSize, int expected) => Assert.Equal(expected, MapGrid.CellCount(worldSize)); - [Theory] - [InlineData(3000u, 2925f)] // round-down case: remainder 75 < 120 - [InlineData(3500u, 3510f)] // round-up case: remainder 136.25 >= 120 - public void CorrectedWorldSize_snaps_to_a_whole_multiple_of_cell_size(uint worldSize, float expected) => - Assert.Equal(expected, MapGrid.CorrectedWorldSize(worldSize)); - [Theory] [InlineData(0, "A")] [InlineData(25, "Z")] @@ -29,9 +24,9 @@ public void ColumnLetters_is_spreadsheet_style(int index, string expected) => [Fact] public void LabelFor_origin_is_bottom_left_last_row() { - // 4000 world: remainder 4000 % 146.25 = 51.25 < 120 -> corrected 3948.75 -> 27 cells. - // World (0,0) = SW corner = column A, bottom row (cells - 1 = 26). - Assert.Equal("A26", MapGrid.LabelFor(0f, 0f, 4000u)); + // 4000 world: 4000 / 146.25 = 27.35 -> 27 whole + 1 partial = 28 cells. + // World (0,0) = SW corner = column A, bottom row (cells - 1 = 27). + Assert.Equal("A27", MapGrid.LabelFor(0f, 0f, 4000u)); } [Fact] @@ -44,7 +39,7 @@ public void LabelFor_north_west_corner_is_A0() public void LabelFor_beyond_world_size_clamps_to_last_cell() { // Regression for the old GridReference bug: clamping against IMAGE pixels, not world units. - // 4000 world -> 27 cells (see LabelFor_origin_is_bottom_left_last_row); last column index 26 = "AA". - Assert.Equal("AA26", MapGrid.LabelFor(4500f, 0f, 4000u)); + // 4000 world -> 28 cells (see LabelFor_origin_is_bottom_left_last_row); last column index 27 = "AB". + Assert.Equal("AB27", MapGrid.LabelFor(4500f, 0f, 4000u)); } } From 9c55231350f180c27dcef8ef344f4357a68c5ea2 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Thu, 9 Jul 2026 22:59:50 +0200 Subject: [PATCH 36/40] fix(map): anchor the grid at the world's north-west corner, lattice over the whole image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix kept the partial edge cell but anchored rows at the SOUTH edge, leaving the remainder as a squished sliver row at the top — rows sat visibly displaced versus RustMaps and the companion app (columns were fine because they were already west-anchored). Rust, the app and RustMaps anchor the grid at the world's NW corner: rows step south from the top and the partial cell sits at the south/east, bleeding past the world edge so every visible cell looks full-size. They also draw the grid lattice across the WHOLE map image (ocean margin included), not clipped to the world square. - MapRenderer.DrawGrid: lattice anchored at the NW world corner, lines spanning the full image in all directions; labels (A0..) only for the world's ceil-count cells, top-left corner placement unchanged. - MapGrid.LabelFor: rows bin from the north edge (floor((worldSize - y) / CellSize)) so grid references match the visible grid; columns unchanged. - Regression tests lock the north anchoring (1500 world: y=1360 → A0, y=10 → partial row A10). Verified against RustMaps 1500/1234 (tmp/images/image.png) and the companion-app geometry with an ocean-margin base tile. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Connections/MapGrid.cs | 18 ++++---- .../Rendering/MapRenderer.cs | 45 +++++++++++-------- .../Connections/MapGridTests.cs | 16 +++++++ 3 files changed, 52 insertions(+), 27 deletions(-) diff --git a/src/RustPlusBot.Abstractions/Connections/MapGrid.cs b/src/RustPlusBot.Abstractions/Connections/MapGrid.cs index aa051813..b5c43db9 100644 --- a/src/RustPlusBot.Abstractions/Connections/MapGrid.cs +++ b/src/RustPlusBot.Abstractions/Connections/MapGrid.cs @@ -5,9 +5,10 @@ namespace RustPlusBot.Abstractions.Connections; /// /// Rust map grid math shared by the map renderer and grid-reference formatting. -/// One cell is 146.25 game units; the grid covers the whole world including the partial edge -/// cell (Rust / RustMaps / companion-app behaviour). Columns are lettered west→east from A; rows are -/// numbered north→south from 0. +/// One cell is 146.25 game units; the grid is anchored at the world's NORTH-WEST corner +/// (Rust / RustMaps / companion-app behaviour): columns are lettered west→east from A, rows are +/// numbered north→south from 0, and the partial edge cell (when the world size is not a whole +/// multiple of the cell size) sits along the south and east edges. /// public static class MapGrid { @@ -16,9 +17,9 @@ public static class MapGrid /// /// Number of grid cells per axis, covering the whole world size — ceil(worldSize / CellSize). - /// The final cell along each axis is a partial (narrower) edge cell whenever the world size is not a - /// whole multiple of ; it is still a labelled cell, matching how Rust, the Rust+ - /// companion app and RustMaps draw the grid. (A 1500 world → 11 cells: A–K, rows 0–10.) + /// The last cell (east-most column / south-most row) is a partial edge cell whenever the world size + /// is not a whole multiple of ; it is still a labelled cell, matching how Rust, + /// the Rust+ companion app and RustMaps draw the grid. (A 1500 world → 11 cells: A–K, rows 0–10.) /// /// The world size in game units. /// The cell count (at least 1). @@ -52,10 +53,11 @@ public static string ColumnLetters(int index) /// The grid label, rows numbered from the top; out-of-world coordinates clamp to the edge cell. public static string LabelFor(float x, float y, uint worldSize) { + // Rows bin from the NORTH edge (the grid anchor), not the south — with a partial edge cell the + // two disagree, and the north anchoring is what the in-game map, the app and RustMaps use. var cells = CellCount(worldSize); var col = Math.Clamp((int)MathF.Floor(x / CellSize), 0, cells - 1); - var rowFromBottom = Math.Clamp((int)MathF.Floor(y / CellSize), 0, cells - 1); - var row = cells - 1 - rowFromBottom; + var row = Math.Clamp((int)MathF.Floor((worldSize - y) / CellSize), 0, cells - 1); return string.Create(CultureInfo.InvariantCulture, $"{ColumnLetters(col)}{row}"); } } diff --git a/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs b/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs index e32ae699..7ccaf0f6 100644 --- a/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs +++ b/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs @@ -112,38 +112,45 @@ private static void DrawGrid(Image image, MapProjection projection) var lineColor = Color.FromRgba(255, 255, 255, 80); var labelColor = Color.FromRgba(255, 255, 255, 140); - var worldSize = projection.WorldSize; - var cells = MapGrid.CellCount(worldSize); - var (left, top) = projection.ToPixel(0f, worldSize); - var (right, bottom) = projection.ToPixel(worldSize, 0f); + var cells = MapGrid.CellCount(projection.WorldSize); + + // The lattice is anchored at the world's NORTH-WEST corner and spans the whole image, ocean + // margin included — exactly how the in-game map, the companion app and RustMaps draw it. The + // partial edge cell (world size not a whole multiple of the cell size) sits at the south/east + // and simply bleeds past the world edge, so every rendered cell looks full-size. + var (anchorX, anchorY) = projection.ToPixel(0f, projection.WorldSize); + var (cellEndX, cellEndY) = projection.ToPixel(MapGrid.CellSize, projection.WorldSize - MapGrid.CellSize); + var stepX = cellEndX - anchorX; + var stepY = cellEndY - anchorY; + if (stepX <= 0f || stepY <= 0f) + { + return; // Degenerate projection (no drawable world area). + } image.Mutate(ctx => { - for (var i = 0; i <= cells; i++) + for (var k = (int)MathF.Ceiling(-anchorX / stepX); anchorX + (k * stepX) <= image.Width; k++) + { + var x = anchorX + (k * stepX); + ctx.DrawLine(lineColor, OutlinePenWidth, new PointF(x, 0f), new PointF(x, image.Height)); + } + + for (var k = (int)MathF.Ceiling(-anchorY / stepY); anchorY + (k * stepY) <= image.Height; k++) { - // Cover the whole world including the partial edge cell: the final boundary is clamped to - // worldSize (Rust / RustMaps behaviour), so the last cell is narrower when the world size - // is not a whole multiple of MapGrid.CellSize. - var boundary = MathF.Min(i * MapGrid.CellSize, worldSize); - var (vx, _) = projection.ToPixel(boundary, 0f); - ctx.DrawLine(lineColor, OutlinePenWidth, new PointF(vx, top), new PointF(vx, bottom)); - var (_, hy) = projection.ToPixel(0f, boundary); - ctx.DrawLine(lineColor, OutlinePenWidth, new PointF(left, hy), new PointF(right, hy)); + var y = anchorY + (k * stepY); + ctx.DrawLine(lineColor, OutlinePenWidth, new PointF(0f, y), new PointF(image.Width, y)); } + // Only the world's cells carry labels (A0 in the north-west corner), each just inside its + // cell's top-left corner (companion-app placement). for (var col = 0; col < cells; col++) { for (var row = 0; row < cells; row++) { - // Label sits just inside each cell's top-left corner (companion-app placement). Row 0 is - // the northernmost cell, so its top edge is the world's north edge (worldSize). - var worldX = col * MapGrid.CellSize; - var worldY = MathF.Min((cells - row) * MapGrid.CellSize, worldSize); - var (lx, ly) = projection.ToPixel(worldX, worldY); var label = MapGrid.ColumnLetters(col) + row.ToString(CultureInfo.InvariantCulture); ctx.DrawText(new RichTextOptions(GridLabelFont) { - Origin = new PointF(lx + 2f, ly + 2f) + Origin = new PointF(anchorX + (col * stepX) + 2f, anchorY + (row * stepY) + 2f) }, label, labelColor); } diff --git a/tests/RustPlusBot.Abstractions.Tests/Connections/MapGridTests.cs b/tests/RustPlusBot.Abstractions.Tests/Connections/MapGridTests.cs index 336c77d4..61bda930 100644 --- a/tests/RustPlusBot.Abstractions.Tests/Connections/MapGridTests.cs +++ b/tests/RustPlusBot.Abstractions.Tests/Connections/MapGridTests.cs @@ -35,6 +35,22 @@ public void LabelFor_north_west_corner_is_A0() Assert.Equal("A0", MapGrid.LabelFor(0f, 3999f, 4000u)); } + [Fact] + public void LabelFor_rows_bin_from_the_north_edge() + { + // 1500 world -> 11 cells with a 37.5-unit partial edge cell. y = 1360 is 140 units below the + // north edge: row 0 with north-anchored rows (in-game / app / RustMaps behaviour); binning from + // the south would put it in row 1. Locks in the anchoring. + Assert.Equal("A0", MapGrid.LabelFor(0f, 1360f, 1500u)); + } + + [Fact] + public void LabelFor_south_edge_falls_in_the_partial_row() + { + // 1500 world: the southernmost 37.5 units are the partial row 10. + Assert.Equal("A10", MapGrid.LabelFor(0f, 10f, 1500u)); + } + [Fact] public void LabelFor_beyond_world_size_clamps_to_last_cell() { From 47f6f1cfe0e7916d2606b94d894e9656ab94217c Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Thu, 9 Jul 2026 23:58:02 +0200 Subject: [PATCH 37/40] =?UTF-8?q?feat(map):=20per-server=20grid=20style=20?= =?UTF-8?q?=E2=80=94=20in-game=20vs=20Rust+/RustMaps=20(100-unit=20row=20o?= =?UTF-8?q?ffset)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-game (F1) map and the Rust+ companion app / rustmaps.com use DIFFERENT grid conventions: both anchor columns at the world's west edge, but Rust+/RustMaps rows start exactly 100 game-units south of the world's north edge (measured from RustMaps' own pre-rendered grid tiles across sizes 1500–4500), while the in-game map starts them at the edge itself. That's why the render could never match both references at once. Add a per-server MapGridStyle option (default: InGame) governing BOTH the rendered #map grid and every event grid reference, so callouts read consistently with whichever map the team uses: - InGame — matches the F1 map; event callouts read like in-game grid refs. - RustPlus — matches the companion app and rustmaps.com. Plumbing: - MapGridStyle enum + MapGrid.RowInset/LabelFor(style); MapRenderer.DrawGrid anchors the lattice at (worldSize − inset). - ServerMapSettings.GridStyle + MapGridStyle EF migration; MapLayerSettings carries it; IMapSettingsStore.SetGridStyleAsync. - #map control message gains a third row with the two style buttons (active = Primary) and a help line explaining the difference; new workspace:map:gridstyle:* component handled by MapComponentModule (ManageGuild, reconcile + repaint like the layer toggles). - Style threads through EventRelay/EventEmbedRenderer, PlayerEventRelay/ PlayerEventRenderer, and the !cargo/!heli/!chinook/!events handlers via GridReference.From(x, y, dims, style). - Localization: map.gridstyle.{ingame,rustplus,help} EN/FR. Full suite green (17 projects, 916 tests, 1 skipped). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Connections/MapGrid.cs | 22 +- .../Connections/MapGridStyle.cs | 15 + .../Map/ServerMapSettings.cs | 5 + .../Handlers/CargoCommandHandler.cs | 16 +- .../Handlers/ChinookCommandHandler.cs | 16 +- .../Handlers/EventsCommandHandler.cs | 18 +- .../Handlers/HeliCommandHandler.cs | 16 +- .../Handlers/MarkerReply.cs | 13 +- .../Formatting/GridReference.cs | 5 +- .../Relaying/EventRelay.cs | 25 +- .../Rendering/EventEmbedRenderer.cs | 23 +- .../Composing/MapComposer.cs | 7 +- .../Rendering/MapRenderer.cs | 22 +- .../Relaying/PlayerEventRelay.cs | 18 +- .../Rendering/PlayerEventRenderer.cs | 30 +- .../Messages/MapControlMessageRenderer.cs | 23 +- .../Modules/MapComponentModule.cs | 47 +- .../WorkspaceComponentIds.cs | 3 + src/RustPlusBot.Localization/Strings.fr.resx | 9 + src/RustPlusBot.Localization/Strings.resx | 9 + .../Map/IMapSettingsStore.cs | 13 + src/RustPlusBot.Persistence/Map/MapLayer.cs | 6 +- .../Map/MapSettingsStore.cs | 26 +- .../20260709213331_MapGridStyle.Designer.cs | 726 ++++++++++++++++++ .../Migrations/20260709213331_MapGridStyle.cs | 29 + .../Migrations/BotDbContextModelSnapshot.cs | 3 + .../Connections/MapGridTests.cs | 23 + .../CommandRegistrationTests.cs | 3 + .../Handlers/EventHandlersTests.cs | 27 +- .../EventRegistrationTests.cs | 2 + .../Relaying/EventRelayTests.cs | 5 + .../MapRendererTests.cs | 17 + .../Hosting/PlayersHostedServiceTests.cs | 5 + .../PlayerEventRegistrationTests.cs | 2 + .../PlayerEventRelayTests.cs | 5 + .../MapControlMessageRendererTests.cs | 46 +- .../StringsResourceParityTests.cs | 2 +- .../Map/MapSettingsStoreTests.cs | 46 ++ 38 files changed, 1238 insertions(+), 90 deletions(-) create mode 100644 src/RustPlusBot.Abstractions/Connections/MapGridStyle.cs create mode 100644 src/RustPlusBot.Persistence/Migrations/20260709213331_MapGridStyle.Designer.cs create mode 100644 src/RustPlusBot.Persistence/Migrations/20260709213331_MapGridStyle.cs diff --git a/src/RustPlusBot.Abstractions/Connections/MapGrid.cs b/src/RustPlusBot.Abstractions/Connections/MapGrid.cs index b5c43db9..2b78a069 100644 --- a/src/RustPlusBot.Abstractions/Connections/MapGrid.cs +++ b/src/RustPlusBot.Abstractions/Connections/MapGrid.cs @@ -15,6 +15,19 @@ public static class MapGrid /// Edge length of one (whole) grid cell, in game units. public const float CellSize = 146.25f; + /// + /// How far south of the world's north edge the Rust+/RustMaps grid rows start, in game units. + /// Measured exactly (100.0) from RustMaps' own pre-rendered grid tiles across map sizes + /// 1500–4500; the in-game (F1) map uses no inset. Columns have no inset in either style. + /// + public const float RustPlusRowInset = 100f; + + /// Gets the row inset (south of the world's north edge) for a grid style. + /// The grid style. + /// The inset in game units. + public static float RowInset(MapGridStyle style) => + style == MapGridStyle.RustPlus ? RustPlusRowInset : 0f; + /// /// Number of grid cells per axis, covering the whole world size — ceil(worldSize / CellSize). /// The last cell (east-most column / south-most row) is a partial edge cell whenever the world size @@ -50,14 +63,15 @@ public static string ColumnLetters(int index) /// World X (west→east). /// World Y (south→north). /// The world size in game units. + /// Which grid convention to bin against (defaults to the in-game map). /// The grid label, rows numbered from the top; out-of-world coordinates clamp to the edge cell. - public static string LabelFor(float x, float y, uint worldSize) + public static string LabelFor(float x, float y, uint worldSize, MapGridStyle style = MapGridStyle.InGame) { - // Rows bin from the NORTH edge (the grid anchor), not the south — with a partial edge cell the - // two disagree, and the north anchoring is what the in-game map, the app and RustMaps use. + // Rows bin from the style's row anchor (north edge in-game; 100 units south of it for + // Rust+/RustMaps), not the south — with a partial edge cell the two directions disagree. var cells = CellCount(worldSize); var col = Math.Clamp((int)MathF.Floor(x / CellSize), 0, cells - 1); - var row = Math.Clamp((int)MathF.Floor((worldSize - y) / CellSize), 0, cells - 1); + var row = Math.Clamp((int)MathF.Floor((worldSize - RowInset(style) - y) / CellSize), 0, cells - 1); return string.Create(CultureInfo.InvariantCulture, $"{ColumnLetters(col)}{row}"); } } diff --git a/src/RustPlusBot.Abstractions/Connections/MapGridStyle.cs b/src/RustPlusBot.Abstractions/Connections/MapGridStyle.cs new file mode 100644 index 00000000..b87f9a1b --- /dev/null +++ b/src/RustPlusBot.Abstractions/Connections/MapGridStyle.cs @@ -0,0 +1,15 @@ +namespace RustPlusBot.Abstractions.Connections; + +/// +/// Which map-grid convention to use. The in-game (F1) map and the Rust+ companion app / RustMaps +/// website disagree on where grid ROWS sit: the in-game map anchors row 0 at the world's north edge, +/// while Rust+/RustMaps draw the rows 100 game-units further south. Columns are identical in both. +/// +public enum MapGridStyle +{ + /// Match the in-game (F1) map — grid references read exactly like in-game callouts. + InGame = 0, + + /// Match the Rust+ companion app and rustmaps.com — rows sit 100 game-units south of in-game. + RustPlus = 1, +} diff --git a/src/RustPlusBot.Domain/Map/ServerMapSettings.cs b/src/RustPlusBot.Domain/Map/ServerMapSettings.cs index 841ded60..f6e44ee9 100644 --- a/src/RustPlusBot.Domain/Map/ServerMapSettings.cs +++ b/src/RustPlusBot.Domain/Map/ServerMapSettings.cs @@ -1,3 +1,5 @@ +using RustPlusBot.Abstractions.Connections; + namespace RustPlusBot.Domain.Map; /// Per-(guild, server) rendered-map layer settings; one row per server. Layers default on. @@ -26,4 +28,7 @@ public sealed class ServerMapSettings /// Whether oil rigs are styled by activation state. public bool ShowRigs { get; set; } = true; + + /// Which grid convention the rendered map and event grid references use. + public MapGridStyle GridStyle { get; set; } = MapGridStyle.InGame; } diff --git a/src/RustPlusBot.Features.Commands/Handlers/CargoCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/CargoCommandHandler.cs index 6ca4d356..938960d7 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/CargoCommandHandler.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/CargoCommandHandler.cs @@ -3,6 +3,7 @@ using RustPlusBot.Features.Commands.Dispatching; using RustPlusBot.Features.Events.State; using RustPlusBot.Localization; +using RustPlusBot.Persistence.Map; namespace RustPlusBot.Features.Commands.Handlers; @@ -10,17 +11,24 @@ namespace RustPlusBot.Features.Commands.Handlers; /// The live event state. /// The reply localizer. /// For "how long ago". -internal sealed class CargoCommandHandler(IEventState state, ILocalizer localizer, IClock clock) +/// Supplies the server's grid style. +internal sealed class CargoCommandHandler( + IEventState state, + ILocalizer localizer, + IClock clock, + IMapSettingsStore mapSettings) : ICommandHandler { /// public string Name => "cargo"; /// - public Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) + public async Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(context); - return Task.FromResult( - MarkerReply.For(state, context, MarkerKind.CargoShip, "command.cargo", localizer, clock)); + return await MarkerReply + .ForAsync(state, context, MarkerKind.CargoShip, "command.cargo", localizer, clock, mapSettings, + cancellationToken) + .ConfigureAwait(false); } } diff --git a/src/RustPlusBot.Features.Commands/Handlers/ChinookCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/ChinookCommandHandler.cs index 69999c35..ec01cb36 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/ChinookCommandHandler.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/ChinookCommandHandler.cs @@ -3,6 +3,7 @@ using RustPlusBot.Features.Commands.Dispatching; using RustPlusBot.Features.Events.State; using RustPlusBot.Localization; +using RustPlusBot.Persistence.Map; namespace RustPlusBot.Features.Commands.Handlers; @@ -10,17 +11,24 @@ namespace RustPlusBot.Features.Commands.Handlers; /// The live event state. /// The reply localizer. /// For "how long ago". -internal sealed class ChinookCommandHandler(IEventState state, ILocalizer localizer, IClock clock) +/// Supplies the server's grid style. +internal sealed class ChinookCommandHandler( + IEventState state, + ILocalizer localizer, + IClock clock, + IMapSettingsStore mapSettings) : ICommandHandler { /// public string Name => "chinook"; /// - public Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) + public async Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(context); - return Task.FromResult( - MarkerReply.For(state, context, MarkerKind.Chinook, "command.chinook", localizer, clock)); + return await MarkerReply + .ForAsync(state, context, MarkerKind.Chinook, "command.chinook", localizer, clock, mapSettings, + cancellationToken) + .ConfigureAwait(false); } } diff --git a/src/RustPlusBot.Features.Commands/Handlers/EventsCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/EventsCommandHandler.cs index 63657a48..b79904e0 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/EventsCommandHandler.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/EventsCommandHandler.cs @@ -3,31 +3,38 @@ using RustPlusBot.Features.Events.Formatting; using RustPlusBot.Features.Events.State; using RustPlusBot.Localization; +using RustPlusBot.Persistence.Map; namespace RustPlusBot.Features.Commands.Handlers; /// !events — lists the most recent live events. /// The live event state. /// The reply localizer. -internal sealed class EventsCommandHandler(IEventState state, ILocalizer localizer) : ICommandHandler +/// Supplies the server's grid style. +internal sealed class EventsCommandHandler( + IEventState state, + ILocalizer localizer, + IMapSettingsStore mapSettings) : ICommandHandler { /// public string Name => "events"; /// /// A recent event has an unsupported . - public Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) + public async Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(context); var events = state.GetRecentEvents(context.GuildId, context.ServerId); if (events.Count == 0) { - return Task.FromResult(localizer.Get("command.events.none", context.Culture)); + return localizer.Get("command.events.none", context.Culture); } + var settings = await mapSettings.GetAsync(context.GuildId, context.ServerId, cancellationToken) + .ConfigureAwait(false); var parts = events.Select(e => { - var grid = GridReference.From(e.X, e.Y, e.Dimensions); + var grid = GridReference.From(e.X, e.Y, e.Dimensions, settings.GridStyle); var key = e.Kind switch { MapEventKind.CargoEntered => "command.event.cargoentered", @@ -40,7 +47,6 @@ internal sealed class EventsCommandHandler(IEventState state, ILocalizer localiz return localizer.Get(key, context.Culture, grid); }); - return Task.FromResult( - localizer.Get("command.events.ok", context.Culture, string.Join(", ", parts))); + return localizer.Get("command.events.ok", context.Culture, string.Join(", ", parts)); } } diff --git a/src/RustPlusBot.Features.Commands/Handlers/HeliCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/HeliCommandHandler.cs index a12f1c1e..3faae87c 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/HeliCommandHandler.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/HeliCommandHandler.cs @@ -3,6 +3,7 @@ using RustPlusBot.Features.Commands.Dispatching; using RustPlusBot.Features.Events.State; using RustPlusBot.Localization; +using RustPlusBot.Persistence.Map; namespace RustPlusBot.Features.Commands.Handlers; @@ -10,17 +11,24 @@ namespace RustPlusBot.Features.Commands.Handlers; /// The live event state. /// The reply localizer. /// For "how long ago". -internal sealed class HeliCommandHandler(IEventState state, ILocalizer localizer, IClock clock) +/// Supplies the server's grid style. +internal sealed class HeliCommandHandler( + IEventState state, + ILocalizer localizer, + IClock clock, + IMapSettingsStore mapSettings) : ICommandHandler { /// public string Name => "heli"; /// - public Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) + public async Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(context); - return Task.FromResult( - MarkerReply.For(state, context, MarkerKind.PatrolHelicopter, "command.heli", localizer, clock)); + return await MarkerReply + .ForAsync(state, context, MarkerKind.PatrolHelicopter, "command.heli", localizer, clock, mapSettings, + cancellationToken) + .ConfigureAwait(false); } } diff --git a/src/RustPlusBot.Features.Commands/Handlers/MarkerReply.cs b/src/RustPlusBot.Features.Commands/Handlers/MarkerReply.cs index 5e40b8f4..cf4a49e2 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/MarkerReply.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/MarkerReply.cs @@ -5,6 +5,7 @@ using RustPlusBot.Features.Events.Formatting; using RustPlusBot.Features.Events.State; using RustPlusBot.Localization; +using RustPlusBot.Persistence.Map; namespace RustPlusBot.Features.Commands.Handlers; @@ -18,14 +19,18 @@ internal static class MarkerReply /// The localization key prefix ("command.cargo" / "command.heli" / "command.chinook"). /// The reply localizer. /// For the "how long ago" suffix. + /// Supplies the server's grid style for the reference. + /// A cancellation token. /// The localized reply. - public static string For( + public static async Task ForAsync( IEventState state, CommandContext context, MarkerKind kind, string prefix, ILocalizer localizer, - IClock clock) + IClock clock, + IMapSettingsStore mapSettings, + CancellationToken cancellationToken) { var markers = state.GetActiveMarkers(context.GuildId, context.ServerId, kind); if (markers.Count == 0) @@ -33,8 +38,10 @@ public static string For( return localizer.Get($"{prefix}.none", context.Culture); } + var settings = await mapSettings.GetAsync(context.GuildId, context.ServerId, cancellationToken) + .ConfigureAwait(false); var m = markers[0]; - var grid = GridReference.From(m.X, m.Y, m.Dimensions); + var grid = GridReference.From(m.X, m.Y, m.Dimensions, settings.GridStyle); var ago = DurationFormat.Compact(clock.UtcNow - m.SeenAtUtc); return localizer.Get($"{prefix}.ok", context.Culture, grid, ago); } diff --git a/src/RustPlusBot.Features.Events/Formatting/GridReference.cs b/src/RustPlusBot.Features.Events/Formatting/GridReference.cs index 12b8c058..845f3697 100644 --- a/src/RustPlusBot.Features.Events/Formatting/GridReference.cs +++ b/src/RustPlusBot.Features.Events/Formatting/GridReference.cs @@ -10,8 +10,9 @@ public static class GridReference /// World X coordinate. /// World Y coordinate. /// Map dimensions, or null when unavailable. + /// Which grid convention to bin against (defaults to the in-game map). /// A grid reference like "D7", or "(x, y)" when dimensions are unavailable. - public static string From(float x, float y, MapDimensions? dims) + public static string From(float x, float y, MapDimensions? dims, MapGridStyle style = MapGridStyle.InGame) { if (dims is null || dims.WorldSize == 0) { @@ -20,6 +21,6 @@ public static string From(float x, float y, MapDimensions? dims) $"({Math.Round(x)}, {Math.Round(y)})"); } - return MapGrid.LabelFor(x, y, dims.WorldSize); + return MapGrid.LabelFor(x, y, dims.WorldSize, style); } } diff --git a/src/RustPlusBot.Features.Events/Relaying/EventRelay.cs b/src/RustPlusBot.Features.Events/Relaying/EventRelay.cs index b960f7f3..7b58f36c 100644 --- a/src/RustPlusBot.Features.Events/Relaying/EventRelay.cs +++ b/src/RustPlusBot.Features.Events/Relaying/EventRelay.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Events; using RustPlusBot.Features.Connections.Listening; using RustPlusBot.Features.Events.Classifying; @@ -6,6 +7,7 @@ using RustPlusBot.Features.Events.Rendering; using RustPlusBot.Features.Events.State; using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Persistence.Map; using RustPlusBot.Persistence.Workspace; namespace RustPlusBot.Features.Events.Relaying; @@ -48,18 +50,19 @@ public async Task RelayAsync(MapMarkersChangedEvent evt, CancellationToken cance return; } - var culture = await GetCultureAsync(evt.GuildId, cancellationToken).ConfigureAwait(false); + var (culture, gridStyle) = await GetRenderSettingsAsync(evt.GuildId, evt.ServerId, cancellationToken) + .ConfigureAwait(false); var channelId = await channels.Locator.GetChannelIdAsync(evt.GuildId, evt.ServerId, cancellationToken) .ConfigureAwait(false); foreach (var e in events) { await channels.TeamChatSender - .SendAsync(evt.GuildId, evt.ServerId, renderer.RenderLine(e, culture), cancellationToken) + .SendAsync(evt.GuildId, evt.ServerId, renderer.RenderLine(e, culture, gridStyle), cancellationToken) .ConfigureAwait(false); if (channelId is { } id) { - await channels.Poster.PostAsync(id, renderer.Render(e, culture), cancellationToken) + await channels.Poster.PostAsync(id, renderer.Render(e, culture, gridStyle), cancellationToken) .ConfigureAwait(false); } } @@ -77,27 +80,33 @@ public async Task RelayRigAsync(RigStateChangedEvent evt, CancellationToken canc rigStore.Apply(evt); } - var culture = await GetCultureAsync(evt.GuildId, cancellationToken).ConfigureAwait(false); + var (culture, gridStyle) = await GetRenderSettingsAsync(evt.GuildId, evt.ServerId, cancellationToken) + .ConfigureAwait(false); await channels.TeamChatSender - .SendAsync(evt.GuildId, evt.ServerId, renderer.RenderRigLine(evt, culture), cancellationToken) + .SendAsync(evt.GuildId, evt.ServerId, renderer.RenderRigLine(evt, culture, gridStyle), cancellationToken) .ConfigureAwait(false); var channelId = await channels.Locator.GetChannelIdAsync(evt.GuildId, evt.ServerId, cancellationToken) .ConfigureAwait(false); if (channelId is { } id) { - await channels.Poster.PostAsync(id, renderer.RenderRig(evt, culture), cancellationToken) + await channels.Poster.PostAsync(id, renderer.RenderRig(evt, culture, gridStyle), cancellationToken) .ConfigureAwait(false); } } - private async Task GetCultureAsync(ulong guildId, CancellationToken cancellationToken) + private async Task<(string Culture, MapGridStyle GridStyle)> GetRenderSettingsAsync(ulong guildId, + Guid serverId, + CancellationToken cancellationToken) { var scope = scopeFactory.CreateAsyncScope(); await using (scope.ConfigureAwait(false)) { var store = scope.ServiceProvider.GetRequiredService(); - return await store.GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false); + var culture = await store.GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false); + var mapSettings = scope.ServiceProvider.GetRequiredService(); + var settings = await mapSettings.GetAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + return (culture, settings.GridStyle); } } } diff --git a/src/RustPlusBot.Features.Events/Rendering/EventEmbedRenderer.cs b/src/RustPlusBot.Features.Events/Rendering/EventEmbedRenderer.cs index 10c52a28..37863b85 100644 --- a/src/RustPlusBot.Features.Events/Rendering/EventEmbedRenderer.cs +++ b/src/RustPlusBot.Features.Events/Rendering/EventEmbedRenderer.cs @@ -1,4 +1,5 @@ using Discord; +using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Events; using RustPlusBot.Features.Events.Classifying; using RustPlusBot.Features.Events.Formatting; @@ -13,12 +14,13 @@ internal sealed class EventEmbedRenderer(ILocalizer localizer) /// Renders the event for a guild culture. /// The event to render. /// The guild culture ("en"/"fr"). + /// Which grid convention the reference uses. /// The built embed. /// The event kind is not a supported . - public Embed Render(RustMapEvent evt, string culture) + public Embed Render(RustMapEvent evt, string culture, MapGridStyle gridStyle = MapGridStyle.InGame) { ArgumentNullException.ThrowIfNull(evt); - var grid = GridReference.From(evt.X, evt.Y, evt.Dimensions); + var grid = GridReference.From(evt.X, evt.Y, evt.Dimensions, gridStyle); var key = evt.Kind switch { MapEventKind.CargoEntered => "event.cargo.entered", @@ -39,11 +41,12 @@ public Embed Render(RustMapEvent evt, string culture) /// Renders a rig boundary event as a Discord embed. /// The rig event. /// The guild culture. + /// Which grid convention the reference uses. /// The built embed. - public Embed RenderRig(RigStateChangedEvent evt, string culture) + public Embed RenderRig(RigStateChangedEvent evt, string culture, MapGridStyle gridStyle = MapGridStyle.InGame) { ArgumentNullException.ThrowIfNull(evt); - var grid = GridReference.From(evt.X, evt.Y, evt.Dimensions); + var grid = GridReference.From(evt.X, evt.Y, evt.Dimensions, gridStyle); return new EmbedBuilder() .WithAuthor(localizer.Get("event.title", culture)) .WithDescription(localizer.Get(RigKey(evt), culture, grid)) @@ -53,23 +56,27 @@ public Embed RenderRig(RigStateChangedEvent evt, string culture) /// Renders the in-game team-chat line for a rig boundary event. /// The rig event. /// The guild culture. + /// Which grid convention the reference uses. /// The line text. - public string RenderRigLine(RigStateChangedEvent evt, string culture) + public string RenderRigLine(RigStateChangedEvent evt, + string culture, + MapGridStyle gridStyle = MapGridStyle.InGame) { ArgumentNullException.ThrowIfNull(evt); - var grid = GridReference.From(evt.X, evt.Y, evt.Dimensions); + var grid = GridReference.From(evt.X, evt.Y, evt.Dimensions, gridStyle); return localizer.Get(RigKey(evt) + ".line", culture, grid); } /// Renders the in-game team-chat line for a cargo/heli/chinook event. /// The map event. /// The guild culture. + /// Which grid convention the reference uses. /// The line text. /// The event kind is not a supported . - public string RenderLine(RustMapEvent evt, string culture) + public string RenderLine(RustMapEvent evt, string culture, MapGridStyle gridStyle = MapGridStyle.InGame) { ArgumentNullException.ThrowIfNull(evt); - var grid = GridReference.From(evt.X, evt.Y, evt.Dimensions); + var grid = GridReference.From(evt.X, evt.Y, evt.Dimensions, gridStyle); var key = evt.Kind switch { MapEventKind.CargoEntered => "event.cargo.entered.line", diff --git a/src/RustPlusBot.Features.Map/Composing/MapComposer.cs b/src/RustPlusBot.Features.Map/Composing/MapComposer.cs index 4ee7a427..a18e0de7 100644 --- a/src/RustPlusBot.Features.Map/Composing/MapComposer.cs +++ b/src/RustPlusBot.Features.Map/Composing/MapComposer.cs @@ -46,13 +46,15 @@ public sealed class MapComposer( var layers = new MapLayerSet(settings.Grid, settings.Markers, settings.Monuments, settings.Vendor, settings.Players, settings.Rigs); - return await ComposeWithLayersAsync(guildId, serverId, layers, cancellationToken).ConfigureAwait(false); + return await ComposeWithLayersAsync(guildId, serverId, layers, settings.GridStyle, cancellationToken) + .ConfigureAwait(false); } private async Task ComposeWithLayersAsync( ulong guildId, Guid serverId, MapLayerSet layers, + MapGridStyle gridStyle, CancellationToken cancellationToken) { var baseImage = await cache.GetAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); @@ -90,7 +92,8 @@ public sealed class MapComposer( .ConfigureAwait(false); var rigPlacements = GatherRigs(guildId, serverId, serverMonuments, projection, layers); - return renderer.Render(baseImage.Bytes, projection, markers, monuments, players, rigPlacements, layers); + return renderer.Render(baseImage.Bytes, projection, markers, monuments, players, rigPlacements, layers, + gridStyle); } private List GatherMarkers( diff --git a/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs b/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs index 7ccaf0f6..a9d19748 100644 --- a/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs +++ b/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs @@ -45,6 +45,7 @@ private static FontFamily LoadFamily() /// Player placements already projected to pixel coordinates. /// Oil-rig placements already projected to pixel coordinates. /// Which overlay layers to draw. + /// Which grid convention to draw (in-game F1 map, or Rust+/RustMaps). /// PNG-encoded bytes of a square image with pixels on each side. /// Kept as an instance method so the class can be registered as a DI singleton. #pragma warning disable CA1822, S2325 // Kept as instance method for DI singleton registration @@ -54,7 +55,8 @@ public byte[] Render(byte[] baseJpeg, IReadOnlyList monuments, IReadOnlyList players, IReadOnlyList rigs, - MapLayerSet layers) + MapLayerSet layers, + MapGridStyle gridStyle = MapGridStyle.InGame) #pragma warning restore CA1822, S2325 { ArgumentNullException.ThrowIfNull(baseJpeg); @@ -70,7 +72,7 @@ public byte[] Render(byte[] baseJpeg, if (layers.Grid) { - DrawGrid(image, projection); + DrawGrid(image, projection, gridStyle); } if (layers.Monuments) @@ -103,7 +105,7 @@ public byte[] Render(byte[] baseJpeg, return ms.ToArray(); } - private static void DrawGrid(Image image, MapProjection projection) + private static void DrawGrid(Image image, MapProjection projection, MapGridStyle gridStyle) { if (projection.WorldSize == 0) { @@ -114,12 +116,14 @@ private static void DrawGrid(Image image, MapProjection projection) var labelColor = Color.FromRgba(255, 255, 255, 140); var cells = MapGrid.CellCount(projection.WorldSize); - // The lattice is anchored at the world's NORTH-WEST corner and spans the whole image, ocean - // margin included — exactly how the in-game map, the companion app and RustMaps draw it. The - // partial edge cell (world size not a whole multiple of the cell size) sits at the south/east - // and simply bleeds past the world edge, so every rendered cell looks full-size. - var (anchorX, anchorY) = projection.ToPixel(0f, projection.WorldSize); - var (cellEndX, cellEndY) = projection.ToPixel(MapGrid.CellSize, projection.WorldSize - MapGrid.CellSize); + // The lattice is anchored at the west edge and at the style's row anchor (the world's north + // edge for the in-game style; 100 game-units south of it for Rust+/RustMaps), and spans the + // whole image, ocean margin included — exactly how those maps draw it. The partial edge cell + // sits at the south/east and simply bleeds past the world edge, so every rendered cell looks + // full-size. + var anchorWorldY = projection.WorldSize - MapGrid.RowInset(gridStyle); + var (anchorX, anchorY) = projection.ToPixel(0f, anchorWorldY); + var (cellEndX, cellEndY) = projection.ToPixel(MapGrid.CellSize, anchorWorldY - MapGrid.CellSize); var stepX = cellEndX - anchorX; var stepY = cellEndY - anchorY; if (stepX <= 0f || stepY <= 0f) diff --git a/src/RustPlusBot.Features.Players/Relaying/PlayerEventRelay.cs b/src/RustPlusBot.Features.Players/Relaying/PlayerEventRelay.cs index 7641cbda..83c1567f 100644 --- a/src/RustPlusBot.Features.Players/Relaying/PlayerEventRelay.cs +++ b/src/RustPlusBot.Features.Players/Relaying/PlayerEventRelay.cs @@ -1,9 +1,11 @@ using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Events; using RustPlusBot.Features.Connections.Listening; using RustPlusBot.Features.Players.Posting; using RustPlusBot.Features.Players.Rendering; using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Persistence.Map; using RustPlusBot.Persistence.Workspace; namespace RustPlusBot.Features.Players.Relaying; @@ -32,31 +34,37 @@ public async Task RelayAsync(PlayerStateChangedEvent evt, CancellationToken canc return; } - var culture = await GetCultureAsync(evt.GuildId, cancellationToken).ConfigureAwait(false); + var (culture, gridStyle) = await GetRenderSettingsAsync(evt.GuildId, evt.ServerId, cancellationToken) + .ConfigureAwait(false); var channelId = await locator.GetChannelIdAsync(evt.GuildId, evt.ServerId, cancellationToken) .ConfigureAwait(false); foreach (var t in evt.Transitions) { await teamChatSender - .SendAsync(evt.GuildId, evt.ServerId, renderer.RenderLine(t, evt.Dimensions, culture), + .SendAsync(evt.GuildId, evt.ServerId, renderer.RenderLine(t, evt.Dimensions, culture, gridStyle), cancellationToken) .ConfigureAwait(false); if (channelId is { } id) { - await poster.PostAsync(id, renderer.Render(t, evt.Dimensions, culture), cancellationToken) + await poster.PostAsync(id, renderer.Render(t, evt.Dimensions, culture, gridStyle), cancellationToken) .ConfigureAwait(false); } } } - private async Task GetCultureAsync(ulong guildId, CancellationToken cancellationToken) + private async Task<(string Culture, MapGridStyle GridStyle)> GetRenderSettingsAsync(ulong guildId, + Guid serverId, + CancellationToken cancellationToken) { var scope = scopeFactory.CreateAsyncScope(); await using (scope.ConfigureAwait(false)) { var store = scope.ServiceProvider.GetRequiredService(); - return await store.GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false); + var culture = await store.GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false); + var mapSettings = scope.ServiceProvider.GetRequiredService(); + var settings = await mapSettings.GetAsync(guildId, serverId, cancellationToken).ConfigureAwait(false); + return (culture, settings.GridStyle); } } } diff --git a/src/RustPlusBot.Features.Players/Rendering/PlayerEventRenderer.cs b/src/RustPlusBot.Features.Players/Rendering/PlayerEventRenderer.cs index 45130cc3..3b9f2132 100644 --- a/src/RustPlusBot.Features.Players/Rendering/PlayerEventRenderer.cs +++ b/src/RustPlusBot.Features.Players/Rendering/PlayerEventRenderer.cs @@ -14,12 +14,16 @@ internal sealed class PlayerEventRenderer(ILocalizer localizer) /// The player transition to render. /// The map dimensions, or null if unavailable. /// The BCP-47 culture tag. - public Embed Render(PlayerTransition transition, MapDimensions? dims, string culture) + /// Which grid convention the reference uses. + public Embed Render(PlayerTransition transition, + MapDimensions? dims, + string culture, + MapGridStyle gridStyle = MapGridStyle.InGame) { ArgumentNullException.ThrowIfNull(transition); return new EmbedBuilder() .WithAuthor(localizer.Get("player.title", culture)) - .WithDescription(Describe(transition, dims, culture, suffix: string.Empty)) + .WithDescription(Describe(transition, dims, culture, gridStyle, suffix: string.Empty)) .WithCurrentTimestamp() .Build(); } @@ -28,28 +32,34 @@ public Embed Render(PlayerTransition transition, MapDimensions? dims, string cul /// The player transition to render. /// The map dimensions, or null if unavailable. /// The BCP-47 culture tag. - public string RenderLine(PlayerTransition transition, MapDimensions? dims, string culture) + /// Which grid convention the reference uses. + public string RenderLine(PlayerTransition transition, + MapDimensions? dims, + string culture, + MapGridStyle gridStyle = MapGridStyle.InGame) { ArgumentNullException.ThrowIfNull(transition); - return Describe(transition, dims, culture, suffix: ".line"); + return Describe(transition, dims, culture, gridStyle, suffix: ".line"); } - private string Describe(PlayerTransition t, MapDimensions? dims, string culture, string suffix) + private string Describe(PlayerTransition t, MapDimensions? dims, string culture, MapGridStyle style, string suffix) { return t.Kind switch { PlayerTransitionKind.Connect => localizer.Get("player.connect" + suffix, culture, t.Name), PlayerTransitionKind.Disconnect => localizer.Get("player.disconnect" + suffix, culture, t.Name), - PlayerTransitionKind.Respawn => localizer.Get("player.respawn" + suffix, culture, t.Name, Grid(t, dims)), + PlayerTransitionKind.Respawn => localizer.Get("player.respawn" + suffix, culture, t.Name, + Grid(t, dims, style)), PlayerTransitionKind.ReturnedFromAfk => localizer.Get("player.afk.back" + suffix, culture, t.Name), - PlayerTransitionKind.BecameAfk => localizer.Get("player.afk" + suffix, culture, t.Name, Grid(t, dims)), + PlayerTransitionKind.BecameAfk => localizer.Get("player.afk" + suffix, culture, t.Name, + Grid(t, dims, style)), PlayerTransitionKind.Death => t.Location is null ? localizer.Get("player.death.unknown" + suffix, culture, t.Name) - : localizer.Get("player.death" + suffix, culture, t.Name, Grid(t, dims)), + : localizer.Get("player.death" + suffix, culture, t.Name, Grid(t, dims, style)), _ => throw new ArgumentOutOfRangeException(nameof(t), t.Kind, "Unsupported transition kind."), }; } - private static string Grid(PlayerTransition t, MapDimensions? dims) - => t.Location is { } loc ? GridReference.From(loc.X, loc.Y, dims) : string.Empty; + private static string Grid(PlayerTransition t, MapDimensions? dims, MapGridStyle style) + => t.Location is { } loc ? GridReference.From(loc.X, loc.Y, dims, style) : string.Empty; } diff --git a/src/RustPlusBot.Features.Workspace/Messages/MapControlMessageRenderer.cs b/src/RustPlusBot.Features.Workspace/Messages/MapControlMessageRenderer.cs index 3c5113ed..b69b0c79 100644 --- a/src/RustPlusBot.Features.Workspace/Messages/MapControlMessageRenderer.cs +++ b/src/RustPlusBot.Features.Workspace/Messages/MapControlMessageRenderer.cs @@ -1,4 +1,5 @@ using Discord; +using RustPlusBot.Abstractions.Connections; using RustPlusBot.Features.Workspace.Gateway; using RustPlusBot.Features.Workspace.Registry; using RustPlusBot.Localization; @@ -6,7 +7,8 @@ namespace RustPlusBot.Features.Workspace.Messages; -/// Renders the per-server #map control message: six ManageGuild layer-toggle buttons. +/// Renders the per-server #map control message: six ManageGuild layer-toggle buttons plus the +/// grid-style picker (in-game vs Rust+/RustMaps grid convention). /// Per-(guild, server) layer toggles. /// String resolution. internal sealed class MapControlMessageRenderer( @@ -37,8 +39,13 @@ public async ValueTask RenderAsync(MessageRenderContext context, 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); + AddGridStyle(builder, MapGridStyle.InGame, "map.gridstyle.ingame", settings.GridStyle, serverId, + context.Culture); + AddGridStyle(builder, MapGridStyle.RustPlus, "map.gridstyle.rustplus", settings.GridStyle, serverId, + context.Culture); - var header = localizer.Get("map.control.header", context.Culture); + var header = localizer.Get("map.control.header", context.Culture) + + "\n-# " + localizer.Get("map.gridstyle.help", context.Culture); return new MessagePayload(header, null, builder.Build()); } @@ -54,4 +61,16 @@ private void AddToggle(ComponentBuilder builder, $"{WorkspaceComponentIds.MapTogglePrefix}{layer}:{serverId}", enabled ? ButtonStyle.Success : ButtonStyle.Secondary, row: row); + + private void AddGridStyle(ComponentBuilder builder, + MapGridStyle style, + string labelKey, + MapGridStyle current, + Guid serverId, + string culture) => + builder.WithButton( + localizer.Get(labelKey, culture), + $"{WorkspaceComponentIds.MapGridStylePrefix}{style}:{serverId}", + style == current ? ButtonStyle.Primary : ButtonStyle.Secondary, + row: 2); } diff --git a/src/RustPlusBot.Features.Workspace/Modules/MapComponentModule.cs b/src/RustPlusBot.Features.Workspace/Modules/MapComponentModule.cs index 2d7ae6d3..76d0f810 100644 --- a/src/RustPlusBot.Features.Workspace/Modules/MapComponentModule.cs +++ b/src/RustPlusBot.Features.Workspace/Modules/MapComponentModule.cs @@ -1,6 +1,7 @@ using Discord; using Discord.Interactions; using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Events; using RustPlusBot.Features.Workspace.Reconciler; using RustPlusBot.Persistence.Map; @@ -8,7 +9,7 @@ namespace RustPlusBot.Features.Workspace.Modules; -/// Handles the #map layer-toggle buttons (ManageGuild). +/// Handles the #map layer-toggle and grid-style 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) @@ -60,6 +61,50 @@ public async Task ToggleAsync(string tail) await FollowupAsync("Updated map layers.", ephemeral: true).ConfigureAwait(false); } + /// Sets the grid style, re-renders the control message, and triggers a map repaint. + /// The "{style}:{serverId}" tail captured from the custom id. + [ComponentInteraction(WorkspaceComponentIds.MapGridStylePrefix + "*")] + [RequireUserPermission(GuildPermission.ManageGuild)] + public async Task SetGridStyleAsync(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 style) + || !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(); + await store.SetGridStyleAsync(Context.Guild.Id, serverId, style).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 grid style.", ephemeral: true).ConfigureAwait(false); + } + private static bool IsEnabled(MapLayerSettings settings, MapLayer layer) => layer switch { MapLayer.Grid => settings.Grid, diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceComponentIds.cs b/src/RustPlusBot.Features.Workspace/WorkspaceComponentIds.cs index 8f00ffe7..6c710b48 100644 --- a/src/RustPlusBot.Features.Workspace/WorkspaceComponentIds.cs +++ b/src/RustPlusBot.Features.Workspace/WorkspaceComponentIds.cs @@ -20,4 +20,7 @@ public static class WorkspaceComponentIds /// Prefix for a #map layer toggle button; "{layer}:{serverId}" is appended. Handled by Workspace. public const string MapTogglePrefix = "workspace:map:toggle:"; + + /// Prefix for a #map grid-style button; "{style}:{serverId}" is appended. Handled by Workspace. + public const string MapGridStylePrefix = "workspace:map:gridstyle:"; } diff --git a/src/RustPlusBot.Localization/Strings.fr.resx b/src/RustPlusBot.Localization/Strings.fr.resx index fe40fab2..7f16ea7f 100644 --- a/src/RustPlusBot.Localization/Strings.fr.resx +++ b/src/RustPlusBot.Localization/Strings.fr.resx @@ -540,6 +540,15 @@ Calques de la carte — activer/désactiver : + + Style de grille : En jeu correspond à la carte F1 (les positions des événements se lisent comme en jeu) ; Rust+/RustMaps correspond à l'application mobile et à rustmaps.com (lignes légèrement plus basses). + + + Grille : En jeu + + + Grille : Rust+/RustMaps + Seed diff --git a/src/RustPlusBot.Localization/Strings.resx b/src/RustPlusBot.Localization/Strings.resx index f144111b..94b863a6 100644 --- a/src/RustPlusBot.Localization/Strings.resx +++ b/src/RustPlusBot.Localization/Strings.resx @@ -540,6 +540,15 @@ Map layers — toggle to show/hide: + + Grid style: In-game matches the F1 map (event callouts read like in-game grid refs); Rust+/RustMaps matches the companion app and rustmaps.com (rows sit slightly lower). + + + Grid: In-game + + + Grid: Rust+/RustMaps + Seed diff --git a/src/RustPlusBot.Persistence/Map/IMapSettingsStore.cs b/src/RustPlusBot.Persistence/Map/IMapSettingsStore.cs index 47a4af17..30c1b782 100644 --- a/src/RustPlusBot.Persistence/Map/IMapSettingsStore.cs +++ b/src/RustPlusBot.Persistence/Map/IMapSettingsStore.cs @@ -1,3 +1,5 @@ +using RustPlusBot.Abstractions.Connections; + namespace RustPlusBot.Persistence.Map; /// Reads/writes per-(guild, server) map layer settings. @@ -22,4 +24,15 @@ Task SetLayerAsync(ulong guildId, MapLayer layer, bool enabled, CancellationToken cancellationToken = default); + + /// Sets the grid style, creating the row (all-on layers) if needed. + /// Owning Discord guild snowflake. + /// The Rust server id. + /// The grid convention to use. + /// A cancellation token. + /// A task that completes when persisted. + Task SetGridStyleAsync(ulong guildId, + Guid serverId, + MapGridStyle style, + CancellationToken cancellationToken = default); } diff --git a/src/RustPlusBot.Persistence/Map/MapLayer.cs b/src/RustPlusBot.Persistence/Map/MapLayer.cs index c17f2092..06e650fa 100644 --- a/src/RustPlusBot.Persistence/Map/MapLayer.cs +++ b/src/RustPlusBot.Persistence/Map/MapLayer.cs @@ -1,3 +1,5 @@ +using RustPlusBot.Abstractions.Connections; + namespace RustPlusBot.Persistence.Map; /// A single toggleable map render layer. @@ -29,13 +31,15 @@ public enum MapLayer /// Travelling vendor. /// Teammate positions. /// Oil-rig activation styling. +/// Which grid convention the render and event grid references use. public sealed record MapLayerSettings( bool Grid, bool Markers, bool Monuments, bool Vendor, bool Players, - bool Rigs) + bool Rigs, + MapGridStyle GridStyle = MapGridStyle.InGame) { /// 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 index ef09cc69..d6e96b79 100644 --- a/src/RustPlusBot.Persistence/Map/MapSettingsStore.cs +++ b/src/RustPlusBot.Persistence/Map/MapSettingsStore.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using RustPlusBot.Abstractions.Connections; using RustPlusBot.Domain.Map; namespace RustPlusBot.Persistence.Map; @@ -18,7 +19,7 @@ public async Task GetAsync(ulong guildId, return row is null ? MapLayerSettings.AllOn : new MapLayerSettings(row.ShowGrid, row.ShowMarkers, row.ShowMonuments, row.ShowVendor, - row.ShowPlayers, row.ShowRigs); + row.ShowPlayers, row.ShowRigs, row.GridStyle); } /// @@ -67,4 +68,27 @@ public async Task SetLayerAsync(ulong guildId, await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } + + /// + public async Task SetGridStyleAsync(ulong guildId, + Guid serverId, + MapGridStyle style, + 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 + }; + row.GridStyle = style; + if (existing is null) + { + context.ServerMapSettings.Add(row); + } + + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } } diff --git a/src/RustPlusBot.Persistence/Migrations/20260709213331_MapGridStyle.Designer.cs b/src/RustPlusBot.Persistence/Migrations/20260709213331_MapGridStyle.Designer.cs new file mode 100644 index 00000000..9e1987be --- /dev/null +++ b/src/RustPlusBot.Persistence/Migrations/20260709213331_MapGridStyle.Designer.cs @@ -0,0 +1,726 @@ +// +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("20260709213331_MapGridStyle")] + partial class MapGridStyle + { + /// + 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.Alarms.SmartAlarm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("LastIsActive") + .HasColumnType("INTEGER"); + + b.Property("LastTriggeredUtc") + .HasColumnType("TEXT"); + + b.Property("MessageId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PairedByUserId") + .HasColumnType("INTEGER"); + + b.Property("PingEveryone") + .HasColumnType("INTEGER"); + + b.Property("Reachability") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("RelayToTeamChat") + .HasColumnType("INTEGER"); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.HasIndex("GuildId", "ServerId", "EntityId") + .IsUnique(); + + b.ToTable("SmartAlarms"); + }); + + 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("GridStyle") + .HasColumnType("INTEGER"); + + 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("FacepunchServerId") + .HasColumnType("TEXT"); + + 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("FacepunchServerId"); + + b.HasIndex("GuildId"); + + b.HasIndex("GuildId", "Ip", "Port") + .IsUnique(); + + b.ToTable("RustServers"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.StorageMonitors.SmartStorageMonitor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("MessageId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PairedByUserId") + .HasColumnType("INTEGER"); + + b.Property("Reachability") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.HasIndex("GuildId", "ServerId", "EntityId") + .IsUnique(); + + b.ToTable("SmartStorageMonitors"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Switches.SmartSwitch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("LastIsActive") + .HasColumnType("INTEGER"); + + b.Property("MessageId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PairedByUserId") + .HasColumnType("INTEGER"); + + b.Property("Reachability") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("ServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.HasIndex("GuildId", "ServerId", "EntityId") + .IsUnique(); + + b.ToTable("SmartSwitches"); + }); + + 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.Alarms.SmartAlarm", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + 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.StorageMonitors.SmartStorageMonitor", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Switches.SmartSwitch", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("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/20260709213331_MapGridStyle.cs b/src/RustPlusBot.Persistence/Migrations/20260709213331_MapGridStyle.cs new file mode 100644 index 00000000..c5499c61 --- /dev/null +++ b/src/RustPlusBot.Persistence/Migrations/20260709213331_MapGridStyle.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RustPlusBot.Persistence.Migrations +{ + /// + public partial class MapGridStyle : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "GridStyle", + table: "ServerMapSettings", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "GridStyle", + table: "ServerMapSettings"); + } + } +} diff --git a/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs b/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs index 1f149feb..21b0ed21 100644 --- a/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs +++ b/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs @@ -363,6 +363,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ServerId") .HasColumnType("TEXT"); + b.Property("GridStyle") + .HasColumnType("INTEGER"); + b.Property("GuildId") .HasColumnType("INTEGER"); diff --git a/tests/RustPlusBot.Abstractions.Tests/Connections/MapGridTests.cs b/tests/RustPlusBot.Abstractions.Tests/Connections/MapGridTests.cs index 61bda930..952867cc 100644 --- a/tests/RustPlusBot.Abstractions.Tests/Connections/MapGridTests.cs +++ b/tests/RustPlusBot.Abstractions.Tests/Connections/MapGridTests.cs @@ -58,4 +58,27 @@ public void LabelFor_beyond_world_size_clamps_to_last_cell() // 4000 world -> 28 cells (see LabelFor_origin_is_bottom_left_last_row); last column index 27 = "AB". Assert.Equal("AB27", MapGrid.LabelFor(4500f, 0f, 4000u)); } + + [Fact] + public void LabelFor_rustplus_style_shifts_rows_100_units_south() + { + // Rust+/RustMaps rows start 100 units below the north edge (measured from RustMaps' own grid + // tiles). y = 830 on a 1500 world: in-game row = floor((1500-830)/146.25) = 4; Rust+ row = + // floor((1400-830)/146.25) = 3. Columns are identical in both styles. + Assert.Equal("A4", MapGrid.LabelFor(0f, 830f, 1500u)); + Assert.Equal("A3", MapGrid.LabelFor(0f, 830f, 1500u, MapGridStyle.RustPlus)); + } + + [Fact] + public void LabelFor_rustplus_style_clamps_the_top_strip_to_row_0() + { + // The 100 units above the Rust+ row anchor still read as row 0 (best effort). + Assert.Equal("A0", MapGrid.LabelFor(0f, 1450f, 1500u, MapGridStyle.RustPlus)); + } + + [Theory] + [InlineData(MapGridStyle.InGame, 0f)] + [InlineData(MapGridStyle.RustPlus, 100f)] + public void RowInset_is_zero_in_game_and_100_for_rustplus(MapGridStyle style, float expected) => + Assert.Equal(expected, MapGrid.RowInset(style)); } diff --git a/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs b/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs index 6a7e3626..1068b591 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs @@ -13,6 +13,7 @@ using RustPlusBot.Features.ItemData; using RustPlusBot.Localization; using RustPlusBot.Persistence.Commands; +using RustPlusBot.Persistence.Map; using RustPlusBot.Persistence.Servers; using RustPlusBot.Persistence.Workspace; @@ -34,6 +35,7 @@ public void Dispatcher_and_handlers_resolve() services.AddSingleton(_ => Substitute.For()); services.AddScoped(_ => Substitute.For()); services.AddScoped(_ => Substitute.For()); + services.AddScoped(_ => Substitute.For()); services.AddScoped(_ => Substitute.For()); services.AddOptions(); services.AddItemData(); @@ -92,6 +94,7 @@ public void Commands_contribute_an_interaction_module_assembly() services.AddSingleton(_ => Substitute.For()); services.AddScoped(_ => Substitute.For()); services.AddScoped(_ => Substitute.For()); + services.AddScoped(_ => Substitute.For()); services.AddScoped(_ => Substitute.For()); services.AddOptions(); services.AddItemData(); diff --git a/tests/RustPlusBot.Features.Commands.Tests/Handlers/EventHandlersTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Handlers/EventHandlersTests.cs index 1cd8d48d..63f696ad 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/Handlers/EventHandlersTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/Handlers/EventHandlersTests.cs @@ -6,6 +6,7 @@ using RustPlusBot.Features.Events.Classifying; using RustPlusBot.Features.Events.State; using RustPlusBot.Localization; +using RustPlusBot.Persistence.Map; namespace RustPlusBot.Features.Commands.Tests.Handlers; @@ -22,6 +23,14 @@ private static (IClock Clock, ILocalizer Loc) Deps() return (clock, new ResxLocalizer()); } + /// A settings store returning the all-on defaults (in-game grid style). + private static IMapSettingsStore Settings() + { + var store = Substitute.For(); + store.GetAsync(Guild, Server, Arg.Any()).Returns(MapLayerSettings.AllOn); + return store; + } + private static CommandContext Ctx() => new(Guild, Server, "en", 0UL, string.Empty, []); [Fact] @@ -35,7 +44,8 @@ public async Task Cargo_with_active_marker_reports_grid() new ActiveMarker(1, MarkerKind.CargoShip, 10f, 3990f, dims, Now.AddMinutes(-5), [new TrailPoint(10f, 3990f)], null) ]); - var reply = await new CargoCommandHandler(state, loc, clock).ExecuteAsync(Ctx(), CancellationToken.None); + var reply = await new CargoCommandHandler(state, loc, clock, Settings()).ExecuteAsync(Ctx(), + CancellationToken.None); Assert.NotNull(reply); Assert.Contains("Cargo Ship at", reply, StringComparison.Ordinal); @@ -49,7 +59,8 @@ public async Task Cargo_without_marker_reports_none() var (clock, loc) = Deps(); var state = Substitute.For(); state.GetActiveMarkers(Guild, Server, MarkerKind.CargoShip).Returns([]); - var reply = await new CargoCommandHandler(state, loc, clock).ExecuteAsync(Ctx(), CancellationToken.None); + var reply = await new CargoCommandHandler(state, loc, clock, Settings()).ExecuteAsync(Ctx(), + CancellationToken.None); Assert.Equal("No cargo ship on the map.", reply); } @@ -60,14 +71,14 @@ public async Task Events_lists_recent_or_reports_none() var state = Substitute.For(); state.GetRecentEvents(Guild, Server).Returns([]); Assert.Equal("No recent events.", - await new EventsCommandHandler(state, loc).ExecuteAsync(Ctx(), CancellationToken.None)); + await new EventsCommandHandler(state, loc, Settings()).ExecuteAsync(Ctx(), CancellationToken.None)); state.GetRecentEvents(Guild, Server).Returns( [ new RustMapEvent(MapEventKind.CargoEntered, 10f, 3990f, new MapDimensions(4000u, 4000u, 500, WorldSize: 4000u), Now) ]); - var reply = await new EventsCommandHandler(state, loc).ExecuteAsync(Ctx(), CancellationToken.None); + var reply = await new EventsCommandHandler(state, loc, Settings()).ExecuteAsync(Ctx(), CancellationToken.None); Assert.Contains("Recent:", reply, StringComparison.Ordinal); } @@ -76,9 +87,9 @@ public async Task Handlers_expose_expected_names() { var (clock, loc) = Deps(); var state = Substitute.For(); - Assert.Equal("cargo", new CargoCommandHandler(state, loc, clock).Name); - Assert.Equal("heli", new HeliCommandHandler(state, loc, clock).Name); - Assert.Equal("chinook", new ChinookCommandHandler(state, loc, clock).Name); - Assert.Equal("events", new EventsCommandHandler(state, loc).Name); + Assert.Equal("cargo", new CargoCommandHandler(state, loc, clock, Settings()).Name); + Assert.Equal("heli", new HeliCommandHandler(state, loc, clock, Settings()).Name); + Assert.Equal("chinook", new ChinookCommandHandler(state, loc, clock, Settings()).Name); + Assert.Equal("events", new EventsCommandHandler(state, loc, Settings()).Name); } } diff --git a/tests/RustPlusBot.Features.Events.Tests/EventRegistrationTests.cs b/tests/RustPlusBot.Features.Events.Tests/EventRegistrationTests.cs index 8466eb3b..c1309282 100644 --- a/tests/RustPlusBot.Features.Events.Tests/EventRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Events.Tests/EventRegistrationTests.cs @@ -13,6 +13,7 @@ using RustPlusBot.Features.Events.State; using RustPlusBot.Features.Workspace.Locating; using RustPlusBot.Persistence.Connections; +using RustPlusBot.Persistence.Map; using RustPlusBot.Persistence.Workspace; namespace RustPlusBot.Features.Events.Tests; @@ -44,6 +45,7 @@ private static ServiceProvider BuildProvider() services.AddSingleton(); services.AddSingleton(Substitute.For()); services.AddScoped(_ => Substitute.For()); + services.AddScoped(_ => Substitute.For()); services.AddScoped(_ => Substitute.For()); services.AddSingleton(Substitute.For()); services.AddSingleton(Options.Create(new ConnectionOptions())); diff --git a/tests/RustPlusBot.Features.Events.Tests/Relaying/EventRelayTests.cs b/tests/RustPlusBot.Features.Events.Tests/Relaying/EventRelayTests.cs index 60b1f539..2c016c5e 100644 --- a/tests/RustPlusBot.Features.Events.Tests/Relaying/EventRelayTests.cs +++ b/tests/RustPlusBot.Features.Events.Tests/Relaying/EventRelayTests.cs @@ -14,6 +14,7 @@ using RustPlusBot.Features.Events.State; using RustPlusBot.Features.Workspace.Locating; using RustPlusBot.Localization; +using RustPlusBot.Persistence.Map; using RustPlusBot.Persistence.Workspace; namespace RustPlusBot.Features.Events.Tests.Relaying; @@ -39,8 +40,12 @@ private static (EventRelay Relay, EventStateStore Store, IEventChannelPoster Pos var workspaceStore = Substitute.For(); workspaceStore.GetCultureAsync(Guild, Arg.Any()).Returns("en"); + var mapSettings = Substitute.For(); + mapSettings.GetAsync(Guild, Server, Arg.Any()).Returns(MapLayerSettings.AllOn); + var services = new ServiceCollection(); services.AddScoped(_ => workspaceStore); + services.AddScoped(_ => mapSettings); var provider = services.BuildServiceProvider(); var sender = Substitute.For(); diff --git a/tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs b/tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs index c34c4836..65852fd5 100644 --- a/tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs @@ -150,6 +150,23 @@ public void Trail_draws_pixels_between_history_points() Assert.True(bounds.Width > MapRenderStyle.CargoIconSize * 2, $"no trail drawn (width {bounds.Width}px)"); } + [Fact] + public void Grid_style_shifts_the_rendered_rows() + { + // Rust+/RustMaps rows sit 100 world-units south of the in-game rows, so the two styles must + // produce different grid pixels on the same base image. + var renderer = new MapRenderer(); + var projection = new MapProjection(1500, 2000, 2000, 100, MapRenderer.OutputSize); + var baseJpeg = SolidJpeg(2000); + var layers = new MapLayerSet(Grid: true, Markers: false, Monuments: false, Vendor: false, + Players: false, Rigs: false); + + var inGame = renderer.Render(baseJpeg, projection, [], [], [], [], layers); + var rustPlus = renderer.Render(baseJpeg, projection, [], [], [], [], layers, MapGridStyle.RustPlus); + + Assert.False(inGame.AsSpan().SequenceEqual(rustPlus)); + } + [Fact] public void Rotation_changes_the_rendered_icon() { diff --git a/tests/RustPlusBot.Features.Players.Tests/Hosting/PlayersHostedServiceTests.cs b/tests/RustPlusBot.Features.Players.Tests/Hosting/PlayersHostedServiceTests.cs index fbd9b027..f4248473 100644 --- a/tests/RustPlusBot.Features.Players.Tests/Hosting/PlayersHostedServiceTests.cs +++ b/tests/RustPlusBot.Features.Players.Tests/Hosting/PlayersHostedServiceTests.cs @@ -11,6 +11,7 @@ using RustPlusBot.Features.Players.Rendering; using RustPlusBot.Features.Workspace.Locating; using RustPlusBot.Localization; +using RustPlusBot.Persistence.Map; using RustPlusBot.Persistence.Workspace; namespace RustPlusBot.Features.Players.Tests.Hosting; @@ -21,8 +22,12 @@ private static Harness Create() { var workspace = Substitute.For(); workspace.GetCultureAsync(Arg.Any(), Arg.Any()).Returns("en"); + var mapSettings = Substitute.For(); + mapSettings.GetAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(MapLayerSettings.AllOn); var services = new ServiceCollection(); services.AddScoped(_ => workspace); + services.AddScoped(_ => mapSettings); var provider = services.BuildServiceProvider(); var scopeFactory = provider.GetRequiredService(); diff --git a/tests/RustPlusBot.Features.Players.Tests/PlayerEventRegistrationTests.cs b/tests/RustPlusBot.Features.Players.Tests/PlayerEventRegistrationTests.cs index 95ae7aea..00a8b04e 100644 --- a/tests/RustPlusBot.Features.Players.Tests/PlayerEventRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Players.Tests/PlayerEventRegistrationTests.cs @@ -8,6 +8,7 @@ using RustPlusBot.Features.Players.Hosting; using RustPlusBot.Features.Players.Relaying; using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Persistence.Map; using RustPlusBot.Persistence.Workspace; namespace RustPlusBot.Features.Players.Tests; @@ -32,6 +33,7 @@ private static ServiceProvider BuildProvider() services.AddSingleton(); services.AddSingleton(Substitute.For()); services.AddScoped(_ => Substitute.For()); + services.AddScoped(_ => Substitute.For()); services.AddSingleton(Substitute.For()); services.AddPlayers(); return services.BuildServiceProvider(validateScopes: true); diff --git a/tests/RustPlusBot.Features.Players.Tests/PlayerEventRelayTests.cs b/tests/RustPlusBot.Features.Players.Tests/PlayerEventRelayTests.cs index c9da0f0e..478529f2 100644 --- a/tests/RustPlusBot.Features.Players.Tests/PlayerEventRelayTests.cs +++ b/tests/RustPlusBot.Features.Players.Tests/PlayerEventRelayTests.cs @@ -8,6 +8,7 @@ using RustPlusBot.Features.Players.Rendering; using RustPlusBot.Features.Workspace.Locating; using RustPlusBot.Localization; +using RustPlusBot.Persistence.Map; using RustPlusBot.Persistence.Workspace; namespace RustPlusBot.Features.Players.Tests; @@ -30,8 +31,12 @@ private PlayerEventRelay BuildRelay() private static IServiceScopeFactory BuildScopeFactory(IWorkspaceStore workspace) { + var mapSettings = Substitute.For(); + mapSettings.GetAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(MapLayerSettings.AllOn); var services = new ServiceCollection(); services.AddScoped(_ => workspace); + services.AddScoped(_ => mapSettings); return services.BuildServiceProvider().GetRequiredService(); } diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Messages/MapControlMessageRendererTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Messages/MapControlMessageRendererTests.cs index 3b0591ee..8a8ba981 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Messages/MapControlMessageRendererTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Messages/MapControlMessageRendererTests.cs @@ -1,5 +1,7 @@ using Discord; using NSubstitute; +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Features.Workspace.Gateway; using RustPlusBot.Features.Workspace.Messages; using RustPlusBot.Features.Workspace.Registry; using RustPlusBot.Localization; @@ -11,6 +13,12 @@ public sealed class MapControlMessageRendererTests { private static readonly ResxLocalizer Loc = new(); + private static List Buttons(MessagePayload payload) => + [ + .. payload.Components!.Components.OfType() + .SelectMany(r => r.Components).OfType(), + ]; + [Fact] public async Task Renders_six_toggle_buttons_reflecting_settings() { @@ -24,22 +32,45 @@ public async Task Renders_six_toggle_buttons_reflecting_settings() 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 toggles = Buttons(payload) + .Where(b => b.CustomId!.StartsWith(WorkspaceComponentIds.MapTogglePrefix, StringComparison.Ordinal)) + .ToList(); + Assert.Equal(6, toggles.Count); - var monuments = buttons.Single(b => + var monuments = toggles.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)) + foreach (var other in toggles.Where(b => b.CustomId != monuments.CustomId)) { Assert.Equal(ButtonStyle.Success, other.Style); } } [Fact] - public async Task Renders_a_header_text() + public async Task Renders_grid_style_buttons_marking_the_active_style() + { + var serverId = Guid.NewGuid(); + var settings = Substitute.For(); + settings.GetAsync(1, serverId, Arg.Any()) + .Returns(MapLayerSettings.AllOn with + { + GridStyle = MapGridStyle.RustPlus + }); + var renderer = new MapControlMessageRenderer(settings, Loc); + + var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); + + var inGame = Buttons(payload).Single(b => + b.CustomId == $"workspace:map:gridstyle:{MapGridStyle.InGame}:{serverId}"); + var rustPlus = Buttons(payload).Single(b => + b.CustomId == $"workspace:map:gridstyle:{MapGridStyle.RustPlus}:{serverId}"); + Assert.Equal(ButtonStyle.Primary, rustPlus.Style); + Assert.Equal(ButtonStyle.Secondary, inGame.Style); + } + + [Fact] + public async Task Renders_a_header_text_with_the_grid_style_help() { var serverId = Guid.NewGuid(); var settings = Substitute.For(); @@ -49,7 +80,8 @@ public async Task Renders_a_header_text() var payload = await renderer.RenderAsync(new MessageRenderContext(1, serverId, "en"), default); - Assert.Equal(Loc.Get("map.control.header", "en"), payload.Text); + Assert.StartsWith(Loc.Get("map.control.header", "en"), payload.Text, StringComparison.Ordinal); + Assert.Contains(Loc.Get("map.gridstyle.help", "en"), payload.Text, StringComparison.Ordinal); } [Fact] diff --git a/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs b/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs index f4664c3a..c19d6c47 100644 --- a/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs +++ b/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs @@ -41,6 +41,6 @@ public void English_covers_every_french_key() [Fact] public void Catalog_has_expected_key_count() { - Assert.Equal(264, EnglishKeys().Count); + Assert.Equal(267, EnglishKeys().Count); } } diff --git a/tests/RustPlusBot.Persistence.Tests/Map/MapSettingsStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/Map/MapSettingsStoreTests.cs index 4aad7e5a..a5fea841 100644 --- a/tests/RustPlusBot.Persistence.Tests/Map/MapSettingsStoreTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Map/MapSettingsStoreTests.cs @@ -1,3 +1,4 @@ +using RustPlusBot.Abstractions.Connections; using RustPlusBot.Domain.Servers; using RustPlusBot.Persistence.Map; @@ -62,4 +63,49 @@ public async Task SetLayerAsync_updates_existing_row() Assert.True(result.Players); } + + [Fact] + public async Task GridStyle_defaults_to_in_game() + { + 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(MapGridStyle.InGame, result.GridStyle); + } + + [Fact] + public async Task SetGridStyleAsync_creates_row_and_round_trips() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = context; + await using var __ = connection; + var server = SeedServer(context); + var store = new MapSettingsStore(context); + + await store.SetGridStyleAsync(1UL, server.Id, MapGridStyle.RustPlus); + var result = await store.GetAsync(1UL, server.Id); + + Assert.Equal(MapGridStyle.RustPlus, result.GridStyle); + Assert.True(result.Grid); // Layers stay at their all-on defaults. + } + + [Fact] + public async Task SetGridStyleAsync_keeps_existing_layer_toggles() + { + 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.Monuments, enabled: false); + await store.SetGridStyleAsync(1UL, server.Id, MapGridStyle.RustPlus); + var result = await store.GetAsync(1UL, server.Id); + + Assert.False(result.Monuments); + Assert.Equal(MapGridStyle.RustPlus, result.GridStyle); + } } From 93126ff86d0a34c8a59c4e6c9876bbfd296538c8 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Fri, 10 Jul 2026 00:07:29 +0200 Subject: [PATCH 38/40] fix(map): clip the grid to the labelled cell block, not the whole image Draw the grid lattice only over the labelled cells (A0 through the last ceil-count cell) instead of extending the lines across the entire image and its ocean margin. The anchoring (per grid style) and label placement are unchanged; the partial edge cell still bleeds past the world's south/east edge inside the block, so every labelled cell renders full-size. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Rendering/MapRenderer.cs | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs b/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs index a9d19748..95f396bc 100644 --- a/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs +++ b/src/RustPlusBot.Features.Map/Rendering/MapRenderer.cs @@ -117,10 +117,10 @@ private static void DrawGrid(Image image, MapProjection projection, MapG var cells = MapGrid.CellCount(projection.WorldSize); // The lattice is anchored at the west edge and at the style's row anchor (the world's north - // edge for the in-game style; 100 game-units south of it for Rust+/RustMaps), and spans the - // whole image, ocean margin included — exactly how those maps draw it. The partial edge cell - // sits at the south/east and simply bleeds past the world edge, so every rendered cell looks - // full-size. + // edge for the in-game style; 100 game-units south of it for Rust+/RustMaps), and is clipped + // to the LABELLED cell block only (A0 … the last ceil-count cell) — no lines out over the + // ocean margin. The partial edge cell sits at the south/east and simply bleeds past the world + // edge, so every rendered cell looks full-size. var anchorWorldY = projection.WorldSize - MapGrid.RowInset(gridStyle); var (anchorX, anchorY) = projection.ToPixel(0f, anchorWorldY); var (cellEndX, cellEndY) = projection.ToPixel(MapGrid.CellSize, anchorWorldY - MapGrid.CellSize); @@ -131,22 +131,21 @@ private static void DrawGrid(Image image, MapProjection projection, MapG return; // Degenerate projection (no drawable world area). } + var right = anchorX + (cells * stepX); + var bottom = anchorY + (cells * stepY); + image.Mutate(ctx => { - for (var k = (int)MathF.Ceiling(-anchorX / stepX); anchorX + (k * stepX) <= image.Width; k++) + for (var k = 0; k <= cells; k++) { var x = anchorX + (k * stepX); - ctx.DrawLine(lineColor, OutlinePenWidth, new PointF(x, 0f), new PointF(x, image.Height)); - } - - for (var k = (int)MathF.Ceiling(-anchorY / stepY); anchorY + (k * stepY) <= image.Height; k++) - { + ctx.DrawLine(lineColor, OutlinePenWidth, new PointF(x, anchorY), new PointF(x, bottom)); var y = anchorY + (k * stepY); - ctx.DrawLine(lineColor, OutlinePenWidth, new PointF(0f, y), new PointF(image.Width, y)); + ctx.DrawLine(lineColor, OutlinePenWidth, new PointF(anchorX, y), new PointF(right, y)); } - // Only the world's cells carry labels (A0 in the north-west corner), each just inside its - // cell's top-left corner (companion-app placement). + // Every labelled cell (A0 in the north-west corner), each label just inside its cell's + // top-left corner (companion-app placement). for (var col = 0; col < cells; col++) { for (var row = 0; row < cells; row++) From e2d069c05d8ec8051c6ce9929b267dafce96cfa1 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Fri, 10 Jul 2026 00:22:20 +0200 Subject: [PATCH 39/40] fix(review): address Copilot comments on PR #45 - DiscordWorkspaceGateway.DeleteMessageAsync forwards the cancellation token via RequestOptions.CancelToken instead of ignoring it. - Correct the stale DI comment: with no RustMaps API key the #info map renderer returns an empty payload (no message), not a placeholder. - RustMapsParityTests orders the fixture enumeration so the selected fixture is deterministic across filesystems. - MapParity CLI uses TryParse for numeric args and prints the usage line on invalid input instead of throwing. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Gateway/DiscordWorkspaceGateway.cs | 6 +++++- .../WorkspaceServiceCollectionExtensions.cs | 4 ++-- tests/RustPlusBot.Features.Map.Tests/RustMapsParityTests.cs | 3 ++- tools/RustPlusBot.MapParity/Program.cs | 6 +++--- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/RustPlusBot.Features.Workspace/Gateway/DiscordWorkspaceGateway.cs b/src/RustPlusBot.Features.Workspace/Gateway/DiscordWorkspaceGateway.cs index e9ea89e9..c43110f7 100644 --- a/src/RustPlusBot.Features.Workspace/Gateway/DiscordWorkspaceGateway.cs +++ b/src/RustPlusBot.Features.Workspace/Gateway/DiscordWorkspaceGateway.cs @@ -153,7 +153,11 @@ public async Task DeleteMessageAsync(ulong guildId, try { - await channel.DeleteMessageAsync(messageId).ConfigureAwait(false); + await channel.DeleteMessageAsync(messageId, new RequestOptions + { + CancelToken = cancellationToken + }) + .ConfigureAwait(false); } catch (HttpException ex) when (ex.HttpCode == System.Net.HttpStatusCode.NotFound) { diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs index afc1bdf3..a112790b 100644 --- a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs @@ -43,8 +43,8 @@ public static IServiceCollection AddWorkspace(this IServiceCollection services) // the host must compose both AddWorkspace and AddConnections. services.AddScoped(); // ServerInfoMapMessageRenderer takes IInfoMapReadModel as an OPTIONAL dependency: it only resolves - // when Features.Map registered it (RustMaps API key present); otherwise the renderer shows the - // "preparing" placeholder forever. + // when Features.Map registered it (RustMaps API key present); otherwise the renderer returns an + // empty payload and the reconciler never posts a map message. services.AddScoped(); services.AddScoped(); diff --git a/tests/RustPlusBot.Features.Map.Tests/RustMapsParityTests.cs b/tests/RustPlusBot.Features.Map.Tests/RustMapsParityTests.cs index 97876d2f..301b9d69 100644 --- a/tests/RustPlusBot.Features.Map.Tests/RustMapsParityTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/RustMapsParityTests.cs @@ -17,8 +17,9 @@ public sealed class RustMapsParityTests [SkippableFact] public void Monument_world_coords_project_into_consistent_grid_cells() { + // Order the enumeration so the selected fixture is deterministic across machines/filesystems. var fixture = Directory.Exists(FixtureDir) - ? Directory.EnumerateFiles(FixtureDir, "rustmaps-*.json").FirstOrDefault() + ? Directory.EnumerateFiles(FixtureDir, "rustmaps-*.json").Order(StringComparer.Ordinal).FirstOrDefault() : null; Skip.If(fixture is null, "No RustMaps fixture committed yet (run tools/RustPlusBot.MapParity)."); diff --git a/tools/RustPlusBot.MapParity/Program.cs b/tools/RustPlusBot.MapParity/Program.cs index 4a373c04..6c13d489 100644 --- a/tools/RustPlusBot.MapParity/Program.cs +++ b/tools/RustPlusBot.MapParity/Program.cs @@ -8,14 +8,14 @@ using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; -if (args.Length < 4) +if (args.Length < 4 + || !int.TryParse(args[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var size) + || !int.TryParse(args[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var seed)) { await Console.Error.WriteLineAsync("Usage: map-parity ").ConfigureAwait(false); return 1; } -var size = int.Parse(args[0], CultureInfo.InvariantCulture); -var seed = int.Parse(args[1], CultureInfo.InvariantCulture); var apiKey = args[2]; var outDir = Directory.CreateDirectory(args[3]).FullName; From 9b2b957a87e95e6bd5cd068058b07d5e746c87e1 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Fri, 10 Jul 2026 16:05:06 +0200 Subject: [PATCH 40/40] fix(test): deflake InfoMapHostedService tests (publish-before-subscribe race) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ready_key_publishes_InfoMapReadyEvent_once_per_requester failed on the Linux CI runner with 0 events received. Root cause: the test collected events by subscribing to the real InMemoryEventBus from a Task.Run — that bus drops events published before a subscriber is active, and the service's first tick (10 ms poll) can announce the key before the background subscription starts on a loaded runner. The key is announced at most once, so the events are gone for good and the test times out at 0. Replace the racy live-bus collector with a small CapturingEventBus fake that records every published InfoMapReadyEvent synchronously — deterministic, and the tests now finish in ~170 ms instead of riding the 5 s deadline. The Connect_registers test keeps the real bus (it re-publishes in a loop, so the subscribe race self-heals there). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Hosting/InfoMapHostedServiceTests.cs | 61 ++++++++++++------- 1 file changed, 38 insertions(+), 23 deletions(-) diff --git a/tests/RustPlusBot.Features.Map.Tests/Hosting/InfoMapHostedServiceTests.cs b/tests/RustPlusBot.Features.Map.Tests/Hosting/InfoMapHostedServiceTests.cs index cac67637..29f31fe6 100644 --- a/tests/RustPlusBot.Features.Map.Tests/Hosting/InfoMapHostedServiceTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/Hosting/InfoMapHostedServiceTests.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; @@ -36,6 +37,17 @@ private static IOptions ShortPollOptions() => Options.Create(new Map } }); + /// + /// A fake bus that records every published . Deterministic, unlike + /// subscribing to the real from a background task: that bus drops + /// events published before the subscriber is active, which races the service's first tick. + /// + private static (IEventBus Bus, ConcurrentQueue Received) CapturingBus() + { + var bus = new CapturingEventBus(); + return (bus, bus.Received); + } + [Fact] public async Task Ready_key_publishes_InfoMapReadyEvent_once_per_requester() { @@ -55,16 +67,10 @@ public async Task Ready_key_publishes_InfoMapReadyEvent_once_per_requester() var driver = new RustMapsGenerationDriver(client, coordinator, NullLogger.Instance); var query = Substitute.For(); - var bus = new InMemoryEventBus(); - var received = new List(); - using var collectCts = new CancellationTokenSource(); - _ = Task.Run(async () => - { - await foreach (var evt in bus.SubscribeAsync(collectCts.Token)) - { - received.Add(evt); - } - }); + // Capture publishes off a substituted bus: the real InMemoryEventBus drops events published + // before a subscriber is active, so collecting via a background subscription races the + // service's first tick and flakes on slow CI runners. + var (bus, received) = CapturingBus(); var service = new InfoMapHostedService( bus, coordinator, driver, query, ShortPollOptions(), @@ -82,7 +88,6 @@ public async Task Ready_key_publishes_InfoMapReadyEvent_once_per_requester() finally { await service.StopAsync(CancellationToken.None); - await collectCts.CancelAsync(); } Assert.Equal(2, received.Count); @@ -120,16 +125,7 @@ public async Task Tick_registers_and_generates_connected_servers_without_any_con IReadOnlyList<(ulong GuildId, Guid ServerId)> connectable = [(GuildA, serverId)]; store.ListConnectableServersAsync(Arg.Any()).Returns(connectable); - var bus = new InMemoryEventBus(); - var received = new List(); - using var collectCts = new CancellationTokenSource(); - _ = Task.Run(async () => - { - await foreach (var evt in bus.SubscribeAsync(collectCts.Token)) - { - received.Add(evt); - } - }); + var (bus, received) = CapturingBus(); var service = new InfoMapHostedService( bus, coordinator, driver, query, ShortPollOptions(), @@ -139,7 +135,7 @@ public async Task Tick_registers_and_generates_connected_servers_without_any_con try { var deadline = DateTimeOffset.UtcNow.AddSeconds(5); - while (DateTimeOffset.UtcNow < deadline && received.Count < 1) + while (DateTimeOffset.UtcNow < deadline && received.IsEmpty) { await Task.Delay(20); } @@ -147,7 +143,6 @@ public async Task Tick_registers_and_generates_connected_servers_without_any_con finally { await service.StopAsync(CancellationToken.None); - await collectCts.CancelAsync(); } // No ConnectionStatusChangedEvent was ever published — registration came from the store tick. @@ -206,4 +201,24 @@ public async Task Connect_registers_the_servers_world_key_with_the_coordinator() Assert.Contains(Key, coordinator.PendingKeys()); Assert.Contains((GuildA, serverId), coordinator.Requesters(Key)); } + + private sealed class CapturingEventBus : IEventBus + { + public ConcurrentQueue Received { get; } = new(); + + public ValueTask PublishAsync(TEvent @event, CancellationToken cancellationToken = default) + where TEvent : notnull + { + if (@event is InfoMapReadyEvent ready) + { + Received.Enqueue(ready); + } + + return ValueTask.CompletedTask; + } + + public IAsyncEnumerable SubscribeAsync(CancellationToken cancellationToken = default) + where TEvent : notnull => + AsyncEnumerable.Empty(); // No live subscriptions needed: assertions read Received. + } }