Skip to content

Commit 8312171

Browse files
HandyS11claude
andauthored
Subsystem 2b-ii: per-server map layer toggles + monuments/vendor/players/rig-styling layers (icon render) (#16)
* feat(map): ServerMapSettings entity + EF config + MapSettings migration * feat(map): IMapSettingsStore + EF-backed store with all-on defaults * feat(map): vendor icon assets + MapIcons registry + MonumentIconMap Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(map): add MapIcons.Rig + test (review) Implements the missing MapIcons.Rig(RigKind, bool active) API-contract method (Task 3 "Produces" list); adds Rig_resolves_for_known_kinds Theory test covering both RigKind.Small and RigKind.Large. Suppresses RCS1163 on the reserved `active` parameter with a narrow pragma + explaining remarks. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(connections): add TravellingVendor marker kind + surface vendor bucket - Added MarkerKind.TravellingVendor = 5 to the enum (with XML doc) - Wired data.TravellingVendorMarkers into GetMapMarkersAsync via AddMarkers - Activated the deferred MapIcons.Marker TravellingVendor arm (Load("vendor")) that was stubbed out pending the enum member; vendor.png already embedded * feat(connections): expose GetMonumentsAsync on IRustServerQuery Adds GetMonumentsAsync(ulong guildId, Guid serverId, CancellationToken) to IRustServerQuery; ConnectionSupervisor implements it by delegating to the live socket's internal GetMonumentsAsync or returning [] when no socket is registered. Includes supervisor test asserting empty result when no live socket exists. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(map): icon-based renderer with monument/player/rig/vendor layers Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(map): composer reads per-server settings and gathers all layers Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(map): repaint #map immediately on MapSettingsChangedEvent Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(map): poster deletes only prior image posts, sparing the control message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(workspace): #map control message with ManageGuild layer toggles Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(map): restore grid-renders-with-no-markers guard Re-adds the Renders_the_grid_even_with_no_markers test dropped during Task 7's MapComposer rewrite. Uses two MapComposer instances (grid-on vs grid-off, zero markers) and asserts their output PNGs differ, proving the grid still renders on low-activity / just-connected servers. Also fixes player label: alive+offline now shows (offline) instead of (dead); only dead players show (dead). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * style(map): apply jb ReformatAndReorder Cosmetic line-wrapping applied by JetBrains CleanupCode 2026.1.2 with the ReformatAndReorder profile. All 5 touched files are 2b-ii branch files; no pre-existing drift in unrelated files. Files reformatted: - src/RustPlusBot.Features.Map/Composing/MapComposer.cs - src/RustPlusBot.Persistence/Map/MapSettingsStore.cs - tests/RustPlusBot.Features.Map.Tests/MapRendererTests.cs - tests/RustPlusBot.Persistence.Tests/Map/MapSettingsStoreTests.cs - tests/RustPlusBot.Persistence.Tests/Map/ServerMapSettingsSchemaTests.cs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(workspace): harden #map toggle against a forged non-existent server id Validate the parsed serverId exists in this guild before any persistence. Resolves the FK-violation/hung-ack edge case noted in the final review. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * perf(map): batch each render layer into a single image.Mutate Copilot review (PR #16): DrawMonuments (and the sibling marker/rig/player loops) called image.Mutate once per item, building and executing a fresh ImageSharp processing pipeline each time. Refactor all four layers to draw every item inside ONE Mutate per layer, so a server with many monuments pays for one pipeline instead of N. Behaviour-preserving: same icons composited at the same coordinates in the same order; the cached MapIcons sources are still only read (drawn onto the target ctx), never mutated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b6bf89c commit 8312171

83 files changed

Lines changed: 1881 additions & 127 deletions

File tree

Some content is hidden

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

NOTICE

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,15 @@ Liberation Sans (Regular)
1515
The OFL permits embedding and redistribution in software products.
1616

1717
-------------------------------------------------------------------------------
18-
MAP MARKER / MONUMENT ICONS (future 2b-ii slice)
18+
MAP MARKER / MONUMENT ICONS
1919
-------------------------------------------------------------------------------
2020

21-
The current 2b slice draws all map markers programmatically as glyphs; NO
22-
third-party image assets are bundled.
21+
Map marker and monument icons under src/RustPlusBot.Features.Map/Assets/icons/
22+
originate from Rust (© Facepunch Studios) and are reused here under the same
23+
companion-app fair-use basis as the official Rust+ application. They were
24+
sourced via the rustplus-desktop project, which redistributes the game art;
25+
the icons are Facepunch's game assets, not original GPL-licensed work, and are
26+
not treated as GPL-licensed nor as encumbering this MIT-licensed project.
2327

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.
28+
The player position icon (player.png) was sourced from the rustplusplus project
29+
on the same Facepunch-game-art fair-use basis described above.

src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,14 @@ public interface IRustServerQuery
4545
/// <param name="cancellationToken">A cancellation token.</param>
4646
/// <returns>The map dimensions, or null when there is no live socket.</returns>
4747
Task<MapDimensions?> GetMapDimensionsAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken);
48+
49+
/// <summary>Gets the server's monuments, or an empty list when there is no live socket.</summary>
50+
/// <param name="guildId">The owning guild snowflake.</param>
51+
/// <param name="serverId">The target server id.</param>
52+
/// <param name="cancellationToken">A cancellation token.</param>
53+
/// <returns>The monuments, or an empty list when there is no live socket.</returns>
54+
Task<IReadOnlyList<MonumentSnapshot>> GetMonumentsAsync(
55+
ulong guildId,
56+
Guid serverId,
57+
CancellationToken cancellationToken);
4858
}

