Files
brass-and-sigil/server/Services/Broadcaster.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

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();
}