Skip to content

feat(livestreams): GO-orchestrated live spectating via a relay server - #43

Open
doopey655 wants to merge 34 commits into
GeneralsOnlineDevelopmentTeam:mainfrom
nathan-soul:feature/livestreams-upstream
Open

feat(livestreams): GO-orchestrated live spectating via a relay server#43
doopey655 wants to merge 34 commits into
GeneralsOnlineDevelopmentTeam:mainfrom
nathan-soul:feature/livestreams-upstream

Conversation

@doopey655

@doopey655 doopey655 commented Aug 16, 2026

Copy link
Copy Markdown

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.enabled defaults to
false, and with no relay configured every livestream endpoint short-circuits before making a
call. 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 a
    livestream, 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.
  • Lobby integrationallow_streamers as a lobby property, a host-controlled broadcast
    delay, 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 skips
    the stream password and the broadcast delay, and whether a match sorts to the top of Watch Live.
  • Priority management!setpriority / !getuserid / !searchuserid on the existing Discord
    admin-command chain, gated on the same admin channel as !kick and !whois.
  • Config — a Relay section (enabled, base_url, api_key, ingress_api_key). GO
    authenticates to the relay with api_key; the relay authenticates back into GO with
    ingress_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

  • No change to any existing endpoint's request or response shape.
  • New websocket message ids 42–47, additive.
  • Older clients never call the new endpoints and are unaffected.

Schema

One additive column, with a default, and no backfill:

ALTER TABLE `users` ADD COLUMN `user_priority` tinyint(4) NOT NULL DEFAULT 0 AFTER `banned`;

DEFAULT 0 is EUserPriority.None, so every existing row is already correct and the column is
inert 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. UserPriority is mapped on the shared User
entity, so once this is running every query that materialises a User selects the column. The
reverse order is safe: the column is inert for older code.

Connecting the services to the relay

The feature is inert until the Relay config section is complete. enabled is the master switch;
when false — the shipped default — no relay call is ever made. When true, all three keys must
be set: a partially configured section logs a warning at startup and every livestream endpoint
returns 503.

The section can be set in appsettings.json or via environment variables using the standard
Relay__ prefix (double underscore) — the form a containerised deployment uses to inject the two
secrets without touching a config file:

Config key Environment variable What it is
Relay:enabled Relay__enabled Master switch. false = feature inert, no relay call ever made.
Relay:base_url Relay__base_url The URL GO uses to reach the relay, e.g. http://relay:8765 inside the docker stack. A trailing / is trimmed.
Relay:api_key Relay__api_key The secret GO presents to the relay on every /internal/* call. Must equal the relay's INTERNAL_API_KEY.
Relay:ingress_api_key Relay__ingress_api_key The secret the relay presents when it calls back into GO (POST /observers). Must equal the relay's GO_API_KEY. Compared in fixed time.

The relay reads its side from environment variables. The pairing that matters:

Relay env var Must equal Purpose
INTERNAL_API_KEY GO Relay:api_key Without it the relay refuses GO's /internal/* calls with 503.
GO_API_KEY GO Relay:ingress_api_key The relay's credential on its outbound observer-state updates to GO.
GO_OBSERVERS_URL Where the relay posts batched livestream-state updates.
PUBLIC_HOST, PUBLIC_WS_SCHEME, PUBLIC_PATH_PREFIX Public scheme/host/prefix used to build the connect URLs GO hands to clients. base_url is only how GO reaches the relay, not what clients connect to.

Testing

  • dotnet build -c Debug: 0 warnings, 0 errors.
  • Exercised end to end against a local stack (MySQL + GO + relay) with real game clients:
    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

  1. PUT /Lobby reports success = true for any handled field update, where it previously
    returned false even when the update applied. Our stream-delay field needs a truthful answer,
    but the fix applies to every field.

doopey655 and others added 30 commits August 6, 2026 14:50
- 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
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>
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>
@JoKeRZH429

Copy link
Copy Markdown
Contributor

Haven't looked into the code yet, but I think you should split out this change into a separate PR:

The GeoIP database is now optional. It was opened in a static initialiser and the .mmdb is
not tracked here, so a fresh clone crashes the WebSocket controller. Unrelated to livestreams
and happy to split it out, but without it this PR cannot be checked out and run.

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.
@doopey655

Copy link
Copy Markdown
Author

Done - split out into #44. The GeoIP change is reverted on this branch and the PR description updated.

# Conflicts:
#	GenOnlineService/Constants.cs
@doopey655
doopey655 marked this pull request as ready for review August 17, 2026 17:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants