Skip to content

Commit b6bf89c

Browse files
HandyS11claude
andauthored
Subsystem 2b: rendered live map in a per-server #map channel (ImageSharp base tile + grid + cargo/heli/chinook markers) (#15)
* chore(map): scaffold Features.Map project + ImageSharp packages Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(map): world-to-pixel coordinate mapper Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(map): MapLayerSet + MarkerPlacement value objects * feat(map): marker glyph registry (colour + letter per kind) * feat(map): ImageSharp renderer (base tile + grid + marker glyphs -> PNG) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(connections): GetMapImageAsync seam (base map JPEG bytes) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(map): base-map cache singleton * feat(map): composer gathers base map + markers into a PNG Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(map): Discord map-channel poster (delete+repost shim) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(map): isolate delete failures from map repost (PR review) Wrap DeletePriorBotMessagesAsync in its own try/catch to prevent delete failures (e.g. missing Manage Messages permission) from aborting the repost. OperationCanceledException is rethrown for clean shutdown. Add LogDeleteFailed warning logger and document RecentMessageScan constant. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(workspace): per-server #map channel + map channel locator Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(map): hosted service with per-server refresh throttle + disconnect-clear Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(map): AddMap DI extension + host wiring + MapOptions validation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * style(map): apply jb ReformatAndReorder + add NOTICE Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(map): address Copilot review on PR #15 - Periodic refresh loop: repaint every connected server's #map each MapRefreshInterval so moving cargo/heli/chinook (stable ids → no MapMarkersChangedEvent) track, and post the first image on connect. Marker-change + connect + tick all pass through the existing throttle. - MapComposer reads dimensions from the new IRustServerQuery.GetMapDimensionsAsync seam instead of from a marker, so the grid renders on low-activity/zero-marker servers. - WorldToPixel + MapRenderer grid scale each axis from its own dimension (Width/Height) — correct-by-construction for non-square maps. - MapLayerSet doc: Grid draws lines (labels are 2b-ii), not "lines and labels". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 026b585 commit b6bf89c

42 files changed

Lines changed: 1566 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Directory.Packages.props

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
<PackageVersion Include="Persistord.Core" Version="1.0.0-beta.1" />
1414
<PackageVersion Include="RustPlusApi" Version="2.0.0-beta.1" />
1515
<PackageVersion Include="RustPlusApi.Fcm" Version="2.0.0-beta.1" />
16+
<PackageVersion Include="SixLabors.ImageSharp" Version="3.1.12" />
17+
<PackageVersion Include="SixLabors.ImageSharp.Drawing" Version="2.1.7" />
1618
</ItemGroup>
1719
<ItemGroup>
1820
<!-- Test + sample -->

NOTICE

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
RustPlusBot
2+
Copyright (c) HandyS11 and contributors
3+
Licensed under the MIT License. See LICENSE for the full license text.
4+
5+
-------------------------------------------------------------------------------
6+
BUNDLED ASSETS
7+
-------------------------------------------------------------------------------
8+
9+
Liberation Sans (Regular)
10+
File: src/RustPlusBot.Features.Map/Assets/LiberationSans-Regular.ttf
11+
Origin: Liberation Fonts project, Copyright (C) 2007 Red Hat, Inc.
12+
License: SIL Open Font License, Version 1.1
13+
https://scripts.sil.org/OFL
14+
Use: Embedded and used solely for rendering text on the map image.
15+
The OFL permits embedding and redistribution in software products.
16+
17+
-------------------------------------------------------------------------------
18+
MAP MARKER / MONUMENT ICONS (future 2b-ii slice)
19+
-------------------------------------------------------------------------------
20+
21+
The current 2b slice draws all map markers programmatically as glyphs; NO
22+
third-party image assets are bundled.
23+
24+
When marker and monument icons are added in the 2b-ii slice, they will
25+
originate as Facepunch / Rust game art. They are used under the same
26+
companion-app norms that govern the official Rust+ app and established
27+
reference bots — NOT under the GPL of the third-party repositories that
28+
merely redistribute them. Attribution for those assets will be added to this
29+
file at that time.

