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.
110 lines
5.2 KiB
C#
110 lines
5.2 KiB
C#
namespace BrassAndSigil.Server.Services;
|
|
|
|
/// <summary>
|
|
/// Pushes player-visible messages and overlays into Minecraft via stdin
|
|
/// commands (/say, /title, /bossbar). The boss-bar countdown is the primary
|
|
/// primitive the updater uses for restart announcements.
|
|
/// </summary>
|
|
public sealed class Broadcaster
|
|
{
|
|
private readonly ServerProcess _proc;
|
|
private const string BossBarId = "brass:announce";
|
|
|
|
public Broadcaster(ServerProcess proc) => _proc = proc;
|
|
|
|
public Task SayAsync(string message, CancellationToken ct = default)
|
|
=> _proc.SendInputAsync($"say {SingleLine(message)}", ct);
|
|
|
|
public Task ActionBarAsync(string message, CancellationToken ct = default)
|
|
=> _proc.SendInputAsync($"title @a actionbar {{\"text\":\"{EscapeJson(message)}\"}}", ct);
|
|
|
|
/// <summary>
|
|
/// Re-sends the action bar text once per second so it stays sticky for the
|
|
/// full duration. Action bar fades after ~2-3 s of inactivity, so the
|
|
/// re-send is mandatory. Doesn't conflict with boss-bar UI for actual
|
|
/// boss fights -- preferred over BossBarCountdownAsync for restart warnings.
|
|
/// </summary>
|
|
public async Task ActionBarCountdownAsync(
|
|
string title, int durationSeconds, CancellationToken ct = default)
|
|
{
|
|
if (durationSeconds <= 0) return;
|
|
// Silence /title's "Showing new title for X" chat broadcast for the loop --
|
|
// otherwise it spams chat once per second per online player. Restored in
|
|
// the finally block. World save typically isn't quick enough to persist
|
|
// the off state if we crash mid-flight, but worst case admins can flip
|
|
// it back manually with /gamerule sendCommandFeedback true.
|
|
await _proc.SendInputAsync("gamerule sendCommandFeedback false", ct);
|
|
try
|
|
{
|
|
for (int sec = durationSeconds; sec > 0; sec--)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
var mins = sec / 60;
|
|
var secs = sec % 60;
|
|
var label = mins > 0 ? $"{title} -- {mins}m {secs:00}s" : $"{title} -- {secs}s";
|
|
await _proc.SendInputAsync($"title @a actionbar {{\"text\":\"{EscapeJson(label)}\"}}", ct);
|
|
try { await Task.Delay(1000, ct); }
|
|
catch (OperationCanceledException) { throw; }
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
// Clear the action bar AND restore feedback. Both best-effort: if MC
|
|
// is stopping these'll fail and that's fine.
|
|
try { await _proc.SendInputAsync("title @a actionbar {\"text\":\"\"}"); } catch { }
|
|
try { await _proc.SendInputAsync("gamerule sendCommandFeedback true"); } catch { }
|
|
}
|
|
}
|
|
|
|
public Task TitleAsync(string message, CancellationToken ct = default)
|
|
=> _proc.SendInputAsync($"title @a title {{\"text\":\"{EscapeJson(message)}\"}}", ct);
|
|
|
|
/// <summary>
|
|
/// Show a draining boss bar at the top of every player's screen for
|
|
/// <paramref name="durationSeconds"/>. Updates the bar's name with a
|
|
/// "title -- Mm Ss" countdown each second. Returns when the bar is removed.
|
|
/// Honours cancellation: bar is removed cleanly even on cancel.
|
|
/// </summary>
|
|
public async Task BossBarCountdownAsync(
|
|
string title, int durationSeconds, string color = "yellow", CancellationToken ct = default)
|
|
{
|
|
if (durationSeconds <= 0) return;
|
|
|
|
// Silence /bossbar feedback for the same reason as ActionBarCountdownAsync.
|
|
await _proc.SendInputAsync("gamerule sendCommandFeedback false", ct);
|
|
await _proc.SendInputAsync($"bossbar add {BossBarId} {{\"text\":\"{EscapeJson(title)}\"}}", ct);
|
|
await _proc.SendInputAsync($"bossbar set {BossBarId} color {color}", ct);
|
|
await _proc.SendInputAsync($"bossbar set {BossBarId} max {durationSeconds}", ct);
|
|
await _proc.SendInputAsync($"bossbar set {BossBarId} value {durationSeconds}", ct);
|
|
await _proc.SendInputAsync($"bossbar set {BossBarId} players @a", ct);
|
|
|
|
try
|
|
{
|
|
for (int sec = durationSeconds; sec > 0; sec--)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
var mins = sec / 60;
|
|
var secs = sec % 60;
|
|
var label = mins > 0 ? $"{title} -- {mins}m {secs:00}s" : $"{title} -- {secs}s";
|
|
await _proc.SendInputAsync($"bossbar set {BossBarId} name {{\"text\":\"{EscapeJson(label)}\"}}", ct);
|
|
await _proc.SendInputAsync($"bossbar set {BossBarId} value {sec}", ct);
|
|
try { await Task.Delay(1000, ct); }
|
|
catch (OperationCanceledException) { throw; }
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
// Always remove the bar -- even on cancel, so a stuck bar isn't left
|
|
// on every player's screen. Use CancellationToken.None for the cleanup.
|
|
try { await _proc.SendInputAsync($"bossbar remove {BossBarId}"); } catch { }
|
|
try { await _proc.SendInputAsync("gamerule sendCommandFeedback true"); } catch { }
|
|
}
|
|
}
|
|
|
|
private static string EscapeJson(string s) =>
|
|
s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", " ");
|
|
|
|
private static string SingleLine(string s) =>
|
|
s.Replace("\r", " ").Replace("\n", " ").Trim();
|
|
}
|