namespace BrassAndSigil.Server.Services; /// /// 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. /// 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); /// /// 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. /// 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); /// /// Show a draining boss bar at the top of every player's screen for /// . 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. /// 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(); }