Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
<PackageVersion Include="Persistord.Core" Version="1.0.0-beta.1" />
<PackageVersion Include="RustPlusApi" Version="2.0.0-beta.1" />
<PackageVersion Include="RustPlusApi.Fcm" Version="2.0.0-beta.1" />
<PackageVersion Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageVersion Include="SixLabors.ImageSharp.Drawing" Version="2.1.7" />
</ItemGroup>
<ItemGroup>
<!-- Test + sample -->
Expand Down
29 changes: 29 additions & 0 deletions NOTICE
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
RustPlusBot
Copyright (c) HandyS11 and contributors
Licensed under the MIT License. See LICENSE for the full license text.

-------------------------------------------------------------------------------
BUNDLED ASSETS
-------------------------------------------------------------------------------

Liberation Sans (Regular)
File: src/RustPlusBot.Features.Map/Assets/LiberationSans-Regular.ttf
Origin: Liberation Fonts project, Copyright (C) 2007 Red Hat, Inc.
License: SIL Open Font License, Version 1.1
https://scripts.sil.org/OFL
Use: Embedded and used solely for rendering text on the map image.
The OFL permits embedding and redistribution in software products.

-------------------------------------------------------------------------------
MAP MARKER / MONUMENT ICONS (future 2b-ii slice)
-------------------------------------------------------------------------------

The current 2b slice draws all map markers programmatically as glyphs; NO
third-party image assets are bundled.

When marker and monument icons are added in the 2b-ii slice, they will
originate as Facepunch / Rust game art. They are used under the same
companion-app norms that govern the official Rust+ app and established
reference bots — NOT under the GPL of the third-party repositories that
merely redistribute them. Attribution for those assets will be added to this
file at that time.
2 changes: 2 additions & 0 deletions RustPlusBot.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
<Project Path="src/RustPlusBot.Host/RustPlusBot.Host.csproj" />
<Project Path="src/RustPlusBot.Features.Commands/RustPlusBot.Features.Commands.csproj" />
<Project Path="src/RustPlusBot.Features.Connections/RustPlusBot.Features.Connections.csproj" />
<Project Path="src/RustPlusBot.Features.Map/RustPlusBot.Features.Map.csproj" />
<Project Path="src/RustPlusBot.Features.Pairing/RustPlusBot.Features.Pairing.csproj" />
<Project Path="src/RustPlusBot.Features.Chat/RustPlusBot.Features.Chat.csproj" />
<Project Path="src/RustPlusBot.Features.Events/RustPlusBot.Features.Events.csproj" />
Expand All @@ -16,6 +17,7 @@
<Project Path="tests/RustPlusBot.Abstractions.Tests/RustPlusBot.Abstractions.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Commands.Tests/RustPlusBot.Features.Commands.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Connections.Tests/RustPlusBot.Features.Connections.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Map.Tests/RustPlusBot.Features.Map.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Pairing.Tests/RustPlusBot.Features.Pairing.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Workspace.Tests/RustPlusBot.Features.Workspace.Tests.csproj" />
<Project Path="tests/RustPlusBot.Features.Chat.Tests/RustPlusBot.Features.Chat.Tests.csproj" />
Expand Down
14 changes: 14 additions & 0 deletions src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,18 @@ public interface IRustServerQuery
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>True if promoted; false when no live socket or the API fails.</returns>
Task<bool> PromoteToLeaderAsync(ulong guildId, Guid serverId, ulong steamId, CancellationToken cancellationToken);

/// <summary>Gets the base map image (JPEG bytes), or null when there is no live socket.</summary>
/// <param name="guildId">The owning guild snowflake.</param>
/// <param name="serverId">The target server id.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The base-map JPEG bytes, or null when there is no live socket.</returns>
Task<byte[]?> GetMapImageAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken);

