Passa al contenuto principale

Modalità Lobby

Traduzione non disponibile

Questa pagina non è ancora stata tradotta in italiano. Sotto trovi la versione inglese.

A lobby is an optional grouping mechanism that links multiple simultaneous recording sessions across different players' machines so that the resulting MP4s can be aligned in post-production as a multi-POV view of the same match. Lobby data is never the identity of a session — every recording is a complete, uploadable session on its own. The lobby just decorates it with a lobby_id / lobby_code for later correlation.

Lifecycle

The lobby is held in process memory by LobbyState (include/service/lobby_state.hpp). It is not persisted to disk — a core restart drops it. Persistence lives entirely on the Supabase side (the rows the renderer / main process create against the lobbies and lobby_members tables); core only mirrors the currently active lobby so that recording sessions started on this machine can stamp lobby_id / lobby_code into their session row and presigned-upload requests.

State shape

FieldTypeSourcePurpose
idUUIDSupabase lobbies.id (gen_random_uuid())Server-side identity used to join recording_sessions rows across players.
code6 chars, [A-Z0-9]Supabase create_lobby RPC (CSRNG)Human-shareable join code; displayed in the in-game overlay badge.
gameSlugkebab-case, optionalRenderer / main processGame the lobby was created for. Empty means "no game constraint".

HTTP surface

All three routes require the standard core auth (X-Api-Key + session). They are called by playroll-ui's main process after Supabase create/join/leave RPCs complete; the renderer never calls core directly.

MethodRoutePurpose
POST/api/lobby/setSet the active lobby on this core. Body: { lobby_id, lobby_code, game_slug? }.
POST/api/lobby/clearDrop the active lobby.
GET/api/lobby/statusRead the active lobby. Always returns 4 keys; identifier fields are empty strings when inactive.

/api/lobby/set — input validation (EC, May 2026)

Defense in depth against direct callers on localhost:8080 that hold an X-Api-Key but bypass the renderer's zod schemas. Validation runs after the existing 256-byte string caps and the "missing field" check:

FieldRuleRejection message (HTTP 400)
lobby_idMust match ^[0-9a-fA-F]{8}-…-[0-9a-fA-F]{12}$ (UUID)lobby_id must be a UUID
lobby_codeMust match ^[A-Z0-9]{6}$lobby_code must be 6 alphanumeric chars
game_slugIf non-empty, must match ^[a-z0-9]+(-[a-z0-9]+)*$ and be ≤ 128 bytesgame_slug must be lowercase kebab-case

The shapes mirror what Supabase emits for gen_random_uuid() / create_lobby's 6-char code / the kebab-case game slugs used elsewhere in core. The renderer / main process already enforce these shapes; the regex pass on core is purely a belt-and-suspenders barrier so that a compromised local caller cannot smuggle arbitrary 256-byte garbage into LobbyState and from there into outgoing presigned-URL POSTs.

/api/lobby/status — inactive response

When the lobby has been cleared (or was never set), the response keeps the same 4-key shape but with empty identifier strings:

{ "active": false, "lobbyId": "", "lobbyCode": "", "gameSlug": "" }

This is deliberate: the in-memory snapshot may still carry the previous lobby's strings after a leave/clear, and emitting them would let an authenticated caller infer the previous lobby identity. Gating identifier fields behind active keeps the response shape stable for clients (no optionals to handle) while leaking nothing.

Interaction with recording start

RecordingSessionStarter::start() reads the lobby snapshot at session-create time:

  • Game mismatch — hard rejection. If the lobby is active and lobby.gameSlug is non-empty and differs from the target slug, the starter sets result.failed = true, result.rejectionReason = "LOBBY_GAME_MISMATCH", and rejectionDetail = "lobby_slug=… actual_slug=…". recording_start_flow.cpp takes a dedicated rollback branch on !rejectionReason.empty(): stops the capture, clears artifacts, marks the start failed, and publishes a RecorderError built from ErrorCode::LobbyGameMismatch (Tier-1 UiMode::BlockingModal). The renderer renders that as a full blocking modal with copy that adapts to the user's role — "ask the host to switch" for guests, "change the lobby from the home grid" for the host. No session row is created.
  • No mismatch. lobby.id and lobby.code are copied into the RecordingSessionCreateRequest, persisted in the local SQLite sessions table (columns lobby_id, lobby_code added in migrateV4toV5), and propagated to the auth/recording-started API Gateway endpoint via presigned_url_client.
  • Lobby inactive. Session proceeds normally; lobby_id / lobby_code are stored as NULL in SQLite and omitted from the presigned-URL request body.

The renderer keeps its own fast-path guard in handleRecordingBarStart: when the active lobby's slug differs from selectedGameSlug, it bails before the IPC call and emits a local notification with the same role-aware copy. This saves a wasted capture spin-up during the short async race between a host's change_lobby_game call and the auto-snap in CoverGrid re-rendering. The core check stays as the authoritative defense for any caller that bypasses the renderer.

Lobby ↔ game binding (server-enforced)

Supabase migration 20260528170000_lobby_game_binding (in playroll-cpp/supabase/migrations/) makes the lobby's bound game an enforceable invariant rather than a piece of advisory metadata. Four pieces, walked from cheapest to last-defense:

  1. FK on lobbies.game_slugeligible_games.slug (ON UPDATE CASCADE, ON DELETE RESTRICT). Any lobby with a slug not in the eligible catalog is closed during the migration's backfill before the constraint goes live. After this, an unknown game slug can never appear in a lobby row.
  2. change_lobby_game(p_lobby_id, p_new_game_slug) RPC — host-only swap, SECURITY DEFINER, FOR UPDATE row lock. Validates auth.uid() = lobbies.creator_id, status = 'active' + not expired, and FK membership. Returns { success, lobby_id, game_slug, changed }. The main process IPC handler invokes this for the renderer's host clicked a non-lobby cover flow; on success it re-fetches get_lobby_status and re-pushes the canonical snapshot through LobbyManager.setActive, which in turn calls /api/lobby/set on the core so the new slug lands in LobbyState before the next F5.
  3. validate_lobby_recording(p_lobby_id, p_game_slug) — read-only fail-fast helper. Returns { ok: false, code: "GAME_MISMATCH", expected: "<lobby_slug>" } when the caller's game doesn't match the lobby. Not currently called by the core, but available as a pre-start probe path.
  4. BEFORE INSERT OR UPDATE trigger on s3_uploads — last line of defense, even if every higher layer is bypassed. The trigger consults two ground truths in order:
    1. recording_sessions row matching NEW.session_id. The (lobby_id, game_slug) pair was written at recording-start by the core and is immutable. If a session row exists, the trigger compares NEW against it and intentionally skips the live lobbies.game_slug check. This is what lets a legitimate in-flight upload survive a host calling change_lobby_game mid-upload — the new slug applies to future recordings, not the one currently uploading.
    2. lobbies.game_slug (fallback, when no session row matches). Should be vanishingly rare — would mean the uploader raced ahead of the session insert. Acts as the catch-all that closes the window for a smuggled upload row whose session_id never materializes.

When the creator leaves with other participants still inside, leave_lobby promotes the oldest joiner (ORDER BY joined_at ASC, user_id ASC) to creator so the lobby stays callable to change_lobby_game. The lobby itself stays active and keeps its current game_slug.

Recording lifecycle inside a lobby

The diagrams below walk the lobby-active branches of the recording lifecycle — the happy multi-POV path, the mid-upload host-swap edge case, and the four layers of defense that enforce the lobby ↔ game invariant.

Happy path — two players, one lobby, multi-POV grouping

Both players' independent recordings land in recording_sessions and s3_uploads tagged with the same lobby_id. The post-production tooling joins on that column.

Mid-upload host-swap — the recording_sessions ground-truth path

The host calling change_lobby_game while a guest's upload is still in flight is the edge case the s3_uploads trigger's two-tier ground truth handles. The in-flight upload is tagged with the old slug — the trigger compares against recording_sessions (immutable, set at recording-start) and lets it through; the new slug only constrains future recordings.

Defense in depth against mismatch

Four layers can short-circuit a mismatched recording before it contaminates the dataset. Two entry paths (UI button vs F5 hotkey) converge on the same core check, then the SQL trigger acts as the unbypassable boundary with three distinct ground-truth branches and five distinct rejection paths.

The bottom region (everything below the Trigger decision) is the unbypassable line: even a direct PostgREST call holding a forged JWT cannot land an s3_uploads row that contaminates the multi-POV dataset.

Edge cases worth knowing while debugging:

  • Race window between Lambda commit and uploader insert. auth/recording-started writes to recording_sessions asynchronously after core dispatches the recording-started event. If the local uploader inserts the corresponding s3_uploads row before that commit lands, the RowExists? decision falls to the right branch (no session row) and uses lobbies.game_slug as ground truth. If the host has called change_lobby_game in the same window, a legitimate upload may be rejected with a 23514 — the client's upload retry path covers this (the next attempt will find the now-committed session row and pass the immutable check).
  • F5 hotkey bypasses the renderer fast-path notification. Hotkey-initiated recordings skip handleRecordingBarStart entirely and land directly at runRecordingStart in the core. The user gets the blocking modal (correct authoritative defense) but not the fast-path notification — fine for security, slightly worse UX than the UI button. Not currently fixed because the F5 path has no easy notification surface; the modal is acceptable.
  • selectedGameSlug empty + lobby active. The renderer guard's G non-empty AND G != L condition is false (G empty), so the fast-path bail does NOT fire and the IPC goes through. The core then computes target.slug != lobby.gameSlug against an empty target slug, the mismatch fires, and the blocking modal renders. Functionally correct; the renderer just didn't fast-path.
  • Lobby push to core races with F5. Before the fix that made LobbyManager.setActive await onSetActive, the core could be unaware of a freshly-created lobby for up to one HTTP round trip. A recording started in that window would land with lobby_id = NULL and silently bypass the multi-POV aggregation (no error — just a missing tag). Fix: LobbyManager.setActive now awaits the core push before returning, so by the time the IPC handler resolves to the renderer, the core is primed.
  • /api/lobby/set previously accepted empty game_slug. The core HTTP route used to treat game_slug as optional. With binding enforced, an active lobby with empty slug would silently disable the mismatch guard (lobbyGameMismatch requires !gameSlug.empty()), letting any game record under that lobby until the SQL trigger rejected the resulting upload. Fix: the HTTP route now rejects empty slug at the boundary.

Overlay integration

The in-game overlay carries a lobby badge that shows the 6-char lobby_code while a lobby is active. lobby_http_callbacks.cpp wires setOverlayLobbyCode(code) on set and clearOverlayLobbyCode() on clear, both running under the same state mutex that guards LobbyState. The badge is purely a display surface — it does not change recording behavior.

Cross-DB correlation

After upload, the lobby_id written by core flows into Supabase via two paths:

  1. auth/recording-started → API Gateway → Lambda → recording_sessions.lobby_id.
  2. The S3 presigned-URL flow eventually back-fills s3_uploads.lobby_id for the uploaded triplet.

Both rows are joinable to the Supabase-managed lobbies / lobby_members tables for multi-POV analytics and post-production tooling. The lobbies schema and create_lobby RPC live in the playroll Supabase project; this page covers only the core-side mirror.

What lobby data is not

  • Not a session identifier. A recording session is the unit of payment, validation, and support investigation — see Recording Session Lifecycle. Lobby metadata decorates a session; its absence never blocks upload validation, and its presence never gates ownership or rewards.
  • Not durable on core. Restarting playroll-cpp drops the active lobby. The UI is expected to re-set it from the persisted Supabase membership row if needed.
  • Not validated against Supabase for shape, only for invariants. Core's /api/lobby/set does regex shape validation only — it does not look up the lobby or check ownership. Server-side, the change_lobby_game RPC enforces auth.uid() = creator_id under a FOR UPDATE row lock, the s3_uploads trigger enforces the lobby ↔ game binding on every insert/update, and the FK from lobbies.game_slug to eligible_games.slug rejects unknown games at write time. A compromised local caller can still spoof a lobby_id on /api/lobby/set, but it cannot land an s3_uploads row that contaminates the multi-POV dataset.