RustPlusBot.slnx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
<Project Path="src/RustPlusBot.Host/RustPlusBot.Host.csproj" />
77
<Project Path="src/RustPlusBot.Features.Commands/RustPlusBot.Features.Commands.csproj" />
88
<Project Path="src/RustPlusBot.Features.Connections/RustPlusBot.Features.Connections.csproj" />
9+
<Project Path="src/RustPlusBot.Features.Map/RustPlusBot.Features.Map.csproj" />
910
<Project Path="src/RustPlusBot.Features.Pairing/RustPlusBot.Features.Pairing.csproj" />
1011
<Project Path="src/RustPlusBot.Features.Chat/RustPlusBot.Features.Chat.csproj" />
1112
<Project Path="src/RustPlusBot.Features.Events/RustPlusBot.Features.Events.csproj" />
@@ -16,6 +17,7 @@
1617
<Project Path="tests/RustPlusBot.Abstractions.Tests/RustPlusBot.Abstractions.Tests.csproj" />
1718
<Project Path="tests/RustPlusBot.Features.Commands.Tests/RustPlusBot.Features.Commands.Tests.csproj" />
1819
<Project Path="tests/RustPlusBot.Features.Connections.Tests/RustPlusBot.Features.Connections.Tests.csproj" />
20+
<Project Path="tests/RustPlusBot.Features.Map.Tests/RustPlusBot.Features.Map.Tests.csproj" />
1921
<Project Path="tests/RustPlusBot.Features.Pairing.Tests/RustPlusBot.Features.Pairing.Tests.csproj" />
2022
<Project Path="tests/RustPlusBot.Features.Workspace.Tests/RustPlusBot.Features.Workspace.Tests.csproj" />
2123
<Project Path="tests/RustPlusBot.Features.Chat.Tests/RustPlusBot.Features.Chat.Tests.csproj" />

src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,18 @@ public interface IRustServerQuery
3131
/// <param name="cancellationToken">A cancellation token.</param>
3232
/// <returns>True if promoted; false when no live socket or the API fails.</returns>
3333
Task<bool> PromoteToLeaderAsync(ulong guildId, Guid serverId, ulong steamId, CancellationToken cancellationToken);
34+
35+
/// <summary>Gets the base map image (JPEG bytes), or null when there is no live socket.</summary>
36+
/// <param name="guildId">The owning guild snowflake.</param>
37+
/// <param name="serverId">The target server id.</param>
38+
/// <param name="cancellationToken">A cancellation token.</param>
39+
/// <returns>The base-map JPEG bytes, or null when there is no live socket.</returns>
40+
Task<byte[]?> GetMapImageAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken);
41+
42+
/// <summary>Gets the static map dimensions (for grid rendering), or null when there is no live socket.</summary>
43+
/// <param name="guildId">The owning guild snowflake.</param>
44+
/// <param name="serverId">The target server id.</param>
45+
/// <param name="cancellationToken">A cancellation token.</param>
46+
/// <returns>The map dimensions, or null when there is no live socket.</returns>
47+
Task<MapDimensions?> GetMapDimensionsAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken);
3448
}

src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@ Task<IReadOnlyList<MapMarkerSnapshot>> GetMapMarkersAsync(TimeSpan timeout,
6767
Task<IReadOnlyList<MonumentSnapshot>> GetMonumentsAsync(TimeSpan timeout,
6868
CancellationToken cancellationToken = default);
6969

70+
/// <summary>Gets the base map image (JPEG bytes), or null on failure/unavailable.</summary>
71+
/// <param name="timeout">How long to wait for the response.</param>
72+
/// <param name="cancellationToken">A cancellation token.</param>
73+
/// <returns>The base-map JPEG bytes, or null on failure/unavailable.</returns>
74+
Task<byte[]?> GetMapImageAsync(TimeSpan timeout, CancellationToken cancellationToken = default);
75+
7076
/// <summary>Raised for every in-game team chat line received on this socket.</summary>
7177
event EventHandler<TeamChatLine>? TeamMessageReceived;
7278
}

src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ public Task<IReadOnlyList<MonumentSnapshot>> GetMonumentsAsync(TimeSpan timeout,
6060
CancellationToken cancellationToken = default) =>
6161
Task.FromResult<IReadOnlyList<MonumentSnapshot>>([]);
6262