/// <summary>Gets the static map dimensions (for grid rendering), or null when there is no live socket.</summary>
/// <param name="guildId">The owning guild snowflake.</param>
/// <param name="serverId">The target server id.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The map dimensions, or null when there is no live socket.</returns>
Task<MapDimensions?> GetMapDimensionsAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ Task<IReadOnlyList<MapMarkerSnapshot>> GetMapMarkersAsync(TimeSpan timeout,
Task<IReadOnlyList<MonumentSnapshot>> GetMonumentsAsync(TimeSpan timeout,
CancellationToken cancellationToken = default);

/// <summary>Gets the base map image (JPEG bytes), or null on failure/unavailable.</summary>
/// <param name="timeout">How long to wait for the response.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The base-map JPEG bytes, or null on failure/unavailable.</returns>
Task<byte[]?> GetMapImageAsync(TimeSpan timeout, CancellationToken cancellationToken = default);

/// <summary>Raised for every in-game team chat line received on this socket.</summary>
event EventHandler<TeamChatLine>? TeamMessageReceived;
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ public Task<IReadOnlyList<MonumentSnapshot>> GetMonumentsAsync(TimeSpan timeout,
CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<MonumentSnapshot>>([]);

public Task<byte[]?> GetMapImageAsync(TimeSpan timeout, CancellationToken cancellationToken = default) =>
Task.FromResult<byte[]?>(null);

public event EventHandler<TeamChatLine>? TeamMessageReceived
{
add { _ = value; }
Expand Down Expand Up @@ -408,6 +411,30 @@ public async Task<IReadOnlyList<MonumentSnapshot>> GetMonumentsAsync(
return monuments;
}

public async Task<byte[]?> GetMapImageAsync(TimeSpan timeout, CancellationToken cancellationToken = default)
{
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(timeout);
try
{
// CONFIRMED (2.0.0-beta.1): GetMapAsync -> Response<ServerMap>; ServerMap.JpgImage is byte[] (raw JPEG bytes).
var response = await _rustPlus.GetMapAsync(timeoutCts.Token).WaitAsync(timeoutCts.Token)
.ConfigureAwait(false);
return response.IsSuccess ? response.Data?.JpgImage : null;
}
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 ValueTask DisposeAsync()
{
_rustPlus.OnTeamChatReceived -= OnTeamChatReceived;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,33 @@ public async Task<bool> PromoteToLeaderAsync(
.ConfigureAwait(false);
}

/// <inheritdoc />
public async Task<byte[]?> GetMapImageAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken)
{
if (!_liveSockets.TryGetValue((guildId, serverId), out var live))
{
return null;
}

return await live.Connection.GetMapImageAsync(_options.HeartbeatTimeout, cancellationToken)
.ConfigureAwait(false);
}

/// <inheritdoc />
public async Task<MapDimensions?> GetMapDimensionsAsync(
ulong guildId,
Guid serverId,
CancellationToken cancellationToken)
{
if (!_liveSockets.TryGetValue((guildId, serverId), out var live))
{
return null;
}

return await live.Connection.GetMapDimensionsAsync(_options.HeartbeatTimeout, cancellationToken)
.ConfigureAwait(false);
}

/// <inheritdoc />
public async Task<TeamChatSendResult> SendAsync(
ulong guildId,
Expand Down
Binary file not shown.
19 changes: 19 additions & 0 deletions src/RustPlusBot.Features.Map/Assets/MarkerGlyphs.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using RustPlusBot.Features.Connections.Listening;
using SixLabors.ImageSharp;

namespace RustPlusBot.Features.Map.Assets;

/// <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>
public static class MarkerGlyphs
{
/// <summary>Gets the glyph colour and letter for a marker kind.</summary>
/// <param name="kind">The marker kind.</param>
/// <returns>The colour and a one-character label.</returns>
public static (Color Color, string Letter) For(MarkerKind kind) => kind switch
{
MarkerKind.CargoShip => (Color.DodgerBlue, "C"),
MarkerKind.PatrolHelicopter => (Color.Red, "H"),
MarkerKind.Chinook => (Color.Orange, "K"),
_ => (Color.Gray, "?"),
};
}
37 changes: 37 additions & 0 deletions src/RustPlusBot.Features.Map/Composing/BaseMapCache.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using System.Collections.Concurrent;
using RustPlusBot.Features.Connections.Listening;

namespace RustPlusBot.Features.Map.Composing;

/// <summary>Caches the static-per-wipe base map image per (guild, server). Singleton so the cache survives across refreshes.</summary>
/// <param name="query">The live query seam used to fetch the base map on a cache miss.</param>
public sealed class BaseMapCache(IRustServerQuery query)
{
private readonly ConcurrentDictionary<(ulong Guild, Guid Server), byte[]> _images = new();

/// <summary>Gets the cached base map, fetching and caching it on a miss. Null results are not cached.</summary>
/// <param name="guildId">The owning guild snowflake.</param>
/// <param name="serverId">The target server id.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The base-map JPEG bytes, or null if unavailable.</returns>
public async Task<byte[]?> 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)
{
_images[(guildId, serverId)] = fetched;
}

return fetched;
}

/// <summary>Evicts the cached base map for a server (called on disconnect).</summary>
/// <param name="guildId">The owning guild snowflake.</param>
/// <param name="serverId">The target server id.</param>
public void Clear(ulong guildId, Guid serverId) => _images.TryRemove((guildId, serverId), out _);
}
53 changes: 53 additions & 0 deletions src/RustPlusBot.Features.Map/Composing/MapComposer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
using RustPlusBot.Features.Connections.Listening;
using RustPlusBot.Features.Events.State;
using RustPlusBot.Features.Map.Rendering;

namespace RustPlusBot.Features.Map.Composing;

/// <summary>Gathers the cached base map + live markers and renders the map PNG.</summary>
/// <param name="cache">The base-map cache.</param>
/// <param name="events">Live marker state.</param>
/// <param name="query">Live query seam (supplies the static map dimensions).</param>
/// <param name="renderer">The image renderer.</param>
public sealed class MapComposer(BaseMapCache cache, IEventState events, IRustServerQuery query, MapRenderer renderer)
{
private static readonly MarkerKind[] DrawnKinds =
[
MarkerKind.CargoShip, MarkerKind.PatrolHelicopter, MarkerKind.Chinook,
];

/// <summary>Composes the map PNG for a server, or null when no base map is available yet.</summary>
/// <param name="guildId">The owning guild snowflake.</param>
/// <param name="serverId">The target server id.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>PNG bytes, or null.</returns>
public async Task<byte[]?> ComposeAsync(ulong guildId, Guid serverId, 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.
var dims = await query.GetMapDimensionsAsync(guildId, serverId, cancellationToken).ConfigureAwait(false);
if (dims is null)
{
// Dimensions unavailable: render the base tile only (grid/markers both need world→pixel).
return renderer.Render(baseImage, new MapDimensions(0, 0, 0), markers: [],
new MapLayerSet(Grid: false, Markers: false, Monuments: false, Vendor: false, Rigs: false));
}

var placements = DrawnKinds
.SelectMany(kind => events.GetActiveMarkers(guildId, serverId, kind))
.Select(m =>
{
var (px, py) = WorldToPixel.ToPixel(m.X, m.Y, dims, MapRenderer.OutputSize);
return new MarkerPlacement(m.Kind, px, py);
})
.ToList();

return renderer.Render(baseImage, dims, placements, MapLayerSet.Default2b);
}
}
Loading
Loading