From dba469c154538eb4fe9590032ed9257cd290ede9 Mon Sep 17 00:00:00 2001 From: Steve Jones Date: Fri, 7 Aug 2026 14:24:30 -0400 Subject: [PATCH 1/7] BCLOUD-14489 BCLOUD-14490 BCLOUD-14472 BCLOUD-14488 BCLOUD-1449 --- RelayTestApp/src/App.css | 447 +++++++++++++++++- RelayTestApp/src/App.js | 622 ++++++++++++++++++++++--- RelayTestApp/src/GameScreen.js | 286 ++++++------ RelayTestApp/src/GlobalChatPanel.js | 135 ++++++ RelayTestApp/src/LeaderboardPanel.js | 157 +++++++ RelayTestApp/src/LobbyScreen.js | 363 +++++++++++---- RelayTestApp/src/MainMenuScreen.js | 96 ++-- RelayTestApp/src/MatchSummaryScreen.js | 155 ++++++ RelayTestApp/src/coverage.js | 114 +++++ 9 files changed, 2044 insertions(+), 331 deletions(-) create mode 100644 RelayTestApp/src/GlobalChatPanel.js create mode 100644 RelayTestApp/src/LeaderboardPanel.js create mode 100644 RelayTestApp/src/MatchSummaryScreen.js create mode 100644 RelayTestApp/src/coverage.js diff --git a/RelayTestApp/src/App.css b/RelayTestApp/src/App.css index c034faf..e8c8355 100644 --- a/RelayTestApp/src/App.css +++ b/RelayTestApp/src/App.css @@ -62,19 +62,36 @@ small { } .teamBtn { - margin: 16px; - padding: 8px 16px; - font-size: 14pt; - background-color: #abc; - border-radius: 8px; + margin: 8px 0; + padding: 6px 14px; + font-size: 10pt; + font-weight: bold; + background-color: rgba(255, 255, 255, 0.08); + color: white; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; } .teamBtn:hover { - background-color: #def; + background-color: rgba(255, 255, 255, 0.16); } .teamBtn:active { - background-color: #789; + background-color: rgba(255, 255, 255, 0.24); +} + +.memberBadge { + font-size: 8pt; + font-weight: bold; + padding: 1px 6px; + border-radius: 4px; + background-color: rgba(255, 255, 255, 0.15); + color: white; +} + +.memberBadgeHost { + background-color: rgba(255, 215, 0, 0.25); + color: #ffd700; } .GamePlayArea { @@ -94,19 +111,44 @@ small { } .Button { - margin: 16px; - padding: 8px 16px; - font-size: 18pt; - background-color: #abc; - border-radius: 8px; + margin: 10px 6px; + padding: 10px 22px; + font-size: 12pt; + font-weight: bold; + letter-spacing: 0.3px; + background-color: rgba(255, 255, 255, 0.08); + color: white; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; + transition: background-color 0.15s ease; } .Button:hover { - background-color: #def; + background-color: rgba(255, 255, 255, 0.16); } .Button:active { - background-color: #789; + background-color: rgba(255, 255, 255, 0.24); +} + +.Button:disabled { + opacity: 0.5; +} + +/* Primary CTA — matches the reference mockup's blue "Find / Create Lobby" button + (PLAY on the main menu; Start/Join Match in the lobby, the equivalent + progression actions there). */ +.ButtonPrimary { + background-color: #4c73e6; + border-color: #4c73e6; +} + +.ButtonPrimary:hover { + background-color: #5c81ea; +} + +.ButtonPrimary:active { + background-color: #3d5fc4; } .Splotch { @@ -125,6 +167,158 @@ small { mask-position: center; } +/* Shared two-card layout — a fixed-size left setup/member-list card beside a + same-height right card whose content switches via a tab strip (LEADERBOARD/CHAT + on the main menu; CHAT/LEADERBOARDS/INFO in the lobby). Mirrors the cpp reference + mockup's side-by-side card layout. */ +.CardsRow { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 32px; + margin: 12px auto 0; +} + +.Card { + width: 360px; + max-width: 90vw; + min-height: 420px; + display: flex; + flex-direction: column; + background-color: rgba(0, 0, 0, 0.25); + border-radius: 8px; + padding: 12px 16px; + text-align: left; +} + +.CardRight { + justify-content: flex-start; +} + +.CardTabRow { + display: flex; + margin: -4px -4px 8px; +} + +.CardTabBtn { + flex: 1; + padding: 8px 4px; + margin: 4px; + font-size: 10pt; + font-weight: bold; + letter-spacing: 0.5px; + background-color: rgba(255, 255, 255, 0.08); + border-radius: 6px; + color: rgba(255, 255, 255, 0.75); +} + +.CardTabBtn.active { + background-color: #4c73e6; + color: white; +} + +.GlobalChatPanel, .LeaderboardPanel { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +.chatScroll { + flex: 1; + min-height: 80px; + overflow-y: auto; + font-size: 10pt; + padding-right: 4px; +} + +.chatLine { + margin: 2px 0; + word-break: break-word; +} + +.chatFrom { + color: #99bbff; + font-weight: bold; +} + +.chatInputRow { + display: flex; + margin-top: 6px; +} + +.chatInputRow input { + flex: 1; + font-size: 10pt; + padding: 4px 6px; +} + +.chatSendBtn { + margin: 0 0 0 6px; + padding: 4px 10px; + font-size: 10pt; +} + +.lbToggleRow { + display: flex; + gap: 6px; + margin-bottom: 6px; +} + +.lbToggleBtn { + flex: 1; + padding: 6px 4px; + font-size: 9pt; + background-color: rgba(255, 255, 255, 0.08); + border-radius: 6px; + color: rgba(255, 255, 255, 0.75); +} + +.lbToggleBtn.active { + background-color: #2e8b57; + color: white; +} + +.lbDivider { + border-color: rgba(255, 255, 255, 0.15); + margin: 6px 0; + width: 100%; +} + +.lbRow { + display: flex; + align-items: baseline; + gap: 8px; + padding: 3px 0; + font-size: 10pt; +} + +.lbRank { + width: 30px; + flex-shrink: 0; + font-weight: bold; +} + +.lbName { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.lbScore { + flex-shrink: 0; +} + +.lbSelfRow .lbName, .lbSelfRow .lbScore { + color: #59ff73; +} + +.lbEllipsis { + margin: 2px 0; + text-align: center; +} + .Shockwave { position: relative; border-radius: 50%; @@ -149,3 +343,228 @@ small { opacity: 0; } } + +/* Live in-game scoreboard sidebar (GameScreen) — driven by state.coverage. */ +.Scoreboard { + margin-bottom: 12px; +} + +.ScoreboardHeader { + display: flex; + justify-content: space-between; + font-size: 8pt; + opacity: 0.6; + margin-bottom: 4px; +} + +.ScoreboardRow { + display: flex; + align-items: center; + gap: 6px; + padding: 3px 0; + font-size: 10pt; +} + +.ScoreboardRowMe { + background-color: rgba(255, 255, 255, 0.06); + border-radius: 4px; +} + +.ScoreboardRank { + width: 26px; + flex-shrink: 0; + font-weight: bold; +} + +.ScoreboardDot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} + +.ScoreboardName { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ScoreboardCoverage { + flex-shrink: 0; + font-weight: bold; +} + +/* Match Summary screen (post-match results + rematch queue, BCLOUD-14489). */ +.MatchSummaryScreen { + display: flex; + justify-content: center; + width: 100%; +} + +.SummaryPanel { + width: 720px; + max-width: 92vw; + min-height: 0; +} + +.SummaryWinnerBanner { + background-color: rgba(77, 61, 20, 1); + border: 1.5px solid #ffd54f; + color: #ffd54f; + border-radius: 6px; + padding: 10px 14px; + margin: 8px 0; + font-weight: bold; + text-align: left; +} + +.SummaryHeaderRow { + display: flex; + justify-content: space-between; + margin-top: 6px; +} + +.SummaryScroll { + max-height: 320px; + overflow-y: auto; + padding-right: 4px; +} + +.SummaryCard { + background-color: rgba(255, 255, 255, 0.05); + border-radius: 6px; + padding: 10px 12px; + margin: 8px 0; + text-align: left; +} + +.SummaryCardMe { + border: 1.5px solid #59ff73; +} + +.SummaryCardHeader { + display: flex; + align-items: center; + gap: 8px; +} + +.SummaryColorDot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} + +.SummaryName { + flex: 1; +} + +.SummaryCoverageBlock { + display: flex; + flex-direction: column; + align-items: flex-end; + font-size: 9pt; + line-height: 1.3; +} + +.SummaryBadgeRow { + display: flex; + align-items: center; + margin-top: 6px; + flex-wrap: wrap; + gap: 4px; +} + +.SummaryPill { + display: inline-block; + background-color: rgba(255, 255, 255, 0.1); + color: #bfd0ff; + border-radius: 5px; + padding: 3px 10px; + font-size: 9pt; + margin: 3px 0; +} + +.SummaryPillGood { + background-color: rgba(45, 110, 60, 0.6); + color: #72f28a; +} + +.SummaryPillBest { + background-color: rgba(120, 95, 25, 0.6); + color: #ffd54f; +} + +/* Non-blocking "starting the next round" banner, shown over the Lobby screen. */ +.ProvisioningBanner { + position: fixed; + top: 12px; + left: 50%; + transform: translateX(-50%); + background-color: rgba(0, 0, 0, 0.75); + color: #ffd966; + padding: 8px 16px; + border-radius: 6px; + font-size: 10pt; + z-index: 900; +} + +/* Lobby member list (LobbyScreen) — scrollable so a full 40-player lobby doesn't grow + the (fixed-height) card. */ +.memberListScroll { + flex: 1; + min-height: 0; + overflow-y: auto; +} + +.memberRow { + padding: 6px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + text-align: left; +} + +.memberSwatch { + width: 18px; + height: 18px; + border-radius: 4px; + flex-shrink: 0; +} + +.memberStatus { + margin: 2px 0 0 26px; + font-size: 9pt; + opacity: 0.5; +} + +.ColorPickerPopup { + display: flex; + flex-wrap: wrap; + max-width: 320px; + margin: 6px 0 6px 26px; + padding: 8px; + background-color: rgba(0, 0, 0, 0.35); + border-radius: 6px; +} + +.ColorPickerPopup .colorBtn { + width: 20px; + height: 18px; +} + +/* Floating settings/debug panel (GameScreen) — bottom-right, matches the cpp + reference's Debug window position. */ +.DebugPanel { + position: fixed; + right: 16px; + bottom: 16px; + width: 220px; + max-height: 60vh; + overflow-y: auto; + background-color: rgba(0, 0, 0, 0.55); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + padding: 10px 14px; + text-align: left; + z-index: 800; +} diff --git a/RelayTestApp/src/App.js b/RelayTestApp/src/App.js index 18705ad..21177bf 100644 --- a/RelayTestApp/src/App.js +++ b/RelayTestApp/src/App.js @@ -8,6 +8,9 @@ import LoadingScreen from './LoadingScreen' import MainMenuScreen from './MainMenuScreen' import LobbyScreen from './LobbyScreen' import GameScreen from './GameScreen' +import MatchSummaryScreen from './MatchSummaryScreen' +import { computeCoverage } from './coverage' +import { LEADERBOARD_IDS } from './LeaderboardPanel' var Buffer = require('buffer/').Buffer // note: the trailing slash is important! @@ -16,14 +19,28 @@ let colors = require('./Colors').colors const MATCH_DURATION_SEC = 90 const NUM_ARROW_IMAGES = 8 +const RESULT_GRACE_MS = 1000 // delay between match_result broadcast and onEndMatch() +const COVERAGE_RECOMPUTE_MS = 250 // live-scoreboard recompute throttle +const MATCH_SUMMARY_REMATCH_MS = 45000 // how long the Match Summary screen waits before the host auto-starts the next round let presentWhileStarted = false let server = null let showJoinButton = false -let autoEndTimer = null let pingInterval = null +let matchTickInterval = null // live coverage recompute + host auto-end sequence (see tickMatch) +let rematchGateInterval = null // host-only: ticks tickRematchGate while awaitingRematch let loadingTimerStart = null // When the user first clicked Play (drives the elapsed timer) +// Match-end bookkeeping — internal, doesn't drive rendering (mirrors cpp's app.cpp file- +// static equivalents). Reset every fresh round in connectRelay()'s success callback. +let matchPhase = 'running' // 'running' | 'resultsBroadcast' | 'ended' +let resultsSentAtMs = 0 +let coverageComputedAtMs = 0 +let pendingMatchResult = [] // match_result chunk-reassembly buffer +let pendingLbChunk = [] // lb_result chunk-reassembly buffer: [{cxId, delta}] +let pendingLbResults = {} // cxId -> LeaderboardDelta, for lb_result arriving before match_result +let leaderboardPostedRound = -1 // guards against double-posting the cumulative points board + export function getShowJoinButton () { return showJoinButton } @@ -58,7 +75,13 @@ class App extends Component { reliable: false, ordered: true }, - relayProtocol: 'ws' + relayProtocol: 'ws', + coverage: [], + matchResult: { valid: false, round: 0, entries: [] }, + awaitingRematch: false, + matchSummaryArrivalTime: null, + isProvisioning: false, + provisioningStatus: '' } this.state = state @@ -99,6 +122,7 @@ class App extends Component { // Initialize brainCloud library initBC () { + this.rttEnableWaiters = null // drop any pending ensureRTTEnabled callers from the old bc instance this.bc = new brainCloud.BrainCloudWrapper('relayservertest') this.bc.initialize(ids.appId, ids.appSecret, '6.0.0') if (ids.url) this.bc.brainCloudClient.setServerUrl(ids.url) @@ -128,7 +152,13 @@ class App extends Component { relayOptions: { reliable: false, ordered: true - } + }, + coverage: [], + matchResult: { valid: false, round: 0, entries: [] }, + awaitingRematch: false, + matchSummaryArrivalTime: null, + isProvisioning: false, + provisioningStatus: '' } } @@ -154,10 +184,9 @@ class App extends Component { // Reset the app to the login page with an error popup dieWithMessage (message) { - if (autoEndTimer) { - clearTimeout(autoEndTimer) - autoEndTimer = null - } + if (pingInterval) { clearInterval(pingInterval); pingInterval = null } + if (matchTickInterval) { clearInterval(matchTickInterval); matchTickInterval = null } + if (rematchGateInterval) { clearInterval(rematchGateInterval); rematchGateInterval = null } this.bc.logoutOnApplicationClose(false) this.bc.relay.disconnect() this.bc.relay.deregisterSystemCallback() @@ -232,6 +261,15 @@ class App extends Component { appLobbies: allLobbyTypes, splotchDurationSec: splotchDurationSec }) + + // Keep RTT connected from the main menu onward so Global Chat works there + // (brainCloud's chat calls require RTT). onPlayClicked's ensureRTTEnabled + // call resolves immediately once this has already succeeded. + this.ensureRTTEnabled(() => { + let state = this.state + if (state.user) state.user.cxId = this.bc.rttService.getRTTConnectionId() + this.setState(state) + }, () => {}) } else { console.log('globalApp.readProperties failed') } @@ -241,6 +279,50 @@ class App extends Component { } } + // Enables RTT if not already enabled, then invokes onReady. RTT's own enableRTT() + // silently no-ops (without invoking either callback) when already enabled OR still + // connecting, so callers must go through this rather than calling enableRTT + // directly. Also queues concurrent callers instead of re-calling enableRTT while a + // connection is already in flight — e.g. the main menu enables RTT for chat right + // after login, and if Play is clicked before that resolves, a second raw enableRTT + // call would no-op and silently drop onPlayClicked's callback (which registers the + // lobby callback and starts matchmaking), leaving the screen stuck on + // "joiningLobby" forever. + ensureRTTEnabled (onReady, onFail) { + if (this.bc.rttService.isRTTEnabled()) { + // Defer to the next tick rather than calling onReady() inline. enableRTT's own + // success callback is always async in the "not yet enabled" branch below (a real + // network round trip) — callers rely on that: e.g. onPlayClicked calls this + // synchronously right after setState({screen: 'joiningLobby', ...}), and onReady's + // body does this file's usual `let state = this.state; state.x = y; + // this.setState(state)` pattern. React batches setState calls made within one + // event handler, so a SYNCHRONOUS onReady here would read the pre-flush + // this.state (still screen: 'mainMenu'), and passing that stale snapshot back to + // setState — merged AFTER the pending 'joiningLobby' update — would silently + // revert screen back to 'mainMenu'. Deferring keeps the "already enabled" fast + // path's timing consistent with the always-async slow path below. + setTimeout(onReady, 0) + return + } + if (this.rttEnableWaiters) { + this.rttEnableWaiters.push({ onReady, onFail }) + return + } + this.rttEnableWaiters = [{ onReady, onFail }] + this.bc.rttService.enableRTT( + (...args) => { + let waiters = this.rttEnableWaiters + this.rttEnableWaiters = null + waiters.forEach(w => w.onReady(...args)) + }, + (...args) => { + let waiters = this.rttEnableWaiters + this.rttEnableWaiters = null + waiters.forEach(w => w.onFail(...args)) + } + ) + } + onLogout () { this.bc.logout(true, () => { // Close Relay/RTT/BC connections @@ -258,6 +340,7 @@ class App extends Component { // Clicked play from the main menu onPlayClicked (lobbyType, usePingData, protocol) { + console.log('[DEBUG] App.onPlayClicked fired', lobbyType, usePingData, protocol) // TEMP diagnostic — remove once Play navigation is confirmed working loadingTimerStart = Date.now() // Remember the last selected lobby type so the main menu defaults to it next time localStorage.setItem('lobbyType', lobbyType) @@ -271,8 +354,8 @@ class App extends Component { const algo = { strategy: 'ranged-absolute', alignment: 'center', ranges: [1000] } - // Enable RTT service - this.bc.rttService.enableRTT( + // Enable RTT service (no-op if the main menu already enabled it for chat) + this.ensureRTTEnabled( () => { let state = this.state state.user.cxId = this.bc.rttService.getRTTConnectionId() @@ -340,7 +423,11 @@ class App extends Component { onLobbyEvent (result) { if (result.data.lobby) { let state = this.state - state.lobby = { ...result.data.lobby, lobbyId: result.data.lobbyId } + // This-lobby chat (SIGNAL below) lives on state.lobby.chatMessages, but every + // lobby event here rebuilds state.lobby fresh from server data — carry the + // existing history forward instead of losing it on the next MEMBER_JOIN/UPDATE. + let prevChatMessages = (state.lobby && state.lobby.chatMessages) || [] + state.lobby = { ...result.data.lobby, lobbyId: result.data.lobbyId, chatMessages: prevChatMessages } // The lobby RTT event already carries the latest member data (incl. each // member's selected colour in extra.colorIndex), so we derive lobby state @@ -407,33 +494,62 @@ class App extends Component { } else if (result.operation === 'MEMBER_LEFT') { this.setState({ loadingStatus: 'A player left the lobby.' }) } else if (result.operation === 'STARTING') { + // Stay on whatever screen we're already on (Lobby, normally) — chat and the rest + // of the lobby UI keep working through the whole provisioning sequence instead of + // being replaced by a blocking loading screen. isProvisioning just drives a small + // inline status line (see LobbyScreen); the actual screen change to Game only + // happens once relay truly connects (connectRelay's success callback). loadingTimerStart = Date.now() presentWhileStarted = true this.updatePresentSinceStart() this.setState({ - screen: 'connecting', - loadingStatus: 'Provisioning server...' + isProvisioning: true, + provisioningStatus: 'Provisioning server...' }) } else if (result.operation === 'ROOM_ASSIGNED') { - this.setState({ loadingStatus: 'Server assigned...' }) + this.setState({ provisioningStatus: 'Server assigned...' }) } else if (result.operation === 'ROOM_PROGRESS') { let step = result.data && result.data.step ? result.data.step : '' let msg = result.data && result.data.msg ? result.data.msg : '' this.setState({ - loadingStatus: step ? `${step}: ${msg}` : msg || 'Starting server...' + provisioningStatus: step ? `${step}: ${msg}` : msg || 'Starting server...' }) } else if (result.operation === 'ROOM_READY') { server = result.data - this.setState({ loadingStatus: 'Connecting...' }) + this.setState({ provisioningStatus: 'Connecting...' }) if (presentWhileStarted) { this.connectRelay() } else { showJoinButton = true - this.setState({ screen: 'lobby' }) + } + } else if (result.operation === 'SIGNAL') { + // This-lobby chat, via the Lobby service's SendSignal (not the Chat service — + // rides the RTT connection the lobby already has, no separate channel needed). + // "from" is the server's authoritative sender info. Skip echoes of our own + // signal — onSendLobbySignal already appended it locally on send (compared by + // cxId, not name, since two players could share a display name). + let fromCxId = result.data.from && result.data.from.cxId + let text = result.data.signalData && result.data.signalData.text + if (text && this.state.lobby && fromCxId !== this.state.user.cxId) { + let fromName = (result.data.from && result.data.from.name) || 'Player' + let state = this.state + state.lobby.chatMessages = (state.lobby.chatMessages || []).concat([{ fromName, text }]) + this.setState(state) } } } + // Sends a this-lobby chat message via the Lobby service's SendSignal. Appends + // locally right away — the receive handler (onLobbyEvent's SIGNAL case) skips the + // echo of our own signal, which the server does send back to us too. + onSendLobbySignal (text) { + if (!text || !this.state.lobby) return + this.bc.lobby.sendSignal(this.state.lobby.lobbyId, { text }, () => {}) + let state = this.state + state.lobby.chatMessages = (state.lobby.chatMessages || []).concat([{ fromName: state.user.name, text }]) + this.setState(state) + } + // Gameplay option toggles onToggleReliable () { let state = this.state @@ -456,14 +572,18 @@ class App extends Component { // Called to terminate the current session and go back to the main menu onGameScreenClose () { - if (autoEndTimer) { - clearTimeout(autoEndTimer) - autoEndTimer = null - } if (pingInterval) { clearInterval(pingInterval) pingInterval = null } + if (matchTickInterval) { + clearInterval(matchTickInterval) + matchTickInterval = null + } + if (rematchGateInterval) { + clearInterval(rematchGateInterval) + rematchGateInterval = null + } this.bc.relay.deregisterRelayCallback() this.bc.relay.deregisterSystemCallback() this.bc.relay.disconnect() @@ -481,6 +601,11 @@ class App extends Component { state.gameStartTime = null state.round = 0 state.loadingStatus = '' + state.coverage = [] + state.matchResult = { valid: false, round: 0, entries: [] } + state.awaitingRematch = false + state.isProvisioning = false + state.provisioningStatus = '' showJoinButton = false loadingTimerStart = null this.setState(state) @@ -520,10 +645,12 @@ class App extends Component { }) } - // Owner of the lobby clicked the "Start" button + // Owner of the lobby clicked the "Start" button (also reused by tickRematchGate to + // auto-start the next round). onStart () { let state = this.state state.user.isReady = true + state.awaitingRematch = false // in case this was triggered by the rematch gate let extraJson = this.makeExtraJson(state.user.colorIndex, state.user.presentSinceStart) this.setState(state) this.bc.lobby.updateReady( @@ -533,6 +660,44 @@ class App extends Component { ) } + // Marks this player queued for a rematch and takes them back to the Lobby screen — + // used both by the Match Summary screen's "Queue for Rematch" button and by its own + // per-player MATCH_SUMMARY_REMATCH_MS auto-timeout. Either path looks identical from + // here on: the player sits in the Lobby waiting for tickRematchGate() to actually start + // the next round. + onSetRematchReady (ready) { + let state = this.state + state.user.isReady = ready + if (ready) state.screen = 'lobby' + this.setState(state) + this.bc.lobby.updateReady( + state.lobby.lobbyId, ready, + this.makeExtraJson(state.user.colorIndex, state.user.presentSinceStart) + ) + } + + // Host-only gate on starting the next round: waits until every current lobby member has + // queued for a rematch (each auto-queues within MATCH_SUMMARY_REMATCH_MS at the latest — + // see MatchSummaryScreen — so this is mostly a safety net against clock skew) OR that + // same deadline elapses regardless. Once satisfied, calls the same onStart() that starts + // every round — no separate "begin round 2" mechanism needed. isHost is re-evaluated + // every call, so a host migration while some players are still on Match Summary is + // picked up for free. Ticked on an interval from the END_MATCH handler while + // awaitingRematch, regardless of whether the host itself is on Lobby or MatchSummary. + tickRematchGate () { + if (!this.state.awaitingRematch || !this.state.lobby) return + const isHost = this.state.lobby.ownerCxId === this.state.user.cxId + if (!isHost) return + + const members = this.state.lobby.members + const allReady = members.length > 0 && members.every(m => m.isReady) + const elapsedMs = Date.now() - (this.state.matchSummaryArrivalTime || Date.now()) + + if (allReady || elapsedMs >= MATCH_SUMMARY_REMATCH_MS) { + this.onStart() + } + } + // Player clicked the "Join Match" button to join an in-progress game onJoin () { let state = this.state @@ -547,12 +712,10 @@ class App extends Component { this.connectRelay() } - // Return to the lobby with the same players (host-only) + // Ends the current match (host-only) — there's no manual UI trigger anymore (matches + // the cpp reference: no more "End Match" button), only tickMatch()'s auto-end + // sequence RESULT_GRACE_MS after it broadcasts match_result. onEndMatch () { - if (autoEndTimer) { - clearTimeout(autoEndTimer) - autoEndTimer = null - } let extraJson = { cxId: this.bc.brainCloudClient.getRTTConnectionId(), lobbyId: this.state.lobby.lobbyId, @@ -561,17 +724,6 @@ class App extends Component { this.bc.relay.endMatch(extraJson) } - // Host clears all splotches from the canvas - onClearSplotches () { - this.setState({ splotches: [] }) - this.bc.relay.sendToAll( - Buffer.from(JSON.stringify({ op: 'clear_splotches' }), 'ascii'), - true, - false, - this.bc.relay.CHANNEL_HIGH_PRIORITY_2 - ) - } - // A relay message coming from another player onRelayMessage (netId, data) { let state = this.state @@ -623,16 +775,70 @@ class App extends Component { }) break - // Host cleared all splotches - case 'clear_splotches': - state.splotches = [] - break - // Live relay RTT broadcast from another player case 'relay_ping': if (member) member.activePing = json.data.ping break + // Host-broadcast authoritative match result (chunked — see sendMatchResult). + case 'match_result': { + const round = json.data.round + if (!(state.matchResult.valid && state.matchResult.round === round)) { + if (json.data.first) pendingMatchResult = [] + json.data.e.forEach(entry => { + pendingMatchResult.push({ + cxId: entry.cx, + rank: entry.r, + coveragePct: entry.c / 100, + beaten: entry.b, + lbDelta: { ready: false } + }) + }) + if (json.data.last) { + this.applyMatchResult(round, pendingMatchResult) + pendingMatchResult = [] + } + } + break + } + + // Host-computed leaderboard results for the whole round (see + // hostPostMatchResultsToCloud/PostMatchResults), chunked like match_result. Can + // arrive before this round's match_result has populated state.matchResult.entries + // — buffer by cxId in that case (drained in applyMatchResult). + case 'lb_result': { + const round = json.data.round + if (!(state.matchResult.valid && state.matchResult.round === round)) { + if (json.data.first) pendingLbChunk = [] + json.data.e.forEach(entry => { + const delta = { + ready: true, + pointsLifetime: { improved: false }, + pointsQuarterly: { improved: false }, + coverageLifetime: { improved: false }, + coverageQuarterly: { improved: false } + } + const readPeriod = (key, target) => { + if (entry[key]) { target.improved = true; target.rankBefore = entry[key].b; target.rankAfter = entry[key].a } + } + readPeriod('pl', delta.pointsLifetime) + readPeriod('pq', delta.pointsQuarterly) + readPeriod('cl', delta.coverageLifetime) + readPeriod('cq', delta.coverageQuarterly) + pendingLbChunk.push({ cxId: entry.cx, delta }) + }) + if (json.data.last) { + pendingLbChunk.forEach(({ cxId, delta }) => { + const entry = state.matchResult.entries.find(e => e.cxId === cxId) + if (entry) entry.lbDelta = delta + else pendingLbResults[cxId] = delta + }) + pendingLbChunk = [] + } + } + break + } + default: break } @@ -665,28 +871,48 @@ class App extends Component { this.sendSplotchSyncToMask(mask) } } else if (json.op === 'END_MATCH') { - if (autoEndTimer) { - clearTimeout(autoEndTimer) - autoEndTimer = null - } if (pingInterval) { clearInterval(pingInterval) pingInterval = null } + if (matchTickInterval) { + clearInterval(matchTickInterval) + matchTickInterval = null + } this.bc.relay.deregisterRelayCallback() this.bc.relay.deregisterSystemCallback() this.bc.relay.disconnect() loadingTimerStart = null let state = this.state - state.screen = 'lobby' + // The Match Summary + rematch-queue screen (BCLOUD-14489) — state.matchResult is + // always populated by the time END_MATCH arrives (tickMatch's auto-end sequence + // broadcasts it RESULT_GRACE_MS before calling onEndMatch; a manual early end skips + // that, so the screen just shows "Waiting for results..." in that case). + state.screen = 'matchSummary' + state.matchSummaryArrivalTime = Date.now() + state.awaitingRematch = true state.lobbyResetting = true state.user.isReady = false state.user.presentSinceStart = false state.splotches = [] state.shockwaves = [] state.gameStartTime = null + state.isProvisioning = false showJoinButton = false this.setState(state) + + // Actually clear readiness server-side too, not just the local mirror above — + // otherwise the "Queue for Rematch N/M" count starts from whatever everyone's + // pre-match ready state still was. + this.bc.lobby.updateReady( + state.lobby.lobbyId, false, + this.makeExtraJson(state.user.colorIndex, false) + ) + + // Host-only: keeps evaluating whether to auto-start the next round even while + // sitting on Lobby/MatchSummary, whichever screen the host itself is on. + if (rematchGateInterval) clearInterval(rematchGateInterval) + rematchGateInterval = setInterval(() => this.tickRematchGate(), 500) } } @@ -862,6 +1088,24 @@ class App extends Component { result => { let state = this.state state.screen = 'game' + state.isProvisioning = false + + // Fresh round — reset per-round match/coverage state so nothing carries over + // from the previous round. + state.coverage = [] + state.matchResult = { valid: false, round: 0, entries: [] } + state.awaitingRematch = false + matchPhase = 'running' + resultsSentAtMs = 0 + coverageComputedAtMs = 0 + pendingMatchResult = [] + pendingLbChunk = [] + pendingLbResults = {} + leaderboardPostedRound = -1 + if (rematchGateInterval) { + clearInterval(rematchGateInterval) + rematchGateInterval = null + } // Host sets the authoritative game start time and broadcasts it to all players if (state.lobby.ownerCxId === state.user.cxId) { @@ -869,7 +1113,6 @@ class App extends Component { state.round = (state.round || 0) + 1 this.setState(state) this.sendGameStartToMask(this.bc.relay.TO_ALL_PLAYERS) - this.scheduleAutoEnd() } else { this.setState(state) } @@ -877,6 +1120,10 @@ class App extends Component { // Broadcast relay RTT to all players every 2 seconds if (pingInterval) clearInterval(pingInterval) pingInterval = setInterval(() => this.broadcastRelayPing(), 2000) + + // Live coverage recompute + host auto-end sequence (see tickMatch) + if (matchTickInterval) clearInterval(matchTickInterval) + matchTickInterval = setInterval(() => this.tickMatch(), COVERAGE_RECOMPUTE_MS) }, error => this.dieWithMessage('Failed to connect to server, msg: ' + error) ) @@ -942,13 +1189,247 @@ class App extends Component { sendBatch(isFirst, batch) } - // Schedule auto-end of match at MATCH_DURATION_SEC seconds (host only) - scheduleAutoEnd () { - if (autoEndTimer) clearTimeout(autoEndTimer) - autoEndTimer = setTimeout(() => { - autoEndTimer = null + // Mask of every OTHER lobby member (excludes self) — used to broadcast match_result/ + // lb_result to the rest of the match. + buildAllOthersMask () { + let mask = 0 + this.state.lobby.members.forEach(member => { + if (member.cxId === this.state.user.cxId) return + let netId = this.bc.relay.getNetIdForCxId(member.cxId) + if (netId === undefined || netId < 0) return + mask += Math.pow(2, netId) + }) + return mask + } + + toMatchResultEntries (coverage) { + return coverage.map(c => ({ + cxId: c.cxId, + rank: c.rank, + coveragePct: c.coveragePct, + beaten: c.beaten, + lbDelta: { ready: false } + })) + } + + // Broadcasts the host-authoritative match result to the rest of the match, chunked to + // stay under a relay packet's byte budget (mirrors sendSplotchSyncToMask's pattern). + sendMatchResult (mask, round, coverage) { + if (!coverage.length || mask === 0) return + const MAX_BYTES = 900 + const ENVELOPE = 80 + let batches = [] + let batch = [] + let currentSize = ENVELOPE + + coverage.forEach(c => { + let entry = { cx: c.cxId, r: c.rank, c: Math.round(c.coveragePct * 100), b: c.beaten } + let entrySize = JSON.stringify(entry).length + 1 + if (currentSize + entrySize > MAX_BYTES && batch.length > 0) { + batches.push(batch) + batch = [] + currentSize = ENVELOPE + } + batch.push(entry) + currentSize += entrySize + }) + batches.push(batch) + + batches.forEach((entries, i) => { + let msg = { op: 'match_result', data: { round, first: i === 0, last: i === batches.length - 1, e: entries } } + this.bc.relay.sendToPlayers( + Buffer.from(JSON.stringify(msg), 'ascii'), + mask, true, true, this.bc.relay.CHANNEL_HIGH_PRIORITY_1 + ) + }) + } + + // Applies an authoritative coverage snapshot for a round — either a locally-computed one + // (host, or the watchdog fallback) or one just reassembled from a "match_result" + // broadcast. Idempotent per round. Only the host posts to the cloud (see + // hostPostMatchResultsToCloud) — everyone else just waits for the "lb_result" broadcast + // that call produces. + applyMatchResult (round, entries) { + if (this.state.matchResult.valid && this.state.matchResult.round === round) return + let state = this.state + state.matchResult = { valid: true, round, entries } + + // Drain any "lb_result" broadcasts that arrived before this round's match_result did. + entries.forEach(e => { + let pending = pendingLbResults[e.cxId] + if (pending) { + e.lbDelta = pending + delete pendingLbResults[e.cxId] + } + }) + this.setState(state) + + if (leaderboardPostedRound === round) return + leaderboardPostedRound = round + + const isHost = this.state.lobby.ownerCxId === this.state.user.cxId + if (isHost) this.hostPostMatchResultsToCloud(round, entries) + } + + // Host-only: posts the WHOLE round's results to the four leaderboards in one trusted + // server-side call (PostMatchResults cloud script — see brainCloud Design > Cloud Code). + // Replaces per-client direct-leaderboard-API posting entirely; postScoreToLeaderboard- + // OnBehalfOf (which the script uses) is Cloud-Code-only, so individual clients can't + // post to these boards at all anymore. + hostPostMatchResultsToCloud (round, entries) { + const scriptData = { + round, + pointsLeaderboardId: LEADERBOARD_IDS.points.lifetime, + pointsLeaderboardIdQuarterly: LEADERBOARD_IDS.points.quarterly, + coverageLeaderboardId: LEADERBOARD_IDS.coverage.lifetime, + coverageLeaderboardIdQuarterly: LEADERBOARD_IDS.coverage.quarterly, + entries: entries + .map(e => { + const member = this.state.lobby.members.find(m => m.cxId === e.cxId) + if (!member || !member.profileId) return null // can't post server-side without a profileId + return { + profileId: member.profileId, + name: member.name, + points: e.beaten + 1, + coverageBasisPoints: Math.round(e.coveragePct * 100) + } + }) + .filter(Boolean) + } + + this.bc.script.runScript('PostMatchResults', scriptData, result => { + if (result.status === 200) { + this.applyLeaderboardResultsFromCloud(round, result.data.results) + } else { + console.log('[DEBUG] PostMatchResults script call failed for round ' + round, result) + } + }) + } + + // Applies the PostMatchResults response (keyed by profileId) onto state.matchResult. + // entries (keyed by cxId — resolved via state.lobby.members, the only place both ids + // are known together) and broadcasts the result to the rest of the match. Host-only. + applyLeaderboardResultsFromCloud (round, resultsArr) { + if (!(this.state.matchResult.valid && this.state.matchResult.round === round)) return // a newer round has already started + + const profileIdToCxId = {} + this.state.lobby.members.forEach(m => { if (m.profileId) profileIdToCxId[m.profileId] = m.cxId }) + + const readPeriod = j => ({ rankBefore: j.before, rankAfter: j.after, improved: j.improved }) + + let state = this.state + resultsArr.forEach(r => { + const cxId = profileIdToCxId[r.profileId] + if (!cxId) return + const delta = { + ready: true, + pointsLifetime: readPeriod(r.pointsLifetime), + pointsQuarterly: readPeriod(r.pointsQuarterly), + coverageLifetime: readPeriod(r.coverageLifetime), + coverageQuarterly: readPeriod(r.coverageQuarterly) + } + const entry = state.matchResult.entries.find(e => e.cxId === cxId) + if (entry) entry.lbDelta = delta + }) + this.setState(state) + + this.sendLeaderboardResultsToMask(this.buildAllOthersMask(), round, state.matchResult.entries) + } + + // Broadcasts the host's cloud-computed leaderboard results for the whole round to the + // rest of the match — chunked exactly like match_result. Only entries with + // lbDelta.ready are included; a period sub-object is present only when it actually + // improved (absent == "no change" on receipt). + sendLeaderboardResultsToMask (mask, round, entries) { + if (mask === 0) return + const MAX_BYTES = 900 + const ENVELOPE = 80 + let batches = [] + let batch = [] + let currentSize = ENVELOPE + + const putPeriod = (je, key, pd) => { + if (!pd.improved) return + je[key] = { b: pd.rankBefore, a: pd.rankAfter } + } + + entries.filter(e => e.lbDelta && e.lbDelta.ready).forEach(e => { + let je = { cx: e.cxId } + putPeriod(je, 'pl', e.lbDelta.pointsLifetime) + putPeriod(je, 'pq', e.lbDelta.pointsQuarterly) + putPeriod(je, 'cl', e.lbDelta.coverageLifetime) + putPeriod(je, 'cq', e.lbDelta.coverageQuarterly) + let entrySize = JSON.stringify(je).length + 1 + if (currentSize + entrySize > MAX_BYTES && batch.length > 0) { + batches.push(batch) + batch = [] + currentSize = ENVELOPE + } + batch.push(je) + currentSize += entrySize + }) + batches.push(batch) // always at least one (possibly empty) so "last" still fires + + batches.forEach((entriesBatch, i) => { + let msg = { op: 'lb_result', data: { round, first: i === 0, last: i === batches.length - 1, e: entriesBatch } } + this.bc.relay.sendToPlayers( + Buffer.from(JSON.stringify(msg), 'ascii'), + mask, true, true, this.bc.relay.CHANNEL_HIGH_PRIORITY_1 + ) + }) + } + + // Live coverage recompute (drives the in-game scoreboard sidebar) + the host's + // auto-end-at-MATCH_DURATION_SEC sequence: compute+broadcast match_result, wait + // RESULT_GRACE_MS, then call the same onEndMatch() the manual "End Match" button uses. + // Ticked every COVERAGE_RECOMPUTE_MS while in the 'game' screen (started/cleared + // alongside pingInterval). + tickMatch () { + if (!this.state.gameStartTime) return + const nowMs = Date.now() + + if (nowMs - coverageComputedAtMs >= COVERAGE_RECOMPUTE_MS) { + const fresh = computeCoverage(this.state.splotches, this.state.lobby.members) + const prevCoverage = this.state.coverage + fresh.forEach(e => { + const old = prevCoverage.find(o => o.cxId === e.cxId) + const prevRank = old ? old.rank : e.rank + const prevChangedAt = old ? (old.rankChangedAtMs || 0) : 0 + e.prevRank = prevRank + e.rankChangedAtMs = (prevRank !== e.rank) ? nowMs : prevChangedAt + }) + this.setState({ coverage: fresh }) + coverageComputedAtMs = nowMs + } + + const isHost = this.state.lobby.ownerCxId === this.state.user.cxId + const elapsedMs = nowMs - this.state.gameStartTime + + if (matchPhase === 'running' && elapsedMs >= MATCH_DURATION_SEC * 1000 && isHost) { + if (!(this.state.matchResult.valid && this.state.matchResult.round === this.state.round)) { + const finalCoverage = computeCoverage(this.state.splotches, this.state.lobby.members) + this.applyMatchResult(this.state.round, this.toMatchResultEntries(finalCoverage)) + this.sendMatchResult(this.buildAllOthersMask(), this.state.round, finalCoverage) + } + matchPhase = 'resultsBroadcast' + resultsSentAtMs = nowMs + } else if (matchPhase === 'resultsBroadcast' && isHost && nowMs - resultsSentAtMs >= RESULT_GRACE_MS) { this.onEndMatch() - }, MATCH_DURATION_SEC * 1000) + matchPhase = 'ended' + } + + // Watchdog: well past the deadline with no authoritative result at all (a dropped + // broadcast, or a gap during host migration) — compute and apply locally so the round + // can't hang forever. Cheap and idempotent. + if (!this.state.matchResult.valid && elapsedMs >= MATCH_DURATION_SEC * 1000 + RESULT_GRACE_MS + 3000) { + const finalCoverage = computeCoverage(this.state.splotches, this.state.lobby.members) + this.applyMatchResult(this.state.round, this.toMatchResultEntries(finalCoverage)) + if (isHost && matchPhase !== 'ended') { + this.sendMatchResult(this.buildAllOthersMask(), this.state.round, finalCoverage) + matchPhase = 'resultsBroadcast' + resultsSentAtMs = nowMs + } + } } // Add a persistent colour splotch at pos (normalized 0-1 coords) @@ -1052,6 +1533,7 @@ class App extends Component { appLobbies={this.state.appLobbies} usePingData={this.state.usePingData} lastLobbyType={localStorage.getItem('lobbyType')} + bcWrapper={this.bc} onLogout={this.onLogout.bind(this)} onPlay={this.onPlayClicked.bind(this)} /> @@ -1080,25 +1562,21 @@ class App extends Component { lobbyResetting={this.state.lobbyResetting} usePingData={this.state.usePingData} pingData={this.state.pingData} + bcWrapper={this.bc} + isProvisioning={this.state.isProvisioning} + provisioningStatus={this.state.provisioningStatus} + awaitingRematch={this.state.awaitingRematch} + matchResult={this.state.matchResult} onBack={this.onGameScreenClose.bind(this)} onColorChanged={this.onColorChanged.bind(this)} onTeamChanged={this.onTeamChanged.bind(this)} onStart={this.onStart.bind(this)} onJoin={this.onJoin.bind(this)} + onSendLobbySignal={this.onSendLobbySignal.bind(this)} /> ) - case 'connecting': - return ( - - ) - case 'game': return ( <> @@ -1110,16 +1588,14 @@ class App extends Component { user={this.state.user} lobby={this.state.lobby} lobbyType={this.state.lobbyType} - disbandOnStart={this.state.disbandOnStart} teams={this.state.teams} shockwaves={this.state.shockwaves} splotches={this.state.splotches} splotchDurationSec={this.state.splotchDurationSec} gameStartTime={this.state.gameStartTime} relayOptions={this.state.relayOptions} + coverage={this.state.coverage} onBack={this.onGameScreenClose.bind(this)} - onEndMatch={this.onEndMatch.bind(this)} - onClearSplotches={this.onClearSplotches.bind(this)} onPlayerMove={this.onPlayerMove.bind(this)} onPlayerClicked={this.onPlayerClicked.bind(this)} onToggleReliable={this.onToggleReliable.bind(this)} @@ -1130,6 +1606,18 @@ class App extends Component { ) + case 'matchSummary': + return ( + + ) + default: return

Invalid state

} diff --git a/RelayTestApp/src/GameScreen.js b/RelayTestApp/src/GameScreen.js index d30db23..5e2ff4a 100644 --- a/RelayTestApp/src/GameScreen.js +++ b/RelayTestApp/src/GameScreen.js @@ -1,23 +1,25 @@ import React, { Component } from 'react' +import { rankColor } from './LeaderboardPanel' let colors = require('./Colors').colors const MATCH_DURATION_SEC = 90 -const COUNTDOWN_FROM_SEC = 80 +const TIMER_WARN_SEC = 30 +const TIMER_URGENT_SEC = 10 +const SIDEBAR_WIDTH = 220 // Props: // user // lobby // lobbyType -// disbandOnStart // teams // splotches // splotchDurationSec // gameStartTime // relayOptions { reliable, ordered } +// coverage — live RANK/PLAYER/COVERAGE standings (App.tickMatch, ~every 250ms) // numArrowImages -// onBack / onEndMatch / onClearSplotches -// onPlayerMove / onPlayerClicked +// onBack / onPlayerMove / onPlayerClicked // onToggleReliable / onToggleOrdered / onTogglePlayerMask class GameScreen extends Component { constructor () { @@ -35,15 +37,12 @@ class GameScreen extends Component { clearInterval(this.timerInterval) } + // "Exit Match" — leaves the match for yourself only. There's no manual host "End + // Match" override anymore (matches the cpp reference): a round always ends via the + // host's auto-end sequence (App.tickMatch, MATCH_DURATION_SEC after it starts). onBack () { this.props.onBack() } - onEndMatch () { - this.props.onEndMatch() - } - onClearSplotches () { - this.props.onClearSplotches() - } onMouseMove (e) { let elem = document.querySelector('.GamePlayArea') @@ -68,17 +67,6 @@ class GameScreen extends Component { this.props.onTogglePlayerMask(cxId) } - showEndMatchButton () { - return ( - this.props.lobby.ownerCxId === this.props.user.cxId && - !this.props.disbandOnStart - ) - } - - showClearSplotchesButton () { - return this.props.lobby.ownerCxId === this.props.user.cxId - } - // Render a colored SVG cursor arrow matching the C++/Java vector arrow shape renderArrow (color) { return ( @@ -94,25 +82,44 @@ class GameScreen extends Component { ) } - // Render the game timer. Shows countdown in the final 10 seconds. + // Render the game timer, colored by urgency in the closing seconds (matches the + // cpp reference's red/orange countdown thresholds). Sits above the canvas, left of + // the Exit Match button. renderTimer () { if (!this.props.gameStartTime) return null let elapsedMs = Date.now() - this.props.gameStartTime let elapsedSec = Math.max(0, Math.floor(elapsedMs / 1000)) + let remaining = Math.max(0, MATCH_DURATION_SEC - elapsedSec) let minutes = Math.floor(elapsedSec / 60) let seconds = elapsedSec % 60 let timeStr = `${minutes}:${seconds.toString().padStart(2, '0')}` - if (elapsedSec >= COUNTDOWN_FROM_SEC && elapsedSec < MATCH_DURATION_SEC) { - let remaining = MATCH_DURATION_SEC - elapsedSec - return ( -