63+
public Task<byte[]?> GetMapImageAsync(TimeSpan timeout, CancellationToken cancellationToken = default) =>
64+
Task.FromResult<byte[]?>(null);
65+
6366
public event EventHandler<TeamChatLine>? TeamMessageReceived
6467
{
6568
add { _ = value; }
@@ -408,6 +411,30 @@ public async Task<IReadOnlyList<MonumentSnapshot>> GetMonumentsAsync(
408411
return monuments;
409412
}
410413

414+
public async Task<byte[]?> GetMapImageAsync(TimeSpan timeout, CancellationToken cancellationToken = default)
415+
{
416+
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
417+
timeoutCts.CancelAfter(timeout);
418+
try
419+
{
420+
// CONFIRMED (2.0.0-beta.1): GetMapAsync -> Response<ServerMap>; ServerMap.JpgImage is byte[] (raw JPEG bytes).
421+
var response = await _rustPlus.GetMapAsync(timeoutCts.Token).WaitAsync(timeoutCts.Token)
422+
.ConfigureAwait(false);
423+
return response.IsSuccess ? response.Data?.JpgImage : null;
424+
}
425+
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
426+
{
427+
return null;
428+
}
429+
#pragma warning disable CA1031 // Broad catch: any map-query failure maps to null; never surface a token/secret.
430+
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
431+
#pragma warning restore CA1031
432+
{
433+
LogQueryFailed(_logger, ex);
434+
return null;
435+
}
436+
}
437+
411438
public async ValueTask DisposeAsync()
412439
{
413440
_rustPlus.OnTeamChatReceived -= OnTeamChatReceived;

src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,33 @@ public async Task<bool> PromoteToLeaderAsync(
184184
.ConfigureAwait(false);
185185
}
186186

187+
/// <inheritdoc />
188+
public async Task<byte[]?> GetMapImageAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken)
189+
{
190+
if (!_liveSockets.TryGetValue((guildId, serverId), out var live))
191+
{
192+
return null;
193+
}
194+
195+
return await live.Connection.GetMapImageAsync(_options.HeartbeatTimeout, cancellationToken)
196+
.ConfigureAwait(false);
197+
}
198+
199+
/// <inheritdoc />
200+
public async Task<MapDimensions?> GetMapDimensionsAsync(
201+
ulong guildId,
202+
Guid serverId,
203+
CancellationToken cancellationToken)
204+
{
205+
if (!_liveSockets.TryGetValue((guildId, serverId), out var live))
206+
{
207+
return null;
208+
}
209+
210+
return await live.Connection.GetMapDimensionsAsync(_options.HeartbeatTimeout, cancellationToken)
211+
.ConfigureAwait(false);
212+
}
213+
187214
/// <inheritdoc />
188215
public async Task<TeamChatSendResult> SendAsync(
189216
ulong guildId,
401 KB
Binary file not shown.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using RustPlusBot.Features.Connections.Listening;
2+
using SixLabors.ImageSharp;
3+
4+
namespace RustPlusBot.Features.Map.Assets;
5+
6+
/// <summary>Maps a marker kind to a draw glyph (colour + single letter). The keyed registry the map renderer draws from; 2b-ii can swap this for image assets without changing the renderer.</summary>
7+
public static class MarkerGlyphs
8+
{
9+
/// <summary>Gets the glyph colour and letter for a marker kind.</summary>
10+
/// <param name="kind">The marker kind.</param>
11+
/// <returns>The colour and a one-character label.</returns>
12+
public static (Color Color, string Letter) For(MarkerKind kind) => kind switch
13+
{
14+
MarkerKind.CargoShip => (Color.DodgerBlue, "C"),
15+
MarkerKind.PatrolHelicopter => (Color.Red, "H"),
16+
MarkerKind.Chinook => (Color.Orange, "K"),
17+
_ => (Color.Gray, "?"),
18+
};
19+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
using System.Collections.Concurrent;
2+
using RustPlusBot.Features.Connections.Listening;
3+
4+
namespace RustPlusBot.Features.Map.Composing;
5+
6+
/// <summary>Caches the static-per-wipe base map image per (guild, server). Singleton so the cache survives across refreshes.</summary>
7+
/// <param name="query">The live query seam used to fetch the base map on a cache miss.</param>
8+
public sealed class BaseMapCache(IRustServerQuery query)
9+
{
10+
private readonly ConcurrentDictionary<(ulong Guild, Guid Server), byte[]> _images = new();
11+
12+
/// <summary>Gets the cached base map, fetching and caching it on a miss. Null results are not cached.</summary>
13+
/// <param name="guildId">The owning guild snowflake.</param>
14+
/// <param name="serverId">The target server id.</param>
15+
/// <param name="cancellationToken">A cancellation token.</param>
16+
/// <returns>The base-map JPEG bytes, or null if unavailable.</returns>
17+
public async Task<byte[]?> GetAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken)
18+
{
19+
if (_images.TryGetValue((guildId, serverId), out var cached))
20+
{
21+
return cached;
22+
}
23+
24+
var fetched = await query.GetMapImageAsync(guildId, serverId, cancellationToken).ConfigureAwait(false);
25+
if (fetched is not null)
26+
{
27+
_images[(guildId, serverId)] = fetched;
28+
}
29+
30+
return fetched;
31+
}
32+
33+
/// <summary>Evicts the cached base map for a server (called on disconnect).</summary>
34+
/// <param name="guildId">The owning guild snowflake.</param>
35+
/// <param name="serverId">The target server id.</param>
36+
public void Clear(ulong guildId, Guid serverId) => _images.TryRemove((guildId, serverId), out _);
37+
}

0 commit comments

Comments
 (0)