feat(livestreams): GO-orchestrated live spectating via a relay server - #43
Open
doopey655 wants to merge 34 commits into
Open
feat(livestreams): GO-orchestrated live spectating via a relay server#43doopey655 wants to merge 34 commits into
doopey655 wants to merge 34 commits into
Conversation
- 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)
- 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)
…er relay calls 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 <noreply@anthropic.com>
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.
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).
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).
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.
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.
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.
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 <WsBot:api_key>) 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
…splay name)" This reverts commit 5af1abb.
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.
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).
…fter a grace period 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.
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.
Brings the branch up to upstream 640c487 (2026-08-13) from a merge base of 2dcf65e (2026-07-28) - 32 commits, including token revocation/rotation, the refresh token controller, and matchmaking changes. Five conflicts, all resolved to keep both sides: - Program.cs (JwtTokenGenerator): upstream refactored GenerateToken into an overload pair that reports the jti for rotation; we had added an EUserPriority parameter. Kept upstream's TokenGenerationClaim and jti overload and threaded userPriority through, with an 8-arg overload defaulting to EUserPriority.None so upstream's new RefreshTokenController compiles unchanged. - CheckLoginController / LoginWithTokenController: same collision at the call sites. Both now pass userPriority AND take upstream's refresh-token rotation via TokenRevocationManager.OnTokensIssued. - WebSocketController: took upstream's awaited SendPong. - MatchmakingManager: our extra bAllowStreamers argument kept, upstream's real dummyHostUser.ExeCRC/IniCRC used in place of the 123/456 placeholders. Also dropped our ClockSkew = 1s in favour of upstream's 30s, which lands in the same place for the same reason and is upstream's call to make. Verified every change survived, file by file, against the pre-merge inventory in plans/services-upstream/pre-merge-inventory.md - written before the merge precisely because the GameClient merge silently dropped a MetaEvent.cpp hunk that went unnoticed for a full review pass. All 17 files still carry their changes; the two -1 line deltas are lines upstream has since added itself. dotnet build: 0 warnings, 0 errors. Nothing has been reviewed or stripped yet - Bucket B (user_priority, Discord bot) and Bucket C (the CheckLogin ILOVECODE bypass) are still present and must not reach upstream. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ites" This reverts commit c7ec922.
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 /// <summary> 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <key>" -> "ServiceApi <key>" 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…t on|off to mute it
…hat they join on match start, for priority viewers)
…nto the lobby chat
Contributor
|
Haven't looked into the code yet, but I think you should split out this change into a separate PR:
|
The GeoIP-optional change was split out into its own PR (GeneralsOnlineDevelopmentTeam#44) at the reviewer's request; this restores the original non-optional reader here.
Author
|
Done - split out into #44. The GeoIP change is reverted on this branch and the PR description updated. |
# Conflicts: # GenOnlineService/Constants.cs
doopey655
marked this pull request as ready for review
August 17, 2026 17:11
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Adds server-side support for spectating a match in progress. A player's client streams its replay
bytes to a relay server; a spectator's client watches that stream and plays it back locally. GO
owns the orchestration — it decides who may stream, who may watch, and when — while the relay only
moves bytes.
Nothing here changes existing behaviour. The feature ships inert:
Relay.enableddefaults tofalse, and with no relay configured every livestream endpoint short-circuits before making acall. A deployment that does not set the new config section behaves exactly as it does today.
What it adds
LivestreamsController— the client-facing surface: register a stream, list live games,request a watch ticket, report observer state.
RelayClient— the outbound half. Talks to the relay's/internal/*API to create alivestream, mint single-use stream and watch tickets, and tear a session down. Two retries at
400/800 ms, 5 s timeout, because every call sits inside a request a player is waiting on.
allow_streamersas a lobby property, a host-controlled broadcastdelay, pending-observer counts pushed to lobby members, and pre-game observer subscriptions over
the existing websocket (message ids 42–47).
user_priority— the value the gates are checked against. It decides whether a viewer skipsthe stream password and the broadcast delay, and whether a match sorts to the top of Watch Live.
!setpriority/!getuserid/!searchuseridon the existing Discordadmin-command chain, gated on the same admin channel as
!kickand!whois.Relaysection (enabled,base_url,api_key,ingress_api_key). GOauthenticates to the relay with
api_key; the relay authenticates back into GO withingress_api_key, checked in fixed time.Design notes for reviewers
The relay is not trusted with authorisation. It never decides who may watch. GO mints a
single-use ticket per viewer and hands back a complete WebSocket URL; the relay only validates the
ticket it was given. That keeps the password gate and the broadcast delay enforceable server-side
even against a modified client.
The broadcast delay is an admission gate, not a client-side hold. A normal viewer's ticket is
only minted once the match has run for the configured delay. Holding it client-side would let a
modified client fast-forward to the live edge.
Failures degrade to "no stream", never to a 5xx. An unconfigured or unreachable relay means the
feature is not available, which is not an error condition for the caller.
Compatibility
Schema
One additive column, with a default, and no backfill:
DEFAULT 0isEUserPriority.None, so every existing row is already correct and the column isinert until an operator sets it. It is the only schema change in this PR — all stream, lobby and
ticket state lives in memory.
Apply the migration before deploying the code.
UserPriorityis mapped on the sharedUserentity, so once this is running every query that materialises a
Userselects the column. Thereverse order is safe: the column is inert for older code.
Connecting the services to the relay
The feature is inert until the
Relayconfig section is complete.enabledis the master switch;when
false— the shipped default — no relay call is ever made. Whentrue, all three keys mustbe set: a partially configured section logs a warning at startup and every livestream endpoint
returns 503.
The section can be set in
appsettings.jsonor via environment variables using the standardRelay__prefix (double underscore) — the form a containerised deployment uses to inject the twosecrets without touching a config file:
Relay:enabledRelay__enabledfalse= feature inert, no relay call ever made.Relay:base_urlRelay__base_urlhttp://relay:8765inside the docker stack. A trailing/is trimmed.Relay:api_keyRelay__api_key/internal/*call. Must equal the relay'sINTERNAL_API_KEY.Relay:ingress_api_keyRelay__ingress_api_keyPOST /observers). Must equal the relay'sGO_API_KEY. Compared in fixed time.The relay reads its side from environment variables. The pairing that matters:
INTERNAL_API_KEYRelay:api_key/internal/*calls with 503.GO_API_KEYRelay:ingress_api_keyGO_OBSERVERS_URLPUBLIC_HOST,PUBLIC_WS_SCHEME,PUBLIC_PATH_PREFIXbase_urlis only how GO reaches the relay, not what clients connect to.Testing
dotnet build -c Debug: 0 warnings, 0 errors.stream registration, the Watch Live browser, ticket minting, password-gated streams, the
broadcast delay, and observer counts in the pre-game lobby.
Behaviour changes outside the feature
PUT /Lobbyreportssuccess = truefor any handled field update, where it previouslyreturned
falseeven when the update applied. Our stream-delay field needs a truthful answer,but the fix applies to every field.