From 3fa7d6babc642098a6e7a0006da9d76f465b0530 Mon Sep 17 00:00:00 2001 From: Dennis van 't Hul Date: Thu, 6 Aug 2026 14:50:06 +0200 Subject: [PATCH 01/32] feat: GO-orchestrated livestreams (relay integration) - LivestreamsController: GET /livestreams (observer menu), POST /livestreams/register (membership-gated streamer setup), POST /observe/{lobby_id} (external observer watch tickets), POST /ended (relay reports stream ended) - RelayClient: outbound relay calls with side-specific keys (Relay.api_key out, Relay.ingress_api_key for inbound /ended), Polly retry, watch-ticket status enum to distinguish stream-ended (404) from relay failure (502) - s_endedStreams: relay-driven ended-lobby tracking so ended streams drop from /livestreams and /observe returns 404 - LobbyManager: GetAllLobbies() accessor; removed match-end relay teardown hook (relay owns closing) - Program.cs: Discord-style soft-fail startup check for incomplete Relay config - WebSocketController: GeoIP reader made nullable so a missing mmdb no longer bricks WS sessions (TypeInitializationException fix) - appsettings.json: Relay section (enabled:false default) --- .gitignore | 9 +- .../Livestreams/LivestreamsController.cs | 379 ++++++++++++++++++ .../WebSocket/WebSocketController.cs | 46 ++- GenOnlineService/LobbyManager.cs | 7 +- GenOnlineService/Program.cs | 17 + GenOnlineService/RelayClient.cs | 276 +++++++++++++ GenOnlineService/appsettings.json | 6 + 7 files changed, 725 insertions(+), 15 deletions(-) create mode 100644 GenOnlineService/Controllers/Livestreams/LivestreamsController.cs create mode 100644 GenOnlineService/RelayClient.cs diff --git a/.gitignore b/.gitignore index 5b14f16..76d9f6d 100644 --- a/.gitignore +++ b/.gitignore @@ -55,4 +55,11 @@ nunit-*.xml .vs/* */.vs/* -*.csproj.user \ No newline at end of file +*.csproj.user + +# Container files +Dockerfile +.dockerignore + +# Appsettings of any environment +GenOnlineService/appsettings.*.json diff --git a/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs b/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs new file mode 100644 index 0000000..4344485 --- /dev/null +++ b/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs @@ -0,0 +1,379 @@ +/* +** GeneralsOnline Game Services - Backend Services for Command & Conquer Generals Online: Zero Hour +** Copyright (C) 2025 GeneralsOnline Development Team +** +** This program is free software: you can redistribute it and/or modify +** it under the terms of the GNU Affero General Public License as +** published by the Free Software Foundation, either version 3 of the +** License, or (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +** GNU Affero General Public License for more details. +** +** You should have received a copy of the GNU Affero General Public License +** along with this program. If not, see . +*/ + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Text.Json; +using Microsoft.Extensions.Configuration; + +namespace GenOnlineService.Controllers +{ + public class GET_Livestreams_Result : APIResult + { + public override Type GetReturnType() + { + return this.GetType(); + } + + public List livestreams { get; set; } = new(); + } + + public class GET_Livestreams_LivestreamEntry + { + public Int64 lobby_id { get; set; } = -1; + public string name { get; set; } = String.Empty; + public string map_name { get; set; } = String.Empty; + public List players { get; set; } = new(); + public int? delay_seconds { get; set; } = null; + public int? age_seconds { get; set; } = null; + } + + public class POST_Livestreams_Register_Result : APIResult + { + public override Type GetReturnType() + { + return this.GetType(); + } + + public bool success { get; set; } = false; + public string url { get; set; } = String.Empty; + public Dictionary member_urls { get; set; } = new(); + public string detail { get; set; } = String.Empty; + } + + public class POST_Livestreams_Observe_Result : APIResult + { + public override Type GetReturnType() + { + return this.GetType(); + } + + public string url { get; set; } = String.Empty; + public string detail { get; set; } = String.Empty; + } + + public class POST_Livestreams_Ended_Result : APIResult + { + public override Type GetReturnType() + { + return this.GetType(); + } + + public bool received { get; set; } = false; + public string detail { get; set; } = String.Empty; + } + + [ApiController] + [Authorize(Roles = "GameClient")] + [Route("env/{environment}/contract/{contract_version}/[controller]")] + public class LivestreamsController : ControllerBase + { + private readonly LobbyManager _lobbyManager; + + // Lobbies whose relay session has ended (the relay notified GO via POST /Livestreams/ended + // that all sources left / the session was reaped). The relay owns stream liveness, so this + // is the signal that removes a lobby from /livestreams and rejects /observe — even though + // GO's own lobby may still be INGAME. Cleared when a fresh /register re-creates the session. + private static readonly System.Collections.Concurrent.ConcurrentDictionary s_endedStreams = new(); + + public LivestreamsController(LobbyManager lobbyManager) + { + _lobbyManager = lobbyManager; + } + + [HttpGet(Name = "GetLivestreams")] + public APIResult Get() + { + GET_Livestreams_Result result = new GET_Livestreams_Result(); + + // Relay not configured -> no livestreams exist to list. An empty list is the right + // shape here: the observer menu just shows nothing, exactly as if nobody is + // streaming. No 5xx — there is no error, the feature is simply not deployed. + if (!RelayClient.IsEnabled()) + { + return result; + } + + foreach (Lobby lobby in _lobbyManager.GetAllLobbies()) + { + if (lobby.State != ELobbyState.INGAME || !lobby.AllowObservers) + { + continue; + } + + // The relay reported this stream ended — drop it from the menu even though GO's + // lobby object is still INGAME. + if (s_endedStreams.ContainsKey(lobby.LobbyID)) + { + continue; + } + + GET_Livestreams_LivestreamEntry entry = new GET_Livestreams_LivestreamEntry(); + entry.lobby_id = lobby.LobbyID; + entry.name = lobby.Name; + entry.map_name = lobby.MapName; + entry.players = lobby.Members.Where(member => member.IsHuman()).Select(member => member.DisplayName).ToList(); + entry.delay_seconds = null; + entry.age_seconds = Math.Max(0, (int)(DateTime.UtcNow - lobby.TimeCreated).TotalSeconds); + + result.livestreams.Add(entry); + } + + return result; + } + + [HttpPost("register", Name = "RegisterLivestream")] + public async Task Register() + { + POST_Livestreams_Register_Result result = new POST_Livestreams_Register_Result(); + + // The feature is off: refuse loudly rather than pretending a stream was set up. + if (!RelayClient.IsEnabled()) + { + Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable; + result.detail = "Live streaming is not enabled on this server."; + return result; + } + + Int64 user_id = TokenHelper.GetUserID(this); + EUserSessionType sessionType = TokenHelper.GetSessionType(this); + + if (user_id == -1 || !SessionHelpers.SessionTypeHasAccessTo(sessionType, ESessionAccessType.Gameplay)) + { + Response.StatusCode = (int)HttpStatusCode.Unauthorized; + result.detail = "Invalid or missing game client session."; + return result; + } + + Lobby? lobby = _lobbyManager.GetPlayerParticipantLobby(user_id); + if (lobby == null || lobby.State != ELobbyState.INGAME) + { + Response.StatusCode = (int)HttpStatusCode.NotFound; + result.detail = "You are not in an in-progress match."; + return result; + } + + if (lobby.GetMemberFromUserID(user_id) == null && lobby.Owner != user_id) + { + Response.StatusCode = (int)HttpStatusCode.Forbidden; + result.detail = "You are not a member of this lobby."; + return result; + } + + RelayLivestreamResponse? livestream = await RelayClient.CreateLivestreamAsync(lobby.LobbyID, user_id); + if (livestream == null || String.IsNullOrEmpty(livestream.base_url)) + { + Response.StatusCode = (int)HttpStatusCode.BadGateway; + result.detail = "Relay failed to create a livestream session."; + return result; + } + + Dictionary memberUrls = new Dictionary(); + string? requesterUrl = null; + + foreach (LobbyMember member in lobby.Members) + { + if (!member.IsHuman()) + { + continue; + } + + RelayTokenResponse? tokenResponse = await RelayClient.CreateStreamTokenAsync(lobby.LobbyID, member.UserID); + if (tokenResponse == null || String.IsNullOrEmpty(tokenResponse.url)) + { + Console.WriteLine($"[ERROR] Relay stream token mint failed for lobby {lobby.LobbyID} user {member.UserID}"); + continue; + } + + memberUrls[member.UserID] = tokenResponse.url; + + if (member.UserID == user_id) + { + requesterUrl = tokenResponse.url; + } + } + + if (requesterUrl == null) + { + Response.StatusCode = (int)HttpStatusCode.BadGateway; + result.detail = "Relay failed to mint a stream token for you."; + return result; + } + + // Refresh the lobby lists in the network room so the in-progress game shows up in the livestreams menu. + await WebSocketManager.SendNewOrDeletedLobbyToAllNetworkRoomMembers(lobby.NetworkRoomID); + + // A fresh relay session was just created, so this lobby's stream is live again — + // clear any earlier "stream ended" state from a previous session. + s_endedStreams.TryRemove(lobby.LobbyID, out _); + + result.success = true; + result.url = requesterUrl; + result.member_urls = memberUrls; + return result; + } + + [HttpPost("observe/{lobby_id}", Name = "ObserveLivestream")] + public async Task Observe(Int64 lobby_id) + { + POST_Livestreams_Observe_Result result = new POST_Livestreams_Observe_Result(); + + // The feature is off: refuse loudly rather than handing out a broken watch URL. + if (!RelayClient.IsEnabled()) + { + Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable; + result.detail = "Live streaming is not enabled on this server."; + return result; + } + + Int64 user_id = TokenHelper.GetUserID(this); + EUserSessionType sessionType = TokenHelper.GetSessionType(this); + + if (user_id == -1 || !SessionHelpers.SessionTypeHasAccessTo(sessionType, ESessionAccessType.ServerListReadOnly)) + { + Response.StatusCode = (int)HttpStatusCode.Unauthorized; + result.detail = "Invalid or missing game client session."; + return result; + } + + Lobby? lobby = _lobbyManager.GetLobby(lobby_id); + if (lobby == null || lobby.State != ELobbyState.INGAME || !lobby.AllowObservers) + { + Response.StatusCode = (int)HttpStatusCode.NotFound; + result.detail = "No watchable live game found for that lobby_id."; + return result; + } + + // The relay already told us this stream ended — reject before another relay round-trip. + if (s_endedStreams.ContainsKey(lobby.LobbyID)) + { + Response.StatusCode = (int)HttpStatusCode.NotFound; + result.detail = "That livestream has ended."; + return result; + } + + RelayWatchTicketResult ticket = await RelayClient.CreateWatchTicketAsync(lobby.LobbyID, user_id); + if (ticket.Status == RelayWatchTicketStatus.StreamEnded) + { + // The relay session for this lobby is gone (all sources left / reaped): the + // stream is over even though GO's lobby is still INGAME. Tell the client + // "stream ended" (404) rather than a confusing 502, and remember it so the + // lobby drops out of /livestreams. + s_endedStreams[lobby.LobbyID] = 0; + Response.StatusCode = (int)HttpStatusCode.NotFound; + result.detail = "That livestream has ended."; + return result; + } + + if (ticket.Status != RelayWatchTicketStatus.Ok || + ticket.Token == null || String.IsNullOrEmpty(ticket.Token.url)) + { + Response.StatusCode = (int)HttpStatusCode.BadGateway; + result.detail = "Relay failed to issue a watch ticket."; + return result; + } + + result.url = ticket.Token.url; + return result; + } + + [HttpPost("ended", Name = "LivestreamEnded")] + // The relay calls this (not a game client) and authenticates with its own credential + // (Relay.ingress_api_key), so it must bypass the class-level GameClient JWT requirement. + // [AllowAnonymous] opts this one route out; the key is validated manually below. + [AllowAnonymous] + public async Task Ended() + { + POST_Livestreams_Ended_Result result = new POST_Livestreams_Ended_Result(); + + // The relay's credential is distinct from GO's key (Relay.api_key) that GO sends to + // the relay. Missing/mismatched -> 401, matching the relay's /internal/* gate. + if (!RelayClient.IsEnabled()) + { + Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable; + result.detail = "Relay integration is not enabled on this server."; + return result; + } + + IConfigurationSection relaySettings = Program.g_Config.GetSection("Relay"); + string? expectedKey = relaySettings.GetValue("ingress_api_key"); + string? suppliedKey = Request.Headers["X-Relay-Key"].FirstOrDefault(); + + if (string.IsNullOrEmpty(expectedKey) || suppliedKey != expectedKey) + { + Response.StatusCode = (int)HttpStatusCode.Unauthorized; + result.detail = "Invalid or missing relay key."; + return result; + } + + Int64 lobby_id = -1; + string reason = String.Empty; + using (var reader = new StreamReader(HttpContext.Request.Body)) + { + string jsonData = await reader.ReadToEndAsync(); + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var data = JsonSerializer.Deserialize>(jsonData, options); + if (data != null) + { + if (data.TryGetValue("lobby_id", out JsonElement lobbyEl)) + { + // The relay sends lobby_id as a decimal string; accept either for safety. + if (lobbyEl.ValueKind == JsonValueKind.Number && + lobbyEl.TryGetInt64(out long parsedLobby)) + { + lobby_id = parsedLobby; + } + else if (lobbyEl.ValueKind == JsonValueKind.String && + lobbyEl.GetString() != null && + Int64.TryParse(lobbyEl.GetString(), out long parsedStringLobby)) + { + lobby_id = parsedStringLobby; + } + } + if (data.TryGetValue("reason", out JsonElement reasonEl) && + reasonEl.ValueKind == JsonValueKind.String) + { + reason = reasonEl.GetString() ?? String.Empty; + } + } + } + + if (lobby_id == -1) + { + Response.StatusCode = (int)HttpStatusCode.BadRequest; + result.detail = "lobby_id required."; + return result; + } + + // The relay (the authority on stream liveness) says this stream is over. Record it + // so the lobby drops out of /livestreams and /observe rejects — even though GO's + // own lobby object may still be INGAME (the match can continue without a stream). + s_endedStreams[lobby_id] = 0; + + Console.WriteLine($"[Livestream] Relay reported lobby {lobby_id} ended (reason: {reason})."); + + result.received = true; + return result; + } + } +} diff --git a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs index ec57472..df3f859 100644 --- a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs +++ b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs @@ -47,8 +47,25 @@ public WebSocketController(LobbyManager lobbyManager, IDbContextFactory TimeMemberLeft { get; private set; } = new(); // Records the first time each player's in-game WebSocket connection dropped (i.e., when they first "quit" - // while the match was in progress). Only the first disconnect is stored — reconnects do not reset it. + // while the match was in progress). Only the first disconnect is stored � reconnects do not reset it. // Used by DetermineLobbyWinnerIfNotPresent to find who abandoned first (= loser) vs last (= winner). [JsonIgnore] public Dictionary TimePlayerAbandonedIngame { get; private set; } = new(); @@ -1413,6 +1413,11 @@ public List GetAllLobbies(Int16 networkRoomID, bool bIncludePassword, boo return listLobbies; } + public List GetAllLobbies() + { + return m_dictLobbies.Values.ToList(); + } + public Lobby? GetLobby(Int64 lobbyID) { if (m_dictLobbies.TryGetValue(lobbyID, out Lobby? lobby)) diff --git a/GenOnlineService/Program.cs b/GenOnlineService/Program.cs index a512270..dd800fb 100644 --- a/GenOnlineService/Program.cs +++ b/GenOnlineService/Program.cs @@ -727,6 +727,23 @@ public static async Task Main(string[] args) g_Discord = new DiscordBot(); } + // Relay startup check — same principle as Discord: a misconfigured optional + // integration must not take the server down. If the relay is explicitly enabled but + // not fully configured, log a loud warning and continue; the livestream endpoints + // will refuse with 503 via RelayClient.IsEnabled() rather than crashing at startup. + var relaySettings = Program.g_Config.GetSection("Relay"); + if (relaySettings.GetValue("enabled")) + { + string? relayBaseUrl = relaySettings.GetValue("base_url"); + string? relayApiKey = relaySettings.GetValue("api_key"); + string? relayIngressKey = relaySettings.GetValue("ingress_api_key"); + if (string.IsNullOrEmpty(relayBaseUrl) || string.IsNullOrEmpty(relayApiKey) || string.IsNullOrEmpty(relayIngressKey)) + { + Console.WriteLine($"[WARNING] Relay is enabled in config but base_url, api_key and/or ingress_api_key are missing — " + + $"livestream endpoints will return 503 until the Relay section is completed."); + } + } + builder.Services.AddSingleton(); var rateLimitingSettings = Program.g_Config.GetSection("RateLimiting"); diff --git a/GenOnlineService/RelayClient.cs b/GenOnlineService/RelayClient.cs new file mode 100644 index 0000000..85fbaba --- /dev/null +++ b/GenOnlineService/RelayClient.cs @@ -0,0 +1,276 @@ +using System; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Polly; + +namespace GenOnlineService +{ + public class RelayLivestreamResponse + { + public string base_url { get; set; } = String.Empty; + } + + public class RelayTokenResponse + { + public string url { get; set; } = String.Empty; + } + + public enum RelayWatchTicketStatus + { + Ok, + // Relay session for that lobby is gone (all sources left / reaped): the stream ended. + StreamEnded, + // Relay unreachable or returned an error. + Failure + } + + public class RelayWatchTicketResult + { + public RelayWatchTicketStatus Status { get; set; } + public RelayTokenResponse? Token { get; set; } + } + + public static class RelayClient + { + private static readonly HttpClient s_httpClient = new HttpClient + { + Timeout = TimeSpan.FromSeconds(10) + }; + + /// Whether the relay feature is enabled. Off by default — the relay config is + /// intentionally optional on day one, so without an explicit `enabled: true` (and a + /// base_url) the livestream endpoints must not attempt any relay call. + public static bool IsEnabled() + { + if (Program.g_Config == null) + { + return false; + } + + IConfigurationSection? configSection = Program.g_Config.GetSection("Relay"); + if (configSection == null) + { + return false; + } + + // `enabled` is the master switch (mirrors Discord's enable_discord / Sentry's + // enabled). A missing value means off — the feature ships inert. + if (!configSection.GetValue("enabled")) + { + return false; + } + + string? sectionBaseUrl = configSection.GetValue("base_url"); + string? sectionApiKey = configSection.GetValue("api_key"); + string? sectionIngressKey = configSection.GetValue("ingress_api_key"); + + return !string.IsNullOrEmpty(sectionBaseUrl) && + !string.IsNullOrEmpty(sectionApiKey) && + !string.IsNullOrEmpty(sectionIngressKey); + } + + private static void GetRelayConfig(out string baseUrl, out string apiKey) + { + baseUrl = String.Empty; + apiKey = String.Empty; + + if (Program.g_Config == null) + { + throw new Exception("Config not loaded"); + } + + IConfigurationSection configSection = Program.g_Config.GetSection("Relay"); + + string? sectionBaseUrl = configSection.GetValue("base_url"); + string? sectionApiKey = configSection.GetValue("api_key"); + + if (string.IsNullOrEmpty(sectionBaseUrl)) + { + throw new Exception("Relay base_url missing in config"); + } + + if (string.IsNullOrEmpty(sectionApiKey)) + { + throw new Exception("Relay api_key missing in config"); + } + + baseUrl = sectionBaseUrl.TrimEnd('/'); + apiKey = sectionApiKey; + } + + private static Polly.Retry.AsyncRetryPolicy BuildRetryPolicy(string description) + { + // Configure Polly wait-and-retry policy with exponential backoff on HTTP/Socket errors + return Policy + .Handle() + .Or() + .Or() + .WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)), (exception, timeSpan, retryCount, context) => + { + Console.WriteLine($"[WARNING] Relay {description} call failed (attempt {retryCount}). Retrying in {timeSpan.TotalSeconds}s. Error: {exception.Message}"); + }); + } + + private static async Task SendWithRetryAsync(HttpMethod method, string path, string? payloadJson, string description) + { + GetRelayConfig(out string baseUrl, out string apiKey); + + string requestUrl = baseUrl + path; + + var retryPolicy = BuildRetryPolicy(description); + + HttpResponseMessage? response = null; + + await retryPolicy.ExecuteAsync(async () => + { + using (var request = new HttpRequestMessage(method, requestUrl)) + { + request.Headers.TryAddWithoutValidation("X-Relay-Key", apiKey); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + if (payloadJson != null) + { + request.Content = new StringContent(payloadJson, Encoding.UTF8, "application/json"); + } + + response = await s_httpClient.SendAsync(request); + + // Explicitly verify response success inside execution block to ensure retry triggers on HTTP error statuses + response.EnsureSuccessStatusCode(); + } + }); + + if (response == null) + { + throw new Exception(String.Format("Relay {0} call returned no response", description)); + } + + return response; + } + + // Like SendWithRetryAsync but does not throw on non-success statuses, so callers can + // distinguish a relay "stream ended" (404) from a relay failure (5xx / network). + private static async Task SendRawAsync(HttpMethod method, string path, string? payloadJson, string description) + { + GetRelayConfig(out string baseUrl, out string apiKey); + + string requestUrl = baseUrl + path; + + var retryPolicy = BuildRetryPolicy(description); + + HttpResponseMessage? response = null; + + try + { + await retryPolicy.ExecuteAsync(async () => + { + using (var request = new HttpRequestMessage(method, requestUrl)) + { + request.Headers.TryAddWithoutValidation("X-Relay-Key", apiKey); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + if (payloadJson != null) + { + request.Content = new StringContent(payloadJson, Encoding.UTF8, "application/json"); + } + + // No EnsureSuccessStatusCode: a 404 ("stream ended") is a valid, expected + // outcome that must not trigger retries or collapse into a generic failure. + response = await s_httpClient.SendAsync(request); + } + }); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] Relay {description} call failed: {ex.Message}"); + return null; + } + + return response; + } + + public static async Task CreateLivestreamAsync(long lobbyId, long ownerUserId) + { + try + { + string payloadJson = JsonSerializer.Serialize(new { lobby_id = lobbyId, owner_user_id = ownerUserId }); + + using (var response = await SendWithRetryAsync(HttpMethod.Post, "/internal/livestreams", payloadJson, "CreateLivestream")) + { + string responseBody = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(responseBody); + } + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] Relay CreateLivestream failed for lobby {lobbyId}: {ex.Message}"); + return null; + } + } + + public static async Task CreateStreamTokenAsync(long lobbyId, long userId) + { + try + { + string payloadJson = JsonSerializer.Serialize(new { lobby_id = lobbyId, user_id = userId }); + + using (var response = await SendWithRetryAsync(HttpMethod.Post, "/internal/stream_tokens", payloadJson, "CreateStreamToken")) + { + string responseBody = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(responseBody); + } + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] Relay CreateStreamToken failed for lobby {lobbyId} user {userId}: {ex.Message}"); + return null; + } + } + + public static async Task CreateWatchTicketAsync(long lobbyId, long userId) + { + try + { + string payloadJson = JsonSerializer.Serialize(new { lobby_id = lobbyId, user_id = userId }); + + using (var response = await SendRawAsync(HttpMethod.Post, "/internal/watch_tickets", payloadJson, "CreateWatchTicket")) + { + if (response == null) + { + return new RelayWatchTicketResult { Status = RelayWatchTicketStatus.Failure }; + } + + // 404 means the relay session is gone — the stream ended. Everything else + // non-success is a relay failure. + if ((int)response.StatusCode == 404) + { + return new RelayWatchTicketResult { Status = RelayWatchTicketStatus.StreamEnded }; + } + + if (!response.IsSuccessStatusCode) + { + Console.WriteLine($"[ERROR] Relay CreateWatchTicket for lobby {lobbyId} user {userId} returned status {response.StatusCode}."); + return new RelayWatchTicketResult { Status = RelayWatchTicketStatus.Failure }; + } + + string responseBody = await response.Content.ReadAsStringAsync(); + return new RelayWatchTicketResult + { + Status = RelayWatchTicketStatus.Ok, + Token = JsonSerializer.Deserialize(responseBody) + }; + } + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] Relay CreateWatchTicket failed for lobby {lobbyId} user {userId}: {ex.Message}"); + return new RelayWatchTicketResult { Status = RelayWatchTicketStatus.Failure }; + } + } + } +} diff --git a/GenOnlineService/appsettings.json b/GenOnlineService/appsettings.json index 92079ed..ee397d2 100644 --- a/GenOnlineService/appsettings.json +++ b/GenOnlineService/appsettings.json @@ -81,5 +81,11 @@ "GetUrl": null, "GetToken": null, "PostToken": null + }, + "Relay": { + "enabled": false, + "base_url": "", + "api_key": "", + "ingress_api_key": "" } } From 68b0d0ce5c12d9607976fdb1a843a561b580c03c Mon Sep 17 00:00:00 2001 From: Dennis van 't Hul Date: Thu, 6 Aug 2026 17:31:17 +0200 Subject: [PATCH 02/32] feat(livestreams): unify relay state reporting and clean up endpoints - Move livestream state (is_streaming, delay, observer count) onto the Lobby - Register accepts delay_seconds from the host and forwards it to the relay - Replace separate /ended + per-lobby observer posts with one batched POST /observers array [{lobby_id, observer_count, is_live}] - Relay key validation centralized in RelayClient.ValidateIngressKey - [RequireRelay] filter replaces duplicated IsEnabled() 503 checks on POSTs - DRY RelayClient send path into a single SendAsync(method, path, body, desc, throwOnError) --- .../Livestreams/LivestreamsController.cs | 198 +++++++++--------- GenOnlineService/LobbyManager.cs | 21 ++ GenOnlineService/RelayClient.cs | 90 ++++---- 3 files changed, 167 insertions(+), 142 deletions(-) diff --git a/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs b/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs index 4344485..4d4410e 100644 --- a/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs +++ b/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs @@ -18,13 +18,13 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; using System.Text.Json; -using Microsoft.Extensions.Configuration; namespace GenOnlineService.Controllers { @@ -45,6 +45,7 @@ public class GET_Livestreams_LivestreamEntry public string map_name { get; set; } = String.Empty; public List players { get; set; } = new(); public int? delay_seconds { get; set; } = null; + public int observer_count { get; set; } = 0; public int? age_seconds { get; set; } = null; } @@ -72,7 +73,7 @@ public override Type GetReturnType() public string detail { get; set; } = String.Empty; } - public class POST_Livestreams_Ended_Result : APIResult + public class POST_Livestreams_Observers_Result : APIResult { public override Type GetReturnType() { @@ -83,6 +84,31 @@ public override Type GetReturnType() public string detail { get; set; } = String.Empty; } + public class POST_Livestreams_Observers_Entry + { + public string lobby_id { get; set; } = String.Empty; + public int observer_count { get; set; } = 0; + public bool is_live { get; set; } = true; + } + + // The relay is optional: when it is not configured (Relay.enabled off / missing keys) the + // livestream POST endpoints must refuse loudly rather than pretending a stream was set up. + // Applied per-endpoint so the GET /livestreams menu can keep its deliberate empty-list + // behaviour when the feature is not deployed. + public class RequireRelayAttribute : ActionFilterAttribute + { + public override void OnActionExecuting(ActionExecutingContext context) + { + if (!RelayClient.IsEnabled()) + { + context.Result = new ObjectResult(new { detail = "Live streaming is not enabled on this server." }) + { + StatusCode = (int)HttpStatusCode.ServiceUnavailable + }; + } + } + } + [ApiController] [Authorize(Roles = "GameClient")] [Route("env/{environment}/contract/{contract_version}/[controller]")] @@ -90,12 +116,6 @@ public class LivestreamsController : ControllerBase { private readonly LobbyManager _lobbyManager; - // Lobbies whose relay session has ended (the relay notified GO via POST /Livestreams/ended - // that all sources left / the session was reaped). The relay owns stream liveness, so this - // is the signal that removes a lobby from /livestreams and rejects /observe — even though - // GO's own lobby may still be INGAME. Cleared when a fresh /register re-creates the session. - private static readonly System.Collections.Concurrent.ConcurrentDictionary s_endedStreams = new(); - public LivestreamsController(LobbyManager lobbyManager) { _lobbyManager = lobbyManager; @@ -116,14 +136,7 @@ public APIResult Get() foreach (Lobby lobby in _lobbyManager.GetAllLobbies()) { - if (lobby.State != ELobbyState.INGAME || !lobby.AllowObservers) - { - continue; - } - - // The relay reported this stream ended — drop it from the menu even though GO's - // lobby object is still INGAME. - if (s_endedStreams.ContainsKey(lobby.LobbyID)) + if (lobby.State != ELobbyState.INGAME || !lobby.AllowObservers || !lobby.IsStreaming) { continue; } @@ -133,7 +146,8 @@ public APIResult Get() entry.name = lobby.Name; entry.map_name = lobby.MapName; entry.players = lobby.Members.Where(member => member.IsHuman()).Select(member => member.DisplayName).ToList(); - entry.delay_seconds = null; + entry.delay_seconds = lobby.StreamDelaySeconds; + entry.observer_count = lobby.ObserverCount; entry.age_seconds = Math.Max(0, (int)(DateTime.UtcNow - lobby.TimeCreated).TotalSeconds); result.livestreams.Add(entry); @@ -143,18 +157,11 @@ public APIResult Get() } [HttpPost("register", Name = "RegisterLivestream")] + [RequireRelay] public async Task Register() { POST_Livestreams_Register_Result result = new POST_Livestreams_Register_Result(); - // The feature is off: refuse loudly rather than pretending a stream was set up. - if (!RelayClient.IsEnabled()) - { - Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable; - result.detail = "Live streaming is not enabled on this server."; - return result; - } - Int64 user_id = TokenHelper.GetUserID(this); EUserSessionType sessionType = TokenHelper.GetSessionType(this); @@ -180,7 +187,25 @@ public async Task Register() return result; } - RelayLivestreamResponse? livestream = await RelayClient.CreateLivestreamAsync(lobby.LobbyID, user_id); + // The host reports the relay stream delay in seconds when starting the stream. + int? delaySeconds = null; + using (var reader = new StreamReader(HttpContext.Request.Body)) + { + string jsonData = await reader.ReadToEndAsync(); + if (!String.IsNullOrEmpty(jsonData)) + { + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var data = JsonSerializer.Deserialize>(jsonData, options); + if (data != null && data.TryGetValue("delay_seconds", out JsonElement delayEl) && + delayEl.ValueKind == JsonValueKind.Number && + delayEl.TryGetInt32(out int parsedDelay)) + { + delaySeconds = Math.Max(0, parsedDelay); + } + } + } + + RelayLivestreamResponse? livestream = await RelayClient.CreateLivestreamAsync(lobby.LobbyID, user_id, delaySeconds); if (livestream == null || String.IsNullOrEmpty(livestream.base_url)) { Response.StatusCode = (int)HttpStatusCode.BadGateway; @@ -223,9 +248,9 @@ public async Task Register() // Refresh the lobby lists in the network room so the in-progress game shows up in the livestreams menu. await WebSocketManager.SendNewOrDeletedLobbyToAllNetworkRoomMembers(lobby.NetworkRoomID); - // A fresh relay session was just created, so this lobby's stream is live again — - // clear any earlier "stream ended" state from a previous session. - s_endedStreams.TryRemove(lobby.LobbyID, out _); + // A fresh relay session was just created — mark this lobby as actively streaming + // so it shows up in /livestreams and is accepted by /observe. + lobby.SetStreaming(true, delaySeconds, 0); result.success = true; result.url = requesterUrl; @@ -234,18 +259,11 @@ public async Task Register() } [HttpPost("observe/{lobby_id}", Name = "ObserveLivestream")] + [RequireRelay] public async Task Observe(Int64 lobby_id) { POST_Livestreams_Observe_Result result = new POST_Livestreams_Observe_Result(); - // The feature is off: refuse loudly rather than handing out a broken watch URL. - if (!RelayClient.IsEnabled()) - { - Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable; - result.detail = "Live streaming is not enabled on this server."; - return result; - } - Int64 user_id = TokenHelper.GetUserID(this); EUserSessionType sessionType = TokenHelper.GetSessionType(this); @@ -264,8 +282,8 @@ public async Task Observe(Int64 lobby_id) return result; } - // The relay already told us this stream ended — reject before another relay round-trip. - if (s_endedStreams.ContainsKey(lobby.LobbyID)) + // No active relay stream for this lobby — reject before another relay round-trip. + if (!lobby.IsStreaming) { Response.StatusCode = (int)HttpStatusCode.NotFound; result.detail = "That livestream has ended."; @@ -276,10 +294,10 @@ public async Task Observe(Int64 lobby_id) if (ticket.Status == RelayWatchTicketStatus.StreamEnded) { // The relay session for this lobby is gone (all sources left / reaped): the - // stream is over even though GO's lobby is still INGAME. Tell the client - // "stream ended" (404) rather than a confusing 502, and remember it so the - // lobby drops out of /livestreams. - s_endedStreams[lobby.LobbyID] = 0; + // stream is over even though GO's lobby is still INGAME. Deregister it so the + // lobby drops out of /livestreams on the next refresh, and tell the client + // "stream ended" (404) rather than a confusing 502. + lobby.SetStreaming(false); Response.StatusCode = (int)HttpStatusCode.NotFound; result.detail = "That livestream has ended."; return result; @@ -297,80 +315,70 @@ public async Task Observe(Int64 lobby_id) return result; } - [HttpPost("ended", Name = "LivestreamEnded")] - // The relay calls this (not a game client) and authenticates with its own credential + [HttpPost("observers", Name = "LivestreamObservers")] + [RequireRelay] + // The relay calls this (not a game client) with its own credential // (Relay.ingress_api_key), so it must bypass the class-level GameClient JWT requirement. - // [AllowAnonymous] opts this one route out; the key is validated manually below. [AllowAnonymous] - public async Task Ended() + public async Task Observers([FromHeader(Name = "X-Relay-Key")] string? relayKey) { - POST_Livestreams_Ended_Result result = new POST_Livestreams_Ended_Result(); - - // The relay's credential is distinct from GO's key (Relay.api_key) that GO sends to - // the relay. Missing/mismatched -> 401, matching the relay's /internal/* gate. - if (!RelayClient.IsEnabled()) - { - Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable; - result.detail = "Relay integration is not enabled on this server."; - return result; - } - - IConfigurationSection relaySettings = Program.g_Config.GetSection("Relay"); - string? expectedKey = relaySettings.GetValue("ingress_api_key"); - string? suppliedKey = Request.Headers["X-Relay-Key"].FirstOrDefault(); + POST_Livestreams_Observers_Result result = new POST_Livestreams_Observers_Result(); - if (string.IsNullOrEmpty(expectedKey) || suppliedKey != expectedKey) + if (!RelayClient.ValidateIngressKey(relayKey)) { Response.StatusCode = (int)HttpStatusCode.Unauthorized; result.detail = "Invalid or missing relay key."; return result; } - Int64 lobby_id = -1; - string reason = String.Empty; + // The relay batches all lobbies whose livestream state changed into one request as an + // array of {lobby_id, observer_count, is_live} entries, always containing at least one + // update. is_live=false means the relay closed the stream (it owns stream liveness), + // so the lobby is deregistered. + List? updates = null; using (var reader = new StreamReader(HttpContext.Request.Body)) { string jsonData = await reader.ReadToEndAsync(); var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; - var data = JsonSerializer.Deserialize>(jsonData, options); - if (data != null) - { - if (data.TryGetValue("lobby_id", out JsonElement lobbyEl)) - { - // The relay sends lobby_id as a decimal string; accept either for safety. - if (lobbyEl.ValueKind == JsonValueKind.Number && - lobbyEl.TryGetInt64(out long parsedLobby)) - { - lobby_id = parsedLobby; - } - else if (lobbyEl.ValueKind == JsonValueKind.String && - lobbyEl.GetString() != null && - Int64.TryParse(lobbyEl.GetString(), out long parsedStringLobby)) - { - lobby_id = parsedStringLobby; - } - } - if (data.TryGetValue("reason", out JsonElement reasonEl) && - reasonEl.ValueKind == JsonValueKind.String) - { - reason = reasonEl.GetString() ?? String.Empty; - } - } + updates = JsonSerializer.Deserialize>(jsonData, options); } - if (lobby_id == -1) + if (updates == null || updates.Count == 0) { Response.StatusCode = (int)HttpStatusCode.BadRequest; - result.detail = "lobby_id required."; + result.detail = "livestream state updates must be an array."; return result; } - // The relay (the authority on stream liveness) says this stream is over. Record it - // so the lobby drops out of /livestreams and /observe rejects — even though GO's - // own lobby object may still be INGAME (the match can continue without a stream). - s_endedStreams[lobby_id] = 0; + // The relay (the authority on who is watching and stream liveness) reports the + // current livestream state. is_live=false deregisters the stream so the lobby + // drops out of /livestreams and /observe rejects — even though GO's own lobby + // object may still be INGAME (the match can continue without a stream). + foreach (POST_Livestreams_Observers_Entry update in updates) + { + if (!Int64.TryParse(update.lobby_id, out Int64 entryLobby)) + { + continue; + } + + Lobby? observedLobby = _lobbyManager.GetLobby(entryLobby); + if (observedLobby == null) + { + continue; + } - Console.WriteLine($"[Livestream] Relay reported lobby {lobby_id} ended (reason: {reason})."); + if (update.is_live) + { + if (observedLobby.IsStreaming) + { + observedLobby.SetStreaming(true, observerCount: Math.Max(0, update.observer_count)); + } + } + else + { + observedLobby.SetStreaming(false, observerCount: 0); + } + } result.received = true; return result; diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index 0fda44c..228085e 100644 --- a/GenOnlineService/LobbyManager.cs +++ b/GenOnlineService/LobbyManager.cs @@ -257,6 +257,14 @@ public int MaxPlayers public string Password { get; private set; } = String.Empty; public bool AllowObservers { get; private set; } = false; + + // Livestream state, owned by the relay session. IsStreaming is true while the relay has + // a live stream for this lobby; StreamDelaySeconds is the host-reported relay delay; and + // ObserverCount is how many spectators are currently watching. + public bool IsStreaming { get; private set; } = false; + public int? StreamDelaySeconds { get; private set; } = null; + public int ObserverCount { get; private set; } = 0; + public UInt32 ExeCRC { get; private set; } = 0; public UInt32 IniCRC { get; private set; } = 0; @@ -1042,6 +1050,19 @@ public void UpdateJoinability(ELobbyJoinability newJoinability) } } + public void SetStreaming(bool isStreaming, int? delaySeconds = null, int? observerCount = null) + { + IsStreaming = isStreaming; + if (delaySeconds.HasValue) + { + StreamDelaySeconds = delaySeconds; + } + if (observerCount.HasValue) + { + ObserverCount = observerCount.Value; + } + } + public void UpdateMaxCameraHeight(UInt16 maxCamHeight) { if (maxCamHeight >= 210 && maxCamHeight <= 1000) diff --git a/GenOnlineService/RelayClient.cs b/GenOnlineService/RelayClient.cs index 85fbaba..f2dc808 100644 --- a/GenOnlineService/RelayClient.cs +++ b/GenOnlineService/RelayClient.cs @@ -74,6 +74,22 @@ public static bool IsEnabled() !string.IsNullOrEmpty(sectionIngressKey); } + /// Whether a key supplied on an inbound relay call (X-Relay-Key) matches the + /// configured ingress key. The relay authenticates to GO with this credential + /// (Relay.ingress_api_key), distinct from the api_key GO sends to the relay. + public static bool ValidateIngressKey(string? suppliedKey) + { + if (string.IsNullOrEmpty(suppliedKey) || Program.g_Config == null) + { + return false; + } + + IConfigurationSection configSection = Program.g_Config.GetSection("Relay"); + string? expectedKey = configSection.GetValue("ingress_api_key"); + + return !string.IsNullOrEmpty(expectedKey) && suppliedKey == expectedKey; + } + private static void GetRelayConfig(out string baseUrl, out string apiKey) { baseUrl = String.Empty; @@ -116,46 +132,11 @@ private static Polly.Retry.AsyncRetryPolicy BuildRetryPolicy(string description) }); } - private static async Task SendWithRetryAsync(HttpMethod method, string path, string? payloadJson, string description) - { - GetRelayConfig(out string baseUrl, out string apiKey); - - string requestUrl = baseUrl + path; - - var retryPolicy = BuildRetryPolicy(description); - - HttpResponseMessage? response = null; - - await retryPolicy.ExecuteAsync(async () => - { - using (var request = new HttpRequestMessage(method, requestUrl)) - { - request.Headers.TryAddWithoutValidation("X-Relay-Key", apiKey); - request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - - if (payloadJson != null) - { - request.Content = new StringContent(payloadJson, Encoding.UTF8, "application/json"); - } - - response = await s_httpClient.SendAsync(request); - - // Explicitly verify response success inside execution block to ensure retry triggers on HTTP error statuses - response.EnsureSuccessStatusCode(); - } - }); - - if (response == null) - { - throw new Exception(String.Format("Relay {0} call returned no response", description)); - } - - return response; - } - - // Like SendWithRetryAsync but does not throw on non-success statuses, so callers can - // distinguish a relay "stream ended" (404) from a relay failure (5xx / network). - private static async Task SendRawAsync(HttpMethod method, string path, string? payloadJson, string description) + // Sends a relay request with the shared retry policy and auth header. When throwOnError is + // true, non-success statuses raise inside the retry block (so they are retried) and any + // failure surfaces as null. When false, non-success statuses are returned as-is — the + // caller must interpret them (e.g. a relay 404 "stream ended" is valid, not a failure). + private static async Task SendAsync(HttpMethod method, string path, string? payloadJson, string description, bool throwOnError) { GetRelayConfig(out string baseUrl, out string apiKey); @@ -179,9 +160,14 @@ await retryPolicy.ExecuteAsync(async () => request.Content = new StringContent(payloadJson, Encoding.UTF8, "application/json"); } - // No EnsureSuccessStatusCode: a 404 ("stream ended") is a valid, expected - // outcome that must not trigger retries or collapse into a generic failure. response = await s_httpClient.SendAsync(request); + + // Explicitly verify response success inside execution block so the retry + // policy also triggers on HTTP error statuses. + if (throwOnError) + { + response.EnsureSuccessStatusCode(); + } } }); } @@ -194,14 +180,19 @@ await retryPolicy.ExecuteAsync(async () => return response; } - public static async Task CreateLivestreamAsync(long lobbyId, long ownerUserId) + public static async Task CreateLivestreamAsync(long lobbyId, long ownerUserId, int? delaySeconds = null) { try { - string payloadJson = JsonSerializer.Serialize(new { lobby_id = lobbyId, owner_user_id = ownerUserId }); + string payloadJson = JsonSerializer.Serialize(new { lobby_id = lobbyId, owner_user_id = ownerUserId, delay_seconds = delaySeconds }); - using (var response = await SendWithRetryAsync(HttpMethod.Post, "/internal/livestreams", payloadJson, "CreateLivestream")) + using (var response = await SendAsync(HttpMethod.Post, "/internal/livestreams", payloadJson, "CreateLivestream", true)) { + if (response == null) + { + return null; + } + string responseBody = await response.Content.ReadAsStringAsync(); return JsonSerializer.Deserialize(responseBody); } @@ -219,8 +210,13 @@ await retryPolicy.ExecuteAsync(async () => { string payloadJson = JsonSerializer.Serialize(new { lobby_id = lobbyId, user_id = userId }); - using (var response = await SendWithRetryAsync(HttpMethod.Post, "/internal/stream_tokens", payloadJson, "CreateStreamToken")) + using (var response = await SendAsync(HttpMethod.Post, "/internal/stream_tokens", payloadJson, "CreateStreamToken", true)) { + if (response == null) + { + return null; + } + string responseBody = await response.Content.ReadAsStringAsync(); return JsonSerializer.Deserialize(responseBody); } @@ -238,7 +234,7 @@ public static async Task CreateWatchTicketAsync(long lob { string payloadJson = JsonSerializer.Serialize(new { lobby_id = lobbyId, user_id = userId }); - using (var response = await SendRawAsync(HttpMethod.Post, "/internal/watch_tickets", payloadJson, "CreateWatchTicket")) + using (var response = await SendAsync(HttpMethod.Post, "/internal/watch_tickets", payloadJson, "CreateWatchTicket", false)) { if (response == null) { From 5b51273a47871620f4321217009891010171e8ab Mon Sep 17 00:00:00 2001 From: Dennis van 't Hul Date: Thu, 6 Aug 2026 19:27:41 +0200 Subject: [PATCH 03/32] feat(livestreams): one token per client, relay-driven liveness, tighter relay calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follows the relay-side contract fixes in cc-live-relay. - Register minted a stream token for every human member and returned them all to the caller, but nothing ever delivered them to those members and they expire in 30s. Each client now registers itself and receives only its own token; member_urls is gone. - delay_seconds is forwarded for the host only (it is their spoiler window) and moved off SetStreaming onto SetStreamDelay, since it is known before the stream is live. - Registering no longer marks the lobby streaming. The relay reports is_live once it holds the host's replay header, and that transition is what lists the lobby and fires the network-room refresh — closing the window where a lobby was listed with nothing to watch. - Re-registration no longer resets the observer count; later registrants are additional sources joining a live stream. owner_user_id sent to the relay is the lobby owner rather than the caller, so a non-host opening the session records the same owner. - AllowObservers no longer gates GET /livestreams or /observe. That flag governs in-game observer slots, a different feature; a livestream is gated only by whether the host started one. Comments at both sites record why, so it does not get added back. - ValidateIngressKey uses CryptographicOperations.FixedTimeEquals — the endpoint is publicly reachable and an ordinary compare leaks a prefix. - A relay 404 is only read as "stream ended" when it carries the relay's stream_ended marker; a bare 404 is logged as a base_url/proxy-prefix problem instead of being shown to players as an ended stream. - Relay retry budget cut to 2 attempts at 400/800ms with a 5s timeout. These calls block a player waiting on a response, and the old policy could hang a request for ~40s per call. Co-Authored-By: Claude Opus 5 --- .../Livestreams/LivestreamsController.cs | 108 +++++++++--------- GenOnlineService/LobbyManager.cs | 18 ++- GenOnlineService/RelayClient.cs | 76 ++++++++++-- 3 files changed, 135 insertions(+), 67 deletions(-) diff --git a/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs b/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs index 4d4410e..cf919d5 100644 --- a/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs +++ b/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs @@ -58,7 +58,6 @@ public override Type GetReturnType() public bool success { get; set; } = false; public string url { get; set; } = String.Empty; - public Dictionary member_urls { get; set; } = new(); public string detail { get; set; } = String.Empty; } @@ -134,9 +133,13 @@ public APIResult Get() return result; } + // Note: AllowObservers is deliberately not consulted. That flag governs in-game + // observer slots — players joining the match itself — which is a different feature + // from a livestream. A livestream is gated by exactly one thing: whether the host + // started one, which is what IsStreaming records. foreach (Lobby lobby in _lobbyManager.GetAllLobbies()) { - if (lobby.State != ELobbyState.INGAME || !lobby.AllowObservers || !lobby.IsStreaming) + if (lobby.State != ELobbyState.INGAME || !lobby.IsStreaming) { continue; } @@ -187,25 +190,38 @@ public async Task Register() return result; } - // The host reports the relay stream delay in seconds when starting the stream. + // Every streaming client registers itself and receives its own single-use stream + // token, so this runs once per source rather than minting the whole lobby's tokens + // up front (a relay credential is short-lived and single-use — minting one for a + // member who has not asked for it just burns a token that expires unused). + bool isHost = lobby.Owner == user_id; + + // The stream delay is the host's spoiler window, so only the host may set it. A + // non-host member registering their own source sends no delay, and the relay keeps + // whatever the host already established for the session. int? delaySeconds = null; - using (var reader = new StreamReader(HttpContext.Request.Body)) + if (isHost) { - string jsonData = await reader.ReadToEndAsync(); - if (!String.IsNullOrEmpty(jsonData)) + using (var reader = new StreamReader(HttpContext.Request.Body)) { - var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; - var data = JsonSerializer.Deserialize>(jsonData, options); - if (data != null && data.TryGetValue("delay_seconds", out JsonElement delayEl) && - delayEl.ValueKind == JsonValueKind.Number && - delayEl.TryGetInt32(out int parsedDelay)) + string jsonData = await reader.ReadToEndAsync(); + if (!String.IsNullOrEmpty(jsonData)) { - delaySeconds = Math.Max(0, parsedDelay); + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var data = JsonSerializer.Deserialize>(jsonData, options); + if (data != null && data.TryGetValue("delay_seconds", out JsonElement delayEl) && + delayEl.ValueKind == JsonValueKind.Number && + delayEl.TryGetInt32(out int parsedDelay)) + { + delaySeconds = Math.Max(0, parsedDelay); + } } } } - RelayLivestreamResponse? livestream = await RelayClient.CreateLivestreamAsync(lobby.LobbyID, user_id, delaySeconds); + // owner_user_id is the lobby's owner, not the caller: any member may open the relay + // session by registering first, and the relay must record the same owner either way. + RelayLivestreamResponse? livestream = await RelayClient.CreateLivestreamAsync(lobby.LobbyID, lobby.Owner, delaySeconds); if (livestream == null || String.IsNullOrEmpty(livestream.base_url)) { Response.StatusCode = (int)HttpStatusCode.BadGateway; @@ -213,48 +229,23 @@ public async Task Register() return result; } - Dictionary memberUrls = new Dictionary(); - string? requesterUrl = null; - - foreach (LobbyMember member in lobby.Members) - { - if (!member.IsHuman()) - { - continue; - } - - RelayTokenResponse? tokenResponse = await RelayClient.CreateStreamTokenAsync(lobby.LobbyID, member.UserID); - if (tokenResponse == null || String.IsNullOrEmpty(tokenResponse.url)) - { - Console.WriteLine($"[ERROR] Relay stream token mint failed for lobby {lobby.LobbyID} user {member.UserID}"); - continue; - } - - memberUrls[member.UserID] = tokenResponse.url; - - if (member.UserID == user_id) - { - requesterUrl = tokenResponse.url; - } - } - - if (requesterUrl == null) + RelayTokenResponse? tokenResponse = await RelayClient.CreateStreamTokenAsync(lobby.LobbyID, user_id); + if (tokenResponse == null || String.IsNullOrEmpty(tokenResponse.url)) { + Console.WriteLine($"[ERROR] Relay stream token mint failed for lobby {lobby.LobbyID} user {user_id}"); Response.StatusCode = (int)HttpStatusCode.BadGateway; result.detail = "Relay failed to mint a stream token for you."; return result; } - // Refresh the lobby lists in the network room so the in-progress game shows up in the livestreams menu. - await WebSocketManager.SendNewOrDeletedLobbyToAllNetworkRoomMembers(lobby.NetworkRoomID); - - // A fresh relay session was just created — mark this lobby as actively streaming - // so it shows up in /livestreams and is accepted by /observe. - lobby.SetStreaming(true, delaySeconds, 0); + // Registering does NOT make the lobby live. The relay session exists now, but nothing + // has been streamed into it yet — an observer admitted at this point would connect + // and watch nothing. The relay reports is_live once it holds the host's replay + // header (see Observers below), and that is what puts the lobby in the menu. + lobby.SetStreamDelay(delaySeconds); result.success = true; - result.url = requesterUrl; - result.member_urls = memberUrls; + result.url = tokenResponse.url; return result; } @@ -274,8 +265,10 @@ public async Task Observe(Int64 lobby_id) return result; } + // As in Get: watching a livestream is not the same as taking an in-game observer + // slot, so AllowObservers has no say here. IsStreaming below is the gate. Lobby? lobby = _lobbyManager.GetLobby(lobby_id); - if (lobby == null || lobby.State != ELobbyState.INGAME || !lobby.AllowObservers) + if (lobby == null || lobby.State != ELobbyState.INGAME) { Response.StatusCode = (int)HttpStatusCode.NotFound; result.detail = "No watchable live game found for that lobby_id."; @@ -350,10 +343,12 @@ public async Task Observers([FromHeader(Name = "X-Relay-Key")] string return result; } - // The relay (the authority on who is watching and stream liveness) reports the - // current livestream state. is_live=false deregisters the stream so the lobby - // drops out of /livestreams and /observe rejects — even though GO's own lobby - // object may still be INGAME (the match can continue without a stream). + // The relay (the authority on who is watching and on stream liveness) reports the + // current livestream state. is_live=true means it holds the host's replay header and + // the stream is watchable, which is what registers the stream here; is_live=false + // deregisters it so the lobby drops out of /livestreams and /observe rejects — even + // though GO's own lobby object may still be INGAME (a match can continue with nobody + // streaming it). foreach (POST_Livestreams_Observers_Entry update in updates) { if (!Int64.TryParse(update.lobby_id, out Int64 entryLobby)) @@ -369,9 +364,14 @@ public async Task Observers([FromHeader(Name = "X-Relay-Key")] string if (update.is_live) { - if (observedLobby.IsStreaming) + bool wasStreaming = observedLobby.IsStreaming; + observedLobby.SetStreaming(true, observerCount: Math.Max(0, update.observer_count)); + + if (!wasStreaming) { - observedLobby.SetStreaming(true, observerCount: Math.Max(0, update.observer_count)); + // The stream just became watchable. Refresh the lobby lists in the + // network room so the in-progress game appears in the livestreams menu. + await WebSocketManager.SendNewOrDeletedLobbyToAllNetworkRoomMembers(observedLobby.NetworkRoomID); } } else diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index 228085e..10e4a54 100644 --- a/GenOnlineService/LobbyManager.cs +++ b/GenOnlineService/LobbyManager.cs @@ -1050,19 +1050,27 @@ public void UpdateJoinability(ELobbyJoinability newJoinability) } } - public void SetStreaming(bool isStreaming, int? delaySeconds = null, int? observerCount = null) + // Liveness and observer count both come from the relay, which is the only party that can + // see whether a stream has data and who is watching it. + public void SetStreaming(bool isStreaming, int? observerCount = null) { IsStreaming = isStreaming; - if (delaySeconds.HasValue) - { - StreamDelaySeconds = delaySeconds; - } if (observerCount.HasValue) { ObserverCount = observerCount.Value; } } + // The delay is set separately, at registration, because it is known before the stream is + // live and is reported by the host rather than observed by the relay. + public void SetStreamDelay(int? delaySeconds) + { + if (delaySeconds.HasValue) + { + StreamDelaySeconds = delaySeconds; + } + } + public void UpdateMaxCameraHeight(UInt16 maxCamHeight) { if (maxCamHeight >= 210 && maxCamHeight <= 1000) diff --git a/GenOnlineService/RelayClient.cs b/GenOnlineService/RelayClient.cs index f2dc808..546cce8 100644 --- a/GenOnlineService/RelayClient.cs +++ b/GenOnlineService/RelayClient.cs @@ -2,6 +2,7 @@ using System.Net.Http; using System.Net.Http.Headers; using System.Net.Sockets; +using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Threading.Tasks; @@ -37,9 +38,13 @@ public class RelayWatchTicketResult public static class RelayClient { + // Every relay call sits inside a request a player is waiting on (they have just pressed + // "stream" or "watch"), so the budget is tight on purpose: a relay that is not answering + // in a couple of seconds is not going to answer usefully, and the player is better served + // by a prompt failure than by a client that appears to hang. private static readonly HttpClient s_httpClient = new HttpClient { - Timeout = TimeSpan.FromSeconds(10) + Timeout = TimeSpan.FromSeconds(5) }; /// Whether the relay feature is enabled. Off by default — the relay config is @@ -87,7 +92,17 @@ public static bool ValidateIngressKey(string? suppliedKey) IConfigurationSection configSection = Program.g_Config.GetSection("Relay"); string? expectedKey = configSection.GetValue("ingress_api_key"); - return !string.IsNullOrEmpty(expectedKey) && suppliedKey == expectedKey; + if (string.IsNullOrEmpty(expectedKey)) + { + return false; + } + + // Fixed-time compare: this endpoint is reachable by anyone, and an ordinary string + // comparison leaks how many leading bytes of the key were right. The relay does the + // same on its side with hmac.compare_digest. + return CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(suppliedKey), + Encoding.UTF8.GetBytes(expectedKey)); } private static void GetRelayConfig(out string baseUrl, out string apiKey) @@ -121,14 +136,18 @@ private static void GetRelayConfig(out string baseUrl, out string apiKey) private static Polly.Retry.AsyncRetryPolicy BuildRetryPolicy(string description) { - // Configure Polly wait-and-retry policy with exponential backoff on HTTP/Socket errors + // Wait-and-retry with a deliberately short budget. These calls are made while a + // player waits on the response, so the whole policy has to fit inside a request + // they will sit through: two retries at 400ms and 800ms, which covers a dropped + // connection or a relay restart without turning a sick relay into a minute-long + // hang. Anything slower than this is a failure worth surfacing. return Policy .Handle() .Or() .Or() - .WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)), (exception, timeSpan, retryCount, context) => + .WaitAndRetryAsync(2, retryAttempt => TimeSpan.FromMilliseconds(200 * Math.Pow(2, retryAttempt)), (exception, timeSpan, retryCount, context) => { - Console.WriteLine($"[WARNING] Relay {description} call failed (attempt {retryCount}). Retrying in {timeSpan.TotalSeconds}s. Error: {exception.Message}"); + Console.WriteLine($"[WARNING] Relay {description} call failed (attempt {retryCount}). Retrying in {timeSpan.TotalMilliseconds}ms. Error: {exception.Message}"); }); } @@ -228,6 +247,36 @@ await retryPolicy.ExecuteAsync(async () => } } + // The relay answers "no such live session" with {"detail": {"code": "stream_ended", ...}}. + // Anything else 404ing on that path is a routing problem, not an ended stream. + private static bool IsStreamEndedBody(string responseBody) + { + if (String.IsNullOrEmpty(responseBody)) + { + return false; + } + + try + { + using (JsonDocument document = JsonDocument.Parse(responseBody)) + { + if (document.RootElement.ValueKind == JsonValueKind.Object && + document.RootElement.TryGetProperty("detail", out JsonElement detail) && + detail.ValueKind == JsonValueKind.Object && + detail.TryGetProperty("code", out JsonElement code) && + code.ValueKind == JsonValueKind.String) + { + return code.GetString() == "stream_ended"; + } + } + } + catch (JsonException) + { + } + + return false; + } + public static async Task CreateWatchTicketAsync(long lobbyId, long userId) { try @@ -241,11 +290,22 @@ public static async Task CreateWatchTicketAsync(long lob return new RelayWatchTicketResult { Status = RelayWatchTicketStatus.Failure }; } - // 404 means the relay session is gone — the stream ended. Everything else - // non-success is a relay failure. + // A 404 means the relay session is gone — the stream ended — but only when it + // carries the relay's own marker. A bare 404 came from something else on the + // path (wrong base_url, a reverse proxy that mishandled the prefix) and must + // not be reported to the player as "the stream ended", because the stream is + // very likely fine and the deployment is not. if ((int)response.StatusCode == 404) { - return new RelayWatchTicketResult { Status = RelayWatchTicketStatus.StreamEnded }; + string notFoundBody = await response.Content.ReadAsStringAsync(); + if (IsStreamEndedBody(notFoundBody)) + { + return new RelayWatchTicketResult { Status = RelayWatchTicketStatus.StreamEnded }; + } + + Console.WriteLine($"[ERROR] Relay CreateWatchTicket for lobby {lobbyId} got a 404 that did not come from the relay's livestream handler. " + + $"Check Relay.base_url and any reverse-proxy path prefix. Body: {notFoundBody}"); + return new RelayWatchTicketResult { Status = RelayWatchTicketStatus.Failure }; } if (!response.IsSuccessStatusCode) From e6b0236ce088a67a0441e1602555e3a655c325da Mon Sep 17 00:00:00 2001 From: Dennis van 't Hul Date: Fri, 7 Aug 2026 10:53:30 +0200 Subject: [PATCH 04/32] feat(test): random dev user per CheckLogin for custom-auth testing env --- .../CheckLogin/CheckLoginController.cs | 44 ++++++------------- 1 file changed, 13 insertions(+), 31 deletions(-) diff --git a/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs b/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs index 626a396..881e946 100644 --- a/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs +++ b/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs @@ -118,41 +118,23 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr return result; } #if DEBUG - EPendingLoginState state = EPendingLoginState.Waiting; + // Dev/test login: every CheckLogin attempt gets a fresh, unique random + // user so multiple test clients can hold distinct sessions on the same + // service instance at the same time. Skips the pending_login table. + EPendingLoginState state = EPendingLoginState.LoginSuccess; UInt32 user_id = 0; string strDisplayName = String.Empty; - if (gameCode == "ILOVECODE") - { - state = EPendingLoginState.LoginSuccess; - - UInt32 highestIDFound = 0; - // which account should we use? - var sessions = WebSocketManager.GetUserDataCache(); - foreach (var sessionDataByClient in sessions) - { - foreach (var sessionData in sessionDataByClient.Value) - { - UserSession sessIter = sessionData.Value; - if (sessIter.m_UserID > highestIDFound) - { - highestIDFound = (UInt32)sessIter.m_UserID; - } - } - } - user_id = highestIDFound + 1; - - bool bTestSPOP = false; - if (bTestSPOP) - { - user_id = 0; - } - strDisplayName = String.Format("DEV_ACCOUNT_{0}", Math.Abs(user_id) - 1); - - - // make user - await Database.Users.CreateUserIfNotExists_DevAccount(db, user_id, strDisplayName); + byte[] randBytes = new byte[4]; + using (RandomNumberGenerator rng = RandomNumberGenerator.Create()) + { + rng.GetBytes(randBytes); } + user_id = BitConverter.ToUInt32(randBytes, 0) & 0x7FFFFFFF; + strDisplayName = String.Format("DEV_ACCOUNT_{0}", user_id); + + // make user + await Database.Users.CreateUserIfNotExists_DevAccount(db, user_id, strDisplayName); bool bIsAdmin = await Database.Users.IsUserAdmin(db, user_id); #else From 566a397f260f264d24e0d287e9c9458607e9e8a8 Mon Sep 17 00:00:00 2001 From: Dennis van 't Hul Date: Fri, 7 Aug 2026 22:45:36 +0200 Subject: [PATCH 05/32] feat(live): host-controlled broadcast delay as a lobby property The live-stream delay is now chosen by the host in the pre-game lobby and stored on the lobby (LOBBY_STREAM_DELAY, owner-only). GO broadcasts it to members, whose UI shows it read-only, and the relay session is created with the host's delay even when the first registrant is a member ? so a member's stream stays behind the host's spoiler window when the host's own streaming is disabled. --- .../Livestreams/LivestreamsController.cs | 10 ++++++---- .../Controllers/Lobby/LobbyController.cs | 20 +++++++++++++++++-- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs b/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs index cf919d5..00b702d 100644 --- a/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs +++ b/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs @@ -196,10 +196,12 @@ public async Task Register() // member who has not asked for it just burns a token that expires unused). bool isHost = lobby.Owner == user_id; - // The stream delay is the host's spoiler window, so only the host may set it. A - // non-host member registering their own source sends no delay, and the relay keeps - // whatever the host already established for the session. - int? delaySeconds = null; + // The stream delay is the host's spoiler window, so only the host may set it in the + // payload. But the delay is a lobby property the host chose in the game-setup + // screen, so when the first registrant is a member (the host's own streaming is + // off), the session must still be created with the host's delay rather than the + // relay default — members' streams stay behind the host's spoiler window. + int? delaySeconds = lobby.StreamDelaySeconds; if (isHost) { using (var reader = new StreamReader(HttpContext.Request.Body)) diff --git a/GenOnlineService/Controllers/Lobby/LobbyController.cs b/GenOnlineService/Controllers/Lobby/LobbyController.cs index f1b3774..74d81b9 100644 --- a/GenOnlineService/Controllers/Lobby/LobbyController.cs +++ b/GenOnlineService/Controllers/Lobby/LobbyController.cs @@ -134,7 +134,8 @@ enum ELobbyUpdateField AI_START_POS = 16, MAX_CAMERA_HEIGHT = 17, JOINABILITY = 18, - HOST_ACTION_BULK_SLOT_UPDATE = 19 + HOST_ACTION_BULK_SLOT_UPDATE = 19, + LOBBY_STREAM_DELAY = 20 }; public class RouteHandler_PUT_Lobby_Result : APIResult @@ -381,7 +382,8 @@ enum ELobbyUpdatePermissions [ELobbyUpdateField.AI_START_POS] = ELobbyUpdatePermissions.LobbyOwner, [ELobbyUpdateField.MAX_CAMERA_HEIGHT] = ELobbyUpdatePermissions.LobbyOwner, [ELobbyUpdateField.JOINABILITY] = ELobbyUpdatePermissions.LobbyOwner, - [ELobbyUpdateField.HOST_ACTION_BULK_SLOT_UPDATE] = ELobbyUpdatePermissions.LobbyOwner + [ELobbyUpdateField.HOST_ACTION_BULK_SLOT_UPDATE] = ELobbyUpdatePermissions.LobbyOwner, + [ELobbyUpdateField.LOBBY_STREAM_DELAY] = ELobbyUpdatePermissions.LobbyOwner }; @@ -529,6 +531,20 @@ public async Task Post(Int64 lobbyID) await lobby.UpdateLimitSuperweapons(db, bLimitSuperweapons); } } + else if (field == ELobbyUpdateField.LOBBY_STREAM_DELAY) + { + // The host's live-stream broadcast delay, a lobby property: + // stored here, broadcast to members (they display it + // read-only), and reported to the relay at stream + // registration via StreamDelaySeconds. + if (data.ContainsKey("delay_seconds")) + { + int delaySeconds = data["delay_seconds"].GetInt32(); + delaySeconds = Math.Clamp(delaySeconds, 0, 600); + lobby.SetStreamDelay(delaySeconds); + lobby.DirtyRetransmit(); + } + } else if (field == ELobbyUpdateField.HOST_ACTION_FORCE_START) { // dummy action... just force everyone ready From 4ddb7575a388aaa6dad9333e4ef87bb7687ce347 Mon Sep 17 00:00:00 2001 From: Dennis van 't Hul Date: Sat, 8 Aug 2026 22:45:18 +0200 Subject: [PATCH 06/32] feat(livestreams): pre-game lobby observer subscriptions and expanded list --- GenOnlineService/Constants.cs | 14 ++++- .../Livestreams/LivestreamsController.cs | 43 ++++++++++++-- .../WebSocket/WebSocketController.cs | 57 +++++++++++++++++++ GenOnlineService/LobbyManager.cs | 50 ++++++++++++++++ 4 files changed, 159 insertions(+), 5 deletions(-) diff --git a/GenOnlineService/Constants.cs b/GenOnlineService/Constants.cs index 985ae6f..2bff61f 100644 --- a/GenOnlineService/Constants.cs +++ b/GenOnlineService/Constants.cs @@ -2470,7 +2470,12 @@ public enum EWebSocketMessageID SOCIAL_CANT_ADD_FRIEND_LIST_FULL = 38, PROBE_RESP = 39, AC_REGISTER_PLAYER = 40, - AC_DEREGISTER_PLAYER = 41 + AC_DEREGISTER_PLAYER = 41, + LOBBY_OBSERVER_SUBSCRIBE = 42, + LOBBY_OBSERVER_UNSUBSCRIBE = 43, + LOBBY_OBSERVER_LOBBY_CHANGED = 44, + LOBBY_OBSERVER_GAME_STARTING = 45, + LOBBY_OBSERVER_STREAM_LIVE = 46 }; public static class UserPresence @@ -2568,6 +2573,13 @@ public class WebSocketMessage_StartMatch : WebSocketMessage public string screenshot_url { get; set; } = String.Empty; } + // Inbound (subscribe/unsubscribe) and outbound (lobby-changed / game-starting / + // stream-live) share one shape: a lobby id and the msg_id distinguishing the event. + public class WebSocketMessage_LobbyObserverEvent : WebSocketMessage + { + public Int64 lobby_id { get; set; } = -1; + } + public abstract class WebSocketMessage { public int msg_id { get; set; } diff --git a/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs b/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs index 00b702d..673d339 100644 --- a/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs +++ b/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs @@ -24,6 +24,7 @@ using System.IO; using System.Linq; using System.Net; +using System.Text; using System.Text.Json; namespace GenOnlineService.Controllers @@ -47,6 +48,9 @@ public class GET_Livestreams_LivestreamEntry public int? delay_seconds { get; set; } = null; public int observer_count { get; set; } = 0; public int? age_seconds { get; set; } = null; + public int state { get; set; } = 1; + public bool passworded { get; set; } = false; + public int pending_observer_count { get; set; } = 0; } public class POST_Livestreams_Register_Result : APIResult @@ -137,9 +141,16 @@ public APIResult Get() // observer slots — players joining the match itself — which is a different feature // from a livestream. A livestream is gated by exactly one thing: whether the host // started one, which is what IsStreaming records. + // + // The list is the Watch Live screen's one source for everything: live streams + // (INGAME + streaming) first, then every pre-game lobby the client can enter as a + // read-only observer. A pre-game lobby is never "streaming" yet — the entry's + // state flag tells the client which action applies (CONNECT vs OBSERVE). foreach (Lobby lobby in _lobbyManager.GetAllLobbies()) { - if (lobby.State != ELobbyState.INGAME || !lobby.IsStreaming) + bool isPregame = lobby.State == ELobbyState.GAME_SETUP; + bool isLive = lobby.State == ELobbyState.INGAME && lobby.IsStreaming; + if (!isPregame && !isLive) { continue; } @@ -152,6 +163,9 @@ public APIResult Get() entry.delay_seconds = lobby.StreamDelaySeconds; entry.observer_count = lobby.ObserverCount; entry.age_seconds = Math.Max(0, (int)(DateTime.UtcNow - lobby.TimeCreated).TotalSeconds); + entry.state = isLive ? 1 : 0; + entry.passworded = lobby.IsPassworded; + entry.pending_observer_count = lobby.PendingObserverCount; result.livestreams.Add(entry); } @@ -367,12 +381,33 @@ public async Task Observers([FromHeader(Name = "X-Relay-Key")] string if (update.is_live) { bool wasStreaming = observedLobby.IsStreaming; - observedLobby.SetStreaming(true, observerCount: Math.Max(0, update.observer_count)); + + // On the first transition, seed the observer count with the pre-game + // watchers who were parked in the lobby view: they join within moments, so + // without the seed the count would dip to ~0 then climb. The relay's own + // periodic reporting takes over from here. + int seededObserverCount = Math.Max(0, update.observer_count) + observedLobby.PendingObserverCount; + observedLobby.SetStreaming(true, observerCount: seededObserverCount); if (!wasStreaming) { - // The stream just became watchable. Refresh the lobby lists in the - // network room so the in-progress game appears in the livestreams menu. + // The stream just became watchable. Tell the read-only observers so + // they can fetch a watch ticket and join immediately. + if (observedLobby.PendingObservers.Count > 0) + { + WebSocketMessage_LobbyObserverEvent observerEvent = new WebSocketMessage_LobbyObserverEvent(); + observerEvent.msg_id = (int)EWebSocketMessageID.LOBBY_OBSERVER_STREAM_LIVE; + observerEvent.lobby_id = observedLobby.LobbyID; + byte[] observerBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(observerEvent)); + + foreach (UserSession sess in observedLobby.PendingObservers.Keys) + { + sess.QueueWebsocketSend(observerBytes); + } + } + + // Refresh the lobby lists in the network room so the in-progress game + // appears in the livestreams menu. await WebSocketManager.SendNewOrDeletedLobbyToAllNetworkRoomMembers(observedLobby.NetworkRoomID); } } diff --git a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs index df3f859..24689de 100644 --- a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs +++ b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs @@ -236,6 +236,15 @@ public async Task Get([FromHeader(Name = "is-reconnect")] bool bIsReconnect) // close the session if (wsSess != null) { + // Sweep this session's read-only observer subscriptions before the session is + // torn down — a closed websocket is a client that stopped watching, and the + // pending-observer count must not keep counting it. + UserSession? closingSession = WebSocketManager.GetSessionFromUser(user_id, wsSess.m_SessionType); + if (closingSession != null) + { + _lobbyManager.RemovePendingObserver(closingSession); + } + await WebSocketManager.DeleteSession(user_id, wsSess.m_SessionType, wsSess, false); } @@ -718,6 +727,39 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession } } } + else if (msgID == EWebSocketMessageID.LOBBY_OBSERVER_SUBSCRIBE) + { + // A client entering the read-only pre-game lobby view registers as a + // pending observer: it gets lobby-changed / game-starting / stream-live + // pushes and counts towards PendingObserverCount. No membership, no + // password, no lobby-state gate — watching a lobby is a read-only act. + WebSocketMessage_LobbyObserverEvent? subscribeMsg = + JsonSerializer.Deserialize(payload, JsonOpts); + + if (subscribeMsg != null) + { + Lobby? observerLobby = _lobbyManager.GetLobby(subscribeMsg.lobby_id); + if (observerLobby != null && observerLobby.PendingObservers.TryAdd(sourceUserSession, 0)) + { + Console.WriteLine("[OBSERVER] User {0} subscribed to pre-game lobby {1}", sourceUserSession.m_UserID, observerLobby.LobbyID); + } + } + } + else if (msgID == EWebSocketMessageID.LOBBY_OBSERVER_UNSUBSCRIBE) + { + WebSocketMessage_LobbyObserverEvent? unsubscribeMsg = + JsonSerializer.Deserialize(payload, JsonOpts); + + if (unsubscribeMsg != null) + { + Lobby? observerLobby = _lobbyManager.GetLobby(unsubscribeMsg.lobby_id); + if (observerLobby != null) + { + observerLobby.PendingObservers.TryRemove(sourceUserSession, out _); + Console.WriteLine("[OBSERVER] User {0} unsubscribed from pre-game lobby {1}", sourceUserSession.m_UserID, observerLobby.LobbyID); + } + } + } else if (msgID == EWebSocketMessageID.START_GAME_COUNTDOWN_STARTED) { // must be in a lobby @@ -788,6 +830,21 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession } } } + + // The match is starting: tell the read-only observers parked in the lobby + // view so they can run their countdown and get ready to join. + if (lobbyInfo.PendingObservers.Count > 0) + { + WebSocketMessage_LobbyObserverEvent observerEvent = new WebSocketMessage_LobbyObserverEvent(); + observerEvent.msg_id = (int)EWebSocketMessageID.LOBBY_OBSERVER_GAME_STARTING; + observerEvent.lobby_id = lobbyInfo.LobbyID; + byte[] observerBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(observerEvent)); + + foreach (UserSession sess in lobbyInfo.PendingObservers.Keys) + { + sess.QueueWebsocketSend(observerBytes); + } + } } else if (msgID == EWebSocketMessageID.FULL_MESH_CONNECTIVITY_CHECK_HOST_REQUESTS_BEGIN) { diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index 10e4a54..2f670bd 100644 --- a/GenOnlineService/LobbyManager.cs +++ b/GenOnlineService/LobbyManager.cs @@ -265,6 +265,14 @@ public int MaxPlayers public int? StreamDelaySeconds { get; private set; } = null; public int ObserverCount { get; private set; } = 0; + // Pre-game observers: clients parked in the read-only lobby view, subscribed over the + // websocket. Distinct from ObserverCount, which is live-stream watchers reported by the + // relay — these are watchers waiting for the match to start. Keyed by UserSession so a + // closed websocket can be swept from every lobby at once. + [JsonIgnore] + public ConcurrentDictionary PendingObservers { get; } = new(); + public int PendingObserverCount => PendingObservers.Count; + public UInt32 ExeCRC { get; private set; } = 0; public UInt32 IniCRC { get; private set; } = 0; @@ -550,6 +558,21 @@ public async Task Tick() } } + // Ping pending observers too — they are not lobby members, so they get no + // LOBBY_CURRENT_LOBBY_UPDATE, and they refetch GET /Lobby/{id} on the ping. + if (PendingObservers.Count > 0) + { + WebSocketMessage_LobbyObserverEvent observerPing = new WebSocketMessage_LobbyObserverEvent(); + observerPing.msg_id = (int)EWebSocketMessageID.LOBBY_OBSERVER_LOBBY_CHANGED; + observerPing.lobby_id = LobbyID; + byte[] observerBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(observerPing)); + + foreach (UserSession sess in PendingObservers.Keys) + { + sess.QueueWebsocketSend(observerBytes); + } + } + // transmit to those in network room //WebSocketManager.SendNewOrDeletedLobbyToAllNetworkRoomMembers(NetworkRoomID); @@ -1552,6 +1575,16 @@ public async Task LeaveAnyLobby(Int64 userID) } } + // A closed websocket means the client is gone (or reconnecting elsewhere) — its + // read-only observer subscriptions are dead too. Called from the ws disconnect path. + public void RemovePendingObserver(UserSession session) + { + foreach (Lobby lobbyInst in m_dictLobbies.Values) + { + lobbyInst.PendingObservers.TryRemove(session, out _); + } + } + public async Task DeleteLobby(Lobby lobby) { try @@ -1573,6 +1606,23 @@ public async Task DeleteLobby(Lobby lobby) bool bRemoved = m_dictLobbies.Remove(lobby.LobbyID, out _); await WebSocketManager.SendNewOrDeletedLobbyToAllNetworkRoomMembers(lobby.NetworkRoomID); + // Pending observers get one last lobby-changed ping; their next GET /Lobby/{id} + // refetch 404s and they leave the read-only lobby view cleanly. + if (lobby.PendingObservers.Count > 0) + { + WebSocketMessage_LobbyObserverEvent observerEvent = new WebSocketMessage_LobbyObserverEvent(); + observerEvent.msg_id = (int)EWebSocketMessageID.LOBBY_OBSERVER_LOBBY_CHANGED; + observerEvent.lobby_id = lobby.LobbyID; + byte[] observerBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(observerEvent)); + + foreach (UserSession sess in lobby.PendingObservers.Keys) + { + sess.QueueWebsocketSend(observerBytes); + } + + lobby.PendingObservers.Clear(); + } + // only do this once if (bRemoved) { From 3ac9b9733c3e5edb2bb7f8cb57aeb26d4fba90e4 Mon Sep 17 00:00:00 2001 From: Dennis van 't Hul Date: Sun, 9 Aug 2026 19:45:32 +0200 Subject: [PATCH 07/32] fix(livestreams): refresh members' lobby on pending-observer changes The pending-observer count rides on the lobby JSON, so members only see a subscriber join or leave when the lobby is dirtied. DirtyRetransmit on the successful TryAdd/TryRemove (unsubscribe now only reports when it actually removed something). --- .../Controllers/WebSocket/WebSocketController.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs index 24689de..e55acaa 100644 --- a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs +++ b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs @@ -742,6 +742,9 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession if (observerLobby != null && observerLobby.PendingObservers.TryAdd(sourceUserSession, 0)) { Console.WriteLine("[OBSERVER] User {0} subscribed to pre-game lobby {1}", sourceUserSession.m_UserID, observerLobby.LobbyID); + // The pending-observer count is part of the lobby JSON, so members + // get the usual refetch ping when it changes. + observerLobby.DirtyRetransmit(); } } } @@ -753,10 +756,10 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession if (unsubscribeMsg != null) { Lobby? observerLobby = _lobbyManager.GetLobby(unsubscribeMsg.lobby_id); - if (observerLobby != null) + if (observerLobby != null && observerLobby.PendingObservers.TryRemove(sourceUserSession, out _)) { - observerLobby.PendingObservers.TryRemove(sourceUserSession, out _); Console.WriteLine("[OBSERVER] User {0} unsubscribed from pre-game lobby {1}", sourceUserSession.m_UserID, observerLobby.LobbyID); + observerLobby.DirtyRetransmit(); } } } From b142a9a72d2858a012c2efa14c7fa90546f02379 Mon Sep 17 00:00:00 2001 From: Dennis van 't Hul Date: Sun, 9 Aug 2026 19:45:32 +0200 Subject: [PATCH 08/32] feat(livestreams): password gate on watch tickets A livestream inherits its lobby's password. Observe() now requires the lobby password (401 when missing or wrong, mirroring PUT /Lobby/{lobbyID}) before minting a relay watch ticket, so a bad password never burns one. The read-only pre-game lobby view stays password-free; the gate is the stream admission itself (plans/live-watch-password.md). --- .../Livestreams/LivestreamsController.cs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs b/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs index 673d339..27f672a 100644 --- a/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs +++ b/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs @@ -299,6 +299,35 @@ public async Task Observe(Int64 lobby_id) return result; } + // A livestream inherits its lobby's password (see plans/live-watch-password.md). + // The read-only pre-game lobby view stays password-free; this is the formal + // admission gate, mirroring PUT /Lobby/{lobbyID} — missing and wrong both give + // 401, and the check runs before the ticket mint so a bad password never burns + // a relay ticket. + string? strProvidedPassword = null; + using (var reader = new StreamReader(HttpContext.Request.Body)) + { + string jsonData = await reader.ReadToEndAsync(); + if (!String.IsNullOrWhiteSpace(jsonData)) + { + try + { + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var data = JsonSerializer.Deserialize>(jsonData, options); + if (data != null && data.ContainsKey("password") && data["password"].ValueKind == JsonValueKind.String) + strProvidedPassword = data["password"].GetString(); + } + catch { /* unreadable body = no password supplied; the gate below decides */ } + } + } + + if (lobby.IsPassworded && strProvidedPassword != lobby.Password) + { + Response.StatusCode = (int)HttpStatusCode.Unauthorized; + result.detail = "This livestream is password protected."; + return result; + } + RelayWatchTicketResult ticket = await RelayClient.CreateWatchTicketAsync(lobby.LobbyID, user_id); if (ticket.Status == RelayWatchTicketStatus.StreamEnded) { From 81250f77aecdca785b1b9497f6b5673a43d1be74 Mon Sep 17 00:00:00 2001 From: Dennis van 't Hul Date: Sun, 9 Aug 2026 20:19:57 +0200 Subject: [PATCH 09/32] fix(lobby): report success on handled field updates Clients only trust the lobby-value cache (host's broadcast delay among them) when the response says success:true; the field-update path never set it, so a recognized update was indistinguishable from an unknown field silently ignored by an older GO. --- GenOnlineService/Controllers/Lobby/LobbyController.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/GenOnlineService/Controllers/Lobby/LobbyController.cs b/GenOnlineService/Controllers/Lobby/LobbyController.cs index 74d81b9..3102058 100644 --- a/GenOnlineService/Controllers/Lobby/LobbyController.cs +++ b/GenOnlineService/Controllers/Lobby/LobbyController.cs @@ -727,6 +727,12 @@ public async Task Post(Int64 lobbyID) lobby.DirtyRetransmit(); } } + + // A recognized field was processed (unknown fields throw on the + // permission-table lookup above and fall into the catch, which + // keeps success=false). Clients use this flag to distinguish + // "stored and broadcast" from "silently ignored". + result.success = true; } } From 090207a028513badc617f9725d8efebb24704ba3 Mon Sep 17 00:00:00 2001 From: Dennis van 't Hul Date: Sun, 9 Aug 2026 20:38:04 +0200 Subject: [PATCH 10/32] feat(livestreams): sync observers' pre-game countdown to the host's The host's match-start countdown (START_GAME_COUNTDOWN_STARTED) only closed open slots; observers parked in the pre-game lobby were told the match was starting at START_GAME, i.e. after the countdown and the mesh check had already run, so their own 5s countdown always started late. GO now forwards LOBBY_OBSERVER_GAME_STARTING to pending observers the moment the host's countdown starts; the START_GAME forward remains as a fallback for observers that subscribed too late to catch the first event. --- .../WebSocket/WebSocketController.cs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs index e55acaa..fb650fc 100644 --- a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs +++ b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs @@ -785,6 +785,24 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession // lock slots lobbyInfo.CloseOpenSlots(); + + // The host's match-start countdown is running: tell the read-only + // observers parked in the lobby view NOW, so their countdown runs in sync + // with the lobby's instead of starting only when the match is already + // transitioning (the START_GAME forward below stays as a fallback for + // observers that subscribed too late to catch this one). + if (lobbyInfo.PendingObservers.Count > 0) + { + WebSocketMessage_LobbyObserverEvent observerEvent = new WebSocketMessage_LobbyObserverEvent(); + observerEvent.msg_id = (int)EWebSocketMessageID.LOBBY_OBSERVER_GAME_STARTING; + observerEvent.lobby_id = lobbyInfo.LobbyID; + byte[] observerBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(observerEvent)); + + foreach (UserSession sess in lobbyInfo.PendingObservers.Keys) + { + sess.QueueWebsocketSend(observerBytes); + } + } } else if (msgID == EWebSocketMessageID.START_GAME) { From f3401cad6ef9dc292d6f8c684cdbc2b893599609 Mon Sep 17 00:00:00 2001 From: Dennis van 't Hul Date: Sun, 9 Aug 2026 22:06:50 +0200 Subject: [PATCH 11/32] feat(livestreams): carry the match-start countdown as lobby state The host's countdown is now a lobby property (CountdownStarted in the lobby JSON) instead of a bespoke observer message pair: set when the host's countdown starts, cleared by any lobby field update or member leave (the things that cancel it) and when the match starts. Members and read-only observers mirror it through the ordinary lobby-changed refetch, so a cancelled countdown stands the observers down without new message IDs. --- .../Controllers/Lobby/LobbyController.cs | 5 +++++ .../WebSocket/WebSocketController.cs | 10 ++++++++++ GenOnlineService/LobbyManager.cs | 20 +++++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/GenOnlineService/Controllers/Lobby/LobbyController.cs b/GenOnlineService/Controllers/Lobby/LobbyController.cs index 3102058..ecddf36 100644 --- a/GenOnlineService/Controllers/Lobby/LobbyController.cs +++ b/GenOnlineService/Controllers/Lobby/LobbyController.cs @@ -733,6 +733,11 @@ public async Task Post(Int64 lobbyID) // keeps success=false). Clients use this flag to distinguish // "stored and broadcast" from "silently ignored". result.success = true; + + // Any lobby field update cancels the host's match-start + // countdown client-side (that is why the host changed the + // field), so the broadcast countdown state follows. + lobby.SetCountdownStarted(false); } } diff --git a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs index fb650fc..a8f4815 100644 --- a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs +++ b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs @@ -786,6 +786,11 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession // lock slots lobbyInfo.CloseOpenSlots(); + // The countdown is now a lobby property: observers mirror it through the + // ordinary lobby-changed refetch (LOBBY_CURRENT_LOBBY_UPDATE-style ping), + // and the eager GAME_STARTING forward below is just the instant cue. + lobbyInfo.SetCountdownStarted(true); + // The host's match-start countdown is running: tell the read-only // observers parked in the lobby view NOW, so their countdown runs in sync // with the lobby's instead of starting only when the match is already @@ -827,6 +832,11 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession // start match + create placeholder match await lobbyInfo.UpdateState(ELobbyState.INGAME); + // The countdown is over: the match is starting. Observers still in their + // countdown/waiting phase keep it (their refetch now sees INGAME), so this + // clear cannot be mistaken for a cancel. + lobbyInfo.SetCountdownStarted(false); + // simple websocket msg, has no data, so dont even read anything diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index 2f670bd..9acc20d 100644 --- a/GenOnlineService/LobbyManager.cs +++ b/GenOnlineService/LobbyManager.cs @@ -265,6 +265,21 @@ public int MaxPlayers public int? StreamDelaySeconds { get; private set; } = null; public int ObserverCount { get; private set; } = 0; + // True while the host's match-start countdown is running. Broadcast as part of the + // lobby JSON so members and read-only observers can mirror it through the ordinary + // lobby-changed refetch — no separate countdown messages needed. Cleared by any lobby + // field update or member leave (the things that cancel the host's countdown) and when + // the match actually starts. + public bool CountdownStarted { get; private set; } = false; + + public void SetCountdownStarted(bool started) + { + if (CountdownStarted == started) + return; + CountdownStarted = started; + DirtyRetransmit(); + } + // Pre-game observers: clients parked in the read-only lobby view, subscribed over the // websocket. Distinct from ObserverCount, which is live-stream watchers reported by the // relay — these are watchers waiting for the match to start. Keyed by UserSession so a @@ -830,6 +845,11 @@ public async Task RemoveMember(LobbyMember member) await OnAfterPlayerLeft(UserID); + // Any departure cancels the host's match-start countdown client-side, so the + // broadcast countdown state must follow or observers would keep waiting for a + // match that is no longer starting. + CountdownStarted = false; + DirtyRetransmit(); } From e24163a779897d8bfd9ffc86d8a1b7f2aa592715 Mon Sep 17 00:00:00 2001 From: Dennis van 't Hul Date: Tue, 11 Aug 2026 20:06:49 +0200 Subject: [PATCH 12/32] feat(users): user_priority livestream privilege + Discord management user_priority (0 none / 1 player / 2 viewer) gates livestream admission: - carried as a signed JWT claim, minted at login; Observe re-reads it live so mid-session grants apply without re-login - Viewer (and admins) skip the password + broadcast-delay gates; Player latches the lobby as priority and sorts it to the top of the Watch Live browser - user_priority column in the schema dump Discord management surface: - Discord auth scheme (Authorization: Discord ) mirroring the Basic-handler pattern, no game-client JWT needed - UsersController endpoints for the World Series bot: SetPriorityBatch (one call per event window open/close), SetPriority (single user), LookupUser (by id / display name / discord id / partial search, all EF-parameterised) - GO Discord bot commands: !setpriority/!user_setpriority, !getuserid, !searchuserid (admin channel, discord_admins gated) - fix: DiscordBot init ran only in Release builds (constructor #if !DEBUG), so Debug/development instances never connected --- GenOnlineService/Constants.cs | 19 +- .../CheckLogin/CheckLoginController.cs | 6 +- .../Livestreams/LivestreamsController.cs | 81 ++++- .../Controllers/Lobbies/LobbiesController.cs | 17 +- .../Controllers/Lobby/LobbyController.cs | 13 + .../LoginWithTokenController.cs | 5 +- .../Controllers/User/UserController.cs | 322 +++++++++++++++++- .../WebSocket/WebSocketController.cs | 10 +- GenOnlineService/Database/Database.User.cs | 123 +++++++ .../Database_Structure/structure.sql | 1 + GenOnlineService/Discord.cs | 167 ++++++++- GenOnlineService/LobbyManager.cs | 78 ++++- GenOnlineService/MatchmakingManager.cs | 2 +- GenOnlineService/Program.cs | 104 +++++- GenOnlineService/appsettings.json | 3 + 15 files changed, 931 insertions(+), 20 deletions(-) diff --git a/GenOnlineService/Constants.cs b/GenOnlineService/Constants.cs index 2bff61f..55e1a6d 100644 --- a/GenOnlineService/Constants.cs +++ b/GenOnlineService/Constants.cs @@ -1348,6 +1348,17 @@ public enum EAccountType DevAccount = 3 } + // Per-user livestream privilege (users.user_priority): + // None = nothing + // Player = the user's matches are highlighted in the Watch Live browser + // Viewer = the user skips the livestream password + broadcast-delay gates + public enum EUserPriority + { + None = 0, + Player = 1, + Viewer = 2 + } + public class PlayerStats { const int numGeneralsEntries = 15; @@ -2475,7 +2486,8 @@ public enum EWebSocketMessageID LOBBY_OBSERVER_UNSUBSCRIBE = 43, LOBBY_OBSERVER_LOBBY_CHANGED = 44, LOBBY_OBSERVER_GAME_STARTING = 45, - LOBBY_OBSERVER_STREAM_LIVE = 46 + LOBBY_OBSERVER_STREAM_LIVE = 46, + LOBBY_OBSERVER_GAME_STARTED = 47 }; public static class UserPresence @@ -2574,10 +2586,13 @@ public class WebSocketMessage_StartMatch : WebSocketMessage } // Inbound (subscribe/unsubscribe) and outbound (lobby-changed / game-starting / - // stream-live) share one shape: a lobby id and the msg_id distinguishing the event. + // stream-live / game-started) share one shape: a lobby id and the msg_id distinguishing + // the event. GAME_STARTED also carries the host's broadcast delay so waiting observers + // can time their watch-key request. public class WebSocketMessage_LobbyObserverEvent : WebSocketMessage { public Int64 lobby_id { get; set; } = -1; + public int? delay_seconds { get; set; } = null; } public abstract class WebSocketMessage diff --git a/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs b/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs index 881e946..211d618 100644 --- a/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs +++ b/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs @@ -137,6 +137,7 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr await Database.Users.CreateUserIfNotExists_DevAccount(db, user_id, strDisplayName); bool bIsAdmin = await Database.Users.IsUserAdmin(db, user_id); + EUserPriority userPriority = await Database.Users.GetUserPriority(db, user_id); #else EPendingLoginState? loginState = await Database.PendingLogins.GetPendingLoginState(db, gameCode.ToUpper()); @@ -148,6 +149,7 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr string strDisplayName = await Database.Users.GetDisplayName(db, user_id); bool bIsAdmin = await Database.Users.IsUserAdmin(db, user_id); + EUserPriority userPriority = await Database.Users.GetUserPriority(db, user_id); #endif if (state == EPendingLoginState.Waiting) @@ -188,8 +190,8 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr string exe_crc = data.ContainsKey("exe_crc") ? data["exe_crc"].ToString() : "NONE"; Helpers.RegisterInitialPlayerExeCRC(user_id, exe_crc); - var sessiontoken = Program.g_tokenGenerator.GenerateToken(strDisplayName, user_id, ipAddr, Program.JwtTokenGenerator.ETokenType.Session, knownClientID, sessionType, bIsAdmin); - var refreshtoken = Program.g_tokenGenerator.GenerateToken(strDisplayName, user_id, ipAddr, Program.JwtTokenGenerator.ETokenType.Refresh, knownClientID, sessionType, false); + var sessiontoken = Program.g_tokenGenerator.GenerateToken(strDisplayName, user_id, ipAddr, Program.JwtTokenGenerator.ETokenType.Session, knownClientID, sessionType, bIsAdmin, userPriority); + var refreshtoken = Program.g_tokenGenerator.GenerateToken(strDisplayName, user_id, ipAddr, Program.JwtTokenGenerator.ETokenType.Refresh, knownClientID, sessionType, false, EUserPriority.None); result.result = EPendingLoginState.LoginSuccess; result.session_token = sessiontoken; diff --git a/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs b/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs index 27f672a..c3acecb 100644 --- a/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs +++ b/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs @@ -16,9 +16,11 @@ ** along with this program. If not, see . */ +using Database; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.EntityFrameworkCore; using System; using System.Collections.Generic; using System.IO; @@ -51,6 +53,14 @@ public class GET_Livestreams_LivestreamEntry public int state { get; set; } = 1; public bool passworded { get; set; } = false; public int pending_observer_count { get; set; } = 0; + + // Highest EUserPriority among the lobby's human members (0/1/2). The client + // highlights rows with value 1 (a priority Player is in the match). + public int user_priority { get; set; } = 0; + + // Priority-player match (lobby latched when a users.user_priority = Player creates or + // joins): sorts the row to the top of the Watch Live browser. + public bool priority { get; set; } = false; } public class POST_Livestreams_Register_Result : APIResult @@ -74,6 +84,13 @@ public override Type GetReturnType() public string url { get; set; } = String.Empty; public string detail { get; set; } = String.Empty; + + // 423 (delay gate): seconds left before this viewer's ticket may be minted. + public int? delay_remaining_seconds { get; set; } = null; + + // 200: GO owns the broadcast delay (the ticket was only minted after it elapsed), so + // the client must not hold playback itself. + public bool server_held { get; set; } = false; } public class POST_Livestreams_Observers_Result : APIResult @@ -118,14 +135,16 @@ public override void OnActionExecuting(ActionExecutingContext context) public class LivestreamsController : ControllerBase { private readonly LobbyManager _lobbyManager; + private readonly IDbContextFactory _dbFactory; - public LivestreamsController(LobbyManager lobbyManager) + public LivestreamsController(LobbyManager lobbyManager, IDbContextFactory dbFactory) { _lobbyManager = lobbyManager; + _dbFactory = dbFactory; } [HttpGet(Name = "GetLivestreams")] - public APIResult Get() + public async Task Get() { GET_Livestreams_Result result = new GET_Livestreams_Result(); @@ -145,11 +164,19 @@ public APIResult Get() // The list is the Watch Live screen's one source for everything: live streams // (INGAME + streaming) first, then every pre-game lobby the client can enter as a // read-only observer. A pre-game lobby is never "streaming" yet — the entry's - // state flag tells the client which action applies (CONNECT vs OBSERVE). + // state flag tells the client which action applies (OBSERVE either way). foreach (Lobby lobby in _lobbyManager.GetAllLobbies()) { bool isPregame = lobby.State == ELobbyState.GAME_SETUP; bool isLive = lobby.State == ELobbyState.INGAME && lobby.IsStreaming; + // A pre-game lobby is only watchable when the host allowed streamers at + // creation: without that, no stream can ever come, and parking observers on + // it would only lead to an endless wait. Live rows are already streaming, so + // they are always listed. + if (isPregame && !lobby.AllowStreamers) + { + continue; + } if (!isPregame && !isLive) { continue; @@ -166,10 +193,19 @@ public APIResult Get() entry.state = isLive ? 1 : 0; entry.passworded = lobby.IsPassworded; entry.pending_observer_count = lobby.PendingObserverCount; + entry.priority = lobby.IsPriority; result.livestreams.Add(entry); } + // Watch Live order: priority-player matches first, then live before pre-game — + // priority live → priority pre-game → normal live → normal pre-game. Stable, so + // equal rows keep their insertion order. + result.livestreams = result.livestreams + .OrderByDescending(e => e.priority) + .ThenByDescending(e => e.state) + .ToList(); + return result; } @@ -299,6 +335,23 @@ public async Task Observe(Int64 lobby_id) return result; } + // Privileged watchers (admin, or user_priority = Viewer) skip the password and the + // broadcast-delay gates entirely: their ticket mints instantly. The claim is the + // fast path, but it is minted at login, so a privilege applied mid-session (the + // World Series bot's timed grants, Discord !setpriority) is re-read live from the + // DB — a grant must not wait for a re-login. The DB value can only add privilege, + // never take it away from the signed claim. + bool isPriority = TokenHelper.IsAdmin(this) || TokenHelper.GetUserPriority(this) == EUserPriority.Viewer; + if (!isPriority) + { + await using var db = await _dbFactory.CreateDbContextAsync(); + + if (await Database.Users.GetUserPriority(db, user_id) == EUserPriority.Viewer) + { + isPriority = true; + } + } + // A livestream inherits its lobby's password (see plans/live-watch-password.md). // The read-only pre-game lobby view stays password-free; this is the formal // admission gate, mirroring PUT /Lobby/{lobbyID} — missing and wrong both give @@ -321,13 +374,32 @@ public async Task Observe(Int64 lobby_id) } } - if (lobby.IsPassworded && strProvidedPassword != lobby.Password) + if (!isPriority && lobby.IsPassworded && strProvidedPassword != lobby.Password) { Response.StatusCode = (int)HttpStatusCode.Unauthorized; result.detail = "This livestream is password protected."; return result; } + // Broadcast-delay admission gate (plans/live-observer-server-delay.md): a normal + // viewer is held until the match has been running for the host's delay. The clock + // is the match-start transition (TimeMatchStarted), known to GO itself — not the + // relay's liveness report. A match older than the delay — or a zero-delay lobby — + // mints instantly. 423 so the held viewer's retry keeps working without burning a + // relay ticket (nothing was minted). + if (!isPriority && lobby.StreamDelaySeconds > 0 && lobby.TimeMatchStarted != null) + { + int remainingSeconds = lobby.StreamDelaySeconds.Value - + (int)(DateTime.UtcNow - lobby.TimeMatchStarted.Value).TotalSeconds; + if (remainingSeconds > 0) + { + Response.StatusCode = (int)HttpStatusCode.Locked; + result.detail = "This stream starts after its broadcast delay."; + result.delay_remaining_seconds = remainingSeconds; + return result; + } + } + RelayWatchTicketResult ticket = await RelayClient.CreateWatchTicketAsync(lobby.LobbyID, user_id); if (ticket.Status == RelayWatchTicketStatus.StreamEnded) { @@ -350,6 +422,7 @@ public async Task Observe(Int64 lobby_id) } result.url = ticket.Token.url; + result.server_held = true; return result; } diff --git a/GenOnlineService/Controllers/Lobbies/LobbiesController.cs b/GenOnlineService/Controllers/Lobbies/LobbiesController.cs index 60b165b..f4f0fee 100644 --- a/GenOnlineService/Controllers/Lobbies/LobbiesController.cs +++ b/GenOnlineService/Controllers/Lobbies/LobbiesController.cs @@ -330,6 +330,10 @@ public async Task Put() bool bPassworded = data["passworded"].GetBoolean(); string? strPassword = data["password"].GetString(); bool bAllowObservers = data["allow_observers"].GetBoolean(); + // Optional: an older client that does not send it opts out of being + // watchable, which is also the server default. + bool bAllowStreamers = data.TryGetValue("allow_streamers", out var allowStreamersEl) + ? allowStreamersEl.GetBoolean() : false; UInt16 maxCamHeight = Convert.ToUInt16(data["max_cam_height"].GetDouble()); // client sends this as a float... UInt32 exe_crc = data["exe_crc"].GetUInt32(); UInt32 ini_crc = data["ini_crc"].GetUInt32(); @@ -381,13 +385,24 @@ public async Task Put() string strDisplayName = await Database.Users.GetDisplayName(db, user_id); Int64 newLobbyID = await _lobbyManager.CreateLobby(db, playerSession, strDisplayName, strName, strMapName, strMapPath, bMapOfficial, maxPlayers, strIPAddr, - hostPreferredPort, bVanillaTeamsOnly, bTrackStats, starting_cash, bPassworded, strPassword, playerSession.networkRoomID, bAllowObservers, maxCamHeight, exe_crc, ini_crc, ELobbyType.CustomGame, anticheatID); + hostPreferredPort, bVanillaTeamsOnly, bTrackStats, starting_cash, bPassworded, strPassword, playerSession.networkRoomID, bAllowObservers, bAllowStreamers, maxCamHeight, exe_crc, ini_crc, ELobbyType.CustomGame, anticheatID); if (newLobbyID >= 0) { result.result = 1; result.lobby_id = newLobbyID; + // The host's livestream privilege rides on the member (from + // the JWT); a priority Player hosting marks the lobby for the + // Watch Live browser (sorted to the top). + EUserPriority userPriority = TokenHelper.GetUserPriority(this); + Lobby? newLobby = _lobbyManager.GetLobby(newLobbyID); + newLobby?.GetMemberFromUserID(user_id)?.SetPriority(userPriority); + if (userPriority == EUserPriority.Player) + { + newLobby?.SetPriority(true); + } + // mark lobby list as dirty await WebSocketManager.SendNewOrDeletedLobbyToAllNetworkRoomMembers(playerSession.networkRoomID); diff --git a/GenOnlineService/Controllers/Lobby/LobbyController.cs b/GenOnlineService/Controllers/Lobby/LobbyController.cs index ecddf36..63b5615 100644 --- a/GenOnlineService/Controllers/Lobby/LobbyController.cs +++ b/GenOnlineService/Controllers/Lobby/LobbyController.cs @@ -840,6 +840,19 @@ public async Task Put(Int64 lobbyID) string strDisplayName = await Database.Users.GetDisplayName(db, user_id); bool bJoinedSuccessfully = await _lobbyManager.JoinLobby(db, lobby, playerSession, strDisplayName, userPreferredPort, bHasMap); + // The joiner's livestream privilege rides on the member (from + // the JWT); a priority Player joining marks the lobby for + // the Watch Live browser (sorted to the top). + if (bJoinedSuccessfully) + { + EUserPriority userPriority = TokenHelper.GetUserPriority(this); + lobby.GetMemberFromUserID(user_id)?.SetPriority(userPriority); + if (userPriority == EUserPriority.Player) + { + lobby.SetPriority(true); + } + } + result.success = bJoinedSuccessfully; if (!bJoinedSuccessfully) // this basically means full, didnt find a slot in correct state diff --git a/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs b/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs index 2796259..7bc95b0 100644 --- a/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs +++ b/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs @@ -142,13 +142,14 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr await SessionHelpers.SetUsedLoggedIn(user_id, clientID, sessionType); bool bIsAdmin = await Database.Users.IsUserAdmin(db, user_id); + EUserPriority userPriority = await Database.Users.GetUserPriority(db, user_id); result.result = EPendingLoginState.LoginSuccess; // extend token // TODO_TODAY_JWT: just get clientID from token - var sessiontoken = Program.g_tokenGenerator.GenerateToken(strDisplayName, user_id, ipAddr, Program.JwtTokenGenerator.ETokenType.Session, clientID, sessionType, bIsAdmin); - var refreshtoken = Program.g_tokenGenerator.GenerateToken(strDisplayName, user_id, ipAddr, Program.JwtTokenGenerator.ETokenType.Refresh, clientID, sessionType, false); + var sessiontoken = Program.g_tokenGenerator.GenerateToken(strDisplayName, user_id, ipAddr, Program.JwtTokenGenerator.ETokenType.Session, clientID, sessionType, bIsAdmin, userPriority); + var refreshtoken = Program.g_tokenGenerator.GenerateToken(strDisplayName, user_id, ipAddr, Program.JwtTokenGenerator.ETokenType.Refresh, clientID, sessionType, false, EUserPriority.None); result.session_token = sessiontoken; result.refresh_token = refreshtoken; diff --git a/GenOnlineService/Controllers/User/UserController.cs b/GenOnlineService/Controllers/User/UserController.cs index 3580fc0..a36b34a 100644 --- a/GenOnlineService/Controllers/User/UserController.cs +++ b/GenOnlineService/Controllers/User/UserController.cs @@ -22,6 +22,8 @@ using Microsoft.EntityFrameworkCore; using System; using System.Collections.Concurrent; +using System.IO; +using System.Net; using System.Net.WebSockets; using System.Security.Claims; using System.Text; @@ -39,8 +41,60 @@ public override Type GetReturnType() public bool success { get; set; } = false; } + public class POST_User_SetPriority_Result : APIResult + { + public override Type GetReturnType() + { + return typeof(POST_User_SetPriority_Result); + } + + public bool success { get; set; } = false; + public string detail { get; set; } = String.Empty; + public Int64 user_id { get; set; } = -1; + public string display_name { get; set; } = String.Empty; + public int previous_priority { get; set; } = 0; + } + + public class POST_User_LookupUser_Result : APIResult + { + public override Type GetReturnType() + { + return typeof(POST_User_LookupUser_Result); + } + + public bool success { get; set; } = false; + public string detail { get; set; } = String.Empty; + public List users { get; set; } = new List(); + } + + public class POST_User_SetPriorityBatch_Result : APIResult + { + public override Type GetReturnType() + { + return typeof(POST_User_SetPriorityBatch_Result); + } + + public bool success { get; set; } = false; + public string detail { get; set; } = String.Empty; + public int updated { get; set; } = 0; + public List errors { get; set; } = new List(); + } + + public class PriorityBatchError + { + public Int64 user_id { get; set; } = -1; + public string detail { get; set; } = String.Empty; + } + + public class UserLookupEntry + { + public Int64 user_id { get; set; } = -1; + public string display_name { get; set; } = String.Empty; + public int priority { get; set; } = 0; + public EAccountType account_type { get; set; } = EAccountType.Unknown; + } + [ApiController] - [Authorize(Roles = "GameClient,ChatClient,GameLauncher")] [Route("env/{environment}/contract/{contract_version}/[controller]")] public class UsersController : ControllerBase { @@ -111,6 +165,272 @@ string TimeSpanToHumanReadableString(TimeSpan timeSpan) } + return result; + } + + // ---- World Series bot API (the bot owns the event timetable in its own store and + // calls these to apply/restore user_priority at the right times). Authenticated with + // the "Discord" scheme: "Authorization: Discord " — the bot is not a + // player, so no game-client JWT is involved. + + // Body: { "user_id": 12345, "priority": 2 } (priority 0..2 per EUserPriority) + // previous_priority lets the bot restore the user's prior value after the event + // window (the bot stores it and calls SetPriority again with it). + [Authorize(AuthenticationSchemes = "Discord")] + [HttpPost("SetPriority")] + public async Task SetPriority() + { + POST_User_SetPriority_Result result = new POST_User_SetPriority_Result(); + + using (var reader = new StreamReader(HttpContext.Request.Body)) + { + string jsonData = await reader.ReadToEndAsync(); + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + Dictionary? data = null; + try + { + data = JsonSerializer.Deserialize>(jsonData, options); + } + catch + { + data = null; + } + + if (data == null || + !data.TryGetValue("user_id", out JsonElement userIdEl) || + userIdEl.ValueKind != JsonValueKind.Number || + !userIdEl.TryGetInt64(out Int64 userId) || + !data.TryGetValue("priority", out JsonElement priorityEl) || + !priorityEl.TryGetInt32(out int priority)) + { + Response.StatusCode = (int)HttpStatusCode.BadRequest; + result.detail = "Body must be { \"user_id\": , \"priority\": <0|1|2> }."; + return result; + } + + await using var db = await _dbFactory.CreateDbContextAsync(); + + User? user = await Database.Users.GetUserById(db, userId); + if (user == null) + { + Response.StatusCode = (int)HttpStatusCode.NotFound; + result.detail = $"User {userId} does not exist."; + return result; + } + + if (priority < (int)EUserPriority.None || priority > (int)EUserPriority.Viewer) + { + Response.StatusCode = (int)HttpStatusCode.BadRequest; + result.detail = $"Priority must be {(int)EUserPriority.None}, {(int)EUserPriority.Player} or {(int)EUserPriority.Viewer}."; + return result; + } + + int previous = (int)await Database.Users.GetUserPriority(db, userId); + if (await Database.Users.SetUserPriority(db, userId, priority)) + { + result.success = true; + result.user_id = userId; + result.display_name = user.DisplayName ?? String.Empty; + result.previous_priority = previous; + } + else + { + Response.StatusCode = (int)HttpStatusCode.InternalServerError; + result.detail = "Failed to update user_priority."; + } + } + + return result; + } + + // Body: [ { "user_id": 12345, "priority": 2 }, ... ] (priority 0..2 per EUserPriority) + // The event bot registers its streamers up front, so the whole roster is applied in + // one call when the window opens (priority 2) and cleared in one call after it + // (priority 0). Updates are grouped by priority into bulk UPDATE ... WHERE user_id IN + // (...) statements; invalid entries are reported per-user, valid ones all apply. + [Authorize(AuthenticationSchemes = "Discord")] + [HttpPost("SetPriorityBatch")] + public async Task SetPriorityBatch() + { + POST_User_SetPriorityBatch_Result result = new POST_User_SetPriorityBatch_Result(); + + using (var reader = new StreamReader(HttpContext.Request.Body)) + { + string jsonData = await reader.ReadToEndAsync(); + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + List>? entries = null; + try + { + entries = JsonSerializer.Deserialize>>(jsonData, options); + } + catch + { + entries = null; + } + + if (entries == null || entries.Count == 0) + { + Response.StatusCode = (int)HttpStatusCode.BadRequest; + result.detail = "Body must be a non-empty array of { \"user_id\": , \"priority\": <0|1|2> }."; + return result; + } + + await using var db = await _dbFactory.CreateDbContextAsync(); + + Dictionary validById = new Dictionary(); + foreach (Dictionary entry in entries) + { + if (!entry.TryGetValue("user_id", out JsonElement userIdEl) || + userIdEl.ValueKind != JsonValueKind.Number || + !userIdEl.TryGetInt64(out Int64 userId) || + !entry.TryGetValue("priority", out JsonElement priorityEl) || + !priorityEl.TryGetInt32(out int priority)) + { + result.errors.Add(new PriorityBatchError { user_id = -1, detail = "entry must be { \"user_id\": , \"priority\": <0|1|2> }" }); + continue; + } + + if (priority < (int)EUserPriority.None || priority > (int)EUserPriority.Viewer) + { + result.errors.Add(new PriorityBatchError { user_id = userId, detail = $"priority must be {(int)EUserPriority.None}, {(int)EUserPriority.Player} or {(int)EUserPriority.Viewer}" }); + continue; + } + + validById[userId] = priority; + } + + // One UPDATE per distinct priority: UPDATE users SET user_priority = @p + // WHERE user_id IN (...). Nonexistent ids simply match nothing. + foreach (IGrouping> priorityGroup in validById.GroupBy(e => e.Value)) + { + List ids = priorityGroup.Select(e => e.Key).ToList(); + int updated = await db.Users + .Where(u => ids.Contains(u.ID)) + .ExecuteUpdateAsync(setters => setters.SetProperty(u => u.UserPriority, priorityGroup.Key)); + + result.updated += updated; + + // Report ids that did not exist so the bot can skip them when restoring. + if (updated < ids.Count) + { + List foundIds = await db.Users.AsNoTracking() + .Where(u => ids.Contains(u.ID)) + .Select(u => u.ID) + .ToListAsync(); + foreach (Int64 id in ids.Where(id => !foundIds.Contains(id))) + { + result.errors.Add(new PriorityBatchError { user_id = id, detail = "user does not exist" }); + } + } + } + + result.success = true; + } + + return result; + } + + // Body (one of): + // { "user_id": 12345 } exact user-id match + // { "display_name": "x64" } exact display-name match + // { "discord_id": 1234567890 } users.discord_id (website Discord login) + // { "search_parts": ["bob", "x64"]} partial AND search, max 10 rows + // All lookups are EF Core parameterised — arbitrary input cannot reach SQL. + [Authorize(AuthenticationSchemes = "Discord")] + [HttpPost("LookupUser")] + public async Task LookupUser() + { + POST_User_LookupUser_Result result = new POST_User_LookupUser_Result(); + + using (var reader = new StreamReader(HttpContext.Request.Body)) + { + string jsonData = await reader.ReadToEndAsync(); + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + Dictionary? data = null; + try + { + data = JsonSerializer.Deserialize>(jsonData, options); + } + catch + { + data = null; + } + + if (data == null) + { + Response.StatusCode = (int)HttpStatusCode.BadRequest; + result.detail = "Body must be { \"display_name\" } or { \"user_id\" } or { \"discord_id\" } or { \"search_parts\" }."; + return result; + } + + await using var db = await _dbFactory.CreateDbContextAsync(); + + List users = new List(); + + if (data.TryGetValue("display_name", out JsonElement displayNameEl) && + displayNameEl.ValueKind == JsonValueKind.String) + { + User? exact = await Database.Users.GetUserByDisplayName(db, displayNameEl.GetString() ?? String.Empty); + if (exact != null) + { + users.Add(exact); + } + } + else if (data.TryGetValue("user_id", out JsonElement userIdEl) && + userIdEl.TryGetInt64(out Int64 lookupUserId)) + { + User? byId = await Database.Users.GetUserById(db, lookupUserId); + if (byId != null) + { + users.Add(byId); + } + } + else if (data.TryGetValue("discord_id", out JsonElement discordIdEl) && + discordIdEl.TryGetInt64(out Int64 discordId)) + { + User? byDiscord = await Database.Users.GetUserByDiscordID(db, discordId); + if (byDiscord != null) + { + users.Add(byDiscord); + } + } + else if (data.TryGetValue("search_parts", out JsonElement searchPartsEl) && + searchPartsEl.ValueKind == JsonValueKind.Array) + { + List parts = new List(); + foreach (JsonElement partEl in searchPartsEl.EnumerateArray()) + { + if (partEl.ValueKind == JsonValueKind.String) + { + parts.Add(partEl.GetString() ?? String.Empty); + } + } + + if (parts.Count > 0) + { + users = await Database.Users.SearchUsersByDisplayName(db, parts, 10); + } + } + else + { + Response.StatusCode = (int)HttpStatusCode.BadRequest; + result.detail = "Body must be { \"display_name\" } or { \"user_id\" } or { \"discord_id\" } or { \"search_parts\" }."; + return result; + } + + result.success = true; + foreach (User user in users) + { + result.users.Add(new UserLookupEntry + { + user_id = user.ID, + display_name = user.DisplayName ?? String.Empty, + priority = user.UserPriority, + account_type = user.AccountType + }); + } + } + return result; } } diff --git a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs index a8f4815..ca8db50 100644 --- a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs +++ b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs @@ -862,13 +862,17 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession } } - // The match is starting: tell the read-only observers parked in the lobby - // view so they can run their countdown and get ready to join. + // The match has started: tell the read-only observers parked in the lobby + // view to queue their join now. Their watch-ticket request is then held + // by GO's broadcast-delay gate (423 + countdown) until the match has run + // for the host's delay — the delay rides along so the client can show + // the wait from the start. if (lobbyInfo.PendingObservers.Count > 0) { WebSocketMessage_LobbyObserverEvent observerEvent = new WebSocketMessage_LobbyObserverEvent(); - observerEvent.msg_id = (int)EWebSocketMessageID.LOBBY_OBSERVER_GAME_STARTING; + observerEvent.msg_id = (int)EWebSocketMessageID.LOBBY_OBSERVER_GAME_STARTED; observerEvent.lobby_id = lobbyInfo.LobbyID; + observerEvent.delay_seconds = lobbyInfo.StreamDelaySeconds; byte[] observerBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(observerEvent)); foreach (UserSession sess in lobbyInfo.PendingObservers.Keys) diff --git a/GenOnlineService/Database/Database.User.cs b/GenOnlineService/Database/Database.User.cs index 8d73f58..8652e92 100644 --- a/GenOnlineService/Database/Database.User.cs +++ b/GenOnlineService/Database/Database.User.cs @@ -77,6 +77,10 @@ public class User public bool IsAdmin { get; set; } = false; public bool IsBanned { get; set; } = false; + // Livestream privilege: 0 none / 1 player (highlight their matches) / 2 viewer (skip + // the password + broadcast-delay gates). See EUserPriority. + public int UserPriority { get; set; } = 0; + // ELO public int EloRating { get; set; } = EloConfig.BaseRating; public int EloNumberOfMatches { get; set; } = 0; @@ -161,6 +165,7 @@ public void Configure(EntityTypeBuilder builder) builder.Property(e => e.LimitSuperweapons).HasColumnName("favorite_limit_superweapons"); builder.Property(e => e.IsAdmin).HasColumnName("admin"); builder.Property(e => e.IsBanned).HasColumnName("banned"); + builder.Property(e => e.UserPriority).HasColumnName("user_priority"); builder.Property(e => e.EloRating).HasColumnName("elo_rating"); builder.Property(e => e.EloNumberOfMatches).HasColumnName("elo_num_matches"); builder.Property(e => e.BanReason).HasColumnName("ban_reason").HasColumnType("varchar(128)"); ; @@ -484,6 +489,124 @@ public static async Task IsUserAdmin(AppDbContext db, long userId) } } + private static readonly Func> _getUserPriorityQuery = + EF.CompileAsyncQuery((AppDbContext db, long userId) => + db.Users + .AsNoTracking() + .Where(u => u.ID == userId) + .Select(u => u.UserPriority) + .FirstOrDefault()); + + public static async Task GetUserPriority(AppDbContext db, long userId) + { + try + { + return (EUserPriority)await _getUserPriorityQuery(db, userId); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] GetUserPriority failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + return EUserPriority.None; + } + } + + // Management surface for users.user_priority (Discord !setpriority). The value is + // clamped to the known EUserPriority range; a non-existent user id reports failure so + // the command can tell "no such user" from "update went through". + public static async Task SetUserPriority(AppDbContext db, long userId, int priority) + { + try + { + if (priority < (int)EUserPriority.None || priority > (int)EUserPriority.Viewer) + { + return false; + } + + int updated = await db.Users + .Where(u => u.ID == userId) + .ExecuteUpdateAsync(setters => setters.SetProperty(u => u.UserPriority, priority)); + return updated > 0; + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] SetUserPriority failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + return false; + } + } + + // Exact display-name lookup for Discord !getuserid. Display names are unique + // (SetDisplayName enforces it), and the MySQL collation makes the match + // case-insensitive. All input flows through EF Core parameters — never SQL strings. + public static async Task GetUserByDisplayName(AppDbContext db, string displayName) + { + try + { + return await db.Users.AsNoTracking() + .FirstOrDefaultAsync(u => u.DisplayName == displayName); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] GetUserByDisplayName failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + return null; + } + } + + // Partial display-name search for Discord !searchuserid: every part must match (AND). + // Parameterised through EF Core, so arbitrary input cannot reach SQL. + public static async Task> SearchUsersByDisplayName(AppDbContext db, List nameParts, int limit) + { + try + { + IQueryable query = db.Users.AsNoTracking(); + foreach (string part in nameParts) + { + query = query.Where(u => u.DisplayName != null && u.DisplayName.Contains(part)); + } + + return await query.Take(limit).ToListAsync(); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] SearchUsersByDisplayName failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + return new List(); + } + } + + // Discord login mapping: the website writes users.discord_id when the account was + // created/used through Discord OAuth. Resolves a reactor's GO account. + public static async Task GetUserByDiscordID(AppDbContext db, long discordId) + { + try + { + return await db.Users.AsNoTracking() + .FirstOrDefaultAsync(u => u.DiscordID != null && u.DiscordID.Value == discordId); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] GetUserByDiscordID failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + return null; + } + } + + public static async Task GetUserById(AppDbContext db, long userId) + { + try + { + return await db.Users.AsNoTracking().FirstOrDefaultAsync(u => u.ID == userId); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] GetUserById failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + return null; + } + } + public static async Task> GetDisplayNameBulk(AppDbContext db, List lstUserIDs) { var dict = new Dictionary(lstUserIDs.Count); diff --git a/GenOnlineService/Database_Structure/structure.sql b/GenOnlineService/Database_Structure/structure.sql index 178e529..6356469 100644 --- a/GenOnlineService/Database_Structure/structure.sql +++ b/GenOnlineService/Database_Structure/structure.sql @@ -194,6 +194,7 @@ CREATE TABLE IF NOT EXISTS `users` ( `favorite_limit_superweapons` int(11) NOT NULL DEFAULT -1, `admin` tinyint(4) NOT NULL DEFAULT 0, `banned` tinyint(4) NOT NULL DEFAULT 0, + `user_priority` tinyint(4) NOT NULL DEFAULT 0, `elo_rating` int(11) NOT NULL DEFAULT 1000, `elo_num_matches` int(11) NOT NULL DEFAULT 0, `ban_reason` varchar(128) DEFAULT NULL, diff --git a/GenOnlineService/Discord.cs b/GenOnlineService/Discord.cs index 6e9c598..c2fdb54 100644 --- a/GenOnlineService/Discord.cs +++ b/GenOnlineService/Discord.cs @@ -23,6 +23,7 @@ using Discord.Rest; using Discord.WebSocket; using GenOnlineService; +using Microsoft.EntityFrameworkCore; using MySqlX.XDevAPI; using System; using System.Collections.Generic; @@ -111,7 +112,7 @@ enum EBotAction public DiscordBot() { -#if !DEBUG +#if !DEBUG || USE_DISCORD_IN_DEBUG _ = InitAsync().ContinueWith(t => { if (t.IsFaulted) @@ -274,6 +275,35 @@ public bool IsChannelIDDefined(ulong channelID, EDiscordChannelIDs discordChanne return false; } + private bool IsDiscordAdmin(UInt64 userId) + { + try + { + if (Program.g_Config == null) + { + return false; + } + + IConfiguration? discordSettings = Program.g_Config.GetSection("Discord"); + if (discordSettings == null) + { + return false; + } + + List? discord_admins = discordSettings.GetSection("discord_admins").Get>(); + if (discord_admins == null) + { + return false; + } + + return discord_admins.Contains(userId); + } + catch + { + return false; + } + } + private uint g_cooldownLengthSeconds = 20; private Dictionary m_dictCooldowns = new Dictionary(); @@ -680,6 +710,48 @@ private async Task OnMessageReceived(SocketMessage message) } } } + else if (message.Content.ToLower().StartsWith("!setpriority") || message.Content.ToLower().StartsWith("!user_setpriority")) + { + if (message.Channel.Id == g_dictChannelIDs[EDiscordChannelIDs.AdminCommands]) + { + if (IsDiscordAdmin(message.Author.Id)) + { + await HandleSetPriorityCommand(message); + } + else + { + PushDM(message.Author, "You don't have access to staff commands."); + } + } + } + else if (message.Content.ToLower().StartsWith("!getuserid")) + { + if (message.Channel.Id == g_dictChannelIDs[EDiscordChannelIDs.AdminCommands]) + { + if (IsDiscordAdmin(message.Author.Id)) + { + await HandleGetUserIdCommand(message); + } + else + { + PushDM(message.Author, "You don't have access to staff commands."); + } + } + } + else if (message.Content.ToLower().StartsWith("!searchuserid")) + { + if (message.Channel.Id == g_dictChannelIDs[EDiscordChannelIDs.AdminCommands]) + { + if (IsDiscordAdmin(message.Author.Id)) + { + await HandleSearchUserIdCommand(message); + } + else + { + PushDM(message.Author, "You don't have access to staff commands."); + } + } + } //JSONRequest_PushCommand requestToSend = new JSONRequest_PushCommand(new DiscordUser(message.Author.Id, message.Author.Username), message.Content, enumChannelID); @@ -718,6 +790,99 @@ private async Task OnMessageReceived(SocketMessage message) } } + // ---- Staff commands: user priority + user lookup -------------------------------------- + + private async Task HandleSetPriorityCommand(SocketMessage message) + { + string[] strComponents = message.Content.Split(' ', StringSplitOptions.RemoveEmptyEntries); + + if (strComponents.Length == 2 && strComponents[1].ToLower() == "help") + { + PushChannelMessage(EDiscordChannelIDs.AdminCommands, + "!setpriority \nPriority types:\n0 = none\n1 = player (their matches are highlighted in the Watch Live browser)\n2 = viewer (skips the livestream password + broadcast-delay gates)\nExample: !setpriority 12345 2"); + return; + } + + if (strComponents.Length != 3 || + !Int64.TryParse(strComponents[1], out Int64 targetUserId) || + !Int32.TryParse(strComponents[2], out Int32 priority)) + { + PushChannelMessage(EDiscordChannelIDs.AdminCommands, "Invalid Command Syntax. !setpriority <0|1|2> (e.g. !setpriority 12345 2). Use !setpriority help for the priority types."); + return; + } + + using var scope = ServiceLocator.Services.CreateScope(); + var factory = scope.ServiceProvider.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(); + + User? user = await Database.Users.GetUserById(db, targetUserId); + if (user == null) + { + PushChannelMessage(EDiscordChannelIDs.AdminCommands, $"User {targetUserId} does not exist."); + return; + } + + if (await Database.Users.SetUserPriority(db, targetUserId, priority)) + { + PushChannelMessage(EDiscordChannelIDs.AdminCommands, $"User {targetUserId} ({user.DisplayName}) priority set to {priority}."); + } + else + { + PushChannelMessage(EDiscordChannelIDs.AdminCommands, $"Invalid priority type {priority}. Use 0, 1 or 2 (see !setpriority help)."); + } + } + + private async Task HandleGetUserIdCommand(SocketMessage message) + { + string[] strComponents = message.Content.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (strComponents.Length < 2) + { + PushChannelMessage(EDiscordChannelIDs.AdminCommands, "Invalid Command Syntax. !getuserid (e.g. !getuserid x64)"); + return; + } + + string strName = string.Join(' ', strComponents.Skip(1)); + + using var scope = ServiceLocator.Services.CreateScope(); + var factory = scope.ServiceProvider.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(); + + User? user = await Database.Users.GetUserByDisplayName(db, strName); + if (user == null) + { + PushChannelMessage(EDiscordChannelIDs.AdminCommands, $"No user found with display name '{strName}'."); + return; + } + + PushChannelMessage(EDiscordChannelIDs.AdminCommands, $"{user.DisplayName} is user ID {user.ID} (priority {user.UserPriority}, account type {user.AccountType})."); + } + + private async Task HandleSearchUserIdCommand(SocketMessage message) + { + string[] strComponents = message.Content.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (strComponents.Length < 2) + { + PushChannelMessage(EDiscordChannelIDs.AdminCommands, "Invalid Command Syntax. !searchuserid [part2 ...] (e.g. !searchuserid bob x64)"); + return; + } + + List parts = strComponents.Skip(1).ToList(); + + using var scope = ServiceLocator.Services.CreateScope(); + var factory = scope.ServiceProvider.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(); + + List users = await Database.Users.SearchUsersByDisplayName(db, parts, 10); + if (users.Count == 0) + { + PushChannelMessage(EDiscordChannelIDs.AdminCommands, $"No users match '{String.Join(" ", parts)}'."); + return; + } + + string strResults = String.Join("\n", users.Select(u => $"`{u.ID}` {u.DisplayName} (priority {u.UserPriority})")); + PushChannelMessage(EDiscordChannelIDs.AdminCommands, $"Search results ({users.Count} shown):\n{strResults}"); + } + private static Task LogAsync(LogMessage log) { Console.WriteLine(log.ToString()); diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index 9acc20d..a8fe0f8 100644 --- a/GenOnlineService/LobbyManager.cs +++ b/GenOnlineService/LobbyManager.cs @@ -258,6 +258,20 @@ public int MaxPlayers public bool AllowObservers { get; private set; } = false; + // Host decision at lobby creation: may this game be watched live at all? When off, + // the lobby is hidden from Watch Live's pre-game list and the pre-game observer view + // shows no stream controls — the game is simply not watchable, no matter which + // player has their own streamer role enabled. + public bool AllowStreamers { get; private set; } = false; + + public void SetAllowStreamers(bool allowed) + { + if (AllowStreamers == allowed) + return; + AllowStreamers = allowed; + DirtyRetransmit(); + } + // Livestream state, owned by the relay session. IsStreaming is true while the relay has // a live stream for this lobby; StreamDelaySeconds is the host-reported relay delay; and // ObserverCount is how many spectators are currently watching. @@ -265,6 +279,24 @@ public int MaxPlayers public int? StreamDelaySeconds { get; private set; } = null; public int ObserverCount { get; private set; } = 0; + // The moment the match started (the INGAME transition). The clock for the + // broadcast-delay admission gate: normal viewers' watch tickets are held until + // TimeMatchStarted + StreamDelaySeconds, i.e. "held for the delay since the match + // started" — GO learns this moment from its own state transition, not from the + // relay's liveness report. + public DateTime? TimeMatchStarted { get; private set; } = null; + + // Priority-player match: latched TRUE when a user with user_priority = Player creates + // or joins the lobby. Sorts the lobby to the top of the Watch Live browser. Not + // broadcast — the client has no use for it (GO decides everything from the flag). + [JsonIgnore] + public bool IsPriority { get; private set; } = false; + + public void SetPriority(bool priority) + { + IsPriority = priority; + } + // True while the host's match-start countdown is running. Broadcast as part of the // lobby JSON so members and read-only observers can mirror it through the ordinary // lobby-changed refetch — no separate countdown messages needed. Cleared by any lobby @@ -850,6 +882,28 @@ public async Task RemoveMember(LobbyMember member) // match that is no longer starting. CountdownStarted = false; + // A priority Player leaving may demote the lobby: if no priority Player remains, + // the Watch Live sort returns it to the normal group. Each member carries its + // grant from the JWT (set at create/join), so this is a pure in-memory scan — no + // DB lookup. The leaver's slot is already a placeholder by now, so it cannot vote. + if (IsPriority) + { + bool stillHasPriorityPlayer = false; + foreach (LobbyMember memberEntry in Members) + { + if (memberEntry.IsHuman() && memberEntry.Priority == EUserPriority.Player) + { + stillHasPriorityPlayer = true; + break; + } + } + + if (!stillHasPriorityPlayer) + { + IsPriority = false; + } + } + DirtyRetransmit(); } @@ -1040,8 +1094,18 @@ public bool HadAIAtStart() public async Task UpdateState(ELobbyState state) { + bool wasIngame = State == ELobbyState.INGAME; State = state; + // The match-start moment, latched on the transition INTO INGAME. Every start path + // lands here (the host's START_GAME websocket and the matchmaking quickmatch + // start), so this is the single place GO learns "the lobby started". A repeated + // INGAME update must not reset it. + if (state == ELobbyState.INGAME && !wasIngame) + { + TimeMatchStarted = DateTime.UtcNow; + } + // if start, init our AC probe if (state == ELobbyState.INGAME) { @@ -1181,6 +1245,17 @@ public void UpdateSlotIndex(UInt16 index) public string Region { get; private set; } = "Unknown"; public string MiddlewareUserID { get; private set; } = String.Empty; + // Livestream privilege grant, carried from the member's JWT at create/join. Used to + // recompute the lobby's priority latch on leave without a DB lookup. Also rides the + // lobby JSON (the client ignores it; the relay allow-lists member keys, so it never + // reaches observers). + public EUserPriority Priority { get; private set; } = EUserPriority.None; + + public void SetPriority(EUserPriority priority) + { + Priority = priority; + } + [JsonIgnore] // cant serialize refs private WeakReference CurrentLobby = new(null); @@ -1348,7 +1423,7 @@ private async void HandleLobbyNeedsDestroyed(Lobby lobby) } public async Task CreateLobby(AppDbContext _db, UserSession owningSession, string strOwnerDisplayName, string strName, string strMapName, string strMapPath, bool bMapOfficial, int maxPlayers, string HostIPAddr, - UInt16 hostPreferredPort, bool bVanillaTeams, bool bTrackStats, UInt32 default_starting_cash, bool bPassworded, String strPassword, Int16 parentNetworkRoom, bool bAllowObservers, + UInt16 hostPreferredPort, bool bVanillaTeams, bool bTrackStats, UInt32 default_starting_cash, bool bPassworded, String strPassword, Int16 parentNetworkRoom, bool bAllowObservers, bool bAllowStreamers, UInt16 maxCamHeight, UInt32 exe_crc, UInt32 ini_crc, ELobbyType lobbyType, EKnownAnticheatID anticheatID) { Console.WriteLine("Created lobby"); @@ -1379,6 +1454,7 @@ public async Task CreateLobby(AppDbContext _db, UserSession owningSession } Lobby newLobby = new Lobby(newLobbyID, owningSession, strName, ELobbyState.GAME_SETUP, strMapName, strMapPath, bVanillaTeams, starting_cash, bLimitSuperweapons, bTrackStats, bPassworded, strPassword, bMapOfficial, rng_seed, parentNetworkRoom, bAllowObservers, maxCamHeight, exe_crc, ini_crc, maxPlayers, lobbyType, anticheatID); + newLobby.SetAllowStreamers(bAllowStreamers); m_dictLobbies[newLobbyID] = newLobby; diff --git a/GenOnlineService/MatchmakingManager.cs b/GenOnlineService/MatchmakingManager.cs index ba15b1d..a79988b 100644 --- a/GenOnlineService/MatchmakingManager.cs +++ b/GenOnlineService/MatchmakingManager.cs @@ -807,7 +807,7 @@ await SendMatchmakingMessage(memberSession, await using var db = await factory.CreateDbContextAsync(); m_LobbyID = await lobbyManager.CreateLobby(db, dummyHostUser, dummyHostUserData.m_strDisplayName, "Quickmatch Lobby", strMapName, strMapPath + ".map", - true, playlist.DesiredPlayers, "", 12345, false, true, 10000, false, String.Empty, -5, false, Constants.g_DefaultCameraMaxHeight, 123, 456, ELobbyType.QuickMatch, + true, playlist.DesiredPlayers, "", 12345, false, true, 10000, false, String.Empty, -5, false, false, Constants.g_DefaultCameraMaxHeight, 123, 456, ELobbyType.QuickMatch, dummyHostUser.AnticheatID); // tell both to join our lobby diff --git a/GenOnlineService/Program.cs b/GenOnlineService/Program.cs index dd800fb..efcd5a9 100644 --- a/GenOnlineService/Program.cs +++ b/GenOnlineService/Program.cs @@ -119,6 +119,75 @@ public static bool ValidateKey(string strKey) return s_cachedApiKeys.Contains(strKey); } } + + // Shared-key credential for the World Series bot (config WsBot:api_key, presented as + // "Authorization: Discord "). Fixed-time compare so a wrong key does not leak how + // many leading bytes were right. + public static class WsBotKeyValidator + { + public static bool ValidateKey(string? suppliedKey) + { + if (string.IsNullOrEmpty(suppliedKey) || Program.g_Config == null) + { + return false; + } + + string? expectedKey = Program.g_Config.GetSection("WsBot").GetValue("api_key"); + if (string.IsNullOrEmpty(expectedKey)) + { + return false; + } + + return System.Security.Cryptography.CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(suppliedKey), + Encoding.UTF8.GetBytes(expectedKey)); + } + } + + // Authenticates the World Series Discord bot against the shared WsBot:api_key, mirroring + // the Basic handler pattern (scheme name in the Authorization header). The bot is not a + // player, so it gets its own scheme + role instead of a game-client JWT. + public class DiscordAuthenticationHandler : AuthenticationHandler + { + public DiscordAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + TimeProvider timeProvider) + : base(options, logger, encoder) { } + + protected override Task HandleAuthenticateAsync() + { + if (!Request.Headers.ContainsKey("Authorization")) + return Task.FromResult(AuthenticateResult.Fail("Missing Authorization Header")); + + try + { + string? authHeader = Request.Headers["Authorization"].FirstOrDefault(); + if (authHeader == null || !authHeader.StartsWith("Discord ", StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header")); + } + + string suppliedKey = authHeader.Substring("Discord ".Length).Trim(); + if (!WsBotKeyValidator.ValidateKey(suppliedKey)) + { + return Task.FromResult(AuthenticateResult.Fail("Invalid Discord key")); + } + + var claims = new[] { new Claim(ClaimTypes.Name, "wsbot"), new Claim(ClaimTypes.Role, "WsBot") }; + var identity = new ClaimsIdentity(claims, Scheme.Name); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, Scheme.Name); + + return Task.FromResult(AuthenticateResult.Success(ticket)); + } + catch + { + return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header")); + } + } + } public static class CertHelpers { public static X509Certificate2 LoadPemWithPrivateKey(string certPath, string keyPath) @@ -279,6 +348,20 @@ public static bool IsAdmin(ControllerBase controller) return controller.User.IsInRole("Admin"); } + // The caller's livestream privilege from the JWT, minted at login from + // users.user_priority and signed — a client cannot forge it. Absent/malformed claim + // = None (no privilege). + public static EUserPriority GetUserPriority(ControllerBase controller) + { + var claim = controller.User.FindFirst("priority"); + if (claim != null && Int32.TryParse(claim.Value, out int value)) + { + return (EUserPriority)value; + } + + return EUserPriority.None; + } + public static KnownClients.EKnownClients GetClientID(ControllerBase controller) { var first = controller.User.FindFirst("client_id"); @@ -554,7 +637,7 @@ public enum ETokenType Refresh } - public string GenerateToken(string displayname, Int64 userID, string ipAddr, ETokenType tokenType, KnownClients.EKnownClients knownClientID, EUserSessionType sessionType, bool bIsAdmin) + public string GenerateToken(string displayname, Int64 userID, string ipAddr, ETokenType tokenType, KnownClients.EKnownClients knownClientID, EUserSessionType sessionType, bool bIsAdmin, EUserPriority userPriority) { var jwtSettings = _configuration.GetSection("JwtSettings"); @@ -608,6 +691,12 @@ public string GenerateToken(string displayname, Int64 userID, string ipAddr, ETo claims.Add(new Claim(ClaimTypes.Role, "Admin")); } + // Livestream privilege (users.user_priority): carried as an int claim so the + // observe/join gates can read the exact value (Player vs Viewer) from the + // token. Signed like every other claim — a client cannot alter it without + // breaking the HMAC signature, so it is server-trusted, never client-input. + claims.Add(new Claim("priority", ((int)userPriority).ToString())); + var token = new JwtSecurityToken( issuer: jwtSettings["Issuer"], @@ -617,6 +706,12 @@ public string GenerateToken(string displayname, Int64 userID, string ipAddr, ETo signingCredentials: credentials ); + // Every mint logged with its expiry (never the token itself — it is a bearer + // credential and stateless: it lives only in the client's memory and request + // headers, never in a database). Lets a deployed instance be observed for + // refresh behaviour by timestamp alone. + Console.WriteLine($"[JWT] Minted {tokenType} token for user {userID} (expires {token.ValidTo:HH:mm:ss})"); + return new JwtSecurityTokenHandler().WriteToken(token); } } @@ -822,6 +917,10 @@ public static async Task Main(string[] args) ValidateAudience = true, ValidateLifetime = true, ValidateIssuerSigningKey = true, + // The default clock skew is 5 minutes — that extends a session token's + // life a third of its 15-minute lifetime. Tighten it so expiry means + // expiry (1 s covers server/client clock drift). + ClockSkew = TimeSpan.FromSeconds(1), ValidIssuer = jwtSettings["Issuer"], ValidAudience = jwtSettings["Audience"], IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(strKey)) @@ -831,7 +930,8 @@ public static async Task Main(string[] args) { OnTokenValidated = AdditionalValidation }; - }).AddScheme("Basic", null); + }).AddScheme("Basic", null) + .AddScheme("Discord", null); builder.Services.AddAuthorization(options => { diff --git a/GenOnlineService/appsettings.json b/GenOnlineService/appsettings.json index ee397d2..3ae241c 100644 --- a/GenOnlineService/appsettings.json +++ b/GenOnlineService/appsettings.json @@ -87,5 +87,8 @@ "base_url": "", "api_key": "", "ingress_api_key": "" + }, + "WsBot": { + "api_key": "" } } From d1e2eb0025359a6ce0cbcd6b12d8ef31196b833c Mon Sep 17 00:00:00 2001 From: Dennis van 't Hul Date: Tue, 11 Aug 2026 20:38:17 +0200 Subject: [PATCH 13/32] fix(auth): dev login display name uses lowercase dev_{userid} --- GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs b/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs index 211d618..69f1897 100644 --- a/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs +++ b/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs @@ -131,7 +131,7 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr rng.GetBytes(randBytes); } user_id = BitConverter.ToUInt32(randBytes, 0) & 0x7FFFFFFF; - strDisplayName = String.Format("DEV_ACCOUNT_{0}", user_id); + strDisplayName = String.Format("dev_{0}", user_id); // make user await Database.Users.CreateUserIfNotExists_DevAccount(db, user_id, strDisplayName); From 5af1abb7fa6341a8ce087a15d8a0ab1007278759 Mon Sep 17 00:00:00 2001 From: Dennis van 't Hul Date: Tue, 11 Aug 2026 20:44:40 +0200 Subject: [PATCH 14/32] fix(auth): dev login user id is db max+1 (short sequential display name) --- .../CheckLogin/CheckLoginController.cs | 20 +++++++------------ GenOnlineService/Database/Database.User.cs | 18 +++++++++++++++++ 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs b/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs index 69f1897..d463104 100644 --- a/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs +++ b/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs @@ -118,20 +118,14 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr return result; } #if DEBUG - // Dev/test login: every CheckLogin attempt gets a fresh, unique random - // user so multiple test clients can hold distinct sessions on the same - // service instance at the same time. Skips the pending_login table. + // Dev/test login: every CheckLogin attempt gets a fresh user above the + // table's current max, so multiple test clients can hold distinct + // sessions on the same service instance at the same time. Skips the + // pending_login table; the display name is the sequential id, which + // stays short and is easy to spot in menus. EPendingLoginState state = EPendingLoginState.LoginSuccess; - UInt32 user_id = 0; - string strDisplayName = String.Empty; - - byte[] randBytes = new byte[4]; - using (RandomNumberGenerator rng = RandomNumberGenerator.Create()) - { - rng.GetBytes(randBytes); - } - user_id = BitConverter.ToUInt32(randBytes, 0) & 0x7FFFFFFF; - strDisplayName = String.Format("dev_{0}", user_id); + UInt32 user_id = (UInt32)(await Database.Users.GetMaxUserID(db) + 1); + string strDisplayName = String.Format("dev_{0}", user_id); // make user await Database.Users.CreateUserIfNotExists_DevAccount(db, user_id, strDisplayName); diff --git a/GenOnlineService/Database/Database.User.cs b/GenOnlineService/Database/Database.User.cs index 8652e92..960592b 100644 --- a/GenOnlineService/Database/Database.User.cs +++ b/GenOnlineService/Database/Database.User.cs @@ -497,6 +497,24 @@ public static async Task IsUserAdmin(AppDbContext db, long userId) .Select(u => u.UserPriority) .FirstOrDefault()); + private static readonly Func> _getMaxUserIdQuery = + EF.CompileAsyncQuery((AppDbContext db) => + db.Users.Max(u => (long?)u.ID)); + + public static async Task GetMaxUserID(AppDbContext db) + { + try + { + return await _getMaxUserIdQuery(db) ?? 0; + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] GetMaxUserID failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + return 0; + } + } + public static async Task GetUserPriority(AppDbContext db, long userId) { try From ea5d18f1a76eed5e7ff5994dbb93dde5938afcb8 Mon Sep 17 00:00:00 2001 From: Dennis van 't Hul Date: Tue, 11 Aug 2026 20:54:04 +0200 Subject: [PATCH 15/32] Revert "fix(auth): dev login user id is db max+1 (short sequential display name)" This reverts commit 5af1abb7fa6341a8ce087a15d8a0ab1007278759. --- .../CheckLogin/CheckLoginController.cs | 20 ++++++++++++------- GenOnlineService/Database/Database.User.cs | 18 ----------------- 2 files changed, 13 insertions(+), 25 deletions(-) diff --git a/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs b/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs index d463104..69f1897 100644 --- a/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs +++ b/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs @@ -118,14 +118,20 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr return result; } #if DEBUG - // Dev/test login: every CheckLogin attempt gets a fresh user above the - // table's current max, so multiple test clients can hold distinct - // sessions on the same service instance at the same time. Skips the - // pending_login table; the display name is the sequential id, which - // stays short and is easy to spot in menus. + // Dev/test login: every CheckLogin attempt gets a fresh, unique random + // user so multiple test clients can hold distinct sessions on the same + // service instance at the same time. Skips the pending_login table. EPendingLoginState state = EPendingLoginState.LoginSuccess; - UInt32 user_id = (UInt32)(await Database.Users.GetMaxUserID(db) + 1); - string strDisplayName = String.Format("dev_{0}", user_id); + UInt32 user_id = 0; + string strDisplayName = String.Empty; + + byte[] randBytes = new byte[4]; + using (RandomNumberGenerator rng = RandomNumberGenerator.Create()) + { + rng.GetBytes(randBytes); + } + user_id = BitConverter.ToUInt32(randBytes, 0) & 0x7FFFFFFF; + strDisplayName = String.Format("dev_{0}", user_id); // make user await Database.Users.CreateUserIfNotExists_DevAccount(db, user_id, strDisplayName); diff --git a/GenOnlineService/Database/Database.User.cs b/GenOnlineService/Database/Database.User.cs index 960592b..8652e92 100644 --- a/GenOnlineService/Database/Database.User.cs +++ b/GenOnlineService/Database/Database.User.cs @@ -497,24 +497,6 @@ public static async Task IsUserAdmin(AppDbContext db, long userId) .Select(u => u.UserPriority) .FirstOrDefault()); - private static readonly Func> _getMaxUserIdQuery = - EF.CompileAsyncQuery((AppDbContext db) => - db.Users.Max(u => (long?)u.ID)); - - public static async Task GetMaxUserID(AppDbContext db) - { - try - { - return await _getMaxUserIdQuery(db) ?? 0; - } - catch (Exception ex) - { - Console.WriteLine($"[ERROR] GetMaxUserID failed: {ex.Message}"); - SentrySdk.CaptureException(ex); - return 0; - } - } - public static async Task GetUserPriority(AppDbContext db, long userId) { try From e36a2e32d215b17f5457de6f3d61c486dd679a3d Mon Sep 17 00:00:00 2001 From: Dennis van 't Hul Date: Tue, 11 Aug 2026 21:22:15 +0200 Subject: [PATCH 16/32] feat(livestreams): per-viewer watch_action and started-game wait rows Watch Live now lists started lobbies (INGAME) even before the stream is live or while a normal viewer is held behind the broadcast delay. Each entry carries a watch_action computed per viewer (priority re-read live from the DB, exactly like Observe): 0 = observe the pre-game lobby, 1 = wait in the read-only lobby (stream not live, or held behind the delay), 2 = join now (priority viewer or delay passed). Rows also carry the remaining delay hold. The remaining-hold derivation now lives in this controller; the Lobby DTO helper is gone. --- .../Livestreams/LivestreamsController.cs | 61 ++++++++++++++++--- GenOnlineService/LobbyManager.cs | 3 +- 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs b/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs index c3acecb..ed803fe 100644 --- a/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs +++ b/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs @@ -61,6 +61,17 @@ public class GET_Livestreams_LivestreamEntry // Priority-player match (lobby latched when a users.user_priority = Player creates or // joins): sorts the row to the top of the Watch Live browser. public bool priority { get; set; } = false; + + // What the client should do with this row, computed per viewer: + // 0 = observe (pre-game lobby — enter the read-only lobby view), + // 1 = wait (stream not live yet, or this viewer is held behind the broadcast delay — + // enter the read-only lobby view and wait there), + // 2 = join (stream live and this viewer may mint a ticket right now — connect + // directly, skipping the lobby). + public int watch_action { get; set; } = 2; + + // Remaining broadcast-delay hold in seconds for this viewer (null when not held). + public int? delay_remaining_seconds { get; set; } = null; } public class POST_Livestreams_Register_Result : APIResult @@ -162,26 +173,57 @@ public async Task Get() // started one, which is what IsStreaming records. // // The list is the Watch Live screen's one source for everything: live streams - // (INGAME + streaming) first, then every pre-game lobby the client can enter as a - // read-only observer. A pre-game lobby is never "streaming" yet — the entry's - // state flag tells the client which action applies (OBSERVE either way). + // (INGAME + streaming) first, then started-but-waiting lobbies, then every + // pre-game lobby the client can enter as a read-only observer. + // + // watch_action is per viewer (plans/live-observer-server-delay.md): a priority + // viewer (admin or user_priority = Viewer, re-read live from the DB like Observe + // does) is never held; a normal viewer is held behind the host's broadcast delay + // and gets a "wait" row so the client parks them in the lobby view instead of + // sitting on an empty CONNECT. + Int64 user_id = TokenHelper.GetUserID(this); + bool isPriority = TokenHelper.IsAdmin(this) || TokenHelper.GetUserPriority(this) == EUserPriority.Viewer; + if (!isPriority) + { + await using var db = await _dbFactory.CreateDbContextAsync(); + if (await Database.Users.GetUserPriority(db, user_id) == EUserPriority.Viewer) + { + isPriority = true; + } + } + foreach (Lobby lobby in _lobbyManager.GetAllLobbies()) { bool isPregame = lobby.State == ELobbyState.GAME_SETUP; bool isLive = lobby.State == ELobbyState.INGAME && lobby.IsStreaming; + bool isWaiting = lobby.State == ELobbyState.INGAME && !lobby.IsStreaming; // A pre-game lobby is only watchable when the host allowed streamers at // creation: without that, no stream can ever come, and parking observers on // it would only lead to an endless wait. Live rows are already streaming, so // they are always listed. - if (isPregame && !lobby.AllowStreamers) + if (!isLive && !lobby.AllowStreamers) { continue; } - if (!isPregame && !isLive) + if (!isPregame && !isLive && !isWaiting) { continue; } + // 0 = observe (pre-game), 1 = wait (stream not live yet, or this viewer is + // held behind the broadcast delay), 2 = join (stream live, ticket mints now). + int watchAction = isPregame ? 0 : 1; + int? delayRemainingSeconds = null; + if (lobby.State == ELobbyState.INGAME && lobby.TimeMatchStarted != null && lobby.StreamDelaySeconds > 0) + { + delayRemainingSeconds = Math.Max(0, lobby.StreamDelaySeconds.Value - + (int)(DateTime.UtcNow - lobby.TimeMatchStarted.Value).TotalSeconds); + } + if (isLive && (isPriority || delayRemainingSeconds == null || delayRemainingSeconds == 0)) + { + watchAction = 2; + } + GET_Livestreams_LivestreamEntry entry = new GET_Livestreams_LivestreamEntry(); entry.lobby_id = lobby.LobbyID; entry.name = lobby.Name; @@ -194,16 +236,17 @@ public async Task Get() entry.passworded = lobby.IsPassworded; entry.pending_observer_count = lobby.PendingObserverCount; entry.priority = lobby.IsPriority; + entry.watch_action = watchAction; + entry.delay_remaining_seconds = delayRemainingSeconds; result.livestreams.Add(entry); } - // Watch Live order: priority-player matches first, then live before pre-game — - // priority live → priority pre-game → normal live → normal pre-game. Stable, so - // equal rows keep their insertion order. + // Watch Live order: priority-player matches first, then join → wait → pre-game. + // Stable, so equal rows keep their insertion order. result.livestreams = result.livestreams .OrderByDescending(e => e.priority) - .ThenByDescending(e => e.state) + .ThenByDescending(e => e.watch_action) .ToList(); return result; diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index a8fe0f8..1cef527 100644 --- a/GenOnlineService/LobbyManager.cs +++ b/GenOnlineService/LobbyManager.cs @@ -283,7 +283,8 @@ public void SetAllowStreamers(bool allowed) // broadcast-delay admission gate: normal viewers' watch tickets are held until // TimeMatchStarted + StreamDelaySeconds, i.e. "held for the delay since the match // started" — GO learns this moment from its own state transition, not from the - // relay's liveness report. + // relay's liveness report. The remaining hold is derived where it is consumed + // (the livestream controller), not serialized on the lobby itself. public DateTime? TimeMatchStarted { get; private set; } = null; // Priority-player match: latched TRUE when a user with user_priority = Player creates From a178cfe0721e3b4b9cc1f7e896d6aa660d1959de Mon Sep 17 00:00:00 2001 From: Dennis van 't Hul Date: Tue, 11 Aug 2026 22:26:18 +0200 Subject: [PATCH 17/32] feat(livestreams): stamp priority on relay watch tickets CreateWatchTicketAsync now sends { lobby_id, user_id, priority } and Observe passes the isPriority it already computes (admin or user_priority = Viewer). The relay uses the flag to bypass its byte-level broadcast-delay hold for privileged watchers; tickets without it default to held (relay-server-side delay hold, plans/relay/relay-server-side-delay-hold.md). --- .../Controllers/Livestreams/LivestreamsController.cs | 2 +- GenOnlineService/RelayClient.cs | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs b/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs index ed803fe..a8cf928 100644 --- a/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs +++ b/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs @@ -443,7 +443,7 @@ public async Task Observe(Int64 lobby_id) } } - RelayWatchTicketResult ticket = await RelayClient.CreateWatchTicketAsync(lobby.LobbyID, user_id); + RelayWatchTicketResult ticket = await RelayClient.CreateWatchTicketAsync(lobby.LobbyID, user_id, isPriority); if (ticket.Status == RelayWatchTicketStatus.StreamEnded) { // The relay session for this lobby is gone (all sources left / reaped): the diff --git a/GenOnlineService/RelayClient.cs b/GenOnlineService/RelayClient.cs index 546cce8..9983d86 100644 --- a/GenOnlineService/RelayClient.cs +++ b/GenOnlineService/RelayClient.cs @@ -277,11 +277,14 @@ private static bool IsStreamEndedBody(string responseBody) return false; } - public static async Task CreateWatchTicketAsync(long lobbyId, long userId) + // priority: privileged watchers (admin or user_priority = Viewer) get a priority + // watch ticket; the relay lets those connections bypass its byte-level + // broadcast-delay hold (plans/relay/relay-server-side-delay-hold.md). + public static async Task CreateWatchTicketAsync(long lobbyId, long userId, bool priority = false) { try { - string payloadJson = JsonSerializer.Serialize(new { lobby_id = lobbyId, user_id = userId }); + string payloadJson = JsonSerializer.Serialize(new { lobby_id = lobbyId, user_id = userId, priority = priority }); using (var response = await SendAsync(HttpMethod.Post, "/internal/watch_tickets", payloadJson, "CreateWatchTicket", false)) { From 12b5ad688b6dca0d31439426e2f3a3e7e5bc65c6 Mon Sep 17 00:00:00 2001 From: Dennis van 't Hul Date: Wed, 12 Aug 2026 22:07:52 +0200 Subject: [PATCH 18/32] fix(livestreams): drop never-streamed started games from Watch Live after a grace period MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A started game whose relay stream never materialises (host and members all have streaming off) — or whose stream ended mid-match — stays listed as a STARTED/wait row forever, stranding observers on a wait that can never end. Streamers register within seconds of match start, so after the 60s grace the row is dropped and the browser removes it on its next refresh. Also only report the broadcast-delay hold countdown (delay_remaining_seconds) for lobbies that actually have a stream: a never-streamed game showed a countdown for a hold that can never expire. --- .../Livestreams/LivestreamsController.cs | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs b/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs index a8cf928..8452595 100644 --- a/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs +++ b/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs @@ -148,6 +148,12 @@ public class LivestreamsController : ControllerBase private readonly LobbyManager _lobbyManager; private readonly IDbContextFactory _dbFactory; + // A started game whose relay stream never materialises (host and members all have + // streaming off) — or whose stream ended mid-match — is dropped from Watch Live after + // this long. Streamers register within seconds of match start, so 60s is generous; + // keeping such a game listed would only strand observers on a wait that can never end. + private const int NeverStreamedGraceSeconds = 60; + public LivestreamsController(LobbyManager lobbyManager, IDbContextFactory dbFactory) { _lobbyManager = lobbyManager; @@ -210,11 +216,25 @@ public async Task Get() continue; } + // A started game with no stream is only listed for a grace period: after + // that the stream is simply not coming (or has ended), so the row would + // only strand observers on a wait that can never end. The browser picks + // the drop up on its next 5s refresh. + if (isWaiting && lobby.TimeMatchStarted != null && + (DateTime.UtcNow - lobby.TimeMatchStarted.Value).TotalSeconds > NeverStreamedGraceSeconds) + { + continue; + } + // 0 = observe (pre-game), 1 = wait (stream not live yet, or this viewer is // held behind the broadcast delay), 2 = join (stream live, ticket mints now). int watchAction = isPregame ? 0 : 1; int? delayRemainingSeconds = null; - if (lobby.State == ELobbyState.INGAME && lobby.TimeMatchStarted != null && lobby.StreamDelaySeconds > 0) + // The hold clock only means something for lobbies that actually have a + // stream: a never-streamed game would otherwise show a countdown for a + // hold that can never expire. + if (lobby.State == ELobbyState.INGAME && lobby.IsStreaming && + lobby.TimeMatchStarted != null && lobby.StreamDelaySeconds > 0) { delayRemainingSeconds = Math.Max(0, lobby.StreamDelaySeconds.Value - (int)(DateTime.UtcNow - lobby.TimeMatchStarted.Value).TotalSeconds); From c7ec922db6cef3657505ad829bc156da861f8851 Mon Sep 17 00:00:00 2001 From: Dennis van 't Hul Date: Wed, 12 Aug 2026 23:07:02 +0200 Subject: [PATCH 19/32] feat(lobby): bulk slot update without persisting player favorites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host /roll command assigns every slot via HOST_ACTION_BULK_SLOT_UPDATE. The previous handler routed through UpdateSide/UpdateColor, which write the member's DB favorite side/color — a host-forced roll must not overwrite player preferences. UpdateSlotPropertiesForced sets side/color/start_pos/ team in memory only and lets the bulk handler broadcast once. --- .../Controllers/Lobby/LobbyController.cs | 8 +++----- GenOnlineService/LobbyManager.cs | 12 ++++++++++++ 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/GenOnlineService/Controllers/Lobby/LobbyController.cs b/GenOnlineService/Controllers/Lobby/LobbyController.cs index 63b5615..951a758 100644 --- a/GenOnlineService/Controllers/Lobby/LobbyController.cs +++ b/GenOnlineService/Controllers/Lobby/LobbyController.cs @@ -690,7 +690,6 @@ public async Task Post(Int64 lobbyID) { if (data.ContainsKey("slots")) { - await using var db = await _dbFactory.CreateDbContextAsync(); foreach (JsonElement slotEntry in data["slots"].EnumerateArray()) { try @@ -713,10 +712,9 @@ public async Task Post(Int64 lobbyID) LobbyMember? TargetMember = lobby.GetMemberFromSlot(slotIndex); if (TargetMember != null) { - await TargetMember.UpdateSide(db, side, start_pos); - await TargetMember.UpdateColor(db, color); - TargetMember.UpdateStartPos(start_pos); - TargetMember.UpdateTeam(team); + // Host-forced (lobby /roll): assign the slots without + // persisting anyone's favorites. + TargetMember.UpdateSlotPropertiesForced(side, color, start_pos, team); } } catch diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index 1cef527..d7d348c 100644 --- a/GenOnlineService/LobbyManager.cs +++ b/GenOnlineService/LobbyManager.cs @@ -1358,6 +1358,18 @@ public void UpdateTeam(int team) DirtyRetransmit(); } + // Host-forced bulk assignment (the lobby /roll command). Sets every slot property at + // once WITHOUT persisting the values as this member's favorites — only the member + // themselves may change those. No retransmit here: the bulk handler broadcasts once + // after the whole loop. + public void UpdateSlotPropertiesForced(int side, int color, int start_pos, int team) + { + Side = side; + Color = color; + StartingPosition = start_pos; + Team = team; + } + public void UpdateHasMap(bool bHasMap) { HasMap = bHasMap; From 94f637dd1ecb86debdee7249f6badc2672f86ba9 Mon Sep 17 00:00:00 2001 From: Dennis van 't Hul Date: Sat, 15 Aug 2026 13:37:42 +0200 Subject: [PATCH 20/32] Revert "feat(lobby): bulk slot update without persisting player favorites" This reverts commit c7ec922db6cef3657505ad829bc156da861f8851. --- .../Controllers/Lobby/LobbyController.cs | 8 +++++--- GenOnlineService/LobbyManager.cs | 12 ------------ 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/GenOnlineService/Controllers/Lobby/LobbyController.cs b/GenOnlineService/Controllers/Lobby/LobbyController.cs index b275d9b..b5daeb4 100644 --- a/GenOnlineService/Controllers/Lobby/LobbyController.cs +++ b/GenOnlineService/Controllers/Lobby/LobbyController.cs @@ -703,6 +703,7 @@ public async Task Post(Int64 lobbyID) { if (data.ContainsKey("slots")) { + await using var db = await _dbFactory.CreateDbContextAsync(); foreach (JsonElement slotEntry in data["slots"].EnumerateArray()) { try @@ -725,9 +726,10 @@ public async Task Post(Int64 lobbyID) LobbyMember? TargetMember = lobby.GetMemberFromSlot(slotIndex); if (TargetMember != null) { - // Host-forced (lobby /roll): assign the slots without - // persisting anyone's favorites. - TargetMember.UpdateSlotPropertiesForced(side, color, start_pos, team); + await TargetMember.UpdateSide(db, side, start_pos); + await TargetMember.UpdateColor(db, color); + TargetMember.UpdateStartPos(start_pos); + TargetMember.UpdateTeam(team); } } catch diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index 01defed..3fc334a 100644 --- a/GenOnlineService/LobbyManager.cs +++ b/GenOnlineService/LobbyManager.cs @@ -1363,18 +1363,6 @@ public void UpdateTeam(int team) DirtyRetransmit(); } - // Host-forced bulk assignment (the lobby /roll command). Sets every slot property at - // once WITHOUT persisting the values as this member's favorites — only the member - // themselves may change those. No retransmit here: the bulk handler broadcasts once - // after the whole loop. - public void UpdateSlotPropertiesForced(int side, int color, int start_pos, int team) - { - Side = side; - Color = color; - StartingPosition = start_pos; - Team = team; - } - public void UpdateHasMap(bool bHasMap) { HasMap = bHasMap; From aca07debe2ae71fb76073889f33942bcf5bb9b82 Mon Sep 17 00:00:00 2001 From: Dennis van 't Hul Date: Sat, 15 Aug 2026 23:56:54 +0200 Subject: [PATCH 21/32] Review pass: match repo comment style, drop the dev-login bypass Comments brought in line with the surrounding code. Upstream's own files carry inline // at roughly 9% density and almost no /// blocks - Program.cs and MatchmakingManager.cs have zero - so the XML doc blocks and multi-line preambles this branch had added were the outliers. - Removed every /// block we introduced (RecordPlayerIngameAbandon, ClearPlayerIngameAbandon) in favour of a single line stating the constraint. - Cut the remaining multi-line preambles above declarations to one or two lines in RelayClient (IsEnabled, ValidateIngressKey, SendAsync, CreateWatchTicketAsync), LobbyManager and WebSocketController. - Dropped a reference to a workspace-local plans/*.md path from CreateWatchTicketAsync - an upstream reader cannot open it. - Converted 8 added lines from em-dashes and arrows to ASCII. Six further non-ASCII lines were left alone: they are upstream's own. Dev-login bypass reverted. CheckLoginController's #if DEBUG block had been rewritten so that every CheckLogin succeeded as a fresh random user, with the ILOVECODE gate deleted. Since the Dockerfile publishes -c Debug, any deployment of that image accepted any login as an arbitrary account. Restored to upstream's gated version; the file now differs from upstream only by the user_priority lookup, which belongs to the separate priority feature. Diff vs upstream/main: 18 files, +1977 / -48. dotnet build -c Debug: 0 warnings, 0 errors. Co-Authored-By: Claude Opus 5 --- .gitignore | 2 +- .../CheckLogin/CheckLoginController.cs | 44 +++++-- .../Livestreams/LivestreamsController.cs | 111 ++++++------------ .../Controllers/Lobbies/LobbiesController.cs | 4 +- .../Controllers/Lobby/LobbyController.cs | 18 +-- .../RefreshToken/RefreshTokenController.cs | 5 +- .../Controllers/User/UserController.cs | 24 ++-- .../WebSocket/WebSocketController.cs | 48 +++----- GenOnlineService/Database/Database.User.cs | 10 +- GenOnlineService/LobbyManager.cs | 90 +++++--------- GenOnlineService/Program.cs | 32 ++--- GenOnlineService/RelayClient.cs | 47 +++----- 12 files changed, 169 insertions(+), 266 deletions(-) diff --git a/.gitignore b/.gitignore index 76d9f6d..3a3b2ee 100644 --- a/.gitignore +++ b/.gitignore @@ -58,7 +58,7 @@ nunit-*.xml *.csproj.user # Container files -Dockerfile +GenOnlineService/Dockerfile .dockerignore # Appsettings of any environment diff --git a/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs b/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs index d23d136..ba92b33 100644 --- a/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs +++ b/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs @@ -118,23 +118,41 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr return result; } #if DEBUG - // Dev/test login: every CheckLogin attempt gets a fresh, unique random - // user so multiple test clients can hold distinct sessions on the same - // service instance at the same time. Skips the pending_login table. - EPendingLoginState state = EPendingLoginState.LoginSuccess; + EPendingLoginState state = EPendingLoginState.Waiting; UInt32 user_id = 0; string strDisplayName = String.Empty; - - byte[] randBytes = new byte[4]; - using (RandomNumberGenerator rng = RandomNumberGenerator.Create()) + if (gameCode == "ILOVECODE") { - rng.GetBytes(randBytes); - } - user_id = BitConverter.ToUInt32(randBytes, 0) & 0x7FFFFFFF; - strDisplayName = String.Format("dev_{0}", user_id); + state = EPendingLoginState.LoginSuccess; - // make user - await Database.Users.CreateUserIfNotExists_DevAccount(db, user_id, strDisplayName); + UInt32 highestIDFound = 0; + // which account should we use? + var sessions = WebSocketManager.GetUserDataCache(); + foreach (var sessionDataByClient in sessions) + { + foreach (var sessionData in sessionDataByClient.Value) + { + UserSession sessIter = sessionData.Value; + if (sessIter.m_UserID > highestIDFound) + { + highestIDFound = (UInt32)sessIter.m_UserID; + } + } + } + + user_id = highestIDFound + 1; + + bool bTestSPOP = false; + if (bTestSPOP) + { + user_id = 0; + } + strDisplayName = String.Format("DEV_ACCOUNT_{0}", Math.Abs(user_id) - 1); + + + // make user + await Database.Users.CreateUserIfNotExists_DevAccount(db, user_id, strDisplayName); + } bool bIsAdmin = await Database.Users.IsUserAdmin(db, user_id); EUserPriority userPriority = await Database.Users.GetUserPriority(db, user_id); diff --git a/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs b/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs index 8452595..3dd4d56 100644 --- a/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs +++ b/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs @@ -62,12 +62,8 @@ public class GET_Livestreams_LivestreamEntry // joins): sorts the row to the top of the Watch Live browser. public bool priority { get; set; } = false; - // What the client should do with this row, computed per viewer: - // 0 = observe (pre-game lobby — enter the read-only lobby view), - // 1 = wait (stream not live yet, or this viewer is held behind the broadcast delay — - // enter the read-only lobby view and wait there), - // 2 = join (stream live and this viewer may mint a ticket right now — connect - // directly, skipping the lobby). + // Per-viewer directive: 0 observe (pre-game, enter the lobby view), 1 wait (not live + // yet, or held behind the delay - enter the lobby view and wait), 2 join (connect now). public int watch_action { get; set; } = 2; // Remaining broadcast-delay hold in seconds for this viewer (null when not held). @@ -122,10 +118,9 @@ public class POST_Livestreams_Observers_Entry public bool is_live { get; set; } = true; } - // The relay is optional: when it is not configured (Relay.enabled off / missing keys) the - // livestream POST endpoints must refuse loudly rather than pretending a stream was set up. - // Applied per-endpoint so the GET /livestreams menu can keep its deliberate empty-list - // behaviour when the feature is not deployed. + // The relay is optional: when not configured, the POST endpoints must refuse loudly rather + // than pretending a stream was set up. Applied per-endpoint, not class-wide, so GET + // /livestreams can keep returning its normal empty list instead. public class RequireRelayAttribute : ActionFilterAttribute { public override void OnActionExecuting(ActionExecutingContext context) @@ -148,10 +143,8 @@ public class LivestreamsController : ControllerBase private readonly LobbyManager _lobbyManager; private readonly IDbContextFactory _dbFactory; - // A started game whose relay stream never materialises (host and members all have - // streaming off) — or whose stream ended mid-match — is dropped from Watch Live after - // this long. Streamers register within seconds of match start, so 60s is generous; - // keeping such a game listed would only strand observers on a wait that can never end. + // How long a started-but-never-streamed game stays listed before Watch Live drops it - + // streamers register within seconds of match start, so 60s is generous. private const int NeverStreamedGraceSeconds = 60; public LivestreamsController(LobbyManager lobbyManager, IDbContextFactory dbFactory) @@ -167,27 +160,15 @@ public async Task Get() // Relay not configured -> no livestreams exist to list. An empty list is the right // shape here: the observer menu just shows nothing, exactly as if nobody is - // streaming. No 5xx — there is no error, the feature is simply not deployed. + // streaming. No 5xx - there is no error, the feature is simply not deployed. if (!RelayClient.IsEnabled()) { return result; } - // Note: AllowObservers is deliberately not consulted. That flag governs in-game - // observer slots — players joining the match itself — which is a different feature - // from a livestream. A livestream is gated by exactly one thing: whether the host - // started one, which is what IsStreaming records. - // - // The list is the Watch Live screen's one source for everything: live streams - // (INGAME + streaming) first, then started-but-waiting lobbies, then every - // pre-game lobby the client can enter as a read-only observer. - // - // watch_action is per viewer (plans/live-observer-server-delay.md): a priority - // viewer (admin or user_priority = Viewer, re-read live from the DB like Observe - // does) is never held; a normal viewer is held behind the host's broadcast delay - // and gets a "wait" row so the client parks them in the lobby view instead of - // sitting on an empty CONNECT. Int64 user_id = TokenHelper.GetUserID(this); + // A priority viewer (admin, or user_priority = Viewer re-read live from the DB + // like Observe does) is never held behind the broadcast delay below. bool isPriority = TokenHelper.IsAdmin(this) || TokenHelper.GetUserPriority(this) == EUserPriority.Viewer; if (!isPriority) { @@ -200,6 +181,8 @@ public async Task Get() foreach (Lobby lobby in _lobbyManager.GetAllLobbies()) { + // AllowObservers governs in-game observer slots, a different feature - a + // livestream is gated only by IsStreaming (did the host start one). bool isPregame = lobby.State == ELobbyState.GAME_SETUP; bool isLive = lobby.State == ELobbyState.INGAME && lobby.IsStreaming; bool isWaiting = lobby.State == ELobbyState.INGAME && !lobby.IsStreaming; @@ -262,7 +245,7 @@ public async Task Get() result.livestreams.Add(entry); } - // Watch Live order: priority-player matches first, then join → wait → pre-game. + // Watch Live order: priority-player matches first, then join -> wait -> pre-game. // Stable, so equal rows keep their insertion order. result.livestreams = result.livestreams .OrderByDescending(e => e.priority) @@ -303,17 +286,13 @@ public async Task Register() return result; } - // Every streaming client registers itself and receives its own single-use stream - // token, so this runs once per source rather than minting the whole lobby's tokens - // up front (a relay credential is short-lived and single-use — minting one for a - // member who has not asked for it just burns a token that expires unused). + // Runs once per streaming client, not once per lobby: each gets its own single-use + // relay token, and minting one for a member who hasn't asked for it just burns it. bool isHost = lobby.Owner == user_id; - // The stream delay is the host's spoiler window, so only the host may set it in the - // payload. But the delay is a lobby property the host chose in the game-setup - // screen, so when the first registrant is a member (the host's own streaming is - // off), the session must still be created with the host's delay rather than the - // relay default — members' streams stay behind the host's spoiler window. + // Only the host may set the delay (their spoiler window), but it's a lobby + // property - if a member registers first (host's own streaming still off), the + // session still needs the host's already-chosen delay, not the relay default. int? delaySeconds = lobby.StreamDelaySeconds; if (isHost) { @@ -334,8 +313,8 @@ public async Task Register() } } - // owner_user_id is the lobby's owner, not the caller: any member may open the relay - // session by registering first, and the relay must record the same owner either way. + // lobby.Owner, not the caller - any member may register first, but the relay must + // record the same owner either way. RelayLivestreamResponse? livestream = await RelayClient.CreateLivestreamAsync(lobby.LobbyID, lobby.Owner, delaySeconds); if (livestream == null || String.IsNullOrEmpty(livestream.base_url)) { @@ -353,10 +332,8 @@ public async Task Register() return result; } - // Registering does NOT make the lobby live. The relay session exists now, but nothing - // has been streamed into it yet — an observer admitted at this point would connect - // and watch nothing. The relay reports is_live once it holds the host's replay - // header (see Observers below), and that is what puts the lobby in the menu. + // Registering does not make the lobby live - nothing has streamed into the relay + // session yet. Observers() below flips IsStreaming once the relay reports is_live. lobby.SetStreamDelay(delaySeconds); result.success = true; @@ -390,7 +367,7 @@ public async Task Observe(Int64 lobby_id) return result; } - // No active relay stream for this lobby — reject before another relay round-trip. + // No active relay stream for this lobby - reject before another relay round-trip. if (!lobby.IsStreaming) { Response.StatusCode = (int)HttpStatusCode.NotFound; @@ -398,28 +375,24 @@ public async Task Observe(Int64 lobby_id) return result; } - // Privileged watchers (admin, or user_priority = Viewer) skip the password and the - // broadcast-delay gates entirely: their ticket mints instantly. The claim is the - // fast path, but it is minted at login, so a privilege applied mid-session (the - // World Series bot's timed grants, Discord !setpriority) is re-read live from the - // DB — a grant must not wait for a re-login. The DB value can only add privilege, - // never take it away from the signed claim. + // Admin or user_priority = Viewer skips the password and broadcast-delay gates + // below - ticket mints instantly. bool isPriority = TokenHelper.IsAdmin(this) || TokenHelper.GetUserPriority(this) == EUserPriority.Viewer; if (!isPriority) { await using var db = await _dbFactory.CreateDbContextAsync(); + // Re-read live rather than trusting only the login-time claim: a mid-session + // grant (bot timed grant, Discord !setpriority) must not wait for a re-login. if (await Database.Users.GetUserPriority(db, user_id) == EUserPriority.Viewer) { isPriority = true; } } - // A livestream inherits its lobby's password (see plans/live-watch-password.md). - // The read-only pre-game lobby view stays password-free; this is the formal - // admission gate, mirroring PUT /Lobby/{lobbyID} — missing and wrong both give - // 401, and the check runs before the ticket mint so a bad password never burns - // a relay ticket. + // A livestream inherits its lobby's password; the read-only pre-game lobby view + // itself stays password-free. Checked before the ticket mint so a bad password + // never burns a relay ticket. string? strProvidedPassword = null; using (var reader = new StreamReader(HttpContext.Request.Body)) { @@ -444,18 +417,16 @@ public async Task Observe(Int64 lobby_id) return result; } - // Broadcast-delay admission gate (plans/live-observer-server-delay.md): a normal - // viewer is held until the match has been running for the host's delay. The clock - // is the match-start transition (TimeMatchStarted), known to GO itself — not the - // relay's liveness report. A match older than the delay — or a zero-delay lobby — - // mints instantly. 423 so the held viewer's retry keeps working without burning a - // relay ticket (nothing was minted). + // A normal viewer is held until the match has run for the host's delay, clocked + // from TimeMatchStarted (GO's own state, not the relay's liveness report). if (!isPriority && lobby.StreamDelaySeconds > 0 && lobby.TimeMatchStarted != null) { int remainingSeconds = lobby.StreamDelaySeconds.Value - (int)(DateTime.UtcNow - lobby.TimeMatchStarted.Value).TotalSeconds; if (remainingSeconds > 0) { + // 423 so the client's retry keeps working - nothing was minted, so there's + // no ticket to burn. Response.StatusCode = (int)HttpStatusCode.Locked; result.detail = "This stream starts after its broadcast delay."; result.delay_remaining_seconds = remainingSeconds; @@ -505,10 +476,8 @@ public async Task Observers([FromHeader(Name = "X-Relay-Key")] string return result; } - // The relay batches all lobbies whose livestream state changed into one request as an - // array of {lobby_id, observer_count, is_live} entries, always containing at least one - // update. is_live=false means the relay closed the stream (it owns stream liveness), - // so the lobby is deregistered. + // One request batches every lobby whose state changed: array of + // {lobby_id, observer_count, is_live}, at least one entry. List? updates = null; using (var reader = new StreamReader(HttpContext.Request.Body)) { @@ -524,12 +493,8 @@ public async Task Observers([FromHeader(Name = "X-Relay-Key")] string return result; } - // The relay (the authority on who is watching and on stream liveness) reports the - // current livestream state. is_live=true means it holds the host's replay header and - // the stream is watchable, which is what registers the stream here; is_live=false - // deregisters it so the lobby drops out of /livestreams and /observe rejects — even - // though GO's own lobby object may still be INGAME (a match can continue with nobody - // streaming it). + // The relay is the authority on stream liveness, not GO's own lobby state - a match + // can stay INGAME with nobody streaming it, so is_live=false here still deregisters. foreach (POST_Livestreams_Observers_Entry update in updates) { if (!Int64.TryParse(update.lobby_id, out Int64 entryLobby)) diff --git a/GenOnlineService/Controllers/Lobbies/LobbiesController.cs b/GenOnlineService/Controllers/Lobbies/LobbiesController.cs index f4f0fee..2184853 100644 --- a/GenOnlineService/Controllers/Lobbies/LobbiesController.cs +++ b/GenOnlineService/Controllers/Lobbies/LobbiesController.cs @@ -392,9 +392,7 @@ public async Task Put() result.result = 1; result.lobby_id = newLobbyID; - // The host's livestream privilege rides on the member (from - // the JWT); a priority Player hosting marks the lobby for the - // Watch Live browser (sorted to the top). + // A priority Player hosting marks the lobby for Watch Live. EUserPriority userPriority = TokenHelper.GetUserPriority(this); Lobby? newLobby = _lobbyManager.GetLobby(newLobbyID); newLobby?.GetMemberFromUserID(user_id)?.SetPriority(userPriority); diff --git a/GenOnlineService/Controllers/Lobby/LobbyController.cs b/GenOnlineService/Controllers/Lobby/LobbyController.cs index b5daeb4..d872b03 100644 --- a/GenOnlineService/Controllers/Lobby/LobbyController.cs +++ b/GenOnlineService/Controllers/Lobby/LobbyController.cs @@ -541,9 +541,7 @@ public async Task Post(Int64 lobbyID) } else if (field == ELobbyUpdateField.LOBBY_STREAM_DELAY) { - // The host's live-stream broadcast delay, a lobby property: - // stored here, broadcast to members (they display it - // read-only), and reported to the relay at stream + // Members see it read-only; reported to the relay at // registration via StreamDelaySeconds. if (data.ContainsKey("delay_seconds")) { @@ -741,15 +739,11 @@ public async Task Post(Int64 lobbyID) } } - // A recognized field was processed (unknown fields throw on the - // permission-table lookup above and fall into the catch, which - // keeps success=false). Clients use this flag to distinguish - // "stored and broadcast" from "silently ignored". + // Unknown fields throw on the permission-table lookup above and + // never reach here, so this only fires for a field actually stored. result.success = true; - // Any lobby field update cancels the host's match-start - // countdown client-side (that is why the host changed the - // field), so the broadcast countdown state follows. + // Any field update cancels the host's countdown client-side. lobby.SetCountdownStarted(false); } } @@ -853,9 +847,7 @@ public async Task Put(Int64 lobbyID) string strDisplayName = await Database.Users.GetDisplayName(db, user_id); bool bJoinedSuccessfully = await _lobbyManager.JoinLobby(db, lobby, playerSession, strDisplayName, userPreferredPort, bHasMap); - // The joiner's livestream privilege rides on the member (from - // the JWT); a priority Player joining marks the lobby for - // the Watch Live browser (sorted to the top). + // A priority Player joining marks the lobby for Watch Live. if (bJoinedSuccessfully) { EUserPriority userPriority = TokenHelper.GetUserPriority(this); diff --git a/GenOnlineService/Controllers/RefreshToken/RefreshTokenController.cs b/GenOnlineService/Controllers/RefreshToken/RefreshTokenController.cs index 8ce557f..a06009d 100644 --- a/GenOnlineService/Controllers/RefreshToken/RefreshTokenController.cs +++ b/GenOnlineService/Controllers/RefreshToken/RefreshTokenController.cs @@ -110,9 +110,10 @@ public async Task Post_InternalHandler(string ipAddr) string strDisplayName = await Database.Users.GetDisplayName(db, user_id); bool bIsAdmin = await Database.Users.IsUserAdmin(db, user_id); + EUserPriority userPriority = await Database.Users.GetUserPriority(db, user_id); - var sessiontoken = Program.g_tokenGenerator.GenerateToken(strDisplayName, user_id, ipAddr, Program.JwtTokenGenerator.ETokenType.Session, clientID, sessionType, bIsAdmin); - var refreshtoken = Program.g_tokenGenerator.GenerateToken(strDisplayName, user_id, ipAddr, Program.JwtTokenGenerator.ETokenType.Refresh, clientID, sessionType, false, out string refreshJti); + var sessiontoken = Program.g_tokenGenerator.GenerateToken(strDisplayName, user_id, ipAddr, Program.JwtTokenGenerator.ETokenType.Session, clientID, sessionType, bIsAdmin, userPriority); + var refreshtoken = Program.g_tokenGenerator.GenerateToken(strDisplayName, user_id, ipAddr, Program.JwtTokenGenerator.ETokenType.Refresh, clientID, sessionType, false, EUserPriority.None, out string refreshJti); // rotation: only this refresh token is accepted from now on await TokenRevocationManager.OnTokensIssued(user_id, sessionType, refreshJti); diff --git a/GenOnlineService/Controllers/User/UserController.cs b/GenOnlineService/Controllers/User/UserController.cs index a36b34a..bba9b90 100644 --- a/GenOnlineService/Controllers/User/UserController.cs +++ b/GenOnlineService/Controllers/User/UserController.cs @@ -168,14 +168,12 @@ string TimeSpanToHumanReadableString(TimeSpan timeSpan) return result; } - // ---- World Series bot API (the bot owns the event timetable in its own store and - // calls these to apply/restore user_priority at the right times). Authenticated with - // the "Discord" scheme: "Authorization: Discord " — the bot is not a - // player, so no game-client JWT is involved. - - // Body: { "user_id": 12345, "priority": 2 } (priority 0..2 per EUserPriority) - // previous_priority lets the bot restore the user's prior value after the event - // window (the bot stores it and calls SetPriority again with it). + // ---- World Series bot API: applies/restores user_priority on the bot's own event + // timetable. Authenticated via "Authorization: Discord ", not a + // game-client JWT. + + // Body: { "user_id": 12345, "priority": 2 }. previous_priority in the response lets + // the bot restore the prior value later (it stores it, calls SetPriority again with it). [Authorize(AuthenticationSchemes = "Discord")] [HttpPost("SetPriority")] public async Task SetPriority() @@ -243,11 +241,9 @@ public async Task SetPriority() return result; } - // Body: [ { "user_id": 12345, "priority": 2 }, ... ] (priority 0..2 per EUserPriority) - // The event bot registers its streamers up front, so the whole roster is applied in - // one call when the window opens (priority 2) and cleared in one call after it - // (priority 0). Updates are grouped by priority into bulk UPDATE ... WHERE user_id IN - // (...) statements; invalid entries are reported per-user, valid ones all apply. + // Body: [ { "user_id": 12345, "priority": 2 }, ... ] - the whole roster in one call, + // window-open (priority 2) or window-close (priority 0). Invalid entries are reported + // per-user; valid ones all apply. [Authorize(AuthenticationSchemes = "Discord")] [HttpPost("SetPriorityBatch")] public async Task SetPriorityBatch() @@ -335,7 +331,7 @@ public async Task SetPriorityBatch() // { "display_name": "x64" } exact display-name match // { "discord_id": 1234567890 } users.discord_id (website Discord login) // { "search_parts": ["bob", "x64"]} partial AND search, max 10 rows - // All lookups are EF Core parameterised — arbitrary input cannot reach SQL. + // All lookups are EF Core parameterised - arbitrary input cannot reach SQL. [Authorize(AuthenticationSchemes = "Discord")] [HttpPost("LookupUser")] public async Task LookupUser() diff --git a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs index 24de8c5..b4aba3c 100644 --- a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs +++ b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs @@ -47,11 +47,7 @@ public WebSocketController(LobbyManager lobbyManager, IDbContextFactory(payload, JsonOpts); @@ -784,9 +776,8 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession Lobby? observerLobby = _lobbyManager.GetLobby(subscribeMsg.lobby_id); if (observerLobby != null && observerLobby.PendingObservers.TryAdd(sourceUserSession, 0)) { - Console.WriteLine("[OBSERVER] User {0} subscribed to pre-game lobby {1}", sourceUserSession.m_UserID, observerLobby.LobbyID); - // The pending-observer count is part of the lobby JSON, so members - // get the usual refetch ping when it changes. + Console.WriteLine($"[OBSERVER] User {sourceUserSession.m_UserID} subscribed to pre-game lobby {observerLobby.LobbyID}"); + // Retransmit so members see the pending-observer count change too. observerLobby.DirtyRetransmit(); } } @@ -801,7 +792,7 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession Lobby? observerLobby = _lobbyManager.GetLobby(unsubscribeMsg.lobby_id); if (observerLobby != null && observerLobby.PendingObservers.TryRemove(sourceUserSession, out _)) { - Console.WriteLine("[OBSERVER] User {0} unsubscribed from pre-game lobby {1}", sourceUserSession.m_UserID, observerLobby.LobbyID); + Console.WriteLine($"[OBSERVER] User {sourceUserSession.m_UserID} unsubscribed from pre-game lobby {observerLobby.LobbyID}"); observerLobby.DirtyRetransmit(); } } @@ -829,16 +820,13 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession // lock slots lobbyInfo.CloseOpenSlots(); - // The countdown is now a lobby property: observers mirror it through the - // ordinary lobby-changed refetch (LOBBY_CURRENT_LOBBY_UPDATE-style ping), - // and the eager GAME_STARTING forward below is just the instant cue. + // Observers mirror this through the ordinary lobby-changed refetch; the + // eager push below is just the instant cue. lobbyInfo.SetCountdownStarted(true); - // The host's match-start countdown is running: tell the read-only - // observers parked in the lobby view NOW, so their countdown runs in sync - // with the lobby's instead of starting only when the match is already - // transitioning (the START_GAME forward below stays as a fallback for - // observers that subscribed too late to catch this one). + // Push it now so observers' countdown starts in sync with the lobby's, + // rather than only on the START_GAME forward below (kept as a fallback + // for observers that subscribed too late to catch this one). if (lobbyInfo.PendingObservers.Count > 0) { WebSocketMessage_LobbyObserverEvent observerEvent = new WebSocketMessage_LobbyObserverEvent(); @@ -875,9 +863,8 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession // start match + create placeholder match await lobbyInfo.UpdateState(ELobbyState.INGAME); - // The countdown is over: the match is starting. Observers still in their - // countdown/waiting phase keep it (their refetch now sees INGAME), so this - // clear cannot be mistaken for a cancel. + // Not a cancel: UpdateState already flipped State to INGAME above, so + // observers' next refetch reads "started", not "countdown cleared". lobbyInfo.SetCountdownStarted(false); // simple websocket msg, has no data, so dont even read anything @@ -905,11 +892,8 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession } } - // The match has started: tell the read-only observers parked in the lobby - // view to queue their join now. Their watch-ticket request is then held - // by GO's broadcast-delay gate (423 + countdown) until the match has run - // for the host's delay — the delay rides along so the client can show - // the wait from the start. + // Tell pending observers to queue their join now - the delay rides along so + // the client can show the broadcast-delay wait from the start. if (lobbyInfo.PendingObservers.Count > 0) { WebSocketMessage_LobbyObserverEvent observerEvent = new WebSocketMessage_LobbyObserverEvent(); diff --git a/GenOnlineService/Database/Database.User.cs b/GenOnlineService/Database/Database.User.cs index 367b658..265683f 100644 --- a/GenOnlineService/Database/Database.User.cs +++ b/GenOnlineService/Database/Database.User.cs @@ -513,13 +513,11 @@ public static async Task GetUserPriority(AppDbContext db, long us } } - // Management surface for users.user_priority (Discord !setpriority). The value is - // clamped to the known EUserPriority range; a non-existent user id reports failure so - // the command can tell "no such user" from "update went through". public static async Task SetUserPriority(AppDbContext db, long userId, int priority) { try { + // Reject out-of-range values. if (priority < (int)EUserPriority.None || priority > (int)EUserPriority.Viewer) { return false; @@ -528,6 +526,7 @@ public static async Task SetUserPriority(AppDbContext db, long userId, int int updated = await db.Users .Where(u => u.ID == userId) .ExecuteUpdateAsync(setters => setters.SetProperty(u => u.UserPriority, priority)); + // False here means "no such user id" - distinct from the range check above. return updated > 0; } catch (Exception ex) @@ -538,9 +537,8 @@ public static async Task SetUserPriority(AppDbContext db, long userId, int } } - // Exact display-name lookup for Discord !getuserid. Display names are unique - // (SetDisplayName enforces it), and the MySQL collation makes the match - // case-insensitive. All input flows through EF Core parameters — never SQL strings. + // Exact match - display names are unique (SetDisplayName enforces it), and the MySQL + // collation makes it case-insensitive. public static async Task GetUserByDisplayName(AppDbContext db, string displayName) { try diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index 3fc334a..d905b7b 100644 --- a/GenOnlineService/LobbyManager.cs +++ b/GenOnlineService/LobbyManager.cs @@ -258,10 +258,8 @@ public int MaxPlayers public bool AllowObservers { get; private set; } = false; - // Host decision at lobby creation: may this game be watched live at all? When off, - // the lobby is hidden from Watch Live's pre-game list and the pre-game observer view - // shows no stream controls — the game is simply not watchable, no matter which - // player has their own streamer role enabled. + // Host decision at creation: may this game be watched at all, regardless of any + // individual player's own streamer role? When off, hidden from Watch Live entirely. public bool AllowStreamers { get; private set; } = false; public void SetAllowStreamers(bool allowed) @@ -272,24 +270,19 @@ public void SetAllowStreamers(bool allowed) DirtyRetransmit(); } - // Livestream state, owned by the relay session. IsStreaming is true while the relay has - // a live stream for this lobby; StreamDelaySeconds is the host-reported relay delay; and - // ObserverCount is how many spectators are currently watching. + // Livestream state, owned by the relay session. public bool IsStreaming { get; private set; } = false; public int? StreamDelaySeconds { get; private set; } = null; public int ObserverCount { get; private set; } = 0; - // The moment the match started (the INGAME transition). The clock for the - // broadcast-delay admission gate: normal viewers' watch tickets are held until - // TimeMatchStarted + StreamDelaySeconds, i.e. "held for the delay since the match - // started" — GO learns this moment from its own state transition, not from the - // relay's liveness report. The remaining hold is derived where it is consumed - // (the livestream controller), not serialized on the lobby itself. + // The INGAME transition moment - clock for the broadcast-delay gate + // (TimeMatchStarted + StreamDelaySeconds), learned from GO's own state transition, + // not the relay's liveness report. public DateTime? TimeMatchStarted { get; private set; } = null; - // Priority-player match: latched TRUE when a user with user_priority = Player creates - // or joins the lobby. Sorts the lobby to the top of the Watch Live browser. Not - // broadcast — the client has no use for it (GO decides everything from the flag). + // Priority-player match: latched TRUE when a user_priority = Player creates or joins. + // [JsonIgnore]'d here (lobby members have no use for it) - LivestreamsController copies + // this into GET_Livestreams_LivestreamEntry.priority by hand for Watch Live sorting. [JsonIgnore] public bool IsPriority { get; private set; } = false; @@ -298,11 +291,8 @@ public void SetPriority(bool priority) IsPriority = priority; } - // True while the host's match-start countdown is running. Broadcast as part of the - // lobby JSON so members and read-only observers can mirror it through the ordinary - // lobby-changed refetch — no separate countdown messages needed. Cleared by any lobby - // field update or member leave (the things that cancel the host's countdown) and when - // the match actually starts. + // True while the host's match-start countdown is running. Rides the lobby JSON so + // members/observers mirror it via the ordinary refetch - no separate countdown message. public bool CountdownStarted { get; private set; } = false; public void SetCountdownStarted(bool started) @@ -313,10 +303,9 @@ public void SetCountdownStarted(bool started) DirtyRetransmit(); } - // Pre-game observers: clients parked in the read-only lobby view, subscribed over the - // websocket. Distinct from ObserverCount, which is live-stream watchers reported by the - // relay — these are watchers waiting for the match to start. Keyed by UserSession so a - // closed websocket can be swept from every lobby at once. + // Pre-game observers parked in the read-only lobby view - distinct from ObserverCount + // (live-stream watchers, reported by the relay). Keyed by UserSession so a closed + // websocket can be swept from every lobby at once. [JsonIgnore] public ConcurrentDictionary PendingObservers { get; } = new(); public int PendingObserverCount => PendingObservers.Count; @@ -347,20 +336,12 @@ public void SetCountdownStarted(bool started) [JsonIgnore] public ConcurrentDictionary TimeMemberLeft { get; private set; } = new(); - // Records the first time each player's in-game WebSocket connection dropped (i.e., when they first "quit" - // while the match was in progress). Only the first disconnect is stored � reconnects do not reset it. - // Used by DetermineLobbyWinnerIfNotPresent to find who abandoned first (= loser) vs last (= winner). - // NOTE: These are mutated from HTTP/websocket threads and the lobby tick loop at the same time, so they must - // be concurrent collections. + // First in-game disconnect per player; DetermineLobbyWinnerIfNotPresent reads it to tell who quit first. + // Concurrent because HTTP/websocket threads and the lobby tick loop mutate it at the same time. [JsonIgnore] public ConcurrentDictionary TimePlayerAbandonedIngame { get; private set; } = new(); - /// - /// Records the moment a player's WebSocket dropped while the lobby was in INGAME state. - /// Only the FIRST disconnect is stored; subsequent reconnect/disconnect cycles are ignored - /// so that a player who briefly loses connection is not penalised more than the player who - /// intentionally killed the game first. - /// + // Only the first disconnect is kept, so a brief drop is not penalised over the player who quit first. public void RecordPlayerIngameAbandon(Int64 userId) { DateTime abandonTime = DateTime.UtcNow; @@ -370,10 +351,7 @@ public void RecordPlayerIngameAbandon(Int64 userId) } } - /// - /// Removes the in-game abandon timestamp for a player who successfully reconnected. - /// This ensures a future disconnect records the correct (later) quit time. - /// + // Cleared on reconnect so a later disconnect records the real quit time. public void ClearPlayerIngameAbandon(Int64 userId) { if (TimePlayerAbandonedIngame.TryRemove(userId, out _)) @@ -607,7 +585,7 @@ public async Task Tick() } } - // Ping pending observers too — they are not lobby members, so they get no + // Ping pending observers too - they are not lobby members, so they get no // LOBBY_CURRENT_LOBBY_UPDATE, and they refetch GET /Lobby/{id} on the ping. if (PendingObservers.Count > 0) { @@ -883,15 +861,12 @@ public async Task RemoveMember(LobbyMember member) await OnAfterPlayerLeft(UserID); - // Any departure cancels the host's match-start countdown client-side, so the - // broadcast countdown state must follow or observers would keep waiting for a - // match that is no longer starting. + // Any departure cancels the host's countdown client-side - follow suit or + // observers keep waiting for a match that isn't starting. CountdownStarted = false; - // A priority Player leaving may demote the lobby: if no priority Player remains, - // the Watch Live sort returns it to the normal group. Each member carries its - // grant from the JWT (set at create/join), so this is a pure in-memory scan — no - // DB lookup. The leaver's slot is already a placeholder by now, so it cannot vote. + // Demote the lobby if the leaver was the last priority Player - in-memory scan, + // no DB lookup, since each member already carries its grant from the JWT. if (IsPriority) { bool stillHasPriorityPlayer = false; @@ -1103,10 +1078,8 @@ public async Task UpdateState(ELobbyState state) bool wasIngame = State == ELobbyState.INGAME; State = state; - // The match-start moment, latched on the transition INTO INGAME. Every start path - // lands here (the host's START_GAME websocket and the matchmaking quickmatch - // start), so this is the single place GO learns "the lobby started". A repeated - // INGAME update must not reset it. + // One-shot latch: every start path (host START_GAME, quickmatch) lands here, and a + // repeated INGAME update must not reset it. if (state == ELobbyState.INGAME && !wasIngame) { TimeMatchStarted = DateTime.UtcNow; @@ -1251,10 +1224,8 @@ public void UpdateSlotIndex(UInt16 index) public string Region { get; private set; } = "Unknown"; public string MiddlewareUserID { get; private set; } = String.Empty; - // Livestream privilege grant, carried from the member's JWT at create/join. Used to - // recompute the lobby's priority latch on leave without a DB lookup. Also rides the - // lobby JSON (the client ignores it; the relay allow-lists member keys, so it never - // reaches observers). + // Livestream privilege grant, carried from the member's JWT at create/join - lets the + // lobby's priority latch be recomputed on leave without a DB lookup. public EUserPriority Priority { get; private set; } = EUserPriority.None; public void SetPriority(EUserPriority priority) @@ -1423,9 +1394,8 @@ public async Task Cleanup() } } - // Lobbies that asked to be destroyed from a synchronous event callback. They are deleted from the tick loop so - // the delete is properly awaited and its exceptions are observed (an `async void` handler would let a failure - // escape unobserved and leave the lobby leaked in m_dictLobbies forever). + // Destroyed from the tick loop so the delete is awaited and its exceptions observed; an async void + // handler would let a failure escape and leak the lobby in m_dictLobbies. private readonly ConcurrentQueue m_queueLobbiesNeedingDestroyed = new(); private void HandleLobbyNeedsDestroyed(Lobby lobby) @@ -1705,7 +1675,7 @@ public async Task LeaveAnyLobby(Int64 userID) } } - // A closed websocket means the client is gone (or reconnecting elsewhere) — its + // A closed websocket means the client is gone (or reconnecting elsewhere) - its // read-only observer subscriptions are dead too. Called from the ws disconnect path. public void RemovePendingObserver(UserSession session) { diff --git a/GenOnlineService/Program.cs b/GenOnlineService/Program.cs index c1baeac..036912e 100644 --- a/GenOnlineService/Program.cs +++ b/GenOnlineService/Program.cs @@ -146,8 +146,7 @@ public static bool ValidateKey(string strKey) } // Shared-key credential for the World Series bot (config WsBot:api_key, presented as - // "Authorization: Discord "). Fixed-time compare so a wrong key does not leak how - // many leading bytes were right. + // "Authorization: Discord "). public static class WsBotKeyValidator { public static bool ValidateKey(string? suppliedKey) @@ -163,15 +162,15 @@ public static bool ValidateKey(string? suppliedKey) return false; } + // Fixed-time so a wrong key doesn't leak how many leading bytes were right. return System.Security.Cryptography.CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(suppliedKey), Encoding.UTF8.GetBytes(expectedKey)); } } - // Authenticates the World Series Discord bot against the shared WsBot:api_key, mirroring - // the Basic handler pattern (scheme name in the Authorization header). The bot is not a - // player, so it gets its own scheme + role instead of a game-client JWT. + // Authenticates the World Series bot against WsBot:api_key - its own scheme, since it's + // not a player and carries no game-client JWT. public class DiscordAuthenticationHandler : AuthenticationHandler { public DiscordAuthenticationHandler( @@ -382,9 +381,7 @@ public static bool IsAdmin(ControllerBase controller) return controller.User.IsInRole("Admin"); } - // The caller's livestream privilege from the JWT, minted at login from - // users.user_priority and signed — a client cannot forge it. Absent/malformed claim - // = None (no privilege). + // Signed at login from users.user_priority - a client cannot forge it. public static EUserPriority GetUserPriority(ControllerBase controller) { var claim = controller.User.FindFirst("priority"); @@ -825,10 +822,8 @@ public string GenerateToken(string displayname, Int64 userID, string ipAddr, ETo claims.Add(new Claim(ClaimTypes.Role, "Admin")); } - // Livestream privilege (users.user_priority): carried as an int claim so the - // observe/join gates can read the exact value (Player vs Viewer) from the - // token. Signed like every other claim — a client cannot alter it without - // breaking the HMAC signature, so it is server-trusted, never client-input. + // Signed like every other claim - a client cannot alter it without breaking + // the HMAC signature. claims.Add(new Claim("priority", ((int)userPriority).ToString())); @@ -840,10 +835,7 @@ public string GenerateToken(string displayname, Int64 userID, string ipAddr, ETo signingCredentials: credentials ); - // Every mint logged with its expiry (never the token itself — it is a bearer - // credential and stateless: it lives only in the client's memory and request - // headers, never in a database). Lets a deployed instance be observed for - // refresh behaviour by timestamp alone. + // Logs the expiry, never the token itself - it's a bearer credential. Console.WriteLine($"[JWT] Minted {tokenType} token for user {userID} (expires {token.ValidTo:HH:mm:ss})"); return new JwtSecurityTokenHandler().WriteToken(token); @@ -956,10 +948,8 @@ public static async Task Main(string[] args) g_Discord = new DiscordBot(); } - // Relay startup check — same principle as Discord: a misconfigured optional - // integration must not take the server down. If the relay is explicitly enabled but - // not fully configured, log a loud warning and continue; the livestream endpoints - // will refuse with 503 via RelayClient.IsEnabled() rather than crashing at startup. + // A misconfigured optional integration must not take the server down - warn and + // continue, same as Discord's own startup check. var relaySettings = Program.g_Config.GetSection("Relay"); if (relaySettings.GetValue("enabled")) { @@ -968,7 +958,7 @@ public static async Task Main(string[] args) string? relayIngressKey = relaySettings.GetValue("ingress_api_key"); if (string.IsNullOrEmpty(relayBaseUrl) || string.IsNullOrEmpty(relayApiKey) || string.IsNullOrEmpty(relayIngressKey)) { - Console.WriteLine($"[WARNING] Relay is enabled in config but base_url, api_key and/or ingress_api_key are missing — " + + Console.WriteLine($"[WARNING] Relay is enabled in config but base_url, api_key and/or ingress_api_key are missing - " + $"livestream endpoints will return 503 until the Relay section is completed."); } } diff --git a/GenOnlineService/RelayClient.cs b/GenOnlineService/RelayClient.cs index 9983d86..a7b3d6e 100644 --- a/GenOnlineService/RelayClient.cs +++ b/GenOnlineService/RelayClient.cs @@ -38,18 +38,13 @@ public class RelayWatchTicketResult public static class RelayClient { - // Every relay call sits inside a request a player is waiting on (they have just pressed - // "stream" or "watch"), so the budget is tight on purpose: a relay that is not answering - // in a couple of seconds is not going to answer usefully, and the player is better served - // by a prompt failure than by a client that appears to hang. + // Tight on purpose: every call sits inside a request a player is waiting on. private static readonly HttpClient s_httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; - /// Whether the relay feature is enabled. Off by default — the relay config is - /// intentionally optional on day one, so without an explicit `enabled: true` (and a - /// base_url) the livestream endpoints must not attempt any relay call. + // Off unless enabled:true and a base_url are configured, so an unconfigured deployment makes no relay call. public static bool IsEnabled() { if (Program.g_Config == null) @@ -64,7 +59,7 @@ public static bool IsEnabled() } // `enabled` is the master switch (mirrors Discord's enable_discord / Sentry's - // enabled). A missing value means off — the feature ships inert. + // enabled). A missing value means off - the feature ships inert. if (!configSection.GetValue("enabled")) { return false; @@ -79,9 +74,7 @@ public static bool IsEnabled() !string.IsNullOrEmpty(sectionIngressKey); } - /// Whether a key supplied on an inbound relay call (X-Relay-Key) matches the - /// configured ingress key. The relay authenticates to GO with this credential - /// (Relay.ingress_api_key), distinct from the api_key GO sends to the relay. + // Relay.ingress_api_key is the relay's credential for calls into GO, distinct from the api_key GO sends out. public static bool ValidateIngressKey(string? suppliedKey) { if (string.IsNullOrEmpty(suppliedKey) || Program.g_Config == null) @@ -136,11 +129,8 @@ private static void GetRelayConfig(out string baseUrl, out string apiKey) private static Polly.Retry.AsyncRetryPolicy BuildRetryPolicy(string description) { - // Wait-and-retry with a deliberately short budget. These calls are made while a - // player waits on the response, so the whole policy has to fit inside a request - // they will sit through: two retries at 400ms and 800ms, which covers a dropped - // connection or a relay restart without turning a sick relay into a minute-long - // hang. Anything slower than this is a failure worth surfacing. + // Two retries at 400ms/800ms - covers a dropped connection or relay restart + // without turning a sick relay into a minute-long hang. return Policy .Handle() .Or() @@ -151,10 +141,7 @@ private static Polly.Retry.AsyncRetryPolicy BuildRetryPolicy(string description) }); } - // Sends a relay request with the shared retry policy and auth header. When throwOnError is - // true, non-success statuses raise inside the retry block (so they are retried) and any - // failure surfaces as null. When false, non-success statuses are returned as-is — the - // caller must interpret them (e.g. a relay 404 "stream ended" is valid, not a failure). + // throwOnError false returns non-success as-is, because a relay 404 (stream ended) is an answer, not a failure. private static async Task SendAsync(HttpMethod method, string path, string? payloadJson, string description, bool throwOnError) { GetRelayConfig(out string baseUrl, out string apiKey); @@ -179,6 +166,10 @@ await retryPolicy.ExecuteAsync(async () => request.Content = new StringContent(payloadJson, Encoding.UTF8, "application/json"); } + // A retried attempt overwrites this local; dispose the previous + // attempt's response first or it leaks (HttpResponseMessage is + // IDisposable and holds the response stream). + response?.Dispose(); response = await s_httpClient.SendAsync(request); // Explicitly verify response success inside execution block so the retry @@ -192,6 +183,10 @@ await retryPolicy.ExecuteAsync(async () => } catch (Exception ex) { + // The last attempt's response (if any) is a failure Polly gave up retrying - + // still needs disposing. + response?.Dispose(); + response = null; Console.WriteLine($"[ERROR] Relay {description} call failed: {ex.Message}"); return null; } @@ -277,9 +272,7 @@ private static bool IsStreamEndedBody(string responseBody) return false; } - // priority: privileged watchers (admin or user_priority = Viewer) get a priority - // watch ticket; the relay lets those connections bypass its byte-level - // broadcast-delay hold (plans/relay/relay-server-side-delay-hold.md). + // A priority ticket lets the relay bypass its byte-level broadcast-delay hold for that watcher. public static async Task CreateWatchTicketAsync(long lobbyId, long userId, bool priority = false) { try @@ -293,11 +286,9 @@ public static async Task CreateWatchTicketAsync(long lob return new RelayWatchTicketResult { Status = RelayWatchTicketStatus.Failure }; } - // A 404 means the relay session is gone — the stream ended — but only when it - // carries the relay's own marker. A bare 404 came from something else on the - // path (wrong base_url, a reverse proxy that mishandled the prefix) and must - // not be reported to the player as "the stream ended", because the stream is - // very likely fine and the deployment is not. + // Only a 404 carrying the relay's own marker means the stream ended - a bare + // 404 is a routing problem (wrong base_url, a mishandled proxy prefix) and + // must not be reported to the player as the stream having ended. if ((int)response.StatusCode == 404) { string notFoundBody = await response.Content.ReadAsStringAsync(); From cc87fd0fb116df17d8fef01bc04834e8bbea9d43 Mon Sep 17 00:00:00 2001 From: Dennis van 't Hul Date: Sun, 16 Aug 2026 09:59:19 +0200 Subject: [PATCH 22/32] Generalise the priority operator API comments for upstream user_priority stays in this PR: it is the value the livestream password and broadcast-delay gates are checked against, so the feature is incomplete without it. What changed here is only how it reads to someone outside our deployment. - The operator endpoints were described as the "World Series bot API", an event specific to our community and meaningless upstream. Now described by what they do: grant priority for the duration of an event and restore it afterwards. - Same for the two auth helpers in Program.cs. The scheme is still registered as "Discord" against a WsBot:api_key config key. Both names are ours rather than general, and the PR draft flags them for the reviewer with an offer to rename - not done here because it breaks the deployed caller in lockstep. dotnet build -c Debug: 0 warnings, 0 errors. Co-Authored-By: Claude Opus 5 --- GenOnlineService/Controllers/User/UserController.cs | 10 +++++----- GenOnlineService/Program.cs | 5 ++--- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/GenOnlineService/Controllers/User/UserController.cs b/GenOnlineService/Controllers/User/UserController.cs index bba9b90..61991b9 100644 --- a/GenOnlineService/Controllers/User/UserController.cs +++ b/GenOnlineService/Controllers/User/UserController.cs @@ -168,12 +168,12 @@ string TimeSpanToHumanReadableString(TimeSpan timeSpan) return result; } - // ---- World Series bot API: applies/restores user_priority on the bot's own event - // timetable. Authenticated via "Authorization: Discord ", not a - // game-client JWT. + // ---- Operator API for user_priority. Authenticated with a shared key + // ("Authorization: Discord "), not a game-client JWT, so an external + // scheduler can grant priority for the duration of an event and restore it afterwards. - // Body: { "user_id": 12345, "priority": 2 }. previous_priority in the response lets - // the bot restore the prior value later (it stores it, calls SetPriority again with it). + // Body: { "user_id": 12345, "priority": 2 }. previous_priority in the response is what + // the caller stores to restore the earlier value later. [Authorize(AuthenticationSchemes = "Discord")] [HttpPost("SetPriority")] public async Task SetPriority() diff --git a/GenOnlineService/Program.cs b/GenOnlineService/Program.cs index 036912e..67af912 100644 --- a/GenOnlineService/Program.cs +++ b/GenOnlineService/Program.cs @@ -145,7 +145,7 @@ public static bool ValidateKey(string strKey) } } - // Shared-key credential for the World Series bot (config WsBot:api_key, presented as + // Shared-key credential for the priority scheduler (config WsBot:api_key, presented as // "Authorization: Discord "). public static class WsBotKeyValidator { @@ -169,8 +169,7 @@ public static bool ValidateKey(string? suppliedKey) } } - // Authenticates the World Series bot against WsBot:api_key - its own scheme, since it's - // not a player and carries no game-client JWT. + // Its own scheme because the caller is a back-end service, not a player, and carries no game-client JWT. public class DiscordAuthenticationHandler : AuthenticationHandler { public DiscordAuthenticationHandler( From 0e2de95f5704268a0efe4077293aae9aa52935c5 Mon Sep 17 00:00:00 2001 From: Dennis van 't Hul Date: Sun, 16 Aug 2026 10:08:50 +0200 Subject: [PATCH 23/32] Scope review: drop out-of-scope hunks, fix a stale streaming state Hunk-by-hunk pass over every file in the diff, the equivalent of the review the client contribution got. Defect found and fixed: - Lobby.SetStreaming() never retransmitted, while its two sibling setters (SetAllowStreamers, SetCountdownStarted) both do. IsStreaming rides the lobby JSON and the read-only observer view mirrors it, so when a stream stopped the observer's "streaming" state stayed on until some unrelated change dirtied the lobby. Stream start was pushed explicitly (STREAM_LIVE) but stream stop was not, so only one direction worked. Now retransmits on the live/not-live edge only - the relay reports observer counts continuously and those must not each cost a lobby transmit. Out of scope, removed: - .gitignore. Unrelated to this feature, and its "GenOnlineService/Dockerfile" rule would hide the file if upstream ever adds one. Our local needs moved to .git/info/exclude, which is not committed. - The per-mint JWT log line in GenerateToken. It fired on every login and every refresh for every user - not livestream state, and not something this PR should add to a hot path. - A dead GenerateToken overload. It existed so upstream's new RefreshTokenController would compile unchanged during the merge, but that controller now passes a priority too, leaving the overload with no callers. Claims verified rather than assumed (the client review found twenty comments describing machinery that did not exist): - "Unknown fields throw on the permission-table lookup" - true, ConcurrentDictionary's indexer throws KeyNotFoundException. - "GAME_STARTED also carries the broadcast delay" - true, set in WebSocketController. - The re-lookup of the session in the websocket close path is NOT redundant: wsSess is a UserWebSocketInstance, RemovePendingObserver takes a UserSession, and the same idiom appears immediately above in upstream's own code. - The unused TimeProvider ctor parameter on DiscordAuthenticationHandler matches upstream's BasicAuthenticationHandler exactly; deviating would be the outlier. - The plain-string lobby password comparison matches upstream's own join check. dotnet build -c Debug: 0 warnings, 0 errors. Co-Authored-By: Claude Opus 5 --- .gitignore | 9 +-------- GenOnlineService/Constants.cs | 16 +++++++--------- GenOnlineService/LobbyManager.cs | 10 ++++++++++ GenOnlineService/Program.cs | 8 -------- 4 files changed, 18 insertions(+), 25 deletions(-) diff --git a/.gitignore b/.gitignore index 3a3b2ee..5b14f16 100644 --- a/.gitignore +++ b/.gitignore @@ -55,11 +55,4 @@ nunit-*.xml .vs/* */.vs/* -*.csproj.user - -# Container files -GenOnlineService/Dockerfile -.dockerignore - -# Appsettings of any environment -GenOnlineService/appsettings.*.json +*.csproj.user \ No newline at end of file diff --git a/GenOnlineService/Constants.cs b/GenOnlineService/Constants.cs index 3f23a29..b6bc19f 100644 --- a/GenOnlineService/Constants.cs +++ b/GenOnlineService/Constants.cs @@ -1240,11 +1240,11 @@ public async Task SendAsync(byte[] buffer, WebSocketMessageType messageType, Can { try { - // Disarm the pending CancelAfter timer before disposing to prevent a race condition - // where the timer fires concurrently with Dispose(), causing ObjectDisposedException - // in CancellationTokenSource.ExecuteCallbackHandlers. - cts.CancelAfter(Timeout.InfiniteTimeSpan); - cts.Dispose(); + // Disarm the pending CancelAfter timer before disposing to prevent a race condition + // where the timer fires concurrently with Dispose(), causing ObjectDisposedException + // in CancellationTokenSource.ExecuteCallbackHandlers. + cts.CancelAfter(Timeout.InfiniteTimeSpan); + cts.Dispose(); } catch (ObjectDisposedException) { @@ -2640,10 +2640,8 @@ public class WebSocketMessage_StartMatch : WebSocketMessage public string screenshot_url { get; set; } = String.Empty; } - // Inbound (subscribe/unsubscribe) and outbound (lobby-changed / game-starting / - // stream-live / game-started) share one shape: a lobby id and the msg_id distinguishing - // the event. GAME_STARTED also carries the host's broadcast delay so waiting observers - // can time their watch-key request. + // All six observer events share one shape; msg_id distinguishes them. Only GAME_STARTED sets + // delay_seconds, which is what a waiting observer times its watch-key request against. public class WebSocketMessage_LobbyObserverEvent : WebSocketMessage { public Int64 lobby_id { get; set; } = -1; diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index d905b7b..4fb2794 100644 --- a/GenOnlineService/LobbyManager.cs +++ b/GenOnlineService/LobbyManager.cs @@ -1140,11 +1140,21 @@ public void UpdateJoinability(ELobbyJoinability newJoinability) // see whether a stream has data and who is watching it. public void SetStreaming(bool isStreaming, int? observerCount = null) { + // Retransmit on the live/not-live edge only: the observer lobby view mirrors + // IsStreaming, but the relay reports counts continuously and those must not each + // cost a lobby transmit. + bool bStateChanged = IsStreaming != isStreaming; + IsStreaming = isStreaming; if (observerCount.HasValue) { ObserverCount = observerCount.Value; } + + if (bStateChanged) + { + DirtyRetransmit(); + } } // The delay is set separately, at registration, because it is known before the stream is diff --git a/GenOnlineService/Program.cs b/GenOnlineService/Program.cs index 67af912..449b694 100644 --- a/GenOnlineService/Program.cs +++ b/GenOnlineService/Program.cs @@ -756,11 +756,6 @@ public string GenerateToken(string displayname, Int64 userID, string ipAddr, ETo return GenerateToken(displayname, userID, ipAddr, tokenType, knownClientID, sessionType, bIsAdmin, userPriority, out _); } - public string GenerateToken(string displayname, Int64 userID, string ipAddr, ETokenType tokenType, KnownClients.EKnownClients knownClientID, EUserSessionType sessionType, bool bIsAdmin, out string jti) - { - return GenerateToken(displayname, userID, ipAddr, tokenType, knownClientID, sessionType, bIsAdmin, EUserPriority.None, out jti); - } - public string GenerateToken(string displayname, Int64 userID, string ipAddr, ETokenType tokenType, KnownClients.EKnownClients knownClientID, EUserSessionType sessionType, bool bIsAdmin, EUserPriority userPriority, out string jti) { var jwtSettings = _configuration.GetSection("JwtSettings"); @@ -834,9 +829,6 @@ public string GenerateToken(string displayname, Int64 userID, string ipAddr, ETo signingCredentials: credentials ); - // Logs the expiry, never the token itself - it's a bearer credential. - Console.WriteLine($"[JWT] Minted {tokenType} token for user {userID} (expires {token.ValidTo:HH:mm:ss})"); - return new JwtSecurityTokenHandler().WriteToken(token); } } From 37726bd095c96decb8a16f0c9547fbbbf6a9b1fa Mon Sep 17 00:00:00 2001 From: Dennis van 't Hul Date: Sun, 16 Aug 2026 16:24:27 +0200 Subject: [PATCH 24/32] Rename the operator API auth scheme off our bot's name The three user_priority endpoints authenticated with a scheme registered as "Discord" against a config key named WsBot. Both names came from our own deployment: "WsBot" is a separate standalone bot, unrelated to the Discord bot that lives in this service, and "Discord" described the caller rather than the mechanism - which is a plain shared key, not anything Discord-specific. Renamed to describe what it is: scheme / header "Discord " -> "ServiceApi " handler DiscordAuthentication -> ServiceApiAuthenticationHandler validator WsBotKeyValidator -> ServiceApiKeyValidator config WsBot:api_key -> ServiceApi:api_key claims wsbot / WsBot -> service-api / ServiceApi The ServiceApi config section is declared in appsettings.json with an empty key, mirroring how Relay is declared: absent or empty means the endpoints reject every caller, so the surface is inert until an operator sets it. No functional change - same fixed-time key comparison, same endpoints, same authorization. Callers outside this repo were updated in lockstep. dotnet build -c Debug: 0 warnings, 0 errors. Co-Authored-By: Claude Opus 5 --- .../Controllers/User/UserController.cs | 8 +++---- GenOnlineService/Program.cs | 24 +++++++++---------- GenOnlineService/appsettings.json | 2 +- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/GenOnlineService/Controllers/User/UserController.cs b/GenOnlineService/Controllers/User/UserController.cs index 61991b9..b9f59dc 100644 --- a/GenOnlineService/Controllers/User/UserController.cs +++ b/GenOnlineService/Controllers/User/UserController.cs @@ -169,12 +169,12 @@ string TimeSpanToHumanReadableString(TimeSpan timeSpan) } // ---- Operator API for user_priority. Authenticated with a shared key - // ("Authorization: Discord "), not a game-client JWT, so an external + // ("Authorization: ServiceApi "), not a game-client JWT, so an external // scheduler can grant priority for the duration of an event and restore it afterwards. // Body: { "user_id": 12345, "priority": 2 }. previous_priority in the response is what // the caller stores to restore the earlier value later. - [Authorize(AuthenticationSchemes = "Discord")] + [Authorize(AuthenticationSchemes = "ServiceApi")] [HttpPost("SetPriority")] public async Task SetPriority() { @@ -244,7 +244,7 @@ public async Task SetPriority() // Body: [ { "user_id": 12345, "priority": 2 }, ... ] - the whole roster in one call, // window-open (priority 2) or window-close (priority 0). Invalid entries are reported // per-user; valid ones all apply. - [Authorize(AuthenticationSchemes = "Discord")] + [Authorize(AuthenticationSchemes = "ServiceApi")] [HttpPost("SetPriorityBatch")] public async Task SetPriorityBatch() { @@ -332,7 +332,7 @@ public async Task SetPriorityBatch() // { "discord_id": 1234567890 } users.discord_id (website Discord login) // { "search_parts": ["bob", "x64"]} partial AND search, max 10 rows // All lookups are EF Core parameterised - arbitrary input cannot reach SQL. - [Authorize(AuthenticationSchemes = "Discord")] + [Authorize(AuthenticationSchemes = "ServiceApi")] [HttpPost("LookupUser")] public async Task LookupUser() { diff --git a/GenOnlineService/Program.cs b/GenOnlineService/Program.cs index 449b694..7974fe9 100644 --- a/GenOnlineService/Program.cs +++ b/GenOnlineService/Program.cs @@ -145,9 +145,9 @@ public static bool ValidateKey(string strKey) } } - // Shared-key credential for the priority scheduler (config WsBot:api_key, presented as - // "Authorization: Discord "). - public static class WsBotKeyValidator + // Shared-key credential for back-end callers (config ServiceApi:api_key, presented as + // "Authorization: ServiceApi "). + public static class ServiceApiKeyValidator { public static bool ValidateKey(string? suppliedKey) { @@ -156,7 +156,7 @@ public static bool ValidateKey(string? suppliedKey) return false; } - string? expectedKey = Program.g_Config.GetSection("WsBot").GetValue("api_key"); + string? expectedKey = Program.g_Config.GetSection("ServiceApi").GetValue("api_key"); if (string.IsNullOrEmpty(expectedKey)) { return false; @@ -170,9 +170,9 @@ public static bool ValidateKey(string? suppliedKey) } // Its own scheme because the caller is a back-end service, not a player, and carries no game-client JWT. - public class DiscordAuthenticationHandler : AuthenticationHandler + public class ServiceApiAuthenticationHandler : AuthenticationHandler { - public DiscordAuthenticationHandler( + public ServiceApiAuthenticationHandler( IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, @@ -187,18 +187,18 @@ protected override Task HandleAuthenticateAsync() try { string? authHeader = Request.Headers["Authorization"].FirstOrDefault(); - if (authHeader == null || !authHeader.StartsWith("Discord ", StringComparison.OrdinalIgnoreCase)) + if (authHeader == null || !authHeader.StartsWith("ServiceApi ", StringComparison.OrdinalIgnoreCase)) { return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header")); } - string suppliedKey = authHeader.Substring("Discord ".Length).Trim(); - if (!WsBotKeyValidator.ValidateKey(suppliedKey)) + string suppliedKey = authHeader.Substring("ServiceApi ".Length).Trim(); + if (!ServiceApiKeyValidator.ValidateKey(suppliedKey)) { - return Task.FromResult(AuthenticateResult.Fail("Invalid Discord key")); + return Task.FromResult(AuthenticateResult.Fail("Invalid service key")); } - var claims = new[] { new Claim(ClaimTypes.Name, "wsbot"), new Claim(ClaimTypes.Role, "WsBot") }; + var claims = new[] { new Claim(ClaimTypes.Name, "service-api"), new Claim(ClaimTypes.Role, "ServiceApi") }; var identity = new ClaimsIdentity(claims, Scheme.Name); var principal = new ClaimsPrincipal(identity); var ticket = new AuthenticationTicket(principal, Scheme.Name); @@ -1050,7 +1050,7 @@ public static async Task Main(string[] args) OnTokenValidated = AdditionalValidation }; }).AddScheme("Basic", null) - .AddScheme("Discord", null); + .AddScheme("ServiceApi", null); builder.Services.AddAuthorization(options => { diff --git a/GenOnlineService/appsettings.json b/GenOnlineService/appsettings.json index 3ae241c..173a2f4 100644 --- a/GenOnlineService/appsettings.json +++ b/GenOnlineService/appsettings.json @@ -88,7 +88,7 @@ "api_key": "", "ingress_api_key": "" }, - "WsBot": { + "ServiceApi": { "api_key": "" } } From 4698c2fa7ee11b296b95ec2dcd1005f903726726 Mon Sep 17 00:00:00 2001 From: Dennis van 't Hul Date: Sun, 16 Aug 2026 16:27:32 +0200 Subject: [PATCH 25/32] Remove the external operator API; the in-service Discord bot covers it The three user_priority endpoints (SetPriority, SetPriorityBatch, LookupUser) existed for a standalone bot outside this repo. That bot is separate from the Discord bot GO already hosts, and the !setpriority / !getuserid / !searchuserid commands on the existing admin-command chain do the same job without a second authentication scheme. Removed with it: - ServiceApiAuthenticationHandler and ServiceApiKeyValidator - a whole auth scheme that now has no caller. - The ServiceApi config section from appsettings. - The four DTOs only those endpoints returned. - Database.Users.GetUserByDiscordID, dead once LookupUser was gone. Also restores the class-level [Authorize] on UsersController. It had been dropped so the operator endpoints could carry their own scheme, which left the controller relying on per-method attributes alone - a method added later would have defaulted to unauthenticated. UserController.cs is now identical to upstream. Priority itself is unchanged: the column, the token claim, the gates in LivestreamsController, and the Discord admin commands all stay. Diff vs upstream: +1975 -> +1573. dotnet build -c Debug: 0 warnings, 0 errors. Co-Authored-By: Claude Opus 5 --- .../Controllers/User/UserController.cs | 318 +----------------- GenOnlineService/Database/Database.User.cs | 17 - GenOnlineService/Program.cs | 69 +--- GenOnlineService/appsettings.json | 3 - 4 files changed, 2 insertions(+), 405 deletions(-) diff --git a/GenOnlineService/Controllers/User/UserController.cs b/GenOnlineService/Controllers/User/UserController.cs index b9f59dc..3580fc0 100644 --- a/GenOnlineService/Controllers/User/UserController.cs +++ b/GenOnlineService/Controllers/User/UserController.cs @@ -22,8 +22,6 @@ using Microsoft.EntityFrameworkCore; using System; using System.Collections.Concurrent; -using System.IO; -using System.Net; using System.Net.WebSockets; using System.Security.Claims; using System.Text; @@ -41,60 +39,8 @@ public override Type GetReturnType() public bool success { get; set; } = false; } - public class POST_User_SetPriority_Result : APIResult - { - public override Type GetReturnType() - { - return typeof(POST_User_SetPriority_Result); - } - - public bool success { get; set; } = false; - public string detail { get; set; } = String.Empty; - public Int64 user_id { get; set; } = -1; - public string display_name { get; set; } = String.Empty; - public int previous_priority { get; set; } = 0; - } - - public class POST_User_LookupUser_Result : APIResult - { - public override Type GetReturnType() - { - return typeof(POST_User_LookupUser_Result); - } - - public bool success { get; set; } = false; - public string detail { get; set; } = String.Empty; - public List users { get; set; } = new List(); - } - - public class POST_User_SetPriorityBatch_Result : APIResult - { - public override Type GetReturnType() - { - return typeof(POST_User_SetPriorityBatch_Result); - } - - public bool success { get; set; } = false; - public string detail { get; set; } = String.Empty; - public int updated { get; set; } = 0; - public List errors { get; set; } = new List(); - } - - public class PriorityBatchError - { - public Int64 user_id { get; set; } = -1; - public string detail { get; set; } = String.Empty; - } - - public class UserLookupEntry - { - public Int64 user_id { get; set; } = -1; - public string display_name { get; set; } = String.Empty; - public int priority { get; set; } = 0; - public EAccountType account_type { get; set; } = EAccountType.Unknown; - } - [ApiController] + [Authorize(Roles = "GameClient,ChatClient,GameLauncher")] [Route("env/{environment}/contract/{contract_version}/[controller]")] public class UsersController : ControllerBase { @@ -165,268 +111,6 @@ string TimeSpanToHumanReadableString(TimeSpan timeSpan) } - return result; - } - - // ---- Operator API for user_priority. Authenticated with a shared key - // ("Authorization: ServiceApi "), not a game-client JWT, so an external - // scheduler can grant priority for the duration of an event and restore it afterwards. - - // Body: { "user_id": 12345, "priority": 2 }. previous_priority in the response is what - // the caller stores to restore the earlier value later. - [Authorize(AuthenticationSchemes = "ServiceApi")] - [HttpPost("SetPriority")] - public async Task SetPriority() - { - POST_User_SetPriority_Result result = new POST_User_SetPriority_Result(); - - using (var reader = new StreamReader(HttpContext.Request.Body)) - { - string jsonData = await reader.ReadToEndAsync(); - var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; - Dictionary? data = null; - try - { - data = JsonSerializer.Deserialize>(jsonData, options); - } - catch - { - data = null; - } - - if (data == null || - !data.TryGetValue("user_id", out JsonElement userIdEl) || - userIdEl.ValueKind != JsonValueKind.Number || - !userIdEl.TryGetInt64(out Int64 userId) || - !data.TryGetValue("priority", out JsonElement priorityEl) || - !priorityEl.TryGetInt32(out int priority)) - { - Response.StatusCode = (int)HttpStatusCode.BadRequest; - result.detail = "Body must be { \"user_id\": , \"priority\": <0|1|2> }."; - return result; - } - - await using var db = await _dbFactory.CreateDbContextAsync(); - - User? user = await Database.Users.GetUserById(db, userId); - if (user == null) - { - Response.StatusCode = (int)HttpStatusCode.NotFound; - result.detail = $"User {userId} does not exist."; - return result; - } - - if (priority < (int)EUserPriority.None || priority > (int)EUserPriority.Viewer) - { - Response.StatusCode = (int)HttpStatusCode.BadRequest; - result.detail = $"Priority must be {(int)EUserPriority.None}, {(int)EUserPriority.Player} or {(int)EUserPriority.Viewer}."; - return result; - } - - int previous = (int)await Database.Users.GetUserPriority(db, userId); - if (await Database.Users.SetUserPriority(db, userId, priority)) - { - result.success = true; - result.user_id = userId; - result.display_name = user.DisplayName ?? String.Empty; - result.previous_priority = previous; - } - else - { - Response.StatusCode = (int)HttpStatusCode.InternalServerError; - result.detail = "Failed to update user_priority."; - } - } - - return result; - } - - // Body: [ { "user_id": 12345, "priority": 2 }, ... ] - the whole roster in one call, - // window-open (priority 2) or window-close (priority 0). Invalid entries are reported - // per-user; valid ones all apply. - [Authorize(AuthenticationSchemes = "ServiceApi")] - [HttpPost("SetPriorityBatch")] - public async Task SetPriorityBatch() - { - POST_User_SetPriorityBatch_Result result = new POST_User_SetPriorityBatch_Result(); - - using (var reader = new StreamReader(HttpContext.Request.Body)) - { - string jsonData = await reader.ReadToEndAsync(); - var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; - List>? entries = null; - try - { - entries = JsonSerializer.Deserialize>>(jsonData, options); - } - catch - { - entries = null; - } - - if (entries == null || entries.Count == 0) - { - Response.StatusCode = (int)HttpStatusCode.BadRequest; - result.detail = "Body must be a non-empty array of { \"user_id\": , \"priority\": <0|1|2> }."; - return result; - } - - await using var db = await _dbFactory.CreateDbContextAsync(); - - Dictionary validById = new Dictionary(); - foreach (Dictionary entry in entries) - { - if (!entry.TryGetValue("user_id", out JsonElement userIdEl) || - userIdEl.ValueKind != JsonValueKind.Number || - !userIdEl.TryGetInt64(out Int64 userId) || - !entry.TryGetValue("priority", out JsonElement priorityEl) || - !priorityEl.TryGetInt32(out int priority)) - { - result.errors.Add(new PriorityBatchError { user_id = -1, detail = "entry must be { \"user_id\": , \"priority\": <0|1|2> }" }); - continue; - } - - if (priority < (int)EUserPriority.None || priority > (int)EUserPriority.Viewer) - { - result.errors.Add(new PriorityBatchError { user_id = userId, detail = $"priority must be {(int)EUserPriority.None}, {(int)EUserPriority.Player} or {(int)EUserPriority.Viewer}" }); - continue; - } - - validById[userId] = priority; - } - - // One UPDATE per distinct priority: UPDATE users SET user_priority = @p - // WHERE user_id IN (...). Nonexistent ids simply match nothing. - foreach (IGrouping> priorityGroup in validById.GroupBy(e => e.Value)) - { - List ids = priorityGroup.Select(e => e.Key).ToList(); - int updated = await db.Users - .Where(u => ids.Contains(u.ID)) - .ExecuteUpdateAsync(setters => setters.SetProperty(u => u.UserPriority, priorityGroup.Key)); - - result.updated += updated; - - // Report ids that did not exist so the bot can skip them when restoring. - if (updated < ids.Count) - { - List foundIds = await db.Users.AsNoTracking() - .Where(u => ids.Contains(u.ID)) - .Select(u => u.ID) - .ToListAsync(); - foreach (Int64 id in ids.Where(id => !foundIds.Contains(id))) - { - result.errors.Add(new PriorityBatchError { user_id = id, detail = "user does not exist" }); - } - } - } - - result.success = true; - } - - return result; - } - - // Body (one of): - // { "user_id": 12345 } exact user-id match - // { "display_name": "x64" } exact display-name match - // { "discord_id": 1234567890 } users.discord_id (website Discord login) - // { "search_parts": ["bob", "x64"]} partial AND search, max 10 rows - // All lookups are EF Core parameterised - arbitrary input cannot reach SQL. - [Authorize(AuthenticationSchemes = "ServiceApi")] - [HttpPost("LookupUser")] - public async Task LookupUser() - { - POST_User_LookupUser_Result result = new POST_User_LookupUser_Result(); - - using (var reader = new StreamReader(HttpContext.Request.Body)) - { - string jsonData = await reader.ReadToEndAsync(); - var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; - Dictionary? data = null; - try - { - data = JsonSerializer.Deserialize>(jsonData, options); - } - catch - { - data = null; - } - - if (data == null) - { - Response.StatusCode = (int)HttpStatusCode.BadRequest; - result.detail = "Body must be { \"display_name\" } or { \"user_id\" } or { \"discord_id\" } or { \"search_parts\" }."; - return result; - } - - await using var db = await _dbFactory.CreateDbContextAsync(); - - List users = new List(); - - if (data.TryGetValue("display_name", out JsonElement displayNameEl) && - displayNameEl.ValueKind == JsonValueKind.String) - { - User? exact = await Database.Users.GetUserByDisplayName(db, displayNameEl.GetString() ?? String.Empty); - if (exact != null) - { - users.Add(exact); - } - } - else if (data.TryGetValue("user_id", out JsonElement userIdEl) && - userIdEl.TryGetInt64(out Int64 lookupUserId)) - { - User? byId = await Database.Users.GetUserById(db, lookupUserId); - if (byId != null) - { - users.Add(byId); - } - } - else if (data.TryGetValue("discord_id", out JsonElement discordIdEl) && - discordIdEl.TryGetInt64(out Int64 discordId)) - { - User? byDiscord = await Database.Users.GetUserByDiscordID(db, discordId); - if (byDiscord != null) - { - users.Add(byDiscord); - } - } - else if (data.TryGetValue("search_parts", out JsonElement searchPartsEl) && - searchPartsEl.ValueKind == JsonValueKind.Array) - { - List parts = new List(); - foreach (JsonElement partEl in searchPartsEl.EnumerateArray()) - { - if (partEl.ValueKind == JsonValueKind.String) - { - parts.Add(partEl.GetString() ?? String.Empty); - } - } - - if (parts.Count > 0) - { - users = await Database.Users.SearchUsersByDisplayName(db, parts, 10); - } - } - else - { - Response.StatusCode = (int)HttpStatusCode.BadRequest; - result.detail = "Body must be { \"display_name\" } or { \"user_id\" } or { \"discord_id\" } or { \"search_parts\" }."; - return result; - } - - result.success = true; - foreach (User user in users) - { - result.users.Add(new UserLookupEntry - { - user_id = user.ID, - display_name = user.DisplayName ?? String.Empty, - priority = user.UserPriority, - account_type = user.AccountType - }); - } - } - return result; } } diff --git a/GenOnlineService/Database/Database.User.cs b/GenOnlineService/Database/Database.User.cs index 265683f..6c202ab 100644 --- a/GenOnlineService/Database/Database.User.cs +++ b/GenOnlineService/Database/Database.User.cs @@ -576,23 +576,6 @@ public static async Task> SearchUsersByDisplayName(AppDbContext db, L } } - // Discord login mapping: the website writes users.discord_id when the account was - // created/used through Discord OAuth. Resolves a reactor's GO account. - public static async Task GetUserByDiscordID(AppDbContext db, long discordId) - { - try - { - return await db.Users.AsNoTracking() - .FirstOrDefaultAsync(u => u.DiscordID != null && u.DiscordID.Value == discordId); - } - catch (Exception ex) - { - Console.WriteLine($"[ERROR] GetUserByDiscordID failed: {ex.Message}"); - SentrySdk.CaptureException(ex); - return null; - } - } - public static async Task GetUserById(AppDbContext db, long userId) { try diff --git a/GenOnlineService/Program.cs b/GenOnlineService/Program.cs index 7974fe9..b8a0c69 100644 --- a/GenOnlineService/Program.cs +++ b/GenOnlineService/Program.cs @@ -145,72 +145,6 @@ public static bool ValidateKey(string strKey) } } - // Shared-key credential for back-end callers (config ServiceApi:api_key, presented as - // "Authorization: ServiceApi "). - public static class ServiceApiKeyValidator - { - public static bool ValidateKey(string? suppliedKey) - { - if (string.IsNullOrEmpty(suppliedKey) || Program.g_Config == null) - { - return false; - } - - string? expectedKey = Program.g_Config.GetSection("ServiceApi").GetValue("api_key"); - if (string.IsNullOrEmpty(expectedKey)) - { - return false; - } - - // Fixed-time so a wrong key doesn't leak how many leading bytes were right. - return System.Security.Cryptography.CryptographicOperations.FixedTimeEquals( - Encoding.UTF8.GetBytes(suppliedKey), - Encoding.UTF8.GetBytes(expectedKey)); - } - } - - // Its own scheme because the caller is a back-end service, not a player, and carries no game-client JWT. - public class ServiceApiAuthenticationHandler : AuthenticationHandler - { - public ServiceApiAuthenticationHandler( - IOptionsMonitor options, - ILoggerFactory logger, - UrlEncoder encoder, - TimeProvider timeProvider) - : base(options, logger, encoder) { } - - protected override Task HandleAuthenticateAsync() - { - if (!Request.Headers.ContainsKey("Authorization")) - return Task.FromResult(AuthenticateResult.Fail("Missing Authorization Header")); - - try - { - string? authHeader = Request.Headers["Authorization"].FirstOrDefault(); - if (authHeader == null || !authHeader.StartsWith("ServiceApi ", StringComparison.OrdinalIgnoreCase)) - { - return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header")); - } - - string suppliedKey = authHeader.Substring("ServiceApi ".Length).Trim(); - if (!ServiceApiKeyValidator.ValidateKey(suppliedKey)) - { - return Task.FromResult(AuthenticateResult.Fail("Invalid service key")); - } - - var claims = new[] { new Claim(ClaimTypes.Name, "service-api"), new Claim(ClaimTypes.Role, "ServiceApi") }; - var identity = new ClaimsIdentity(claims, Scheme.Name); - var principal = new ClaimsPrincipal(identity); - var ticket = new AuthenticationTicket(principal, Scheme.Name); - - return Task.FromResult(AuthenticateResult.Success(ticket)); - } - catch - { - return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header")); - } - } - } public static class CertHelpers { public static X509Certificate2 LoadPemWithPrivateKey(string certPath, string keyPath) @@ -1049,8 +983,7 @@ public static async Task Main(string[] args) { OnTokenValidated = AdditionalValidation }; - }).AddScheme("Basic", null) - .AddScheme("ServiceApi", null); + }).AddScheme("Basic", null); builder.Services.AddAuthorization(options => { diff --git a/GenOnlineService/appsettings.json b/GenOnlineService/appsettings.json index 173a2f4..ee397d2 100644 --- a/GenOnlineService/appsettings.json +++ b/GenOnlineService/appsettings.json @@ -87,8 +87,5 @@ "base_url": "", "api_key": "", "ingress_api_key": "" - }, - "ServiceApi": { - "api_key": "" } } From 39759c0c876e83682fb02037cc56907d95f60dde Mon Sep 17 00:00:00 2001 From: Dennis van 't Hul Date: Sun, 16 Aug 2026 16:36:23 +0200 Subject: [PATCH 26/32] Second review pass: trim the comment blocks the removal left behind Re-ran the standards sweep after the operator API came out, on the theory that a 400-line removal strands things. - Cut five multi-line comment blocks to the constraint they carry: the EUserPriority enum (meanings moved onto the members, where they read better than as a table above), RequireRelayAttribute, and three Lobby fields (TimeMatchStarted, IsPriority, PendingObservers). - Confirmed nothing was stranded: every added Lobby member and every Livestreams DTO still has a consumer, and the one comment that mentioned "batch" is about the relay's observer report, not the removed endpoint. Sweep results: 0 non-ASCII lines, 0 /// blocks, 0 TODO/FIXME, 0 references to files outside this repo, and no line-ending drift - the only whole-file entries are LivestreamsController.cs and RelayClient.cs, which are new. dotnet build -c Debug: 0 warnings, 0 errors. Co-Authored-By: Claude Opus 5 --- GenOnlineService/Constants.cs | 9 +++------ .../Livestreams/LivestreamsController.cs | 5 ++--- GenOnlineService/LobbyManager.cs | 15 ++++++--------- 3 files changed, 11 insertions(+), 18 deletions(-) diff --git a/GenOnlineService/Constants.cs b/GenOnlineService/Constants.cs index b6bc19f..2df85b4 100644 --- a/GenOnlineService/Constants.cs +++ b/GenOnlineService/Constants.cs @@ -1381,15 +1381,12 @@ public enum EAccountType DevAccount = 3 } - // Per-user livestream privilege (users.user_priority): - // None = nothing - // Player = the user's matches are highlighted in the Watch Live browser - // Viewer = the user skips the livestream password + broadcast-delay gates + // Per-user livestream privilege (users.user_priority). public enum EUserPriority { None = 0, - Player = 1, - Viewer = 2 + Player = 1, // their matches are highlighted in the Watch Live browser + Viewer = 2 // skips the livestream password and broadcast-delay gates } public class PlayerStats diff --git a/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs b/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs index 3dd4d56..e1e1e56 100644 --- a/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs +++ b/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs @@ -118,9 +118,8 @@ public class POST_Livestreams_Observers_Entry public bool is_live { get; set; } = true; } - // The relay is optional: when not configured, the POST endpoints must refuse loudly rather - // than pretending a stream was set up. Applied per-endpoint, not class-wide, so GET - // /livestreams can keep returning its normal empty list instead. + // Per-endpoint rather than class-wide: an unconfigured relay must refuse the POSTs, but GET + // /livestreams should still answer with its ordinary empty list. public class RequireRelayAttribute : ActionFilterAttribute { public override void OnActionExecuting(ActionExecutingContext context) diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index 4fb2794..15b3d0b 100644 --- a/GenOnlineService/LobbyManager.cs +++ b/GenOnlineService/LobbyManager.cs @@ -275,14 +275,12 @@ public void SetAllowStreamers(bool allowed) public int? StreamDelaySeconds { get; private set; } = null; public int ObserverCount { get; private set; } = 0; - // The INGAME transition moment - clock for the broadcast-delay gate - // (TimeMatchStarted + StreamDelaySeconds), learned from GO's own state transition, - // not the relay's liveness report. + // Clock for the broadcast-delay gate (TimeMatchStarted + StreamDelaySeconds), taken from GO's + // own INGAME transition rather than the relay's liveness report. public DateTime? TimeMatchStarted { get; private set; } = null; - // Priority-player match: latched TRUE when a user_priority = Player creates or joins. - // [JsonIgnore]'d here (lobby members have no use for it) - LivestreamsController copies - // this into GET_Livestreams_LivestreamEntry.priority by hand for Watch Live sorting. + // Latched TRUE when a user_priority = Player creates or joins. Not in the lobby JSON: + // LivestreamsController copies it into the Watch Live entry itself, for sorting. [JsonIgnore] public bool IsPriority { get; private set; } = false; @@ -303,9 +301,8 @@ public void SetCountdownStarted(bool started) DirtyRetransmit(); } - // Pre-game observers parked in the read-only lobby view - distinct from ObserverCount - // (live-stream watchers, reported by the relay). Keyed by UserSession so a closed - // websocket can be swept from every lobby at once. + // Pre-game watchers in the read-only lobby view, distinct from ObserverCount (live watchers, + // reported by the relay). Keyed by UserSession so one closed socket sweeps every lobby. [JsonIgnore] public ConcurrentDictionary PendingObservers { get; } = new(); public int PendingObserverCount => PendingObservers.Count; From 4a7381fa2fc768caceeb367e6ad2f1a5b3062666 Mon Sep 17 00:00:00 2001 From: Dennis van 't Hul Date: Sun, 16 Aug 2026 17:02:16 +0200 Subject: [PATCH 27/32] Added obserer chat to lobby and obsever join notification for lobby --- .../WebSocket/WebSocketController.cs | 14 +++++++ GenOnlineService/LobbyManager.cs | 42 ++++++++++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs index b4aba3c..6f3ea70 100644 --- a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs +++ b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs @@ -762,6 +762,14 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession } } } + + // Read-only watchers in the pre-game lobby view get the same bytes. + // They are not members, so the announcement rules above cannot apply - + // an observer reads the lobby chat exactly as the members see it. + foreach (UserSession observerSess in playerLobby.PendingObservers.Keys) + { + observerSess.QueueWebsocketSend(bytesJSON); + } } } } @@ -777,6 +785,10 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession if (observerLobby != null && observerLobby.PendingObservers.TryAdd(sourceUserSession, 0)) { Console.WriteLine($"[OBSERVER] User {sourceUserSession.m_UserID} subscribed to pre-game lobby {observerLobby.LobbyID}"); + // The members can be read by this watcher from here on, so say so by + // name: the count alone does not tell them who is listening. + observerLobby.BroadcastSystemChatToMembers( + String.Format("Observer {0} joined the lobby", sourceUserData.m_strDisplayName)); // Retransmit so members see the pending-observer count change too. observerLobby.DirtyRetransmit(); } @@ -793,6 +805,8 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession if (observerLobby != null && observerLobby.PendingObservers.TryRemove(sourceUserSession, out _)) { Console.WriteLine($"[OBSERVER] User {sourceUserSession.m_UserID} unsubscribed from pre-game lobby {observerLobby.LobbyID}"); + observerLobby.BroadcastSystemChatToMembers( + String.Format("Observer {0} left the lobby", sourceUserData.m_strDisplayName)); observerLobby.DirtyRetransmit(); } } diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index 15b3d0b..f106197 100644 --- a/GenOnlineService/LobbyManager.cs +++ b/GenOnlineService/LobbyManager.cs @@ -307,6 +307,34 @@ public void SetCountdownStarted(bool started) public ConcurrentDictionary PendingObservers { get; } = new(); public int PendingObserverCount => PendingObservers.Count; + // A system line in the lobby's chat box, sent as an ordinary LOBBY_CHAT_FROM_SERVER so + // every existing client renders it with no change at all. user_id -2 is the established + // "not a player" sender id (see the admin announcement in Discord.cs); it matches no slot, + // so clients colour it as a generic action line rather than in some player's colour. + public void BroadcastSystemChatToMembers(string message) + { + WebSocketMessage_LobbyChatMessageOutbound outboundMsg = new WebSocketMessage_LobbyChatMessageOutbound(); + outboundMsg.msg_id = (int)EWebSocketMessageID.LOBBY_CHAT_FROM_SERVER; + outboundMsg.user_id = -2; + outboundMsg.message = message; + outboundMsg.action = true; + + byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(outboundMsg)); + + foreach (LobbyMember lobbyMember in Members) + { + if (lobbyMember == null) + { + continue; + } + + if (lobbyMember.GetSession().TryGetTarget(out UserSession? sess) && sess != null) + { + sess.QueueWebsocketSend(bytesJSON); + } + } + } + public UInt32 ExeCRC { get; private set; } = 0; public UInt32 IniCRC { get; private set; } = 0; @@ -1686,9 +1714,21 @@ public async Task LeaveAnyLobby(Int64 userID) // read-only observer subscriptions are dead too. Called from the ws disconnect path. public void RemovePendingObserver(UserSession session) { + SharedUserData? observerData = WebSocketManager.GetSharedDataForUser(session.m_UserID); + string strObserverName = (observerData != null) ? observerData.m_strDisplayName : "An observer"; + foreach (Lobby lobbyInst in m_dictLobbies.Values) { - lobbyInst.PendingObservers.TryRemove(session, out _); + if (!lobbyInst.PendingObservers.TryRemove(session, out _)) + { + continue; + } + + // Same courtesy as an explicit unsubscribe: the members were told this observer + // arrived, so tell them it is gone. DirtyRetransmit keeps the "N observers + // waiting" count honest, which the subscribe/unsubscribe paths already do. + lobbyInst.BroadcastSystemChatToMembers(String.Format("Observer {0} left the lobby", strObserverName)); + lobbyInst.DirtyRetransmit(); } } From b4efb8f61728676d254c71a26935d4f4eced80ea Mon Sep 17 00:00:00 2001 From: Dennis van 't Hul Date: Sun, 16 Aug 2026 18:33:07 +0200 Subject: [PATCH 28/32] Pre-game observers can send chat into a lobby; host gets /observerchat on|off to mute it --- GenOnlineService/Constants.cs | 17 +++- .../Controllers/Lobby/LobbyController.cs | 20 ++++- .../WebSocket/WebSocketController.cs | 90 +++++++++++++++++++ GenOnlineService/LobbyManager.cs | 22 ++++- 4 files changed, 145 insertions(+), 4 deletions(-) diff --git a/GenOnlineService/Constants.cs b/GenOnlineService/Constants.cs index 2df85b4..0901f2d 100644 --- a/GenOnlineService/Constants.cs +++ b/GenOnlineService/Constants.cs @@ -1138,6 +1138,10 @@ public void UpdateSessionLobbyID(Int64 newLobbyID) // lobby id public Int64 currentLobbyID = -1; + + // Observer-chat rate gate: last time this session sent an observer chat message + // (Environment.TickCount64). Dies with the socket, so no cleanup is needed. + public long m_timeLastObserverChatSent = -1; } public class UserWebSocketInstance @@ -2539,7 +2543,8 @@ public enum EWebSocketMessageID LOBBY_OBSERVER_LOBBY_CHANGED = 44, LOBBY_OBSERVER_GAME_STARTING = 45, LOBBY_OBSERVER_STREAM_LIVE = 46, - LOBBY_OBSERVER_GAME_STARTED = 47 + LOBBY_OBSERVER_GAME_STARTED = 47, + LOBBY_OBSERVER_CHAT_FROM_CLIENT = 48 }; public static class UserPresence @@ -2645,6 +2650,16 @@ public class WebSocketMessage_LobbyObserverEvent : WebSocketMessage public int? delay_seconds { get; set; } = null; } + // Observer chat into a pre-game lobby. Deliberately no action / announcement / + // show_announcement_to_host fields: the member path trusts those verbatim (letting a member + // post an unprefixed line that looks like a system message); this path has the server decide + // the formatting instead. + public class WebSocketMessage_LobbyObserverChatInbound : WebSocketMessage + { + public Int64 lobby_id { get; set; } = -1; + public string? message { get; set; } + } + public abstract class WebSocketMessage { public int msg_id { get; set; } diff --git a/GenOnlineService/Controllers/Lobby/LobbyController.cs b/GenOnlineService/Controllers/Lobby/LobbyController.cs index d872b03..98b29ed 100644 --- a/GenOnlineService/Controllers/Lobby/LobbyController.cs +++ b/GenOnlineService/Controllers/Lobby/LobbyController.cs @@ -135,7 +135,8 @@ enum ELobbyUpdateField MAX_CAMERA_HEIGHT = 17, JOINABILITY = 18, HOST_ACTION_BULK_SLOT_UPDATE = 19, - LOBBY_STREAM_DELAY = 20 + LOBBY_STREAM_DELAY = 20, + LOBBY_ALLOW_OBSERVER_CHAT = 21 }; public class RouteHandler_PUT_Lobby_Result : APIResult @@ -391,7 +392,8 @@ enum ELobbyUpdatePermissions [ELobbyUpdateField.MAX_CAMERA_HEIGHT] = ELobbyUpdatePermissions.LobbyOwner, [ELobbyUpdateField.JOINABILITY] = ELobbyUpdatePermissions.LobbyOwner, [ELobbyUpdateField.HOST_ACTION_BULK_SLOT_UPDATE] = ELobbyUpdatePermissions.LobbyOwner, - [ELobbyUpdateField.LOBBY_STREAM_DELAY] = ELobbyUpdatePermissions.LobbyOwner + [ELobbyUpdateField.LOBBY_STREAM_DELAY] = ELobbyUpdatePermissions.LobbyOwner, + [ELobbyUpdateField.LOBBY_ALLOW_OBSERVER_CHAT] = ELobbyUpdatePermissions.LobbyOwner }; @@ -551,6 +553,20 @@ public async Task Post(Int64 lobbyID) lobby.DirtyRetransmit(); } } + else if (field == ELobbyUpdateField.LOBBY_ALLOW_OBSERVER_CHAT) + { + // Host-only kill switch for pre-game observer chat; on by + // default. The setter self-dirties, so no explicit + // DirtyRetransmit here. + if (data.ContainsKey("allow_observer_chat")) + { + bool bAllowObserverChat = data["allow_observer_chat"].GetBoolean(); + lobby.SetAllowObserverChat(bAllowObserverChat); + lobby.BroadcastSystemChatToMembers( + String.Format("The host has {0} observer chat.", bAllowObserverChat ? "enabled" : "disabled"), + includeObservers: true); + } + } else if (field == ELobbyUpdateField.HOST_ACTION_FORCE_START) { // dummy action... just force everyone ready diff --git a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs index 6f3ea70..a42fe47 100644 --- a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs +++ b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs @@ -811,6 +811,96 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession } } } + else if (msgID == EWebSocketMessageID.LOBBY_OBSERVER_CHAT_FROM_CLIENT) + { + // Observer sends chat into a pre-game lobby. Own lane because the member + // path is gated on currentLobbyID != -1 and an observer is not in a lobby. + WebSocketMessage_LobbyObserverChatInbound? observerChatMsg = + JsonSerializer.Deserialize(payload, JsonOpts); + + if (observerChatMsg == null) + { + return; + } + + Lobby? observerChatLobby = _lobbyManager.GetLobby(observerChatMsg.lobby_id); + if (observerChatLobby == null) + { + return; + } + + // Authorization: only sessions actually observing this lobby may post into + // it. This is what stops someone from writing into an arbitrary lobby by + // guessing an id; lobby_id selects which observed lobby this is for. + if (!observerChatLobby.PendingObservers.ContainsKey(sourceUserSession)) + { + return; + } + + // Host kill switch. A stale client that still shows an enabled entry gets + // an explanation rather than silence. + if (!observerChatLobby.AllowObserverChat) + { + WebSocketMessage_LobbyChatMessageOutbound refusalMsg = new WebSocketMessage_LobbyChatMessageOutbound(); + refusalMsg.msg_id = (int)EWebSocketMessageID.LOBBY_CHAT_FROM_SERVER; + refusalMsg.user_id = -2; + refusalMsg.message = "Observer chat is disabled by the host."; + refusalMsg.action = true; + sourceUserSession.QueueWebsocketSend( + Encoding.UTF8.GetBytes(JsonSerializer.Serialize(refusalMsg))); + return; + } + + string strText = observerChatMsg.message ?? String.Empty; + strText = strText.Trim(); + if (strText.Length == 0) + { + return; + } + if (strText.Length > 200) + { + strText = strText.Substring(0, 200); + } + + // Server-side rate gate (3000 ms, matching the client's network-room + // slowmode): the client-side slowmode is only courtesy, a modded client + // ignores it. The timestamp lives on the session and dies with the socket. + long timeNow = Environment.TickCount64; + if (sourceUserSession.m_timeLastObserverChatSent != -1 && + timeNow - sourceUserSession.m_timeLastObserverChatSent < 3000) + { + return; + } + sourceUserSession.m_timeLastObserverChatSent = timeNow; + + // Server-controlled formatting: exactly the member path's [Name] message, + // but with the flags fixed so a client can never smuggle in an action or + // announcement line through this lane. + WebSocketMessage_LobbyChatMessageOutbound outboundMsg = new WebSocketMessage_LobbyChatMessageOutbound(); + outboundMsg.msg_id = (int)EWebSocketMessageID.LOBBY_CHAT_FROM_SERVER; + outboundMsg.user_id = sourceUserSession.m_UserID; + outboundMsg.message = String.Format("[{0}] {1}", sourceUserData.m_strDisplayName, strText); + outboundMsg.action = false; + outboundMsg.announcement = false; + outboundMsg.show_announcement_to_host = false; + + byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(outboundMsg)); + + foreach (LobbyMember lobbyMember in observerChatLobby.Members) + { + if (lobbyMember != null && lobbyMember.GetSession().TryGetTarget(out UserSession? sess) && sess != null) + { + sess.QueueWebsocketSend(bytesJSON); + } + } + + // Observers get the same bytes; the sender's own copy comes back this way, + // so no local echo is needed. + foreach (UserSession observerSess in observerChatLobby.PendingObservers.Keys) + { + observerSess.QueueWebsocketSend(bytesJSON); + } + } else if (msgID == EWebSocketMessageID.START_GAME_COUNTDOWN_STARTED) { // must be in a lobby diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index f106197..5b53715 100644 --- a/GenOnlineService/LobbyManager.cs +++ b/GenOnlineService/LobbyManager.cs @@ -270,6 +270,18 @@ public void SetAllowStreamers(bool allowed) DirtyRetransmit(); } + // Host decision: may pre-game observers send chat into the lobby? On by default; the host + // opts out. Public so it lands in GET /Lobby/{id} (which returns the live object). + public bool AllowObserverChat { get; private set; } = true; + + public void SetAllowObserverChat(bool allowed) + { + if (AllowObserverChat == allowed) + return; + AllowObserverChat = allowed; + DirtyRetransmit(); + } + // Livestream state, owned by the relay session. public bool IsStreaming { get; private set; } = false; public int? StreamDelaySeconds { get; private set; } = null; @@ -311,7 +323,7 @@ public void SetCountdownStarted(bool started) // every existing client renders it with no change at all. user_id -2 is the established // "not a player" sender id (see the admin announcement in Discord.cs); it matches no slot, // so clients colour it as a generic action line rather than in some player's colour. - public void BroadcastSystemChatToMembers(string message) + public void BroadcastSystemChatToMembers(string message, bool includeObservers = false) { WebSocketMessage_LobbyChatMessageOutbound outboundMsg = new WebSocketMessage_LobbyChatMessageOutbound(); outboundMsg.msg_id = (int)EWebSocketMessageID.LOBBY_CHAT_FROM_SERVER; @@ -333,6 +345,14 @@ public void BroadcastSystemChatToMembers(string message) sess.QueueWebsocketSend(bytesJSON); } } + + if (includeObservers) + { + foreach (UserSession observerSession in PendingObservers.Keys) + { + observerSession.QueueWebsocketSend(bytesJSON); + } + } } public UInt32 ExeCRC { get; private set; } = 0; From c50323ae73b85b372105fe9fdb2b9f7a9811ebd7 Mon Sep 17 00:00:00 2001 From: Dennis van 't Hul Date: Sun, 16 Aug 2026 19:15:19 +0200 Subject: [PATCH 29/32] Added observer leave/join messages to other observers --- .../WebSocket/WebSocketController.cs | 12 +++++++++--- GenOnlineService/LobbyManager.cs | 17 ++++++++++++----- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs index a42fe47..5707424 100644 --- a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs +++ b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs @@ -786,9 +786,12 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession { Console.WriteLine($"[OBSERVER] User {sourceUserSession.m_UserID} subscribed to pre-game lobby {observerLobby.LobbyID}"); // The members can be read by this watcher from here on, so say so by - // name: the count alone does not tell them who is listening. + // name: the count alone does not tell them who is listening. Other + // observers in the same lobby see the line too - except the one who + // just joined, who knows. observerLobby.BroadcastSystemChatToMembers( - String.Format("Observer {0} joined the lobby", sourceUserData.m_strDisplayName)); + String.Format("Observer {0} joined the lobby", sourceUserData.m_strDisplayName), + includeObservers: true, excludeObserverSession: sourceUserSession); // Retransmit so members see the pending-observer count change too. observerLobby.DirtyRetransmit(); } @@ -805,8 +808,11 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession if (observerLobby != null && observerLobby.PendingObservers.TryRemove(sourceUserSession, out _)) { Console.WriteLine($"[OBSERVER] User {sourceUserSession.m_UserID} unsubscribed from pre-game lobby {observerLobby.LobbyID}"); + // The leaving session is already out of PendingObservers, so the + // remaining observers and the members all get the line. observerLobby.BroadcastSystemChatToMembers( - String.Format("Observer {0} left the lobby", sourceUserData.m_strDisplayName)); + String.Format("Observer {0} left the lobby", sourceUserData.m_strDisplayName), + includeObservers: true, excludeObserverSession: sourceUserSession); observerLobby.DirtyRetransmit(); } } diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index 5b53715..a089d38 100644 --- a/GenOnlineService/LobbyManager.cs +++ b/GenOnlineService/LobbyManager.cs @@ -323,7 +323,7 @@ public void SetCountdownStarted(bool started) // every existing client renders it with no change at all. user_id -2 is the established // "not a player" sender id (see the admin announcement in Discord.cs); it matches no slot, // so clients colour it as a generic action line rather than in some player's colour. - public void BroadcastSystemChatToMembers(string message, bool includeObservers = false) + public void BroadcastSystemChatToMembers(string message, bool includeObservers = false, UserSession? excludeObserverSession = null) { WebSocketMessage_LobbyChatMessageOutbound outboundMsg = new WebSocketMessage_LobbyChatMessageOutbound(); outboundMsg.msg_id = (int)EWebSocketMessageID.LOBBY_CHAT_FROM_SERVER; @@ -350,6 +350,10 @@ public void BroadcastSystemChatToMembers(string message, bool includeObservers = { foreach (UserSession observerSession in PendingObservers.Keys) { + if (observerSession == excludeObserverSession) + { + continue; + } observerSession.QueueWebsocketSend(bytesJSON); } } @@ -1744,10 +1748,13 @@ public void RemovePendingObserver(UserSession session) continue; } - // Same courtesy as an explicit unsubscribe: the members were told this observer - // arrived, so tell them it is gone. DirtyRetransmit keeps the "N observers - // waiting" count honest, which the subscribe/unsubscribe paths already do. - lobbyInst.BroadcastSystemChatToMembers(String.Format("Observer {0} left the lobby", strObserverName)); +// Same courtesy as an explicit unsubscribe: the members were told this observer + // arrived, so tell them it is gone, and the remaining observers in the same lobby + // see it too. DirtyRetransmit keeps the "N observers waiting" count honest, which + // the subscribe/unsubscribe paths already do. The swept session is already out of + // PendingObservers, so the exclusion is moot but keeps the shape uniform. + lobbyInst.BroadcastSystemChatToMembers(String.Format("Observer {0} left the lobby", strObserverName), + includeObservers: true, excludeObserverSession: session); lobbyInst.DirtyRetransmit(); } } From e382560fe8ae6ee90912d9aef15b88283f94e9a5 Mon Sep 17 00:00:00 2001 From: Dennis van 't Hul Date: Sun, 16 Aug 2026 19:24:27 +0200 Subject: [PATCH 30/32] Tell a pre-game observer at subscribe time what delay they hold (or that they join on match start, for priority viewers) --- .../WebSocket/WebSocketController.cs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs index 5707424..b6ebdfc 100644 --- a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs +++ b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs @@ -792,6 +792,34 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession observerLobby.BroadcastSystemChatToMembers( String.Format("Observer {0} joined the lobby", sourceUserData.m_strDisplayName), includeObservers: true, excludeObserverSession: sourceUserSession); + + // Tell the joining observer what they are in for: the broadcast delay + // they will hold, or a straight join at match start. Priority viewers + // skip the delay, and a lobby without a configured delay holds nobody. + // The priority flag never reaches the client, so the server must pick + // the message - same authoritative live re-read as the Observe endpoint. + await using (var db = await _dbFactory.CreateDbContextAsync()) + { + bool bIsPriority = await Database.Users.GetUserPriority(db, sourceUserSession.m_UserID) == EUserPriority.Viewer; + + string strJoinMessage; + if (bIsPriority || observerLobby.StreamDelaySeconds == null || observerLobby.StreamDelaySeconds <= 0) + { + strJoinMessage = "Joining on match start."; + } + else + { + strJoinMessage = String.Format("Broadcast delay: {0}s - joining automatically when it ends", observerLobby.StreamDelaySeconds); + } + + WebSocketMessage_LobbyChatMessageOutbound joinMsg = new WebSocketMessage_LobbyChatMessageOutbound(); + joinMsg.msg_id = (int)EWebSocketMessageID.LOBBY_CHAT_FROM_SERVER; + joinMsg.user_id = -2; + joinMsg.message = strJoinMessage; + joinMsg.action = true; + sourceUserSession.QueueWebsocketSend(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(joinMsg))); + } + // Retransmit so members see the pending-observer count change too. observerLobby.DirtyRetransmit(); } From 87675a3704971d98d5ba13f5f3de0f82f7a4570b Mon Sep 17 00:00:00 2001 From: Dennis van 't Hul Date: Sun, 16 Aug 2026 19:45:00 +0200 Subject: [PATCH 31/32] Add host-only /observers: GO announces the pre-game observer roster into the lobby chat --- GenOnlineService/Constants.cs | 3 +- .../WebSocket/WebSocketController.cs | 37 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/GenOnlineService/Constants.cs b/GenOnlineService/Constants.cs index 0901f2d..10d36e5 100644 --- a/GenOnlineService/Constants.cs +++ b/GenOnlineService/Constants.cs @@ -2544,7 +2544,8 @@ public enum EWebSocketMessageID LOBBY_OBSERVER_GAME_STARTING = 45, LOBBY_OBSERVER_STREAM_LIVE = 46, LOBBY_OBSERVER_GAME_STARTED = 47, - LOBBY_OBSERVER_CHAT_FROM_CLIENT = 48 + LOBBY_OBSERVER_CHAT_FROM_CLIENT = 48, + LOBBY_OBSERVER_LIST_REQUEST = 49 }; public static class UserPresence diff --git a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs index b6ebdfc..910659a 100644 --- a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs +++ b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs @@ -935,6 +935,43 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession observerSess.QueueWebsocketSend(bytesJSON); } } + else if (msgID == EWebSocketMessageID.LOBBY_OBSERVER_LIST_REQUEST) + { + // Host-only: the players already see join/leave lines as they happen, but + // the host can ask for the current roster by name. The list is announced + // into the lobby chat, so the whole game sees it. + WebSocketMessage_LobbyObserverEvent? listMsg = + JsonSerializer.Deserialize(payload, JsonOpts); + + if (listMsg == null) + { + return; + } + + Lobby? observerListLobby = _lobbyManager.GetLobby(listMsg.lobby_id); + if (observerListLobby == null) + { + return; + } + + if (observerListLobby.Owner != sourceUserSession.m_UserID) + { + return; + } + + List lstObserverNames = new List(); + foreach (UserSession observerSession in observerListLobby.PendingObservers.Keys) + { + SharedUserData? observerData = WebSocketManager.GetSharedDataForUser(observerSession.m_UserID); + lstObserverNames.Add(observerData != null ? observerData.m_strDisplayName : "Unknown"); + } + + string strMessage = lstObserverNames.Count == 0 + ? "No observers are watching this lobby." + : String.Format("Observers watching: {0}", String.Join(", ", lstObserverNames)); + + observerListLobby.BroadcastSystemChatToMembers(strMessage); + } else if (msgID == EWebSocketMessageID.START_GAME_COUNTDOWN_STARTED) { // must be in a lobby From fa4009acd434efca36c463158ac3a6d5ea2ec033 Mon Sep 17 00:00:00 2001 From: Dennis van 't Hul Date: Sun, 16 Aug 2026 20:53:28 +0200 Subject: [PATCH 32/32] fix(ws): keep the GeoIP reader as a static initialiser The GeoIP-optional change was split out into its own PR (#44) at the reviewer's request; this restores the original non-optional reader here. --- .../WebSocket/WebSocketController.cs | 40 ++++++------------- 1 file changed, 12 insertions(+), 28 deletions(-) diff --git a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs index 910659a..9cd5d83 100644 --- a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs +++ b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs @@ -47,21 +47,8 @@ public WebSocketController(LobbyManager lobbyManager, IDbContextFactory