a1331212cb
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.
146 lines
6.6 KiB
C#
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);
|
|
}
|
|
}
|