using System.Globalization; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; namespace BrassAndSigil.Server.Services; /// /// 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 /// /map/maps/overworld/live/players.json roughly every 2 s, and the /// daemon intercepts that path and calls per /// request. Closed tab = no requests = no RCON calls -- same model as /// /api/players, no server-side timer to manage. /// public static class BlueMapPlayers { public static async Task> 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() : listResp.Substring(colon + 1) .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var result = new List(); 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 /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? _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(json, JsonOpts.CaseInsensitive); var dict = new Dictionary(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); } }