- Ending in {remaining}... -

- ) + if (remaining <= TIMER_WARN_SEC) { + let color = remaining <= TIMER_URGENT_SEC ? '#ff4d4d' : '#ffbf33' + return Ending in {remaining}... } - return

Game Time: {timeStr}

+ return Game Time: {timeStr} + } + + // Single self-relay-RTT readout — ping lives outside the scoreboard sidebar entirely, + // just below the timer/Exit Match row (matches the cpp reference). + renderSelfPing () { + const me = this.props.lobby.members.find(m => m.cxId === this.props.user.cxId) + const ping = me ? me.activePing : undefined + let text, color + if (ping === undefined || ping === null || ping < 0) { text = '...'; color = '#888888' } else if (ping >= 999) { + text = 'T/O'; color = '#ff4d4d' + } else { + text = `${ping} ms` + color = ping < 100 ? '#66e585' : ping < 200 ? '#f2cc52' : '#ff4d4d' + } + return ( +

+ {'●'}{' '} + Ping: {text} +

+ ) } // Render persistent colour splotches, applying fade-out during the final 3 seconds of their life @@ -161,91 +168,63 @@ class GameScreen extends Component { }) } - render () { - let numColors = colors.length + // Live RANK/PLAYER/COVERAGE standings — see coverage.js (App.tickMatch recomputes + // this.props.coverage roughly every 250ms from the current splotches). + renderScoreboard () { + const coverage = this.props.coverage || [] + if (coverage.length === 0) return null + const numColors = colors.length return ( -
- {/** Info Area */} -
- {this.renderTimer()} - - {/** Current lobby id */} - {this.props.lobby && this.props.lobby.lobbyId ? ( -

- Lobby: {this.props.lobby.lobbyId} -

- ) : null} - - {/** Players List / Mask */} -
-