src/RustPlusBot.Abstractions/Connections/MarkerKind.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,7 @@ public enum MarkerKind
1717

1818
/// <summary>A locked crate.</summary>
1919
Crate = 4,
20+
21+
/// <summary>The travelling vendor.</summary>
22+
TravellingVendor = 5,
2023
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
namespace RustPlusBot.Abstractions.Events;
2+
3+
/// <summary>Published when a server's map layer settings change, so the map image repaints immediately.</summary>
4+
/// <param name="GuildId">The owning guild snowflake.</param>
5+
/// <param name="ServerId">The target server id.</param>
6+
public sealed record MapSettingsChangedEvent(ulong GuildId, Guid ServerId);
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
namespace RustPlusBot.Domain.Map;
2+
3+
/// <summary>Per-(guild, server) rendered-map layer settings; one row per server. Layers default on.</summary>
4+
public sealed class ServerMapSettings
5+
{
6+
/// <summary>The owning guild snowflake.</summary>
7+
public ulong GuildId { get; set; }
8+
9+
/// <summary>The server id (FK to RustServer; primary key, one row per server).</summary>
10+
public Guid ServerId { get; set; }
11+
12+
/// <summary>Whether the grid layer is drawn.</summary>
13+
public bool ShowGrid { get; set; } = true;
14+
15+
/// <summary>Whether live cargo/heli/chinook markers are drawn.</summary>
16+
public bool ShowMarkers { get; set; } = true;
17+
18+
/// <summary>Whether monument icons are drawn.</summary>
19+
public bool ShowMonuments { get; set; } = true;
20+
21+
/// <summary>Whether the travelling-vendor marker is drawn.</summary>
22+
public bool ShowVendor { get; set; } = true;
23+
24+
/// <summary>Whether teammate position markers are drawn.</summary>
25+
public bool ShowPlayers { get; set; } = true;
26+
27+
/// <summary>Whether oil rigs are styled by activation state.</summary>
28+
public bool ShowRigs { get; set; } = true;
29+
}

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,9 +324,10 @@ public async Task<IReadOnlyList<MapMarkerSnapshot>> GetMapMarkersAsync(
324324
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
325325
timeoutCts.CancelAfter(timeout);
326326
// CONFIRMED (2.0.0-beta.1): GetMapMarkersAsync returns Task<Response<RustPlusApi.Data.MapMarkers>>.
327-
// MapMarkers has typed dictionaries CargoShipMarkers/PatrolHelicopterMarkers/Ch47Markers
327+
// MapMarkers has typed dictionaries CargoShipMarkers/PatrolHelicopterMarkers/Ch47Markers/TravellingVendorMarkers
328328
// (Dictionary<ulong, XMarker>); each marker exposes Nullable<ulong> Id, Nullable<float> X/Y.
329329
// No flat list, no Type field, NO crate bucket (the game stopped sending crate markers) → core-3.
330+
// Surfaced buckets: CargoShip, PatrolHelicopter, Chinook, TravellingVendor.
330331
var response = await _rustPlus.GetMapMarkersAsync(timeoutCts.Token).WaitAsync(timeoutCts.Token)
331332
.ConfigureAwait(false);
332333
if (!response.IsSuccess || response.Data is null)
@@ -339,6 +340,7 @@ public async Task<IReadOnlyList<MapMarkerSnapshot>> GetMapMarkersAsync(
339340
AddMarkers(markers, data.CargoShipMarkers, MarkerKind.CargoShip);
340341
AddMarkers(markers, data.PatrolHelicopterMarkers, MarkerKind.PatrolHelicopter);
341342
AddMarkers(markers, data.Ch47Markers, MarkerKind.Chinook);
343+
AddMarkers(markers, data.TravellingVendorMarkers, MarkerKind.TravellingVendor);
342344
return markers;
343345
}
344346

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,21 @@ public async Task<bool> PromoteToLeaderAsync(
211211
.ConfigureAwait(false);
212212
}
213213

214+
/// <inheritdoc />
215+
public async Task<IReadOnlyList<MonumentSnapshot>> GetMonumentsAsync(
216+
ulong guildId,
217+
Guid serverId,
218+
CancellationToken cancellationToken)
219+
{
220+
if (!_liveSockets.TryGetValue((guildId, serverId), out var live))
221+
{
222+
return [];
223+
}
224+
225+
return await live.Connection.GetMonumentsAsync(_options.HeartbeatTimeout, cancellationToken)
226+
.ConfigureAwait(false);
227+
}
228+
214229
/// <inheritdoc />
215230
public async Task<TeamChatSendResult> SendAsync(
216231
ulong guildId,
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
using System.Collections.Concurrent;
2+
using RustPlusBot.Abstractions.Events;
3+
using RustPlusBot.Features.Connections.Listening;
4+
using SixLabors.ImageSharp;
5+
using SixLabors.ImageSharp.PixelFormats;
6+
7+
namespace RustPlusBot.Features.Map.Assets;
8+
9+
/// <summary>
10+
/// Loads vendored icon assets (embedded PNGs) once and serves them by marker kind or monument token.
11+
/// Cached images are immutable inputs the renderer draws from; never mutate them.
12+
/// </summary>
13+
/// <remarks>
14+
/// <c>MapIcons.Marker</c> includes a <c>TravellingVendor</c> arm that returns the embedded vendor.png.
15+
/// <see cref="Vendor"/> is retained as a bridge accessor for callers that reference it directly.
16+
/// </remarks>
17+
public static class MapIcons
18+
{
19+
private const string ResourcePrefix = "RustPlusBot.Features.Map.Assets.icons.";
20+
21+
private static readonly ConcurrentDictionary<string, Image<Rgba32>?> Cache = new(StringComparer.Ordinal);
22+
23+
/// <summary>Gets the icon for a marker kind, or null when there is no icon for that kind.</summary>
24+
/// <param name="kind">The marker kind.</param>
25+
/// <returns>The cached icon image, or null.</returns>
26+
public static Image<Rgba32>? Marker(MarkerKind kind) => kind switch
27+
{
28+
MarkerKind.CargoShip => Load("cargo"),
29+
MarkerKind.PatrolHelicopter => Load("patrol"),
30+
MarkerKind.Chinook => Load("ch47"),
31+
MarkerKind.TravellingVendor => Load("vendor"),
32+
_ => null,
33+
};
34+
35+
/// <summary>Gets the rig icon for a rig kind, or null when there is no icon for that kind.</summary>
36+
/// <param name="kind">Which rig.</param>
37+
/// <param name="active">Whether the rig is currently active (reserved; activation styling is applied by the renderer).</param>
38+
/// <returns>The cached rig icon image, or null.</returns>
39+
/// <remarks>
40+
/// The <paramref name="active"/> parameter is reserved for future use: the renderer is responsible
41+
/// for overlaying activation styling; this method always returns the base icon regardless of state.
42+
/// </remarks>
43+
#pragma warning disable RCS1163 // Unused parameter — 'active' is part of the public API contract; activation styling is applied by the renderer, not here.
44+
public static Image<Rgba32>? Rig(RigKind kind, bool active) => kind switch
45+
{
46+
RigKind.Small => Load("oilrig"),
47+
RigKind.Large => Load("largeoilrig"),
48+
_ => null,
49+
};
50+
#pragma warning restore RCS1163
51+
52+
/// <summary>Gets the travelling vendor icon.</summary>
53+
/// <returns>The cached vendor icon image, or null.</returns>
54+
public static Image<Rgba32>? Vendor() => Load("vendor");
55+
56+
/// <summary>Gets the player position icon, or null when the asset is missing.</summary>
57+
/// <returns>The player icon, or null.</returns>
58+
public static Image<Rgba32>? Player() => Load("player");
59+
60+
/// <summary>Gets the icon for a monument token, or null when the token is unmapped or the file is missing.</summary>
61+
/// <param name="token">The Rust+ monument protobuf token.</param>
62+
/// <returns>The cached icon image, or null.</returns>
63+
public static Image<Rgba32>? Monument(string token)
64+
{
65+
var key = MonumentIconMap.IconKeyFor(token);
66+
return key is null ? null : Load(key);
67+
}
68+
69+
private static Image<Rgba32>? Load(string key) => Cache.GetOrAdd(key, static k =>
70+
{
71+
var asm = typeof(MapIcons).Assembly;
72+
using var stream = asm.GetManifestResourceStream(ResourcePrefix + k + ".png");
73+
return stream is null ? null : Image.Load<Rgba32>(stream);
74+
});
75+
}

src/RustPlusBot.Features.Map/Assets/MarkerGlyphs.cs

Lines changed: 0 additions & 19 deletions
This file was deleted.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
namespace RustPlusBot.Features.Map.Assets;
2+
3+
/// <summary>Maps a Rust+ monument protobuf token to a vendored icon key (filename without extension). Unknown tokens return null and are skipped.</summary>
4+
public static class MonumentIconMap
5+
{
6+
private static readonly Dictionary<string, string> Map = new(StringComparer.Ordinal)
7+
{
8+
["AbandonedMilitaryBase"] = "militarybase",
9+
["airfield_display_name"] = "airfield",
10+
["arctic_base_a"] = "arcticresearch",
11+
["bandit_camp"] = "banditcamp",
12+
["dome_monument_name"] = "dome",
13+
["excavator"] = "excavator",
14+
["ferryterminal"] = "ferryterminal",
15+
["fishing_village_display_name"] = "fishingvillage",
16+
["large_fishing_village_display_name"] = "fishingvillagelarge",
17+
["gas_station"] = "gasstation",
18+
["harbor_display_name"] = "harbour",
19+
["harbor_2_display_name"] = "harbour2",
20+
["junkyard_display_name"] = "junkyard",
21+
["large_oil_rig"] = "largeoilrig",
22+
["oilrig_1"] = "oilrig",
23+
["launchsite"] = "launchsite",
24+
["lighthouse_display_name"] = "lighthouse",
25+
["military_tunnels_display_name"] = "militarytunnel",
26+
["mining_outpost_display_name"] = "miningoutpost",
27+
["mining_quarry_hqm_display_name"] = "hqmquarry",
28+
["mining_quarry_stone_display_name"] = "stonequarry",
29+
["mining_quarry_sulfur_display_name"] = "sulfurquarry",
30+
["missile_silo_monument"] = "missilesilo",
31+
["outpost"] = "outpost",
32+
["power_plant_display_name"] = "powerplant",
33+
["satellite_dish_display_name"] = "satellitedish",
34+
["sewer_display_name"] = "sewerbranch",
35+
["stables_a"] = "stable",
36+
["stables_b"] = "stable",
37+
["supermarket"] = "supermarket",
38+
["swamp_c"] = "swamp",
39+
["train_yard_display_name"] = "trainyard",
40+
["train_tunnel_display_name"] = "traintunnel",
41+
["water_treatment_plant_display_name"] = "watertreatment",
42+
};
43+
44+
/// <summary>Gets the icon key for a monument token, or null when unmapped.</summary>
45+
/// <param name="token">The Rust+ monument protobuf token.</param>
46+
/// <returns>The icon key (filename without extension), or null.</returns>
47+
public static string? IconKeyFor(string token) =>
48+
token is not null && Map.TryGetValue(token, out var key) ? key : null;
49+
}

0 commit comments

Comments
 (0)