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