Player Mask (For splotches)

- {this.props.lobby.members.map(member => { - const ping = member.activePing - let pingText = '' - if (ping === undefined || ping === null || ping < 0) pingText = '...' - else if (ping >= 999) pingText = 'T/O' - else pingText = `${ping} ms` - return ( -
- this.onTogglePlayerMask(member.cxId)} - defaultChecked={member.allowSendTo} - /> - - {pingText} -
- ) - })} -
+
+
+ RANK / PLAYER + COVERAGE +
+ {coverage.map(entry => { + const member = this.props.lobby.members.find(m => m.cxId === entry.cxId) + if (!member) return null + const isMe = entry.cxId === this.props.user.cxId + const colorIndex = member.extra ? member.extra.colorIndex : 0 + return ( +
+ #{entry.rank} + + + {member.name}{isMe ? ' (YOU)' : ''} + + + {entry.coveragePct.toFixed(0)}% + +
+ ) + })} +
+ ) + } - {/** Reliable/Ordered Options */} -
-

Reliable options (For mouse position)

- - -
- - -
+ render () { + let numColors = colors.length - {/** Team Instructions */} - {this.props.teams.length > 1 ? ( -
-

Instructions

-

Left Click = Splotch everybody

-

Right Click = Splotch team mates

-

Middle Click = Splotch opponents

-
- ) : ( - '' - )} + return ( +
+ {/** Docked full-height scoreboard sidebar — RANK/PLAYER/COVERAGE only, matching the cpp reference */} +
+ {this.renderScoreboard()}
- {/** Game Canvas */} + {/** Game area: timer/ping/Exit Match header above the canvas */}
+
+
+ {this.renderTimer()} + {this.renderSelfPing()} +
+ +
+
- {/** Buttons */} -
- - {this.showEndMatchButton() ? ( - - ) : ( - '' - )} - {this.showClearSplotchesButton() ? ( - - ) : ( - '' - )} -
+ {/** Team Instructions */} + {this.props.teams.length > 1 ? ( +
+

Left Click = Splotch everybody

+

Right Click = Splotch team mates

+

Middle Click = Splotch opponents

+
+ ) : ( + '' + )} +
+ + {/** Debug/settings panel — floating bottom-right, matches the cpp reference's + Debug window (region/round/lobby + Reliable/Ordered/Channel controls; the + per-player send-mask list lives here too since this app needs a place for it). */} +
+ {this.props.lobby && this.props.lobby.lobbyId ? ( +

Lobby: {this.props.lobby.lobbyId}

+ ) : null} + +

Player Mask (for splotches)

+ {this.props.lobby.members.map(member => { + const ping = member.activePing + let pingText = '' + let pingColor = '#888888' + if (ping === undefined || ping === null || ping < 0) pingText = '...' + else if (ping >= 999) { pingText = 'T/O'; pingColor = '#ff4d4d' } else { + pingText = `${ping} ms` + pingColor = ping < 100 ? '#66e585' : ping < 200 ? '#f2cc52' : '#ff4d4d' + } + return ( +
+ this.onTogglePlayerMask(member.cxId)} + defaultChecked={member.allowSendTo} + /> + + {pingText} +
+ ) + })} + +
+

Reliable options (for mouse position)

+ + +
+ +
) diff --git a/RelayTestApp/src/GlobalChatPanel.js b/RelayTestApp/src/GlobalChatPanel.js new file mode 100644 index 0000000..3ceee80 --- /dev/null +++ b/RelayTestApp/src/GlobalChatPanel.js @@ -0,0 +1,135 @@ +import React, { Component } from 'react' + +// Must match a channel Code pre-registered in the portal (App > Design > Messaging > +// Chat Channels) — global ("gl") chat channels aren't created ad hoc by getChannelId, +// they resolve an existing registration or fail with CHAT_UNRECOGNIZED_CHANNEL (40603). +const CHAT_CHANNEL_TYPE = 'gl' +const CHAT_CHANNEL_SUB_ID = 'gl' +const MAX_HISTORY = 30 +const RETRY_BACKOFF_MS = 5000 + +// Shared app-wide "Global" chat channel — used from both the main menu and the lobby, +// so channel resolution / history / send all live here. brainCloud's chat calls all +// require RTT to be enabled (RTT_NOT_ENABLED otherwise); App.js enables RTT as soon as +// login succeeds, which covers both mount points. Poll-based (explicit fetch after +// send / once resolved), not a live RTT push. +// +// Props: +// bcWrapper — the app's BrainCloudWrapper instance +class GlobalChatPanel extends Component { + constructor (props) { + super(props) + this.state = { + channelId: null, + resolving: false, + fetchedOnce: false, + messages: [], + input: '', + sending: false + } + this.retryAtMs = 0 + this.scrollRef = React.createRef() + } + + componentDidMount () { + this.ensureChannel() + } + + componentDidUpdate (prevProps, prevState) { + this.ensureChannel() + if (this.state.messages.length !== prevState.messages.length) { + const el = this.scrollRef.current + if (el) el.scrollTop = el.scrollHeight + } + } + + // Resolves the shared global channel once RTT is up, then fetches history. Safe to + // call on every update — no-ops once resolved, in flight, or backing off after a + // recent failure (so a persistent failure can't turn into a same-call-every-render + // loop — brainCloud's abuse detection disables the client after enough repeated + // failures on one API call). + ensureChannel () { + const { bcWrapper } = this.props + if (!bcWrapper || this.state.channelId || this.state.resolving) return + if (!bcWrapper.rttService.isRTTEnabled()) return + if (Date.now() < this.retryAtMs) return + + this.setState({ resolving: true }) + bcWrapper.chat.getChannelId(CHAT_CHANNEL_TYPE, CHAT_CHANNEL_SUB_ID, result => { + if (result.status === 200 && result.data.channelId) { + const channelId = result.data.channelId + this.setState({ channelId, resolving: false }) + this.fetchMessages(channelId) + } else { + this.retryAtMs = Date.now() + RETRY_BACKOFF_MS + this.setState({ resolving: false }) + } + }) + } + + fetchMessages (channelId) { + this.props.bcWrapper.chat.getRecentChatMessages(channelId, MAX_HISTORY, result => { + this.setState({ fetchedOnce: true }) + if (result.status === 200) { + // Server returns newest-first; flip to oldest-first for natural top-to-bottom + // reading order. + const messages = result.data.messages.slice().reverse().map(m => ({ + fromName: (m.from && m.from.name) || 'Player', + text: (m.content && m.content.text) || '' + })) + this.setState({ messages }) + } + }) + } + + onInputChange (e) { + this.setState({ input: e.target.value }) + } + + onKeyDown (e) { + if (e.key === 'Enter') this.send() + } + + send () { + const { channelId, input, sending } = this.state + if (!channelId || !input.trim() || sending) return + this.setState({ input: '', sending: true }) + this.props.bcWrapper.chat.postChatMessageSimple(channelId, input, true, () => { + this.setState({ sending: false }) + this.fetchMessages(channelId) + }) + } + + render () { + const { channelId, resolving, fetchedOnce, messages, input } = this.state + return ( +
+ {!channelId ? ( +

{resolving || !fetchedOnce ? 'Connecting...' : 'Chat unavailable.'}

+ ) : ( + <> +
+ {messages.map((m, i) => ( +

+ {m.fromName}: {m.text} +

+ ))} +
+
+ + +
+ + )} +
+ ) + } +} + +export default GlobalChatPanel diff --git a/RelayTestApp/src/LeaderboardPanel.js b/RelayTestApp/src/LeaderboardPanel.js new file mode 100644 index 0000000..4d45277 --- /dev/null +++ b/RelayTestApp/src/LeaderboardPanel.js @@ -0,0 +1,157 @@ +import React, { Component } from 'react' + +// Must match leaderboards configured on the app (Design > Leaderboards) — these are +// the same default ids leaderboardPanel.cpp posts to and reads from. +export const LEADERBOARD_IDS = { + points: { lifetime: 'CursorParty_Points', quarterly: 'CursorParty_Points_Quarterly' }, + coverage: { lifetime: 'CursorParty_HighestCoverage', quarterly: 'CursorParty_HighestCoverage_Quarterly' } +} + +// Gold/silver/bronze/white — shared with the lobby member list's rank display. +export function rankColor (rank) { + if (rank === 1) return '#ffd700' + if (rank === 2) return '#c0c0c0' + if (rank === 3) return '#cc8833' + return '#ffffff' +} + +// The coverage board's raw score is basis points (coveragePct*100, since brainCloud +// leaderboard scores are integers) — divide back down for display. The points board's +// raw score is already the real value. +function formatScore (score, boardType) { + if (boardType === 'coverage') return `${(score / 100).toFixed(1)}%` + return String(score).replace(/\B(?=(\d{3})+(?!\d))/g, ',') +} + +// Shared leaderboard viewer — top 5 + "you" row, toggleable between the two boards this +// app posts to (points / coverage) and Lifetime vs Quarterly. Mirrors leaderboardPanel.cpp. +// +// NOTE: scores are only posted once a match's coverage/win-ranking is computed and +// submitted (BCLOUD-14472/14490, still landing on cpp) — until some client posts to +// these boards, they'll show "No scores yet." +// +// Props: +// bcWrapper +class LeaderboardPanel extends Component { + constructor (props) { + super(props) + this.state = { + boardType: 'points', // 'points' | 'coverage' + period: 'lifetime', // 'lifetime' | 'quarterly' + resultsByKey: {} // ":" -> { top: [...], self: {...}|null } + } + this.pendingKeys = new Set() + } + + componentDidMount () { + this.fetchIfNeeded() + } + + componentDidUpdate () { + this.fetchIfNeeded() + } + + currentKey () { + return `${this.state.boardType}:${this.state.period}` + } + + parseEntry (entry) { + return { + name: (entry.data && entry.data.name) || 'Player', + score: entry.score, + rank: entry.rank + } + } + + fetchIfNeeded () { + const { bcWrapper } = this.props + const key = this.currentKey() + if (!bcWrapper || this.state.resultsByKey[key] || this.pendingKeys.has(key)) return + this.pendingKeys.add(key) + + const leaderboardId = LEADERBOARD_IDS[this.state.boardType][this.state.period] + const HIGH_TO_LOW = bcWrapper.socialLeaderboard.sortOrder.HIGH_TO_LOW + + const mergeResult = (partial) => { + this.setState(prev => ({ + resultsByKey: { + ...prev.resultsByKey, + [key]: { top: [], self: null, ...prev.resultsByKey[key], ...partial } + } + })) + } + + bcWrapper.socialLeaderboard.getGlobalLeaderboardPage(leaderboardId, HIGH_TO_LOW, 0, 4, result => { + mergeResult({ top: result.status === 200 ? (result.data.leaderboard || []).map(this.parseEntry) : [] }) + }) + + // beforeCount=0/afterCount=0 returns just the current player's own entry. + bcWrapper.socialLeaderboard.getGlobalLeaderboardView(leaderboardId, HIGH_TO_LOW, 0, 0, result => { + const entries = result.status === 200 ? result.data.leaderboard : [] + mergeResult({ self: entries && entries.length > 0 ? this.parseEntry(entries[0]) : null }) + }) + } + + setBoardType (boardType) { + this.setState({ boardType }) + } + + setPeriod (period) { + this.setState({ period }) + } + + render () { + const { boardType, period } = this.state + const { top, self } = this.state.resultsByKey[this.currentKey()] || { top: [], self: null } + const alreadyShown = self && top.some(row => row.rank === self.rank) + + return ( +
+
+ + +
+
+ + +
+
+ {top.length === 0 ? ( +

No scores yet — be the first!

+ ) : ( + top.map((row, i) => ( +
+ #{row.rank} + {row.name} + {formatScore(row.score, boardType)} +
+ )) + )} + {self && !alreadyShown ? ( + <> + {top.length > 0 ?

...

: null} +
+ #{self.rank} + {self.name} (You) + {formatScore(self.score, boardType)} +
+ + ) : null} +
+ ) + } +} + +export default LeaderboardPanel diff --git a/RelayTestApp/src/LobbyScreen.js b/RelayTestApp/src/LobbyScreen.js index 6fe00b3..a1a6e16 100644 --- a/RelayTestApp/src/LobbyScreen.js +++ b/RelayTestApp/src/LobbyScreen.js @@ -1,6 +1,8 @@ import React, { Component } from 'react' import { getShowJoinButton } from './App' +import GlobalChatPanel from './GlobalChatPanel' +import LeaderboardPanel, { rankColor } from './LeaderboardPanel' let colors = require('./Colors').colors @@ -18,9 +20,31 @@ function regionFromLobbyId (lobbyId) { // Props: // user // lobby +// teams +// lobbyResetting // usePingData // pingData +// bcWrapper +// isProvisioning / provisioningStatus — non-blocking "starting the next round" banner +// awaitingRematch — true between END_MATCH and the next round actually starting +// matchResult — last finished round's standings (valid/round/entries), for the INFO tab +// onBack / onColorChanged / onTeamChanged / onStart / onJoin / onSendLobbySignal class LobbyScreen extends Component { + constructor (props) { + super(props) + this.state = { rightTab: 'chat', chatSubTab: 'lobby', signalInput: '', colorPickerOpen: false } + this.arrivalTime = Date.now() + } + + componentDidMount () { + // Live-updates the INFO tab's "time in lobby" clock. + this.tickInterval = setInterval(() => this.forceUpdate(), 1000) + } + + componentWillUnmount () { + clearInterval(this.tickInterval) + } + onBack () { this.props.onBack() } @@ -43,6 +67,11 @@ class LobbyScreen extends Component { } localStorage.setItem('color', '' + index) this.props.onColorChanged(index) + this.setState({ colorPickerOpen: false }) + } + + toggleColorPicker () { + this.setState(s => ({ colorPickerOpen: !s.colorPickerOpen })) } onTeamSelected (teamName) { @@ -50,6 +79,125 @@ class LobbyScreen extends Component { this.props.onTeamChanged(teamName) } + setRightTab (tab) { + this.setState({ rightTab: tab }) + } + + setChatSubTab (tab) { + this.setState({ chatSubTab: tab }) + } + + onSignalInputChange (e) { + this.setState({ signalInput: e.target.value }) + } + + onSignalKeyDown (e) { + if (e.key === 'Enter') this.sendSignal() + } + + sendSignal () { + const text = this.state.signalInput + if (!text.trim()) return + this.props.onSendLobbySignal(text) + this.setState({ signalInput: '' }) + } + + // This-lobby chat (Lobby service SendSignal — state.lobby.chatMessages, appended to + // by App.onSendLobbySignal on send and the SIGNAL case in onLobbyEvent on receive). + renderLobbySignalChat () { + const messages = this.props.lobby.chatMessages || [] + return ( +
+
+ {messages.map((m, i) => ( +

+ {m.fromName}: {m.text} +

+ ))} +
+
+ + +
+
+ ) + } + + renderChatTab () { + return ( + <> +
+ + +
+
+ {this.state.chatSubTab === 'lobby' ? ( + this.renderLobbySignalChat() + ) : ( + + )} + + ) + } + + // INFO tab — lobby id/region/player count/time-in-lobby + ping data (when enabled). + renderInfoTab () { + const lobbyRegionId = regionFromLobbyId(this.props.lobby.lobbyId) + const secondsInLobby = Math.floor((Date.now() - this.arrivalTime) / 1000) + const mm = Math.floor(secondsInLobby / 60) + const ss = String(secondsInLobby % 60).padStart(2, '0') + + return ( +
+

