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
Original file line number Diff line number Diff line change
Expand Up @@ -134,31 +134,26 @@ Task<DeviceReachability> StrobeSmartSwitchAsync(ulong entityId,
Task<MapMarkersSnapshot> GetMapMarkersAsync(TimeSpan timeout,
CancellationToken cancellationToken = default);

/// <summary>Gets the static map dimensions for grid-reference rendering, or null on failure/timeout.</summary>
/// <summary>
/// Gets the whole map in one round trip: dimensions, monuments and the base-map JPEG. Throws on failure.
/// </summary>
/// <remarks>
/// Deliberately one call rather than three. Rust+ answers dimensions, monuments and the image from a
/// single <c>GetMap</c> response that always carries the ~683 KB JPEG, so a per-purpose accessor would
/// download the entire map to read a couple of integers. Callers must cache the result for the connected
/// window (see <c>ServerMapWindowCache</c>) rather than calling this per read.
/// </remarks>
/// <param name="timeout">How long to wait for the response.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The map dimensions, or null on failure/timeout.</returns>
Task<MapDimensions?> GetMapDimensionsAsync(TimeSpan timeout, CancellationToken cancellationToken = default);
/// <returns>The map snapshot.</returns>
Task<ServerMapSnapshot> GetServerMapAsync(TimeSpan timeout, CancellationToken cancellationToken = default);

/// <summary>Gets the world size and seed from server info, or null when unavailable.</summary>
/// <param name="timeout">The per-call timeout.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The world snapshot, or null.</returns>
Task<WorldSnapshot?> GetWorldAsync(TimeSpan timeout, CancellationToken cancellationToken = default);

/// <summary>Gets the map monuments (for locating oil rigs). Throws on failure.</summary>
/// <param name="timeout">How long to wait for the response.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The map monuments (token + position).</returns>
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;

Expand Down
117 changes: 24 additions & 93 deletions src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,20 +94,13 @@ public Task<MapMarkersSnapshot> GetMapMarkersAsync(TimeSpan timeout,
CancellationToken cancellationToken = default) =>
Task.FromResult(MapMarkersSnapshot.Empty);

public Task<MapDimensions?> GetMapDimensionsAsync(TimeSpan timeout,
public Task<ServerMapSnapshot> GetServerMapAsync(TimeSpan timeout,
CancellationToken cancellationToken = default) =>
Task.FromResult<MapDimensions?>(null);
Task.FromResult(new ServerMapSnapshot(null, [], null));

public Task<WorldSnapshot?> GetWorldAsync(TimeSpan timeout, CancellationToken cancellationToken = default) =>
Task.FromResult<WorldSnapshot?>(null);

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 @@ -631,102 +624,33 @@ public async Task<MapMarkersSnapshot> GetMapMarkersAsync(
return new MapMarkersSnapshot(markers, MapVendingMachines(data.VendingMachineMarkers));
}

public async Task<MapDimensions?> GetMapDimensionsAsync(
public async Task<ServerMapSnapshot> GetServerMapAsync(
TimeSpan timeout,
CancellationToken cancellationToken = default)
{
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(timeout);
try
{
// CONFIRMED (2.0.0-beta.1): GetMapAsync returns Task<Response<RustPlusApi.Data.ServerMap>>.
// ServerMap has Nullable<uint> Width/Height, Nullable<int> OceanMargin, JpgImage, Monuments.
// 2a uses dims only; if any dim is null, treat the whole thing as unavailable (return null).
var response = await _rustPlus.GetMapAsync(timeoutCts.Token).WaitAsync(timeoutCts.Token)
.ConfigureAwait(false);
if (!response.IsSuccess || response.Data is null)
{
return null;
}

var map = response.Data;
if (map.Width is not { } width || map.Height is not { } height || map.OceanMargin is not { } margin)
{
return null;
}

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<WorldSnapshot?> 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)
{
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<IReadOnlyList<MonumentSnapshot>> GetMonumentsAsync(
TimeSpan timeout,
CancellationToken cancellationToken = default)
{
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(timeout);
// CONFIRMED (2.0.0-beta.1): GetMapAsync returns Task<Response<RustPlusApi.Data.ServerMap>>.
// ServerMap.Monuments is List<ServerMapMonument> with Name (= protobuf token, e.g. "oil_rig_small"),
// Nullable<float> X/Y. We surface (token, x, y) and skip monuments with incomplete coordinates.
// ServerMap carries Nullable<uint> Width/Height, Nullable<int> OceanMargin, Monuments
// (List<ServerMapMonument> with Name = protobuf token, e.g. "oil_rig_small", and Nullable<float>
// X/Y) and JpgImage (raw JPEG bytes). One response, one ~683 KB download: read all three here.
// The world size that completes MapDimensions lives on GetInfo, which is cheap and fails
// independently, so it is resolved separately rather than being able to sink this whole read.
var response = await _rustPlus.GetMapAsync(timeoutCts.Token).WaitAsync(timeoutCts.Token)
.ConfigureAwait(false);
if (!response.IsSuccess || response.Data is null)
{
// Name the reason: the caller only ever sees this message, and "rate_limit" (three heavy
// GetMap calls land back-to-back on connect) reads very differently from "no_map".
// Name the reason: the caller only ever sees this message, and "rate_limit" reads very
// differently from "no_map".
throw new InvalidOperationException(
"GetMap returned no data; error: " + (response.Error?.Code.ToString() ?? "none") +
" (" + (response.Error?.Message ?? "no message") + ").");
}

var map = response.Data;
var monuments = new List<MonumentSnapshot>();
foreach (var m in response.Data.Monuments ?? [])
foreach (var m in map.Monuments ?? [])
{
if (m.Name is null || m.X is not { } x || m.Y is not { } y)
{
Expand All @@ -736,19 +660,26 @@ public async Task<IReadOnlyList<MonumentSnapshot>> GetMonumentsAsync(
monuments.Add(new MonumentSnapshot(m.Name, x, y));
}

return monuments;
var geometry = map.Width is { } width && map.Height is { } height && map.OceanMargin is { } margin
? new MapGeometry(width, height, margin)
: null;
return new ServerMapSnapshot(geometry, monuments, map.JpgImage);
}

public async Task<byte[]?> GetMapImageAsync(TimeSpan timeout, CancellationToken cancellationToken = default)
public async Task<WorldSnapshot?> GetWorldAsync(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)
var response = await _rustPlus.GetInfoAsync(timeoutCts.Token).WaitAsync(timeoutCts.Token)
.ConfigureAwait(false);
return response.IsSuccess ? response.Data?.JpgImage : null;
if (!response.IsSuccess || response.Data is not { MapSize: { } size, Seed: { } seed })
{
return null;
}

return new WorldSnapshot(size, seed);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using RustPlusBot.Abstractions.Connections;

namespace RustPlusBot.Features.Connections.Listening;

/// <summary>
/// Everything Rust+ returns for one map query. The <c>GetMap</c> endpoint answers the map geometry, the
/// monument list and the base-map JPEG in a single response, so splitting them into separate calls makes
/// each reader pay for the whole ~683 KB image. This record keeps them together, letting a connected window
/// resolve the map with one round trip and serve every reader from it.
/// </summary>
/// <param name="Geometry">The map's pixel geometry, or null when the response was incomplete.</param>
/// <param name="Monuments">The map monuments (token + position); empty when the server sends none.</param>
/// <param name="JpgImage">The base-map JPEG bytes, or null when the server sends no image.</param>
internal sealed record ServerMapSnapshot(
MapGeometry? Geometry,
IReadOnlyList<MonumentSnapshot> Monuments,
byte[]? JpgImage);

/// <summary>
/// The pixel geometry of the base-map image. Deliberately not <see cref="MapDimensions"/>: that also carries
/// the world size, which <c>GetMap</c> does not return. Pairing them here would tie the expensive map
/// download to a <c>GetInfo</c> round trip that fails independently of it.
/// </summary>
/// <param name="Width">Image width in pixels.</param>
/// <param name="Height">Image height in pixels.</param>
/// <param name="OceanMargin">Ocean border baked into the image, in pixels per side.</param>
internal sealed record MapGeometry(uint Width, uint Height, int OceanMargin);
Loading
Loading