Files
brass-and-sigil/server/Services/BlueMapPlayers.cs
T
Matt Sijbers a1331212cb Initial commit: Brass & Sigil monorepo
Self-hosted Minecraft modpack distribution + administration system.

- launcher/  Avalonia 12 desktop client; single-file win-x64 publish.
             Microsoft auth via XboxAuthNet, manifest+SHA-1 mod sync,
             portable install path, sidecar settings.
- server/    brass-sigil-server daemon (.NET 8, linux-x64). Wraps the
             MC subprocess, embedded Kestrel admin panel with cookie
             auth + rate limiting, RCON bridge, scheduled backups,
             BlueMap CLI integration with player markers + skin proxy,
             friend-side whitelist request flow, world wipe with seed
             selection (keep current / random / custom).
- pack/      pack.lock.json (Modrinth + manual CurseForge entries),
             data-only tweak source under tweaks/, build outputs in
             overrides/ (gitignored).
- scripts/   Build-Pack / Build-Tweaks / Update-Pack / Check-Updates
             plus Deploy-Brass.ps1 unified one-shot deploy with
             version-bump pre-flight and daemon-state detection.
2026-05-05 00:19:05 +01:00

146 lines
6.6 KiB
C#

using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// On-demand snapshot of online players + positions, formatted for BlueMap's
/// live player overlay. Pull-based: the BlueMap web UI polls a JSON file at
/// <c>/map/maps/overworld/live/players.json</c> roughly every 2 s, and the
/// daemon intercepts that path and calls <see cref="SnapshotAsync"/> per
/// request. Closed tab = no requests = no RCON calls -- same model as
/// <c>/api/players</c>, no server-side timer to manage.
/// </summary>
public static class BlueMapPlayers
{
public static async Task<List<object>> SnapshotAsync(RconManager rcon, string serverDir, CancellationToken ct)
{
var listResp = await rcon.SendCommandAsync("list", ct);
// Format: "There are N of a max of M players online: name1, name2, ..."
var colon = listResp.IndexOf(':');
var names = colon < 0
? Array.Empty<string>()
: listResp.Substring(colon + 1)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var result = new List<object>();
foreach (var name in names)
{
ct.ThrowIfCancellationRequested();
try
{
var posResp = await rcon.SendCommandAsync($"data get entity {name} Pos", ct);
var pos = ParseDoubleTriple(posResp);
if (pos is null) continue;
var rotResp = await rcon.SendCommandAsync($"data get entity {name} Rotation", ct);
var rot = ParseFloatPair(rotResp);
result.Add(new
{
uuid = ResolveUuid(serverDir, name),
name,
foreign = false,
position = new { x = pos.Value.x, y = pos.Value.y, z = pos.Value.z },
rotation = new { pitch = rot?.pitch ?? 0, yaw = rot?.yaw ?? 0, roll = 0.0 },
});
}
catch
{
// One bad player shouldn't drop the rest of the list.
}
}
return result;
}
// ── UUID resolution ──────────────────────────────────────────────────────
// BlueMap uses the UUID for two things: marker identity across polls (so a
// changing UUID makes the marker flash on/off as it thinks the player keeps
// leaving and rejoining) and skin lookup against Mojang's profile API. We
// need the *real* Mojang UUID to satisfy both -- MC writes name→uuid pairs
// into <serverDir>/usercache.json after each successful auth. We cache that
// file's contents in memory and reload on mtime change, since usercache
// updates rarely (player join, periodic refresh).
private sealed class UserCacheEntry
{
public string? Name { get; set; }
public string? Uuid { get; set; }
}
private static readonly object _cacheLock = new();
private static Dictionary<string, string>? _cache; // case-insensitive name → uuid
private static DateTime _cacheLoadedAt = DateTime.MinValue;
private static string? _cachePath;
private static string ResolveUuid(string serverDir, string name)
{
var path = Path.Combine(Path.GetFullPath(serverDir), "usercache.json");
lock (_cacheLock)
{
if (File.Exists(path))
{
var mtime = File.GetLastWriteTimeUtc(path);
if (_cache is null || _cachePath != path || mtime > _cacheLoadedAt)
{
try
{
var json = File.ReadAllText(path);
var entries = JsonSerializer.Deserialize<UserCacheEntry[]>(json, JsonOpts.CaseInsensitive);
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (entries is not null)
foreach (var e in entries)
if (!string.IsNullOrEmpty(e.Name) && !string.IsNullOrEmpty(e.Uuid))
dict[e.Name] = e.Uuid;
_cache = dict;
_cachePath = path;
_cacheLoadedAt = mtime;
}
catch
{
// Corrupt usercache shouldn't take down the marker -- fall through.
}
}
}
if (_cache is not null && _cache.TryGetValue(name, out var uuid)) return uuid;
}
// Fallback when usercache hasn't been written yet (very early after
// first auth) or has been wiped: deterministic UUID derived from the
// name. Stable across polls so the marker doesn't flash; skin lookup
// will 404 against Mojang and BlueMap will show a default head.
return DeriveStableUuid(name).ToString();
}
private static Guid DeriveStableUuid(string name)
{
var bytes = SHA1.HashData(Encoding.UTF8.GetBytes("brass-sigil:" + name.ToLowerInvariant()));
var guidBytes = new byte[16];
Array.Copy(bytes, guidBytes, 16);
return new Guid(guidBytes);
}
// Parse a NBT-style double triple like "[123.4d, 64.0d, -56.7d]" out of an
// RCON `data get` response.
private static (double x, double y, double z)? ParseDoubleTriple(string resp)
{
var m = Regex.Match(resp, @"\[([\-\d\.]+)d?,\s*([\-\d\.]+)d?,\s*([\-\d\.]+)d?\]");
if (!m.Success) return null;
if (!double.TryParse(m.Groups[1].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var x)) return null;
if (!double.TryParse(m.Groups[2].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var y)) return null;
if (!double.TryParse(m.Groups[3].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var z)) return null;
return (x, y, z);
}
// Rotation comes as `[yaw, pitch]` in floats.
private static (double pitch, double yaw)? ParseFloatPair(string resp)
{
var m = Regex.Match(resp, @"\[([\-\d\.]+)f?,\s*([\-\d\.]+)f?\]");
if (!m.Success) return null;
if (!double.TryParse(m.Groups[1].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var yaw)) return null;
if (!double.TryParse(m.Groups[2].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var pitch)) return null;
return (pitch, yaw);
}
}