Lobby: {this.props.lobby.lobbyId}

+ {lobbyRegionId ?

Region: {lobbyRegionId}

: null} +

Players: {this.props.lobby.members.length}

+

Time in lobby: {mm}:{ss}

+ {this.renderLastMatchResults()} + {this.renderPingTable()} +
+ ) + } + + // Last match results — shown once a round finishes (App.state.matchResult) until the + // next round starts. Entries are already in rank order (see coverage.js). + renderLastMatchResults () { + const matchResult = this.props.matchResult + if (!matchResult || !matchResult.valid) return null + + return ( +
+
+

Last Match Results

+ {matchResult.entries.map(entry => { + const member = this.props.lobby.members.find(m => m.cxId === entry.cxId) + const name = member ? member.name : '?' + const isMe = entry.cxId === this.props.user.cxId + return ( +

+ #{entry.rank}{' '} + + {name} {entry.coveragePct.toFixed(1)}% (+{entry.beaten + 1} pts) + +

+ ) + })} +
+ ) + } + // Render the ping data table — only shown when usePingData is true and there is data renderPingTable () { if (!this.props.usePingData) return null @@ -131,103 +279,146 @@ class LobbyScreen extends Component { ) } + // Colour picker — a small popup under your own swatch, not an always-visible 40-swatch + // grid (only shown in FFA; team colour is auto-assigned by team). + renderColorPicker () { + if (!this.state.colorPickerOpen) return null + return ( +
+ {colors.map((color, i) => ( +
+ ))} +
+ ) + } + + // A member's row — colour swatch (clickable, opens the picker, on your own row in FFA + // lobbies), name, YOU/HOST badges, ready-state line. Used by both FFA and team member + // lists. + renderMemberRow (member) { + const isMe = member.cxId === this.props.user.cxId + const isHost = member.cxId === this.props.lobby.ownerCxId + const color = colors[member.extra.colorIndex % colors.length] + const canPickColor = isMe && this.props.teams.length === 1 + + return ( +
  • +
    +
    + {member.name} + {isMe ? YOU : null} + {isHost ? HOST : null} +
    + {canPickColor ? this.renderColorPicker() : null} +

    {member.isReady ? 'Ready' : 'Not ready'}

    +
  • + ) + } + + // Small non-blocking status line shown while a round is being provisioned (STARTING -> + // ROOM_READY) — the rest of the Lobby screen (chat, member list, etc.) stays fully + // usable underneath it instead of being replaced by a blocking loading screen. + renderProvisioningBanner () { + if (!this.props.isProvisioning) return null + return ( +
    + Starting round... {this.props.provisioningStatus} +
    + ) + } + render () { return (
    -
    - { - /** If there are no teams, users can select their own colour. Otherwise, colour will initially be assigned automatically by team. */ - this.props.teams.length === 1 ? ( -
    - {colors.map((color, i) => ( -
    - ))} -
    - ) : ( - '' - ) - } -
    -
    - {this.props.teams.map((team, index) => ( -
    - {this.props.teams.length > 1 ? ( + {this.renderProvisioningBanner()} +
    +
    +

    Lobby

    +
    +

    Members

    +
    + {this.props.teams.map((team, index) => ( +
    + {this.props.teams.length > 1 ? ( + + ) : ( + '' + )} +
      + {team.members.map(member => this.renderMemberRow(member))} +
    +
    + ))} +
    +
    + + {this.props.lobby.ownerCxId === this.props.user.cxId && this.props.awaitingRematch ? ( + // Rematch flow is fully automatic (App.tickRematchGate) — no manual + // override here, so a host who returns early can't skip the "wait for + // stragglers or 45s" window. +

    + Waiting for other players to return... +

    + ) : this.props.lobby.ownerCxId === this.props.user.cxId && + !this.props.user.isReady ? ( + ) : ( + '' + )} + {getShowJoinButton() ? ( + ) : ( '' )} -
      - {team.members.map(member => - member.cxId === this.props.lobby.ownerCxId ? ( -
    • - {member.name + ' (host)'} -
    • - ) : ( -
    • - {member.name} -
    • - ) - )} -
    - ))} -
    -
    - - {this.props.lobby.ownerCxId === this.props.user.cxId && - !this.props.user.isReady ? ( - - ) : ( - '' - )} - {getShowJoinButton() ? ( - - ) : ( - '' - )} +
    + +
    +
    + + + +
    + {this.state.rightTab === 'chat' ? this.renderChatTab() : null} + {this.state.rightTab === 'leaderboards' ? : null} + {this.state.rightTab === 'info' ? this.renderInfoTab() : null} +
    -

    Lobby ID: {this.props.lobby.lobbyId}

    - {this.renderPingTable()}
    ) } diff --git a/RelayTestApp/src/MainMenuScreen.js b/RelayTestApp/src/MainMenuScreen.js index e893bb2..30de7ab 100644 --- a/RelayTestApp/src/MainMenuScreen.js +++ b/RelayTestApp/src/MainMenuScreen.js @@ -1,19 +1,22 @@ import React, { Component } from 'react' import packageJson from '../package.json' import ids from './ids' +import GlobalChatPanel from './GlobalChatPanel' +import LeaderboardPanel from './LeaderboardPanel' // Props: // user // appLobbies // usePingData // lastLobbyType — previously selected lobby type, used to default the dropdown +// bcWrapper // onPlay // onLogout class MainMenuScreen extends Component { constructor(props) { super(props) - this.state = { usePingData: !!props.usePingData } + this.state = { usePingData: !!props.usePingData, rightTab: 'leaderboard' } } onLogout() @@ -22,13 +25,19 @@ class MainMenuScreen extends Component } onPlay(e) { + console.log('[DEBUG] MainMenuScreen.onPlay fired') // TEMP diagnostic — remove once Play navigation is confirmed working e.preventDefault(); const form = e.target const formData = new FormData(form) const formJson = Object.fromEntries(formData.entries()); + console.log('[DEBUG] onPlay form values', formJson) // TEMP diagnostic this.props.onPlay(formJson.lobbyTypes, formJson.usePingData === 'on', formJson.relayProtocol) } + setRightTab(tab) { + this.setState({ rightTab: tab }) + } + render() { let versionSuffix = "" @@ -56,40 +65,61 @@ class MainMenuScreen extends Component return (
    -
    -

    Logged in as {this.props.user.name}

    - - -
    - - -
    -
    - - +
    +
    + +

    Logged in as {this.props.user.name}

    + + +
    + + +
    +
    + + +
    +
    + + +
    +
    -
    - - -
    - +
    +
    + + +
    + {this.state.rightTab === 'leaderboard' ? ( + + ) : ( + + )} +
    +
    Version: {packageJson.version}{versionSuffix} diff --git a/RelayTestApp/src/MatchSummaryScreen.js b/RelayTestApp/src/MatchSummaryScreen.js new file mode 100644 index 0000000..e11db54 --- /dev/null +++ b/RelayTestApp/src/MatchSummaryScreen.js @@ -0,0 +1,155 @@ +import React, { Component } from 'react' +import { rankColor } from './LeaderboardPanel' + +let colors = require('./Colors').colors + +const MATCH_SUMMARY_REMATCH_MS = 45000 + +// Builds the "Lifetime #before->#after | Quarterly #before->#after" tail for whichever +// periods actually improved on one board — periods that didn't improve are simply +// omitted, matching the reference mockup (a badge might only mention Quarterly because +// the player's Lifetime rank didn't move this round). +function periodsText (lifetime, quarterly) { + const parts = [] + if (lifetime.improved) parts.push(`Lifetime #${lifetime.rankBefore < 0 ? '-' : lifetime.rankBefore} -> #${lifetime.rankAfter}`) + if (quarterly.improved) parts.push(`Quarterly #${quarterly.rankBefore < 0 ? '-' : quarterly.rankBefore} -> #${quarterly.rankAfter}`) + return parts.join(' | ') +} + +const EMPTY_DELTA = { ready: false, pointsLifetime: {}, pointsQuarterly: {}, coverageLifetime: {}, coverageQuarterly: {} } + +// Post-match results + rematch-queue screen (BCLOUD-14489). Shows how everyone placed +// this round and what it did to their leaderboard ranks, then either auto-rematches after +// MATCH_SUMMARY_REMATCH_MS or as soon as everyone queues up (see App.tickRematchGate). +// +// Props: +// user, lobby, matchResult ({valid, round, entries}), matchSummaryArrivalTime +// onSetRematchReady(ready) / onMainMenu() +class MatchSummaryScreen extends Component { + componentDidMount () { + // Live-updates the countdown and drives this player's own auto-queue timeout. + this.tickInterval = setInterval(() => this.tick(), 1000) + } + + componentWillUnmount () { + clearInterval(this.tickInterval) + } + + tick () { + // Per-player auto-queue: if this player hasn't clicked "Queue for Rematch" themselves + // by MATCH_SUMMARY_REMATCH_MS, queue them automatically — matches the "if players do + // nothing, auto rematch" requirement without waiting on anyone else. + if (!this.props.user.isReady) { + const elapsedMs = Date.now() - (this.props.matchSummaryArrivalTime || Date.now()) + if (elapsedMs >= MATCH_SUMMARY_REMATCH_MS) { + this.props.onSetRematchReady(true) + return + } + } + this.forceUpdate() + } + + renderPlayerCard (entry) { + const member = this.props.lobby.members.find(m => m.cxId === entry.cxId) + const name = member ? member.name : '?' + const colorIndex = (member && member.extra) ? member.extra.colorIndex : 0 + const isMe = entry.cxId === this.props.user.cxId + const delta = entry.lbDelta || EMPTY_DELTA + + const anyPointsUp = delta.ready && (delta.pointsLifetime.improved || delta.pointsQuarterly.improved) + const anyCoverageBest = delta.ready && (delta.coverageLifetime.improved || delta.coverageQuarterly.improved) + + return ( +
    +
    + #{entry.rank} + + {name} + {isMe ? (YOU) : null} + + {entry.coveragePct.toFixed(0)}% + COVERAGE + +
    + +
    + +{entry.beaten + 1} pts + {entry.beaten} beaten + 1 for playing +
    + + {!delta.ready ? ( +

    Updating leaderboards...

    + ) : ( + <> + {anyPointsUp ? ( +
    ^ Rank up - Opponents Beaten {periodsText(delta.pointsLifetime, delta.pointsQuarterly)}
    + ) : null} + {anyCoverageBest ? ( +
    * Personal best - Coverage % {periodsText(delta.coverageLifetime, delta.coverageQuarterly)}
    + ) : null} + {!anyPointsUp && !anyCoverageBest ? ( +

    No leaderboard rank change this round

    + ) : null} + + )} +
    + ) + } + + render () { + const entries = this.props.matchResult.entries || [] + const winner = entries.length > 0 ? entries[0] : null + const winnerMember = winner ? this.props.lobby.members.find(m => m.cxId === winner.cxId) : null + + const elapsedMs = Date.now() - (this.props.matchSummaryArrivalTime || Date.now()) + const remainingSec = Math.max(0, Math.ceil((MATCH_SUMMARY_REMATCH_MS - elapsedMs) / 1000)) + const mm = Math.floor(remainingSec / 60) + const ss = String(remainingSec % 60).padStart(2, '0') + + const readyCount = this.props.lobby.members.filter(m => m.isReady).length + const iAmReady = this.props.user.isReady + + return ( +
    +
    +

    Match Summary

    +

    + Lobby {this.props.lobby.lobbyId} - {this.props.lobby.members.length} Players +

    + + {winner ? ( +
    + {(winnerMember ? winnerMember.name : '?')} wins the round, covering {winner.coveragePct.toFixed(0)}% of the board. +
    + ) : ( +

    Waiting for results...

    + )} + +
    + RANK / PLAYER + LEADERBOARD RESULT +
    +
    + +
    + {entries.map(entry => this.renderPlayerCard(entry))} +
    + +
    +

    Next Round: {mm}:{ss}

    +
    + + +
    +
    +
    + ) + } +} + +export default MatchSummaryScreen diff --git a/RelayTestApp/src/coverage.js b/RelayTestApp/src/coverage.js new file mode 100644 index 0000000..b5bcbba --- /dev/null +++ b/RelayTestApp/src/coverage.js @@ -0,0 +1,114 @@ +// Canvas coverage % + win-ranking (BCLOUD-14472/14490) — pure port of cpp's coverage.cpp, +// which the cpp source itself flags as the exact artifact every RelayTestApp port should +// copy (no ImGui/brainCloud/globals dependency there either). +// +// Algorithm: rasterize a COVERAGE_GRID_CELL_SIZE-resolution ownership grid over the canvas +// by stamping every splotch, in paint order, as a filled circle of radius SPLOTCH_RADIUS +// centered on it — so a later splotch always overwrites an earlier one wherever they +// overlap. This is exactly what ends up rendered on screen (paint order = draw order = +// "last one wins"), not an approximation of it. +// +// coveragePct is each player's owned-cell-count * cell-area / canvas-area (clamped to 100) +// — ABSOLUTE canvas-area coverage, NOT a share of painted area, so a solo match doesn't +// trivially score 100%. +// +// Attribution: cpp attributes a splotch by ownerCxId first, falling back to a colorIndex +// match against a live member. This app's splotches don't carry an owner cxId (see +// App.js's createSplotch/onRelayMessage — the wire format was never extended with one), +// so this always uses the colorIndex fallback; a splotch whose colour matches no current +// member still overwrites the grid (correctly obscuring whatever was under it) but credits +// no one, matching cpp's designed fallback path. +// +// All clients must use the same constants (CANVAS_W/H, SPLOTCH_RADIUS, +// COVERAGE_GRID_CELL_SIZE) so scores match across ports. +const CANVAS_W = 800 +const CANVAS_H = 600 +const SPLOTCH_RADIUS = 32 // SPLOTCH_DISPLAY_SIZE (64) * 0.5 +const COVERAGE_GRID_CELL_SIZE = 2 + +const GRID_W = Math.floor(CANVAS_W / COVERAGE_GRID_CELL_SIZE) +const GRID_H = Math.floor(CANVAS_H / COVERAGE_GRID_CELL_SIZE) +const CELL_RADIUS = Math.ceil(SPLOTCH_RADIUS / COVERAGE_GRID_CELL_SIZE) +const R2 = SPLOTCH_RADIUS * SPLOTCH_RADIUS + +// Reused across calls (this runs every ~250ms mid-match). +let ownerGrid = new Int16Array(GRID_W * GRID_H) + +// splotches: [{x, y (normalized 0-1), colorIndex, ...}] +// members: [{cxId, extra: {colorIndex}}] +// Returns entries sorted best-first: [{cxId, colorIndex, visibleCount, coveragePct, rank, beaten}] +export function computeCoverage (splotches, members) { + const result = members.map(m => ({ + cxId: m.cxId, + colorIndex: (m.extra && m.extra.colorIndex !== undefined) ? m.extra.colorIndex : 0, + visibleCount: 0, + coveragePct: 0, + rank: 1, + beaten: 0 + })) + + const N = splotches.length + if (N > 0) { + ownerGrid.fill(-1) + + for (let i = 0; i < N; i++) { + const s = splotches[i] + const px = s.x * CANVAS_W + const py = s.y * CANVAS_H + + let idx = -1 + for (let k = 0; k < result.length; k++) { + if (result[k].colorIndex === s.colorIndex) { idx = k; break } + } + + const cx = Math.floor(px / COVERAGE_GRID_CELL_SIZE) + const cy = Math.floor(py / COVERAGE_GRID_CELL_SIZE) + for (let dy = -CELL_RADIUS; dy <= CELL_RADIUS; dy++) { + const gy = cy + dy + if (gy < 0 || gy >= GRID_H) continue + const cellPy = (gy + 0.5) * COVERAGE_GRID_CELL_SIZE + const ddy = cellPy - py + const rowBase = gy * GRID_W + for (let dx = -CELL_RADIUS; dx <= CELL_RADIUS; dx++) { + const gx = cx + dx + if (gx < 0 || gx >= GRID_W) continue + const cellPx = (gx + 0.5) * COVERAGE_GRID_CELL_SIZE + const ddx = cellPx - px + if (ddx * ddx + ddy * ddy <= R2) { + ownerGrid[rowBase + gx] = idx // may be -1 — still overwrites, credits no one + } + } + } + } + + for (let c = 0, count = GRID_W * GRID_H; c < count; c++) { + const idx = ownerGrid[c] + if (idx >= 0) result[idx].visibleCount++ + } + } + + const cellArea = COVERAGE_GRID_CELL_SIZE * COVERAGE_GRID_CELL_SIZE + const canvasArea = CANVAS_W * CANVAS_H + result.forEach(e => { + e.coveragePct = Math.min(100, (e.visibleCount * cellArea / canvasArea) * 100) + }) + + result.sort((a, b) => { + if (a.coveragePct !== b.coveragePct) return b.coveragePct - a.coveragePct + if (a.visibleCount !== b.visibleCount) return b.visibleCount - a.visibleCount + return a.cxId < b.cxId ? -1 : (a.cxId > b.cxId ? 1 : 0) + }) + + for (let i = 0; i < result.length; i++) { + result[i].rank = (i > 0 && + result[i].coveragePct === result[i - 1].coveragePct && + result[i].visibleCount === result[i - 1].visibleCount) + ? result[i - 1].rank // tie shares rank + : i + 1 + } + result.forEach(e => { + e.beaten = result.filter(other => other.rank > e.rank).length + }) + + return result +} From 6498dc59216ffa06780abc1c490a3d25bbd5f8bc Mon Sep 17 00:00:00 2001 From: Nick H Date: Mon, 17 Aug 2026 09:23:26 -0400 Subject: [PATCH 2/7] Now supporting wss connection to relay --- RelayTestApp/src/App.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/RelayTestApp/src/App.js b/RelayTestApp/src/App.js index 21177bf..e2d7c64 100644 --- a/RelayTestApp/src/App.js +++ b/RelayTestApp/src/App.js @@ -1053,7 +1053,7 @@ class App extends Component { connectRelay () { const ports = server.connectData.ports - const host = server.connectData.address + let host = server.connectData.address let port = 0 let ssl = false @@ -1067,10 +1067,16 @@ class App extends Component { port = ports.i3d ssl = false console.log('[DEBUG] relay connect: i3d port=' + port) + } else if (this.state.relayProtocol === 'wss' && ports.wss) { + // Use the secure hostname and WSS port when the server advertises them. + ssl = true + port = ports.wss + host = server.connectData.secureAddress || server.connectData.address + console.log('[DEBUG] relay connect: wss host=' + host + ' port=' + port) } else { - ssl = this.state.relayProtocol === 'wss' + ssl = false port = ports.ws - console.log('[DEBUG] relay connect: protocol=' + this.state.relayProtocol + ' ws=' + ports.ws + ' tcp=' + (ports.tcp || -1) + ' udp=' + (ports.udp || -1)) + console.log('[DEBUG] relay connect: ws port=' + port) } presentWhileStarted = false From da31c07726c6e6c1ff0a73628580af40311e8c36 Mon Sep 17 00:00:00 2001 From: Steve Jones Date: Mon, 17 Aug 2026 11:56:04 -0400 Subject: [PATCH 3/7] Mask the overlow, ready up signals, chat sorted correctly --- RelayTestApp/src/App.css | 1 + RelayTestApp/src/App.js | 17 ++++++ RelayTestApp/src/GlobalChatPanel.js | 89 ++++++++++++++++++++--------- RelayTestApp/src/LobbyScreen.js | 38 +++++++++++- 4 files changed, 117 insertions(+), 28 deletions(-) diff --git a/RelayTestApp/src/App.css b/RelayTestApp/src/App.css index e8c8355..70cc178 100644 --- a/RelayTestApp/src/App.css +++ b/RelayTestApp/src/App.css @@ -99,6 +99,7 @@ small { height: 600px; background-color: #333; position: relative; + overflow: hidden; } .Entity { diff --git a/RelayTestApp/src/App.js b/RelayTestApp/src/App.js index e2d7c64..85e9efb 100644 --- a/RelayTestApp/src/App.js +++ b/RelayTestApp/src/App.js @@ -660,6 +660,22 @@ class App extends Component { ) } + // Non-host lobby members have no "Start" button (only the host can start the round), + // but they still need a way to signal they're ready before the host starts — this is + // that toggle, reused for both the initial pre-match lobby and un-readying if a guest + // changes their mind. (Rematch queuing uses the separate onSetRematchReady below.) + onToggleReady () { + let state = this.state + state.user.isReady = !state.user.isReady + let extraJson = this.makeExtraJson(state.user.colorIndex, state.user.presentSinceStart) + this.setState(state) + this.bc.lobby.updateReady( + this.state.lobby.lobbyId, + this.state.user.isReady, + extraJson + ) + } + // Marks this player queued for a rematch and takes them back to the Lobby screen — // used both by the Match Summary screen's "Queue for Rematch" button and by its own // per-player MATCH_SUMMARY_REMATCH_MS auto-timeout. Either path looks identical from @@ -1577,6 +1593,7 @@ class App extends Component { onColorChanged={this.onColorChanged.bind(this)} onTeamChanged={this.onTeamChanged.bind(this)} onStart={this.onStart.bind(this)} + onToggleReady={this.onToggleReady.bind(this)} onJoin={this.onJoin.bind(this)} onSendLobbySignal={this.onSendLobbySignal.bind(this)} /> diff --git a/RelayTestApp/src/GlobalChatPanel.js b/RelayTestApp/src/GlobalChatPanel.js index 3ceee80..a17ab0b 100644 --- a/RelayTestApp/src/GlobalChatPanel.js +++ b/RelayTestApp/src/GlobalChatPanel.js @@ -8,11 +8,21 @@ const CHAT_CHANNEL_SUB_ID = 'gl' const MAX_HISTORY = 30 const RETRY_BACKOFF_MS = 5000 +function parseWireMessage (m) { + return { + msgId: m.msgId || '', + fromName: (m.from && m.from.name) || 'Player', + text: (m.content && m.content.text) || '' + } +} + // Shared app-wide "Global" chat channel — used from both the main menu and the lobby, // so channel resolution / history / send all live here. brainCloud's chat calls all // require RTT to be enabled (RTT_NOT_ENABLED otherwise); App.js enables RTT as soon as -// login succeeds, which covers both mount points. Poll-based (explicit fetch after -// send / once resolved), not a live RTT push. +// login succeeds, which covers both mount points. Live RTT push: channelConnect both +// registers the listener AND returns initial history in one call; every message after +// that (including our own sends, edits, and deletes) arrives via registerRTTChatCallback +// — see knowledge-articles/01-chat.md. No re-fetch after posting. // // Props: // bcWrapper — the app's BrainCloudWrapper instance @@ -22,13 +32,13 @@ class GlobalChatPanel extends Component { this.state = { channelId: null, resolving: false, - fetchedOnce: false, messages: [], input: '', sending: false } this.retryAtMs = 0 this.scrollRef = React.createRef() + this.onRTTChatEvent = this.onRTTChatEvent.bind(this) } componentDidMount () { @@ -43,11 +53,18 @@ class GlobalChatPanel extends Component { } } - // Resolves the shared global channel once RTT is up, then fetches history. Safe to - // call on every update — no-ops once resolved, in flight, or backing off after a - // recent failure (so a persistent failure can't turn into a same-call-every-render - // loop — brainCloud's abuse detection disables the client after enough repeated - // failures on one API call). + componentWillUnmount () { + if (this.state.channelId) { + this.props.bcWrapper.rttService.deregisterRTTChatCallback() + } + } + + // Resolves the shared global channel once RTT is up, connects (which also returns + // initial history in the same response), and registers for live push. Safe to call + // on every update — no-ops once resolved, in flight, or backing off after a recent + // failure (so a persistent failure can't turn into a same-call-every-render loop — + // brainCloud's abuse detection disables the client after enough repeated failures + // on one API call). ensureChannel () { const { bcWrapper } = this.props if (!bcWrapper || this.state.channelId || this.state.resolving) return @@ -57,9 +74,7 @@ class GlobalChatPanel extends Component { this.setState({ resolving: true }) bcWrapper.chat.getChannelId(CHAT_CHANNEL_TYPE, CHAT_CHANNEL_SUB_ID, result => { if (result.status === 200 && result.data.channelId) { - const channelId = result.data.channelId - this.setState({ channelId, resolving: false }) - this.fetchMessages(channelId) + this.connectChannel(result.data.channelId) } else { this.retryAtMs = Date.now() + RETRY_BACKOFF_MS this.setState({ resolving: false }) @@ -67,21 +82,44 @@ class GlobalChatPanel extends Component { }) } - fetchMessages (channelId) { - this.props.bcWrapper.chat.getRecentChatMessages(channelId, MAX_HISTORY, result => { - this.setState({ fetchedOnce: true }) - if (result.status === 200) { - // Server returns newest-first; flip to oldest-first for natural top-to-bottom - // reading order. - const messages = result.data.messages.slice().reverse().map(m => ({ - fromName: (m.from && m.from.name) || 'Player', - text: (m.content && m.content.text) || '' - })) - this.setState({ messages }) + connectChannel (channelId) { + this.props.bcWrapper.chat.channelConnect(channelId, MAX_HISTORY, result => { + if (result.status !== 200) { + this.retryAtMs = Date.now() + RETRY_BACKOFF_MS + this.setState({ resolving: false }) + return } + // Server already returns these ascending/oldest-first (it re-sorts from its + // native descending storage order before responding) — no reverse needed. Same + // order as the RTT push appends, so history and live messages never conflict. + const messages = result.data.messages.map(parseWireMessage) + this.props.bcWrapper.rttService.registerRTTChatCallback(this.onRTTChatEvent) + this.setState({ channelId, resolving: false, messages }) }) } + // New messages, edits, and deletes all arrive on the same RTT event, keyed by msgId — + // a message updated or deleted after it's scrolled out of the fetched window is simply + // not found below and the event is a no-op, which is correct (nothing on screen to change). + onRTTChatEvent (result) { + const data = result.data || {} + if (result.operation === 'INCOMING') { + this.setState({ messages: this.state.messages.concat([parseWireMessage(data)]) }) + } else if (result.operation === 'UPDATE') { + // Same shape as INCOMING (full message, edited content) — replace in place so it + // doesn't jump to the bottom of the scroll. + const updated = parseWireMessage(data) + this.setState({ + messages: this.state.messages.map(m => (m.msgId === updated.msgId ? updated : m)) + }) + } else if (result.operation === 'DELETE') { + // DELETE's payload is just {chId, msgId} -- no content/from -- so only msgId is usable here. + this.setState({ + messages: this.state.messages.filter(m => m.msgId !== data.msgId) + }) + } + } + onInputChange (e) { this.setState({ input: e.target.value }) } @@ -96,21 +134,20 @@ class GlobalChatPanel extends Component { this.setState({ input: '', sending: true }) this.props.bcWrapper.chat.postChatMessageSimple(channelId, input, true, () => { this.setState({ sending: false }) - this.fetchMessages(channelId) }) } render () { - const { channelId, resolving, fetchedOnce, messages, input } = this.state + const { channelId, resolving, messages, input } = this.state return (
    {!channelId ? ( -

    {resolving || !fetchedOnce ? 'Connecting...' : 'Chat unavailable.'}

    +

    {resolving ? 'Connecting...' : 'Chat unavailable.'}

    ) : ( <>
    {messages.map((m, i) => ( -

    +

    {m.fromName}: {m.text}

    ))} diff --git a/RelayTestApp/src/LobbyScreen.js b/RelayTestApp/src/LobbyScreen.js index a1a6e16..4039838 100644 --- a/RelayTestApp/src/LobbyScreen.js +++ b/RelayTestApp/src/LobbyScreen.js @@ -28,17 +28,36 @@ function regionFromLobbyId (lobbyId) { // isProvisioning / provisioningStatus — non-blocking "starting the next round" banner // awaitingRematch — true between END_MATCH and the next round actually starting // matchResult — last finished round's standings (valid/round/entries), for the INFO tab -// onBack / onColorChanged / onTeamChanged / onStart / onJoin / onSendLobbySignal +// onBack / onColorChanged / onTeamChanged / onStart / onToggleReady / onJoin / onSendLobbySignal class LobbyScreen extends Component { constructor (props) { super(props) this.state = { rightTab: 'chat', chatSubTab: 'lobby', signalInput: '', colorPickerOpen: false } this.arrivalTime = Date.now() + this.lobbyChatScrollRef = React.createRef() } componentDidMount () { // Live-updates the INFO tab's "time in lobby" clock. this.tickInterval = setInterval(() => this.forceUpdate(), 1000) + + // Start scrolled to the newest message if there's already history (e.g. returning + // to the lobby with chat carried over from before the match) — componentDidUpdate + // only catches messages arriving after mount, not what's already there. + const el = this.lobbyChatScrollRef.current + if (el) el.scrollTop = el.scrollHeight + } + + componentDidUpdate (prevProps) { + // Keep the This-Lobby chat scrolled to the newest message (mirrors GlobalChatPanel's + // own auto-scroll) — chatMessages only grows via App.onSendLobbySignal/the SIGNAL + // case in onLobbyEvent, so a length change always means new messages arrived. + const prevCount = (prevProps.lobby && prevProps.lobby.chatMessages && prevProps.lobby.chatMessages.length) || 0 + const count = (this.props.lobby && this.props.lobby.chatMessages && this.props.lobby.chatMessages.length) || 0 + if (count !== prevCount) { + const el = this.lobbyChatScrollRef.current + if (el) el.scrollTop = el.scrollHeight + } } componentWillUnmount () { @@ -53,6 +72,10 @@ class LobbyScreen extends Component { this.props.onStart() } + onToggleReady () { + this.props.onToggleReady() + } + onJoin () { this.props.onJoin() } @@ -108,7 +131,7 @@ class LobbyScreen extends Component { const messages = this.props.lobby.chatMessages || [] return (
    -
    +
    {messages.map((m, i) => (

    {m.fromName}: {m.text} @@ -386,6 +409,17 @@ class LobbyScreen extends Component { > Start + ) : this.props.lobby.ownerCxId !== this.props.user.cxId && !this.props.awaitingRematch ? ( + // Only the host has a "Start" button (starting the round is host-only), + // but every other member still needs a way to signal they're ready — + // this is that toggle. Once awaiting a rematch, MatchSummaryScreen's + // "Queue for Rematch" button already handles readying up instead. + ) : ( '' )} From b516dcf9b724a4fa10f33171e4f118c082c0b258 Mon Sep 17 00:00:00 2001 From: Steve Jones Date: Tue, 18 Aug 2026 15:36:44 -0400 Subject: [PATCH 4/7] join in progress, coverage calculations on join in progress along with the match summary syncing --- RelayTestApp/src/App.js | 295 +++++++++++++------------ RelayTestApp/src/GameScreen.js | 45 +++- RelayTestApp/src/GlobalChatPanel.js | 12 +- RelayTestApp/src/LeaderboardPanel.js | 5 +- RelayTestApp/src/LobbyScreen.js | 18 +- RelayTestApp/src/MainMenuScreen.js | 22 +- RelayTestApp/src/MatchSummaryScreen.js | 32 ++- RelayTestApp/src/coverage.js | 43 ++-- 8 files changed, 270 insertions(+), 202 deletions(-) diff --git a/RelayTestApp/src/App.js b/RelayTestApp/src/App.js index 85e9efb..3008e32 100644 --- a/RelayTestApp/src/App.js +++ b/RelayTestApp/src/App.js @@ -19,10 +19,17 @@ let colors = require('./Colors').colors const MATCH_DURATION_SEC = 90 const NUM_ARROW_IMAGES = 8 -const RESULT_GRACE_MS = 1000 // delay between match_result broadcast and onEndMatch() +const RESULT_GRACE_MS = 3000 // delay between match_result broadcast and onEndMatch() const COVERAGE_RECOMPUTE_MS = 250 // live-scoreboard recompute throttle const MATCH_SUMMARY_REMATCH_MS = 45000 // how long the Match Summary screen waits before the host auto-starts the next round +// Test-time overrides read from the URL query string, e.g. ?lobbyType=CursorPartyGameLift +// or ?protocol=wss. Both take priority over their localStorage'd/hardcoded defaults so a +// link can be shared that pre-selects a specific lobby type or relay protocol. +function getUrlParam (name) { + return new URLSearchParams(window.location.search).get(name) +} + let presentWhileStarted = false let server = null let showJoinButton = false @@ -248,6 +255,18 @@ class App extends Component { } catch (e) {} } + // Leaderboard ids — optional global properties so boards can be renamed in the + // portal with no client rebuild (matches cpp's readLeaderboardId); falls back to + // LEADERBOARD_IDS' compiled-in defaults when absent. + const readLeaderboardId = (propName, fallback) => { + const prop = readPropertiesResponse.data[propName] + return (prop && prop.value) ? prop.value : fallback + } + LEADERBOARD_IDS.points.lifetime = readLeaderboardId('PointsLeaderboardId', LEADERBOARD_IDS.points.lifetime) + LEADERBOARD_IDS.points.quarterly = readLeaderboardId('PointsLeaderboardIdQuarterly', LEADERBOARD_IDS.points.quarterly) + LEADERBOARD_IDS.coverage.lifetime = readLeaderboardId('CoverageLeaderboardId', LEADERBOARD_IDS.coverage.lifetime) + LEADERBOARD_IDS.coverage.quarterly = readLeaderboardId('CoverageLeaderboardIdQuarterly', LEADERBOARD_IDS.coverage.quarterly) + this.setState({ screen: 'mainMenu', user: { @@ -262,9 +281,7 @@ class App extends Component { splotchDurationSec: splotchDurationSec }) - // Keep RTT connected from the main menu onward so Global Chat works there - // (brainCloud's chat calls require RTT). onPlayClicked's ensureRTTEnabled - // call resolves immediately once this has already succeeded. + // Keep RTT connected from the main menu onward so Global Chat works there. this.ensureRTTEnabled(() => { let state = this.state if (state.user) state.user.cxId = this.bc.rttService.getRTTConnectionId() @@ -279,28 +296,14 @@ class App extends Component { } } - // Enables RTT if not already enabled, then invokes onReady. RTT's own enableRTT() - // silently no-ops (without invoking either callback) when already enabled OR still - // connecting, so callers must go through this rather than calling enableRTT - // directly. Also queues concurrent callers instead of re-calling enableRTT while a - // connection is already in flight — e.g. the main menu enables RTT for chat right - // after login, and if Play is clicked before that resolves, a second raw enableRTT - // call would no-op and silently drop onPlayClicked's callback (which registers the - // lobby callback and starts matchmaking), leaving the screen stuck on - // "joiningLobby" forever. + // Wraps RTT's enableRTT(), which silently no-ops with no callback when RTT is already + // enabled or connecting. Also queues concurrent callers instead of firing a second + // enableRTT while one's in flight, so a Play click racing the main menu's chat-enable + // call doesn't get its callback silently dropped. ensureRTTEnabled (onReady, onFail) { if (this.bc.rttService.isRTTEnabled()) { - // Defer to the next tick rather than calling onReady() inline. enableRTT's own - // success callback is always async in the "not yet enabled" branch below (a real - // network round trip) — callers rely on that: e.g. onPlayClicked calls this - // synchronously right after setState({screen: 'joiningLobby', ...}), and onReady's - // body does this file's usual `let state = this.state; state.x = y; - // this.setState(state)` pattern. React batches setState calls made within one - // event handler, so a SYNCHRONOUS onReady here would read the pre-flush - // this.state (still screen: 'mainMenu'), and passing that stale snapshot back to - // setState — merged AFTER the pending 'joiningLobby' update — would silently - // revert screen back to 'mainMenu'. Deferring keeps the "already enabled" fast - // path's timing consistent with the always-async slow path below. + // Deferred so this fast path is async like the "not yet enabled" path below — + // callers (e.g. onPlayClicked) rely on onReady seeing a flushed this.state. setTimeout(onReady, 0) return } @@ -368,38 +371,49 @@ class App extends Component { this.state.user.colorIndex, this.state.user.presentSinceStart ) + const onFindResult = result => { + if (result.status !== 200) { + console.error('[DEBUG] findOrCreateLobby failed', result) + this.dieWithMessage('Failed to find lobby: ' + (result.status_message || result.reason_code || JSON.stringify(result))) + } + } if (withPingData) { this.bc.lobby.findOrCreateLobbyWithPingData( lobbyType, 0, 1, algo, {}, null, {}, false, extraJson, '', - result => { - if (result.status !== 200) this.dieWithMessage('Failed to find lobby') - } + onFindResult ) } else { this.bc.lobby.findOrCreateLobby( lobbyType, 0, 1, algo, {}, null, {}, false, extraJson, '', - result => { - if (result.status !== 200) this.dieWithMessage('Failed to find lobby') - } + onFindResult ) } } - // Ping regions when explicitly requested or for GameLift lobbies - const needsPing = usePingData || lobbyType.toLowerCase().includes('gamelift') - if (needsPing) { + // Ping regions only when the user explicitly checked "Use Ping Data" — matches + // cpp/GDScript, which never infer this from the lobby-type name either. + if (usePingData) { this.setState({ loadingStatus: 'Getting regions...' }) this.bc.lobby.getRegionsForLobbies([lobbyType], result => { if (result.status !== 200) { + console.error('[DEBUG] getRegionsForLobbies failed, falling back to no ping data', result) doFindLobby(false) return } this.bc.lobby.pingRegions(result => { if (result.status !== 200) { + console.error('[DEBUG] pingRegions failed, falling back to no ping data', result) doFindLobby(false) return } let pingData = result.data || {} + // Some lobby types have no discrete regions to ping, so pingRegions succeeds + // with an empty result — fall back to the plain find/create in that case, since + // *WithPingData rejects locally on an empty ping cache. + if (Object.keys(pingData).length === 0) { + doFindLobby(false) + return + } this.setState({ pingData, loadingStatus: 'Searching...' }, () => { doFindLobby(true) }) @@ -423,31 +437,21 @@ class App extends Component { onLobbyEvent (result) { if (result.data.lobby) { let state = this.state - // This-lobby chat (SIGNAL below) lives on state.lobby.chatMessages, but every - // lobby event here rebuilds state.lobby fresh from server data — carry the - // existing history forward instead of losing it on the next MEMBER_JOIN/UPDATE. + // This-lobby chat lives on state.lobby.chatMessages; every lobby event here rebuilds + // state.lobby fresh from server data, so carry the existing history forward. let prevChatMessages = (state.lobby && state.lobby.chatMessages) || [] state.lobby = { ...result.data.lobby, lobbyId: result.data.lobbyId, chatMessages: prevChatMessages } - // The lobby RTT event already carries the latest member data (incl. each - // member's selected colour in extra.colorIndex), so we derive lobby state - // directly from it instead of issuing a redundant getLobbyData call on - // every event. This matches the C++/Java/Godot RTAs, which never re-fetch. - // - // lobbyTypeDef (teams + rules) is only present on the fuller events - // (e.g. MEMBER_JOIN); the server strips it from MEMBER_UPDATE events - // (which fire on every colour/ready change). So cache the team names and - // disbandOnStart when they arrive, and always rebuild the team member - // grouping from the current members — otherwise team rows would keep - // pointing at stale member objects and miss colour changes. + // lobbyTypeDef (teams + rules) only shows up on fuller events like MEMBER_JOIN — the + // server strips it from MEMBER_UPDATE — so cache it when seen, but always rebuild the + // team groupings from current members so colour changes aren't missed. let lobbyTypeDef = result.data.lobby.lobbyTypeDef if (lobbyTypeDef) { state.disbandOnStart = lobbyTypeDef.rules.disbandOnStart state.lobbyTeamNames = Object.keys(lobbyTypeDef.teams) } - // Fall back to the team names present on the members if we haven't seen a - // lobbyTypeDef yet (e.g. first event is a MEMBER_UPDATE). + // Fall back to the team names present on the members if we haven't seen a lobbyTypeDef yet. let teamNames = state.lobbyTeamNames if (teamNames.length === 0) { let seen = new Set() @@ -523,11 +527,8 @@ class App extends Component { showJoinButton = true } } else if (result.operation === 'SIGNAL') { - // This-lobby chat, via the Lobby service's SendSignal (not the Chat service — - // rides the RTT connection the lobby already has, no separate channel needed). - // "from" is the server's authoritative sender info. Skip echoes of our own - // signal — onSendLobbySignal already appended it locally on send (compared by - // cxId, not name, since two players could share a display name). + // This-lobby chat via the Lobby service's SendSignal. Skip echoes of our own + // signal (compared by cxId, not name) since onSendLobbySignal already appended it. let fromCxId = result.data.from && result.data.from.cxId let text = result.data.signalData && result.data.signalData.text if (text && this.state.lobby && fromCxId !== this.state.user.cxId) { @@ -572,6 +573,12 @@ class App extends Component { // Called to terminate the current session and go back to the main menu onGameScreenClose () { + // Otherwise this player stays a ghost lobby member, blocking the all-ready rematch + // fast path for everyone else until the server times them out. + if (this.state.lobby && this.state.lobby.lobbyId) { + this.bc.lobby.leaveLobby(this.state.lobby.lobbyId, () => {}) + } + if (pingInterval) { clearInterval(pingInterval) pingInterval = null @@ -676,11 +683,8 @@ class App extends Component { ) } - // Marks this player queued for a rematch and takes them back to the Lobby screen — - // used both by the Match Summary screen's "Queue for Rematch" button and by its own - // per-player MATCH_SUMMARY_REMATCH_MS auto-timeout. Either path looks identical from - // here on: the player sits in the Lobby waiting for tickRematchGate() to actually start - // the next round. + // Marks this player queued for a rematch and takes them back to the Lobby screen, where + // they wait for tickRematchGate() to start the next round. onSetRematchReady (ready) { let state = this.state state.user.isReady = ready @@ -692,14 +696,8 @@ class App extends Component { ) } - // Host-only gate on starting the next round: waits until every current lobby member has - // queued for a rematch (each auto-queues within MATCH_SUMMARY_REMATCH_MS at the latest — - // see MatchSummaryScreen — so this is mostly a safety net against clock skew) OR that - // same deadline elapses regardless. Once satisfied, calls the same onStart() that starts - // every round — no separate "begin round 2" mechanism needed. isHost is re-evaluated - // every call, so a host migration while some players are still on Match Summary is - // picked up for free. Ticked on an interval from the END_MATCH handler while - // awaitingRematch, regardless of whether the host itself is on Lobby or MatchSummary. + // Host-only gate on starting the next round: fires onStart() once every member has + // queued for a rematch, or once MATCH_SUMMARY_REMATCH_MS elapses regardless. tickRematchGate () { if (!this.state.awaitingRematch || !this.state.lobby) return const isHost = this.state.lobby.ownerCxId === this.state.user.cxId @@ -728,9 +726,7 @@ class App extends Component { this.connectRelay() } - // Ends the current match (host-only) — there's no manual UI trigger anymore (matches - // the cpp reference: no more "End Match" button), only tickMatch()'s auto-end - // sequence RESULT_GRACE_MS after it broadcasts match_result. + // Ends the current match (host-only, called by tickMatch()'s auto-end sequence). onEndMatch () { let extraJson = { cxId: this.bc.brainCloudClient.getRTTConnectionId(), @@ -762,6 +758,8 @@ class App extends Component { x: json.data.x, y: json.data.y, colorIndex: colorIndex, + // For per-player coverage attribution so two players sharing a colour don't merge. + cxId: memberCxId, // Use the sender's synced rotation (default random if an older client omits it) angle: json.data.angle === undefined ? Math.random() * Math.PI * 2 : json.data.angle, startTimeMs: Date.now() @@ -780,10 +778,19 @@ class App extends Component { case 'splotch_sync': if (json.data.first) state.splotches = [] json.data.splotches.forEach(s => { + // "o" is a compact netId, resolved to a cxId now — the map that resolves it is + // torn down at END_MATCH, so this must happen at receive time, never deferred. + // Missing/unresolvable -> unattributed (coverage falls back to a colorIndex match). + let ownerCxId + if (s.o !== undefined) { + let resolved = this.bc.relay.getCxIdForNetId(s.o) + if (resolved) ownerCxId = resolved + } state.splotches.push({ x: s.x, y: s.y, colorIndex: s.c, + cxId: ownerCxId, // "a" = synced rotation (default random if absent); "t" = original timestamp angle: s.a === undefined ? Math.random() * Math.PI * 2 : s.a, startTimeMs: s.t @@ -818,39 +825,35 @@ class App extends Component { break } - // Host-computed leaderboard results for the whole round (see - // hostPostMatchResultsToCloud/PostMatchResults), chunked like match_result. Can - // arrive before this round's match_result has populated state.matchResult.entries - // — buffer by cxId in that case (drained in applyMatchResult). + // Host-computed leaderboard results for the round (see hostPostMatchResultsToCloud), + // chunked like match_result. Can arrive before match_result has populated + // state.matchResult.entries — buffer by cxId in that case (drained in applyMatchResult). case 'lb_result': { - const round = json.data.round - if (!(state.matchResult.valid && state.matchResult.round === round)) { - if (json.data.first) pendingLbChunk = [] - json.data.e.forEach(entry => { - const delta = { - ready: true, - pointsLifetime: { improved: false }, - pointsQuarterly: { improved: false }, - coverageLifetime: { improved: false }, - coverageQuarterly: { improved: false } - } - const readPeriod = (key, target) => { - if (entry[key]) { target.improved = true; target.rankBefore = entry[key].b; target.rankAfter = entry[key].a } - } - readPeriod('pl', delta.pointsLifetime) - readPeriod('pq', delta.pointsQuarterly) - readPeriod('cl', delta.coverageLifetime) - readPeriod('cq', delta.coverageQuarterly) - pendingLbChunk.push({ cxId: entry.cx, delta }) - }) - if (json.data.last) { - pendingLbChunk.forEach(({ cxId, delta }) => { - const entry = state.matchResult.entries.find(e => e.cxId === cxId) - if (entry) entry.lbDelta = delta - else pendingLbResults[cxId] = delta - }) - pendingLbChunk = [] + if (json.data.first) pendingLbChunk = [] + json.data.e.forEach(entry => { + const delta = { + ready: true, + pointsLifetime: { improved: false }, + pointsQuarterly: { improved: false }, + coverageLifetime: { improved: false }, + coverageQuarterly: { improved: false } + } + const readPeriod = (key, target) => { + if (entry[key]) { target.improved = true; target.rankBefore = entry[key].b; target.rankAfter = entry[key].a } } + readPeriod('pl', delta.pointsLifetime) + readPeriod('pq', delta.pointsQuarterly) + readPeriod('cl', delta.coverageLifetime) + readPeriod('cq', delta.coverageQuarterly) + pendingLbChunk.push({ cxId: entry.cx, delta }) + }) + if (json.data.last) { + pendingLbChunk.forEach(({ cxId, delta }) => { + const entry = state.matchResult.entries.find(e => e.cxId === cxId) + if (entry) entry.lbDelta = delta + else pendingLbResults[cxId] = delta + }) + pendingLbChunk = [] } break } @@ -874,6 +877,17 @@ class App extends Component { state.lobby.members.forEach( member => (member.allowSendTo = member.cxId !== state.user.cxId) ) + + // The relay's CONNECT can beat the Lobby service's own MEMBER_JOIN/UPDATE event to + // us, so state.lobby.members may not have this peer yet. Seed a placeholder so the + // coverage board doesn't just omit them — the next lobby event's rebuild overwrites + // it with real data. Without this, a JIP joiner's own coverage board can render empty + // for the whole match, and a player who disconnects right after connecting can be + // dropped from the round's leaderboard entries entirely. + if (json.cxId && !state.lobby.members.find(m => m.cxId === json.cxId)) { + state.lobby.members.push({ cxId: json.cxId, allowSendTo: true, extra: {} }) + } + this.setState(state) // If we are the host and in-game, send current state to the newly joined player @@ -886,7 +900,21 @@ class App extends Component { this.sendGameStartToMask(mask) this.sendSplotchSyncToMask(mask) } + } else if (json.op === 'MIGRATE_OWNER') { + // Relay reassigned the host role — reconcile into state.lobby.ownerCxId (the field + // every isHost check reads) faster than waiting for the next lobby-update event. + if (json.cxId) { + let state = this.state + state.lobby.ownerCxId = json.cxId + this.setState(state) + } } else if (json.op === 'END_MATCH') { + // Fallback if match_result never arrived (dropped/incomplete chunk) — recompute + // locally so the summary screen isn't stuck on "Waiting for results...". + if (!(this.state.matchResult.valid && this.state.matchResult.round === this.state.round)) { + const finalCoverage = computeCoverage(this.state.splotches, this.state.lobby.members) + this.applyMatchResult(this.state.round, this.toMatchResultEntries(finalCoverage)) + } if (pingInterval) { clearInterval(pingInterval) pingInterval = null @@ -900,10 +928,6 @@ class App extends Component { this.bc.relay.disconnect() loadingTimerStart = null let state = this.state - // The Match Summary + rematch-queue screen (BCLOUD-14489) — state.matchResult is - // always populated by the time END_MATCH arrives (tickMatch's auto-end sequence - // broadcasts it RESULT_GRACE_MS before calling onEndMatch; a manual early end skips - // that, so the screen just shows "Waiting for results..." in that case). state.screen = 'matchSummary' state.matchSummaryArrivalTime = Date.now() state.awaitingRematch = true @@ -917,16 +941,13 @@ class App extends Component { showJoinButton = false this.setState(state) - // Actually clear readiness server-side too, not just the local mirror above — - // otherwise the "Queue for Rematch N/M" count starts from whatever everyone's - // pre-match ready state still was. + // Clear readiness server-side too, or "Queue for Rematch N/M" starts from stale state. this.bc.lobby.updateReady( state.lobby.lobbyId, false, this.makeExtraJson(state.user.colorIndex, false) ) - // Host-only: keeps evaluating whether to auto-start the next round even while - // sitting on Lobby/MatchSummary, whichever screen the host itself is on. + // Host-only: keeps evaluating whether to auto-start the next round. if (rematchGateInterval) clearInterval(rematchGateInterval) rematchGateInterval = setInterval(() => this.tickRematchGate(), 500) } @@ -954,7 +975,7 @@ class App extends Component { let playerMask = this.state.lobby.members.reduce((playerMask, member) => { if (!member.allowSendTo) return playerMask let netId = this.bc.relay.getNetIdForCxId(member.cxId) - // Note: we avoid bitwise ops here to support up to 40 players (JS bitwise is 32-bit) + // Avoiding bitwise ops here — need up to 40 players and JS bitwise ops are 32-bit only return playerMask + Math.pow(2, netId) }, 0) @@ -1194,6 +1215,12 @@ class App extends Component { for (let i = 0; i < splotches.length; i++) { let s = splotches[i] let entry = { x: s.x, y: s.y, c: s.colorIndex, a: s.angle, t: s.startTimeMs } + if (s.cxId) { + // Compact netId, not the ~80-char cxId, to stay inside the chunk byte budget — + // resolved back to a cxId at receive time (onRelayMessage's splotch_sync case). + let ownerNetId = this.bc.relay.getNetIdForCxId(s.cxId) + if (ownerNetId >= 0 && ownerNetId < this.bc.relay.MAX_PLAYERS) entry.o = ownerNetId + } let entrySize = JSON.stringify(entry).length + 1 // +1 for comma separator if (currentSize + entrySize > MAX_BYTES && batch.length > 0) { @@ -1266,11 +1293,9 @@ class App extends Component { }) } - // Applies an authoritative coverage snapshot for a round — either a locally-computed one - // (host, or the watchdog fallback) or one just reassembled from a "match_result" - // broadcast. Idempotent per round. Only the host posts to the cloud (see - // hostPostMatchResultsToCloud) — everyone else just waits for the "lb_result" broadcast - // that call produces. + // Applies an authoritative coverage snapshot for a round (idempotent per round). Only + // the host posts to the cloud (hostPostMatchResultsToCloud) — everyone else waits for + // the "lb_result" broadcast that call produces. applyMatchResult (round, entries) { if (this.state.matchResult.valid && this.state.matchResult.round === round) return let state = this.state @@ -1293,11 +1318,8 @@ class App extends Component { if (isHost) this.hostPostMatchResultsToCloud(round, entries) } - // Host-only: posts the WHOLE round's results to the four leaderboards in one trusted - // server-side call (PostMatchResults cloud script — see brainCloud Design > Cloud Code). - // Replaces per-client direct-leaderboard-API posting entirely; postScoreToLeaderboard- - // OnBehalfOf (which the script uses) is Cloud-Code-only, so individual clients can't - // post to these boards at all anymore. + // Host-only: posts the whole round's results to the four leaderboards in one trusted + // server-side call (PostMatchResults cloud script). hostPostMatchResultsToCloud (round, entries) { const scriptData = { round, @@ -1328,9 +1350,8 @@ class App extends Component { }) } - // Applies the PostMatchResults response (keyed by profileId) onto state.matchResult. - // entries (keyed by cxId — resolved via state.lobby.members, the only place both ids - // are known together) and broadcasts the result to the rest of the match. Host-only. + // Applies the PostMatchResults response (keyed by profileId) onto state.matchResult.entries + // (keyed by cxId) and broadcasts the result to the rest of the match. Host-only. applyLeaderboardResultsFromCloud (round, resultsArr) { if (!(this.state.matchResult.valid && this.state.matchResult.round === round)) return // a newer round has already started @@ -1358,10 +1379,8 @@ class App extends Component { this.sendLeaderboardResultsToMask(this.buildAllOthersMask(), round, state.matchResult.entries) } - // Broadcasts the host's cloud-computed leaderboard results for the whole round to the - // rest of the match — chunked exactly like match_result. Only entries with - // lbDelta.ready are included; a period sub-object is present only when it actually - // improved (absent == "no change" on receipt). + // Broadcasts the host's cloud-computed leaderboard results, chunked like match_result. + // A period sub-object is present only when it actually improved. sendLeaderboardResultsToMask (mask, round, entries) { if (mask === 0) return const MAX_BYTES = 900 @@ -1401,11 +1420,8 @@ class App extends Component { }) } - // Live coverage recompute (drives the in-game scoreboard sidebar) + the host's - // auto-end-at-MATCH_DURATION_SEC sequence: compute+broadcast match_result, wait - // RESULT_GRACE_MS, then call the same onEndMatch() the manual "End Match" button uses. - // Ticked every COVERAGE_RECOMPUTE_MS while in the 'game' screen (started/cleared - // alongside pingInterval). + // Live coverage recompute (scoreboard sidebar) + the host's auto-end sequence: at + // MATCH_DURATION_SEC, broadcast match_result, wait RESULT_GRACE_MS, then onEndMatch(). tickMatch () { if (!this.state.gameStartTime) return const nowMs = Date.now() @@ -1440,9 +1456,8 @@ class App extends Component { matchPhase = 'ended' } - // Watchdog: well past the deadline with no authoritative result at all (a dropped - // broadcast, or a gap during host migration) — compute and apply locally so the round - // can't hang forever. Cheap and idempotent. + // Watchdog: no authoritative result well past the deadline — compute locally so the + // round can't hang forever. if (!this.state.matchResult.valid && elapsedMs >= MATCH_DURATION_SEC * 1000 + RESULT_GRACE_MS + 3000) { const finalCoverage = computeCoverage(this.state.splotches, this.state.lobby.members) this.applyMatchResult(this.state.round, this.toMatchResultEntries(finalCoverage)) @@ -1454,12 +1469,15 @@ class App extends Component { } } - // Add a persistent colour splotch at pos (normalized 0-1 coords) + // Add a persistent colour splotch at pos (normalized 0-1 coords) — always a self-paint, + // so cxId is always our own (coverage attribution needs the real player, not just colour — + // two players can share a colourIndex). createSplotch (pos, colorIndex, angle) { let splotch = { x: pos.x, y: pos.y, colorIndex: colorIndex % colors.length, + cxId: this.state.user.cxId, angle: angle === undefined ? Math.random() * Math.PI * 2 : angle, startTimeMs: Date.now() } @@ -1554,7 +1572,8 @@ class App extends Component { user={this.state.user} appLobbies={this.state.appLobbies} usePingData={this.state.usePingData} - lastLobbyType={localStorage.getItem('lobbyType')} + lastLobbyType={getUrlParam('lobbyType') || localStorage.getItem('lobbyType')} + defaultProtocol={getUrlParam('protocol')} bcWrapper={this.bc} onLogout={this.onLogout.bind(this)} onPlay={this.onPlayClicked.bind(this)} diff --git a/RelayTestApp/src/GameScreen.js b/RelayTestApp/src/GameScreen.js index 5e2ff4a..481b483 100644 --- a/RelayTestApp/src/GameScreen.js +++ b/RelayTestApp/src/GameScreen.js @@ -8,6 +8,11 @@ const TIMER_WARN_SEC = 30 const TIMER_URGENT_SEC = 10 const SIDEBAR_WIDTH = 220 +// Hold-to-paint: holding the left mouse button down auto-repeats a splotch at this fixed +// interval (the initial click still paints immediately) — same value across cpp/js/both +// Godot ports so holding paints at the same rate for everyone in a shared match. +const AUTO_PAINT_INTERVAL_MS = 150 + // Props: // user // lobby @@ -35,6 +40,7 @@ class GameScreen extends Component { componentWillUnmount () { clearInterval(this.timerInterval) + this.stopAutoPaint() } // "Exit Match" — leaves the match for yourself only. There's no manual host "End @@ -57,6 +63,33 @@ class GameScreen extends Component { onMouseClick (e) { this.props.onPlayerClicked(this.mousePos, e.button) } + + // Left-button hold starts auto-paint (in addition to the immediate click above); any + // other button is just a single click, same as before. + onMouseDown (e) { + this.onMouseClick(e) + if (e.button === 0) this.startAutoPaint() + } + + onMouseUp () { + this.stopAutoPaint() + } + + startAutoPaint () { + this.stopAutoPaint() + this.autoPaintInterval = setInterval(() => { + this.props.onPlayerClicked(this.mousePos, 0) + }, AUTO_PAINT_INTERVAL_MS) + } + + // Also called when the cursor leaves the play area — the DOM only reports mouse position + // while inside it, so once outside there's no live position to keep painting at. + stopAutoPaint () { + if (this.autoPaintInterval) { + clearInterval(this.autoPaintInterval) + this.autoPaintInterval = null + } + } onToggleReliable () { this.props.onToggleReliable() } @@ -90,13 +123,15 @@ class GameScreen extends Component { let elapsedMs = Date.now() - this.props.gameStartTime let elapsedSec = Math.max(0, Math.floor(elapsedMs / 1000)) let remaining = Math.max(0, MATCH_DURATION_SEC - elapsedSec) - let minutes = Math.floor(elapsedSec / 60) - let seconds = elapsedSec % 60 + let minutes = Math.floor(remaining / 60) + let seconds = remaining % 60 let timeStr = `${minutes}:${seconds.toString().padStart(2, '0')}` + // Counts DOWN the whole match (matches cpp/Godot) — only the colour escalates as it + // gets low, the format never swaps to a different "Ending in..." string. if (remaining <= TIMER_WARN_SEC) { let color = remaining <= TIMER_URGENT_SEC ? '#ff4d4d' : '#ffbf33' - return Ending in {remaining}... + return Game Time: {timeStr} } return Game Time: {timeStr} @@ -232,7 +267,9 @@ class GameScreen extends Component { float: 'left' }} onMouseMove={this.onMouseMove.bind(this)} - onMouseDown={this.onMouseClick.bind(this)} + onMouseDown={this.onMouseDown.bind(this)} + onMouseUp={this.onMouseUp.bind(this)} + onMouseLeave={this.onMouseUp.bind(this)} onContextMenu={e => e.preventDefault()} > {/** Transient shockwave rings */} diff --git a/RelayTestApp/src/GlobalChatPanel.js b/RelayTestApp/src/GlobalChatPanel.js index a17ab0b..f071695 100644 --- a/RelayTestApp/src/GlobalChatPanel.js +++ b/RelayTestApp/src/GlobalChatPanel.js @@ -16,13 +16,11 @@ function parseWireMessage (m) { } } -// Shared app-wide "Global" chat channel — used from both the main menu and the lobby, -// so channel resolution / history / send all live here. brainCloud's chat calls all -// require RTT to be enabled (RTT_NOT_ENABLED otherwise); App.js enables RTT as soon as -// login succeeds, which covers both mount points. Live RTT push: channelConnect both -// registers the listener AND returns initial history in one call; every message after -// that (including our own sends, edits, and deletes) arrives via registerRTTChatCallback -// — see knowledge-articles/01-chat.md. No re-fetch after posting. +// Shared app-wide "Global" chat channel, used from both the main menu and the lobby. +// Needs RTT enabled (App.js does this right after login). channelConnect registers the +// listener and returns initial history in one call; everything after that (including our +// own sends/edits/deletes) arrives via registerRTTChatCallback — see +// knowledge-articles/04-chat.md. // // Props: // bcWrapper — the app's BrainCloudWrapper instance diff --git a/RelayTestApp/src/LeaderboardPanel.js b/RelayTestApp/src/LeaderboardPanel.js index 4d45277..baeb30f 100644 --- a/RelayTestApp/src/LeaderboardPanel.js +++ b/RelayTestApp/src/LeaderboardPanel.js @@ -25,10 +25,7 @@ function formatScore (score, boardType) { // Shared leaderboard viewer — top 5 + "you" row, toggleable between the two boards this // app posts to (points / coverage) and Lifetime vs Quarterly. Mirrors leaderboardPanel.cpp. -// -// NOTE: scores are only posted once a match's coverage/win-ranking is computed and -// submitted (BCLOUD-14472/14490, still landing on cpp) — until some client posts to -// these boards, they'll show "No scores yet." +// Scores only appear once a match's coverage/win-ranking has been posted (BCLOUD-14472/14490). // // Props: // bcWrapper diff --git a/RelayTestApp/src/LobbyScreen.js b/RelayTestApp/src/LobbyScreen.js index 4039838..3eda576 100644 --- a/RelayTestApp/src/LobbyScreen.js +++ b/RelayTestApp/src/LobbyScreen.js @@ -26,7 +26,6 @@ function regionFromLobbyId (lobbyId) { // pingData // bcWrapper // isProvisioning / provisioningStatus — non-blocking "starting the next round" banner -// awaitingRematch — true between END_MATCH and the next round actually starting // matchResult — last finished round's standings (valid/round/entries), for the INFO tab // onBack / onColorChanged / onTeamChanged / onStart / onToggleReady / onJoin / onSendLobbySignal class LobbyScreen extends Component { @@ -393,15 +392,11 @@ class LobbyScreen extends Component { - {this.props.lobby.ownerCxId === this.props.user.cxId && this.props.awaitingRematch ? ( - // Rematch flow is fully automatic (App.tickRematchGate) — no manual - // override here, so a host who returns early can't skip the "wait for - // stragglers or 45s" window. -

    - Waiting for other players to return... -

    - ) : this.props.lobby.ownerCxId === this.props.user.cxId && + {this.props.lobby.ownerCxId === this.props.user.cxId && !this.props.user.isReady ? ( + // Host can always start manually, round 2+ included — App.tickRematchGate() + // still auto-starts in the background once everyone's queued up or the wait + // times out, so this button is just an early-start option, not the only way in. - ) : this.props.lobby.ownerCxId !== this.props.user.cxId && !this.props.awaitingRematch ? ( + ) : this.props.lobby.ownerCxId !== this.props.user.cxId ? ( // Only the host has a "Start" button (starting the round is host-only), // but every other member still needs a way to signal they're ready — - // this is that toggle. Once awaiting a rematch, MatchSummaryScreen's - // "Queue for Rematch" button already handles readying up instead. + // this toggle stays available in every round, not just the first.