From b587390fa05ae28fc9bf17214ec2262b9f11e972 Mon Sep 17 00:00:00 2001 From: Steve Jones Date: Fri, 7 Aug 2026 10:18:48 -0400 Subject: [PATCH 1/8] BCLOUD-14472 BCLOUD-14490 BCLOUD-14489 Updating Main menu with leaderboards, Updated in game logic for "winners" and points based off of coverage UPdated in game display --- relaytestapp/CMakeLists.txt | 2 + relaytestapp/src/BCCallback.h | 1 + relaytestapp/src/app.cpp | 439 +++++++++++++++++-- relaytestapp/src/app.h | 10 +- relaytestapp/src/coverage.cpp | 162 +++++++ relaytestapp/src/coverage.h | 46 ++ relaytestapp/src/game.cpp | 342 +++++++++------ relaytestapp/src/game.h | 1 + relaytestapp/src/globals.cpp | 100 ++++- relaytestapp/src/globals.h | 96 ++++- relaytestapp/src/loading.h | 1 + relaytestapp/src/lobby.cpp | 28 ++ relaytestapp/src/lobby.h | 1 + relaytestapp/src/login.h | 1 + relaytestapp/src/mainMenu.cpp | 781 ++++++++++++++++++++++++++-------- relaytestapp/src/mainMenu.h | 1 + relaytestapp/src/mainSDL.cpp | 1 + relaytestapp/src/mainUWP.cpp | 1 + 18 files changed, 1670 insertions(+), 344 deletions(-) create mode 100644 relaytestapp/src/coverage.cpp create mode 100644 relaytestapp/src/coverage.h diff --git a/relaytestapp/CMakeLists.txt b/relaytestapp/CMakeLists.txt index 807ccc9..f010cbc 100644 --- a/relaytestapp/CMakeLists.txt +++ b/relaytestapp/CMakeLists.txt @@ -67,6 +67,8 @@ list(APPEND src_files src/globals.h src/app.cpp src/app.h + src/coverage.cpp + src/coverage.h src/game.cpp src/game.h src/lobby.cpp diff --git a/relaytestapp/src/BCCallback.h b/relaytestapp/src/BCCallback.h index a682da6..61b9b9b 100644 --- a/relaytestapp/src/BCCallback.h +++ b/relaytestapp/src/BCCallback.h @@ -31,6 +31,7 @@ // }) // ); //----------------------------------------------------------------------------- +#pragma once // Thirdparty includes #include diff --git a/relaytestapp/src/app.cpp b/relaytestapp/src/app.cpp index 6cad28a..767e58e 100644 --- a/relaytestapp/src/app.cpp +++ b/relaytestapp/src/app.cpp @@ -20,6 +20,7 @@ // App includes #include "app.h" +#include "coverage.h" #include "game.h" #include "globals.h" #include "loading.h" @@ -62,17 +63,32 @@ static Server parseServer(const Json::Value &serverJson); static void startGame(); static void onRelaySystemMessage(const Json::Value &json); static void onRelayMessage(int netId, const Json::Value &json); +static void startLobbySearchFlow(); static uint64_t getPlayerMask(); static void sendGameStartToMask(uint64_t playerMask); static void sendSplotchSyncToMask(uint64_t mask); +static void sendMatchResultToMask(uint64_t mask, int round, const std::vector &coverage); +static std::vector toMatchResultEntries(const std::vector &coverage); +static void postMatchScores(const MatchResultEntry &mine); +static void applyMatchResult(int round, const std::vector &entries); static void onRelayConnected(); static bool isDisconnecting = false; +// Chunk accumulator for the in-progress "match_result" reassembly (see onRelayMessage). +// Reset on "first":true and whenever a new round starts (onRelayConnected). +static std::vector s_pendingMatchResult; + // Incremented on every app_play() call. Each ping-flow lambda captures this value and // checks it before acting — stale callbacks from a previous session are silently dropped. static int s_playGeneration = 0; +// True only when RTT was enabled to start an actual lobby search (via app_play()) — +// as opposed to being enabled just so main-menu chat has a live RTT connection +// (chat's REST-style calls all fail with RTT_NOT_ENABLED otherwise). Checked in +// onRTTConnected() so reaching the main menu doesn't silently auto-join a lobby. +static bool s_wantsLobbySearch = false; + // Tracks the region chosen for the current geo test lobby attempt. // Set when we pick the best un-tested region; recorded to geoTestedRegions on ROOM_READY. static std::string s_geoTestRegion; @@ -317,7 +333,22 @@ static void applyLobbyTypes(const Json::Value &result) else state.splotchDurationSec = -1; + // Leaderboard ids — optional global properties, so the boards can be created/renamed + // in the portal with no client rebuild. Falls back to the compiled-in defaults in + // globals.h when a property is absent. + auto readLeaderboardId = [&](const char *propName, std::string &out) + { + const auto &prop = result["data"][propName]["value"]; + if (!prop.isNull() && !prop.asString().empty()) + out = prop.asString(); + }; + readLeaderboardId("CoverageLeaderboardId", state.coverageLeaderboardId); + readLeaderboardId("CoverageLeaderboardIdQuarterly", state.coverageLeaderboardIdQuarterly); + readLeaderboardId("PointsLeaderboardId", state.pointsLeaderboardId); + readLeaderboardId("PointsLeaderboardIdQuarterly", state.pointsLeaderboardIdQuarterly); + state.screenState = ScreenState::MainMenu; + app_enableChatRTT(); } // User fully logged in — fetch AllLobbyTypes then show main menu. @@ -333,6 +364,7 @@ void onLoggedIn() if (state.appLobbies.empty()) state.appLobbies.push_back(DEFAULT_LOBBY_TYPE); state.screenState = ScreenState::MainMenu; + app_enableChatRTT(); })); } @@ -383,11 +415,11 @@ static void doFindOrCreateLobbyWithPingData(const std::string &lobbyType) [](const std::string &msg) { errorAndReturnToMenu("Failed to find lobby:\n" + msg); })); } -// RTT connected — optionally ping regions before finding a lobby. -void onRTTConnected() +// RTT connected — optionally ping regions before finding a lobby. Only runs when +// RTT was enabled to actually search for a lobby (see s_wantsLobbySearch) — RTT +// enabled just for main-menu chat should not auto-join anything. +static void startLobbySearchFlow() { - state.user.cxId = pBCWrapper->getRTTService()->getRTTConnectionId(); - // Guard: RTT can reconnect mid-session. Only start one ping flow per app_play() call. static int s_pingStartedGen = -1; if (s_pingStartedGen == s_playGeneration) @@ -468,6 +500,29 @@ void onRTTConnected() })); } +// RTT connected. Always records our RTT connection id; only kicks off a lobby +// search when RTT was enabled for that purpose (app_play() sets s_wantsLobbySearch) — +// RTT enabled for main-menu chat (app_enableChatRTT()) should not auto-join anything. +void onRTTConnected() +{ + state.user.cxId = pBCWrapper->getRTTService()->getRTTConnectionId(); + if (s_wantsLobbySearch) + startLobbySearchFlow(); +} + +// Enables RTT so main-menu chat works — brainCloud's chat calls (getChannelId, +// getRecentChatMessages, postChatMessageSimple) all fail with RTT_NOT_ENABLED +// otherwise. Idempotent: no-ops if RTT is already connected (e.g. a lobby search +// already turned it on). Called whenever the app reaches the MainMenu screen. +void app_enableChatRTT() +{ + if (pBCWrapper->getRTTService()->getRTTEnabled()) + return; + s_wantsLobbySearch = false; + pBCWrapper->getRTTService()->registerRTTLobbyCallback(&bcRTTCallback); + pBCWrapper->getRTTService()->enableRTT(&bcRTTConnectCallback, true); +} + // Show error and go back to MainMenu without logging out. // Use this for relay/lobby errors where the user is still authenticated. static void errorAndReturnToMenu(const std::string &message) @@ -495,6 +550,7 @@ static void errorAndReturnToMenu(const std::string &message) state.geoTestedRegions = geoTestedRegions; state.geoTestResults = geoTestResults; state.screenState = ScreenState::MainMenu; + app_enableChatRTT(); // RTT was just disabled above — re-enable it for main-menu chat errorMessage = message; ImGui::OpenPopup("Error"); @@ -593,11 +649,20 @@ static void sendSplotchSyncToMask(uint64_t mask) for (const auto &s : state.splotches) { Json::Value entry; - entry["x"] = s.pos.x / 800.0f; - entry["y"] = s.pos.y / 600.0f; + entry["x"] = s.pos.x / CANVAS_W; + entry["y"] = s.pos.y / CANVAS_H; entry["c"] = s.colorIndex; entry["t"] = (Json::Int64)s.startTimeMs; entry["a"] = s.rotation; + if (!s.ownerCxId.empty()) + { + // Compact netId, not the ~80-char cxId, to stay inside the chunk byte budget. + // Resolved back to a cxId at receive time (see onRelayMessage's splotch_sync + // branch) — never deferred, since RelayComms clears its netId maps at END_MATCH. + int ownerNetId = pBCWrapper->getRelayService()->getNetIdForCxId(s.ownerCxId); + if (ownerNetId >= 0 && ownerNetId < MAX_LOBBY_MEMBERS) + entry["o"] = ownerNetId; + } // Measure this entry's serialized size (+1 for the separating comma) int entrySize = (int)writer.write(entry).size() + 1; @@ -610,11 +675,244 @@ static void sendSplotchSyncToMask(uint64_t mask) flushChunk(); } +// Broadcasts the host's authoritative final coverage/ranking to a player mask, chunked +// like sendSplotchSyncToMask but sent reliable + ORDERED (splotch_sync stays unordered — +// cosmetic chunk-reassembly races there are invisible; here they'd corrupt a posted score). +static void sendMatchResultToMask(uint64_t mask, int round, const std::vector &coverage) +{ + if (coverage.empty() || mask == 0) return; + + static const int MAX_RELAY_BYTES = 900; + static const int ENVELOPE_OVERHEAD = 80; + + Json::FastWriter writer; + bool isFirst = true; + std::vector chunk; + int currentSize = ENVELOPE_OVERHEAD; + + auto flushChunk = [&](bool isLast) + { + if (chunk.empty() && !isLast) return; + Json::Value resultJson; + resultJson["op"] = "match_result"; + resultJson["data"]["round"] = round; + resultJson["data"]["first"] = isFirst; + resultJson["data"]["last"] = isLast; + Json::Value arr(Json::arrayValue); + for (const auto &entry : chunk) + arr.append(entry); + resultJson["data"]["e"] = arr; + auto str = writer.write(resultJson); + pBCWrapper->getRelayService()->sendToPlayers( + (const uint8_t *)str.data(), (int)str.length(), + mask, + true, // reliable + true, // ordered — see comment above + (BrainCloud::eRelayChannel)0); + isFirst = false; + chunk.clear(); + currentSize = ENVELOPE_OVERHEAD; + }; + + for (const auto &c : coverage) + { + int netId = pBCWrapper->getRelayService()->getNetIdForCxId(c.cxId); + if (netId < 0 || netId >= MAX_LOBBY_MEMBERS) continue; // no longer connected — skip + + Json::Value entry; + entry["n"] = netId; + entry["r"] = c.rank; + entry["c"] = (int)(c.coveragePct * 100.0f + 0.5f); // basis points, 0-10000 + entry["b"] = c.beaten; + + int entrySize = (int)writer.write(entry).size() + 1; + if (currentSize + entrySize > MAX_RELAY_BYTES && !chunk.empty()) + flushChunk(false); + + chunk.push_back(std::move(entry)); + currentSize += entrySize; + } + flushChunk(true); +} + +static std::vector toMatchResultEntries(const std::vector &coverage) +{ + std::vector out; + out.reserve(coverage.size()); + for (const auto &c : coverage) + { + MatchResultEntry e; + e.cxId = c.cxId; + e.rank = c.rank; + e.coveragePct = c.coveragePct; + e.beaten = c.beaten; + out.push_back(e); + } + return out; +} + +// Posts this client's own final standing to the four leaderboards. Coverage score is +// basis points (0-10000) so the portal isn't stuck with float scores; points score is +// "players beaten" + a flat completion bonus (so a solo match — 0 beaten — still posts 1, +// per the ticket: "+1 bonus point for completing a game"). +static void postMatchScores(const MatchResultEntry &mine) +{ + int basisPoints = (int)(mine.coveragePct * 100.0f + 0.5f); + int points = mine.beaten + 1; + + Json::Value otherData; + otherData["round"] = state.roundNumber; + otherData["rank"] = mine.rank; + // GetGlobalLeaderboardPage/View don't return a usable "name" field for arbitrary + // (non-friend) entries — embedding it in the score's user-defined data is the + // standard way to show a display name in a leaderboard viewer (see mainMenu.cpp). + otherData["name"] = state.user.name; + Json::FastWriter writer; + auto otherDataStr = writer.write(otherData); + + auto postTo = [&](const std::string &leaderboardId, int64_t score) + { + if (leaderboardId.empty()) return; + pBCWrapper->getSocialLeaderboardService()->postScoreToLeaderboard( + leaderboardId.c_str(), score, otherDataStr, + new BCCallback([](const Json::Value &) {}, [](const std::string &) {})); + }; + + postTo(state.coverageLeaderboardId, basisPoints); + postTo(state.coverageLeaderboardIdQuarterly, basisPoints); + postTo(state.pointsLeaderboardId, points); + postTo(state.pointsLeaderboardIdQuarterly, points); +} + +// Applies an authoritative coverage snapshot for a round — either a locally-computed one +// (host, or a no-result fallback) or one just reassembled from a "match_result" broadcast. +// Idempotent per round: a migrated host's broadcast racing the original host's (or the +// END_MATCH fallback racing a late match_result) is safe to apply/post more than once — +// only the FIRST application for a given round has any effect. This guard is mandatory +// because the points leaderboard is CUMULATIVE; a duplicate post would silently and +// permanently inflate a lifetime total with no way to detect it afterward. +static void applyMatchResult(int round, const std::vector &entries) +{ + if (state.matchResult.valid && state.matchResult.round == round) + return; + + state.matchResult.valid = true; + state.matchResult.round = round; + state.matchResult.entries = entries; + + if (state.leaderboardPostedRound == round) + return; + state.leaderboardPostedRound = round; + + for (const auto &e : entries) + { + if (e.cxId == state.user.cxId) + { + postMatchScores(e); + break; + } + } +} + +// Drives the shared coverage/ranking calculation and the host-authoritative match-end +// flow. Called once per frame from game_update() while state.screenState == Game. +// +// isHost is re-evaluated every call from state.lobby.ownerCxId, so a mid-match host +// migration (see the MIGRATE_OWNER handling in onRelaySystemMessage) is handled with no +// special-casing here — the newly-promoted host just starts satisfying "isHost" on its +// next tick and picks up wherever the match clock currently is; it already has +// state.splotches and state.gameStartTime like every other member. +void app_tickMatch() +{ + if (state.gameStartTime == 0) return; + + long long nowMs = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); + long long elapsedMs = nowMs - state.gameStartTime; + + // Live coverage recompute + rank-swap detection — shared by the in-match rank board + // (game.cpp reads state.coverage) and the match-end snapshot below. + if (state.coverageComputedGen != state.splotchGeneration && + nowMs - state.coverageComputedAtMs >= COVERAGE_RECOMPUTE_MS) + { + auto fresh = computeCoverage(state.splotches, state.lobby.members); + for (auto &e : fresh) + { + int prevRank = e.rank; + long long prevChangedAt = 0; + for (const auto &old : state.coverage) + { + if (old.cxId == e.cxId) + { + prevRank = old.rank; + prevChangedAt = old.rankChangedAtMs; + break; + } + } + e.prevRank = prevRank; + e.rankChangedAtMs = (prevRank != e.rank) ? nowMs : prevChangedAt; + } + state.coverage = std::move(fresh); + state.coverageComputedGen = state.splotchGeneration; + state.coverageComputedAtMs = nowMs; + } + + if (!isCursorPartyLobby(settings.lobbyType)) + return; // auto-end / leaderboard flow is CursorParty-specific + + bool isHost = !state.lobby.ownerCxId.empty() && state.user.cxId == state.lobby.ownerCxId; + + if (state.matchPhase == MatchPhase::Running && elapsedMs >= MATCH_DURATION_MS && isHost) + { + if (!(state.matchResult.valid && state.matchResult.round == state.roundNumber)) + { + auto finalCoverage = computeCoverage(state.splotches, state.lobby.members); + applyMatchResult(state.roundNumber, toMatchResultEntries(finalCoverage)); + sendMatchResultToMask(getPlayerMask(), state.roundNumber, finalCoverage); + } + state.matchPhase = MatchPhase::ResultsBroadcast; + state.resultsSentAtMs = nowMs; + } + else if (state.matchPhase == MatchPhase::ResultsBroadcast && isHost && + nowMs - state.resultsSentAtMs >= RESULT_GRACE_MS) + { + app_endMatch(); + state.matchPhase = MatchPhase::Ended; + } + + // Watchdog: well past the deadline with no authoritative result at all (a dropped + // broadcast, or a gap during host migration where no one was host for a while) — + // compute and post locally so the round can't hang forever. Cheap and idempotent. + if (!state.matchResult.valid && elapsedMs >= MATCH_DURATION_MS + RESULT_GRACE_MS + 3000) + { + auto finalCoverage = computeCoverage(state.splotches, state.lobby.members); + applyMatchResult(state.roundNumber, toMatchResultEntries(finalCoverage)); + if (isHost && state.matchPhase != MatchPhase::Ended) + { + sendMatchResultToMask(getPlayerMask(), state.roundNumber, finalCoverage); + state.matchPhase = MatchPhase::ResultsBroadcast; + state.resultsSentAtMs = nowMs; + } + } +} + // Called when relay connection succeeds. Owner sets and broadcasts the authoritative game start time. static void onRelayConnected() { ++state.roundNumber; + // Fresh round — reset all per-round match/coverage state so nothing carries over + // from the previous round (this replaces the old file-static "matchEndRound" guard, + // which had a live bug: it wasn't reset across lobbies, so auto-end could silently + // stop firing on a second lobby in the same session). + state.matchPhase = MatchPhase::Running; + state.matchResult = MatchResult(); + state.leaderboardPostedRound = -1; + state.coverage.clear(); + state.coverageComputedGen = (unsigned long long)-1; + state.resultsSentAtMs = 0; + s_pendingMatchResult.clear(); + // Auto geo test: relay connect confirms the region is reachable. // Record the connect time; app_update() disconnects after a 2.5s soak. if (settings.autoGeoTest) @@ -663,13 +961,37 @@ static void onRelaySystemMessage(const Json::Value &json) sendSplotchSyncToMask(mask); } } + else if (json["op"].asString() == "MIGRATE_OWNER") // Relay reassigned the host role + { + // Reconcile relay-level ownership into the SAME field used everywhere for isHost + // checks (state.lobby.ownerCxId — set from the Lobby/RTT service elsewhere). The + // Lobby service's own owner field will also catch up via the next lobby-update + // event; this is just the faster of the two signals. app_tickMatch() re-evaluates + // isHost every tick, so a newly-promoted host resumes match duties (auto-end, + // match_result broadcast) with no extra state transfer — it already has + // state.splotches and state.gameStartTime like every other member. + const auto &newOwnerCxId = json["cxId"].asString(); + if (!newOwnerCxId.empty()) + state.lobby.ownerCxId = newOwnerCxId; + } else if (json["op"].asString() == "END_MATCH") // Match ended, return all players to lobby { + // Fallback: if no authoritative match_result ever arrived for this round (legacy + // host, dropped broadcast, or a migration gap), compute locally and post using + // local numbers before the canvas clears below. applyMatchResult() is idempotent + // per round, so this is a no-op if a result already landed. + if (!(state.matchResult.valid && state.matchResult.round == state.roundNumber)) + { + auto finalCoverage = computeCoverage(state.splotches, state.lobby.members); + applyMatchResult(state.roundNumber, toMatchResultEntries(finalCoverage)); + } + // Reset per-round state immediately state.user.isAlive = false; state.user.isReady = false; state.shockwaves.clear(); state.splotches.clear(); + ++state.splotchGeneration; state.gameStartTime = 0; state.screenState = ScreenState::Lobby; @@ -689,14 +1011,14 @@ static void onRelayMessage(int netId, const Json::Value &json) if (op == "move") { member.isAlive = true; - member.pos.x = (int)(json["data"]["x"].asFloat() * 800.0f); - member.pos.y = (int)(json["data"]["y"].asFloat() * 600.0f); + member.pos.x = (int)(json["data"]["x"].asFloat() * CANVAS_W); + member.pos.y = (int)(json["data"]["y"].asFloat() * CANVAS_H); } else if (op == "shockwave") { Shockwave shockwave; - shockwave.pos.x = (int)(json["data"]["x"].asFloat() * 800.0f); - shockwave.pos.y = (int)(json["data"]["y"].asFloat() * 600.0f); + shockwave.pos.x = (int)(json["data"]["x"].asFloat() * CANVAS_W); + shockwave.pos.y = (int)(json["data"]["y"].asFloat() * CANVAS_H); shockwave.colorIndex = member.colorIndex; shockwave.startTime = std::chrono::high_resolution_clock::now(); state.shockwaves.push_back(shockwave); @@ -711,7 +1033,9 @@ static void onRelayMessage(int netId, const Json::Value &json) splotch.startTimeMs = std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()).count(); splotch.rotation = angle; + splotch.ownerCxId = member.cxId; // sender is already resolved above — no wire field needed state.splotches.push_back(splotch); + ++state.splotchGeneration; } else if (op == "splotch_sync") { @@ -722,18 +1046,55 @@ static void onRelayMessage(int netId, const Json::Value &json) for (const auto &entry : json["data"]["splotches"]) { Splotch s; - s.pos = {(int)(entry["x"].asFloat() * 800.0f), (int)(entry["y"].asFloat() * 600.0f)}; + s.pos = {(int)(entry["x"].asFloat() * CANVAS_W), (int)(entry["y"].asFloat() * CANVAS_H)}; s.colorIndex = entry["c"].asInt(); s.startTimeMs = entry["t"].asInt64(); s.rotation = entry.isMember("a") ? entry["a"].asFloat() : ((float)rand() / (float)RAND_MAX) * SPLOTCH_TAU; + // "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). + if (entry.isMember("o")) + { + const auto &ownerCxId = pBCWrapper->getRelayService()->getCxIdForNetId(entry["o"].asInt()); + s.ownerCxId = ownerCxId; + } state.splotches.push_back(s); } + ++state.splotchGeneration; } else if (op == "clear_splotches") { state.splotches.clear(); + ++state.splotchGeneration; + } + else if (op == "match_result") + { + int round = json["data"]["round"].asInt(); + if (!(state.matchResult.valid && state.matchResult.round == round)) + { + if (json["data"]["first"].asBool()) + s_pendingMatchResult.clear(); + + for (const auto &entry : json["data"]["e"]) + { + const auto &entryCxId = pBCWrapper->getRelayService()->getCxIdForNetId(entry["n"].asInt()); + MatchResultEntry mre; + mre.cxId = entryCxId; + mre.rank = entry["r"].asInt(); + mre.coveragePct = entry["c"].asInt() / 100.0f; // basis points -> % + mre.beaten = entry["b"].asInt(); + s_pendingMatchResult.push_back(mre); + } + + if (json["data"]["last"].asBool()) + { + applyMatchResult(round, s_pendingMatchResult); + s_pendingMatchResult.clear(); + } + } } else if (op == "game_start") { @@ -831,6 +1192,7 @@ void app_update() state.geoTestedRegions = geoTestedRegions; state.geoTestResults = geoTestResults; state.screenState = ScreenState::MainMenu; + app_enableChatRTT(); // RTT was just disabled above — re-enable it for main-menu chat return; } @@ -1140,9 +1502,21 @@ void app_play(BrainCloud::eRelayConnectionType in_protocol) // Reset loading timer so elapsed time starts from when Play was clicked loading_reset_timer(); - // Enable RTT - pBCWrapper->getRTTService()->registerRTTLobbyCallback(&bcRTTCallback); - pBCWrapper->getRTTService()->enableRTT(&bcRTTConnectCallback, true); + s_wantsLobbySearch = true; + + if (pBCWrapper->getRTTService()->getRTTEnabled()) + { + // RTT is already connected (main-menu chat turned it on) — go straight to + // the lobby search. Calling enableRTT() again here would be redundant at + // best; onRTTConnected() won't fire a second time since we're not + // reconnecting, so this is the only way to pick the search flow back up. + startLobbySearchFlow(); + } + else + { + pBCWrapper->getRTTService()->registerRTTLobbyCallback(&bcRTTCallback); + pBCWrapper->getRTTService()->enableRTT(&bcRTTConnectCallback, true); + } } // Take in lobby json and id and build a lobby object @@ -1159,8 +1533,6 @@ static Lobby parseLobby(const Json::Value &lobbyJson, const std::string &lobbyId user.cxId = jsonMember["cxId"].asString(); user.name = jsonMember["name"].asString(); user.colorIndex = jsonMember["extra"]["colorIndex"].asInt(); - if (user.cxId == state.user.cxId) - user.allowSendTo = false; // Ping data shared via the member's extra field const auto &pingsJson = jsonMember["extra"]["pings"]; if (pingsJson.isObject()) @@ -1396,6 +1768,7 @@ void app_cancelLobby() state.geoTestedRegions = geoTestedRegions; state.geoTestResults = geoTestResults; state.screenState = ScreenState::MainMenu; + app_enableChatRTT(); // RTT was just disabled above — re-enable it for main-menu chat } // Cleanly close the game. Go back to main menu but don't log @@ -1425,6 +1798,7 @@ void app_closeGame() state.geoTestedRegions = geoTestedRegions; state.geoTestResults = geoTestResults; state.screenState = ScreenState::MainMenu; + app_enableChatRTT(); // RTT was just disabled above — re-enable it for main-menu chat } // Ready up and signals RTT service we can start the game @@ -1459,15 +1833,22 @@ void app_changeUserColor(int colorIndex) buildExtraJson()); } +// Mask of "everyone except me" — used to broadcast paint/results without echoing back +// to the sender. This used to be driven by a user-editable "allowSendTo" HUD checkbox +// (BCLOUD-14490 removes that checkbox, which was also a scoring-integrity hole: any +// client could uncheck a peer and desync that peer's canvas, and therefore their score). +// The self-exclusion itself is unconditional now, not gated by an editable flag. static uint64_t getPlayerMask() { uint64_t playerMask = 0; for (const auto &user : state.lobby.members) { - if (!user.allowSendTo) + if (user.cxId == state.user.cxId) continue; auto netId = pBCWrapper->getRelayService()->getNetIdForCxId(user.cxId); + if (netId < 0 || netId >= MAX_LOBBY_MEMBERS) + continue; playerMask |= (uint64_t)1 << (uint64_t)netId; } @@ -1492,8 +1873,8 @@ void app_mouseMoved(const Point &pos) // Send to other players Json::Value json; json["op"] = "move"; - json["data"]["x"] = pos.x / 800.0f; - json["data"]["y"] = pos.y / 600.0f; + json["data"]["x"] = pos.x / CANVAS_W; + json["data"]["y"] = pos.y / CANVAS_H; Json::FastWriter writer; auto str = writer.write(json); @@ -1526,8 +1907,8 @@ void app_shockwave(const Point &pos) Json::Value json; json["op"] = "shockwave"; - json["data"]["x"] = pos.x / 800.0f; - json["data"]["y"] = pos.y / 600.0f; + json["data"]["x"] = pos.x / CANVAS_W; + json["data"]["y"] = pos.y / CANVAS_H; json["data"]["angle"] = angle; Json::FastWriter writer; @@ -1554,19 +1935,7 @@ void app_shockwave(const Point &pos) splotch.startTimeMs = std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()).count(); splotch.rotation = angle; + splotch.ownerCxId = state.user.cxId; state.splotches.push_back(splotch); -} - -// Host clears all splotches on every client -void app_clearSplotches() -{ - state.splotches.clear(); - - Json::Value json; - json["op"] = "clear_splotches"; - Json::FastWriter writer; - auto str = writer.write(json); - pBCWrapper->getRelayService()->sendToAll( - (const uint8_t *)str.data(), (int)str.length(), - true, false, (BrainCloud::eRelayChannel)0); + ++state.splotchGeneration; } diff --git a/relaytestapp/src/app.h b/relaytestapp/src/app.h index c3f06f1..5bb20e6 100644 --- a/relaytestapp/src/app.h +++ b/relaytestapp/src/app.h @@ -17,6 +17,7 @@ // Desc: Interface for main application logic // Author: David St-Louis //----------------------------------------------------------------------------- +#pragma once // brainCloud #include @@ -46,6 +47,10 @@ void app_reconnect(); // Find lobby void app_play(BrainCloud::eRelayConnectionType protocol); +// Enables RTT so main-menu chat works (idempotent — safe to call any time the +// MainMenu screen is reached; no-ops if RTT is already connected). +void app_enableChatRTT(); + // Cancel lobby search or leave lobby. Go back to main menu without logging out. void app_cancelLobby(); @@ -67,5 +72,6 @@ void app_mouseMoved(const Point& pos); // User clicked mouse in the play area void app_shockwave(const Point& pos); -// Host clears all splotches on every client -void app_clearSplotches(); +// Drives coverage/ranking recompute + the host-authoritative match-end + leaderboard-post +// flow. Called once per frame from game_update() while on the Game screen. +void app_tickMatch(); diff --git a/relaytestapp/src/coverage.cpp b/relaytestapp/src/coverage.cpp new file mode 100644 index 0000000..d8c6901 --- /dev/null +++ b/relaytestapp/src/coverage.cpp @@ -0,0 +1,162 @@ +//----------------------------------------------------------------------------- +// Copyright 2026 bitHeads inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//----------------------------------------------------------------------------- +// File: coverage.cpp +// Desc: Canvas coverage % + win-ranking calculation (BCLOUD-14472 / BCLOUD-14490). +//----------------------------------------------------------------------------- + +#include "coverage.h" + +#include +#include +#include + +std::vector computeCoverage(const std::vector &splotches, + const std::vector &members) +{ + std::vector result; + result.reserve(members.size()); + + std::map indexByCxId; + for (const auto &m : members) + { + indexByCxId[m.cxId] = (int)result.size(); + CoverageEntry e; + e.cxId = m.cxId; + e.colorIndex = m.colorIndex; + result.push_back(e); + } + + const int N = (int)splotches.size(); + if (N > 0) + { + // Uniform grid over the canvas, cell size = obscure-check neighborhood unit. + // Only a 3x3 cell neighborhood can contain a splotch within SPLOTCH_RADIUS, + // since SPLOTCH_RADIUS == cell size / 2. + const float CELL = SPLOTCH_DISPLAY_SIZE; + const int gridW = (int)(CANVAS_W / CELL) + 2; + const int gridH = (int)(CANVAS_H / CELL) + 2; + std::vector> grid(gridW * gridH); + + auto cellX = [&](int x) { + int cx = (int)(x / CELL); + return std::max(0, std::min(gridW - 1, cx)); + }; + auto cellY = [&](int y) { + int cy = (int)(y / CELL); + return std::max(0, std::min(gridH - 1, cy)); + }; + + const int R2 = (int)(SPLOTCH_RADIUS * SPLOTCH_RADIUS); // strict '<' obscure radius, squared + std::vector visible(N, true); + + // Sweep last-painted -> first. The grid at step i contains only splotches + // painted AFTER i (i.e. "on top" of it), which is exactly what "visible on + // the top layer" needs to check against. + for (int i = N - 1; i >= 0; --i) + { + const Splotch &s = splotches[i]; + int cx = cellX(s.pos.x); + int cy = cellY(s.pos.y); + bool obscured = false; + + for (int dy = -1; dy <= 1 && !obscured; ++dy) + { + int ny = cy + dy; + if (ny < 0 || ny >= gridH) continue; + for (int dx = -1; dx <= 1; ++dx) + { + int nx = cx + dx; + if (nx < 0 || nx >= gridW) continue; + const auto &cell = grid[nx + ny * gridW]; + for (int j : cell) + { + int ddx = splotches[j].pos.x - s.pos.x; + int ddy = splotches[j].pos.y - s.pos.y; + if (ddx * ddx + ddy * ddy < R2) + { + obscured = true; + break; + } + } + if (obscured) break; + } + } + + visible[i] = !obscured; + grid[cx + cy * gridW].push_back(i); + } + + for (int i = 0; i < N; ++i) + { + if (!visible[i]) continue; + const Splotch &s = splotches[i]; + + int idx = -1; + if (!s.ownerCxId.empty()) + { + auto it = indexByCxId.find(s.ownerCxId); + if (it != indexByCxId.end()) + idx = it->second; + } + if (idx < 0) + { + // Unattributed (legacy sender / departed player) — fall back to a + // colorIndex match against a live member, matching pre-attribution behavior. + for (size_t k = 0; k < result.size(); ++k) + { + if (result[k].colorIndex == s.colorIndex) + { + idx = (int)k; + break; + } + } + } + if (idx >= 0) + result[idx].visibleCount++; + } + } + + const float splotchArea = 3.14159265f * SPLOTCH_RADIUS * SPLOTCH_RADIUS; + const float canvasArea = CANVAS_W * CANVAS_H; + for (auto &e : result) + e.coveragePct = std::min(100.0f, e.visibleCount * splotchArea / canvasArea * 100.0f); + + std::sort(result.begin(), result.end(), [](const CoverageEntry &a, const CoverageEntry &b) { + if (a.coveragePct != b.coveragePct) return a.coveragePct > b.coveragePct; + if (a.visibleCount != b.visibleCount) return a.visibleCount > b.visibleCount; + return a.cxId < b.cxId; + }); + + for (size_t i = 0; i < result.size(); ++i) + { + if (i > 0 && + result[i].coveragePct == result[i - 1].coveragePct && + result[i].visibleCount == result[i - 1].visibleCount) + result[i].rank = result[i - 1].rank; // tie shares rank + else + result[i].rank = (int)i + 1; + } + + for (size_t i = 0; i < result.size(); ++i) + { + int beaten = 0; + for (size_t j = 0; j < result.size(); ++j) + if (result[j].rank > result[i].rank) ++beaten; + result[i].beaten = beaten; + } + + return result; +} diff --git a/relaytestapp/src/coverage.h b/relaytestapp/src/coverage.h new file mode 100644 index 0000000..72feb76 --- /dev/null +++ b/relaytestapp/src/coverage.h @@ -0,0 +1,46 @@ +//----------------------------------------------------------------------------- +// Copyright 2026 bitHeads inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//----------------------------------------------------------------------------- +// File: coverage.h +// Desc: Canvas coverage % + win-ranking calculation (BCLOUD-14472 / BCLOUD-14490). +// Pure logic — no ImGui, no brainCloud, no globals — so this is the exact +// artifact other RelayTestApp ports (dotnet/godot/java/react) should copy. +//----------------------------------------------------------------------------- +#pragma once + +#include "globals.h" + +// Computes each member's canvas coverage and ranks them. +// +// Algorithm (matches the ticket's own rule — "check all splotches whose centers are +// visible on the top layer" — grid-accelerated so it doesn't cost O(N^2)): +// Sweep splotches from last-painted to first, maintaining a uniform spatial grid of +// already-swept (i.e. later-painted / "on top") splotches. A splotch's center is +// "visible" iff no later splotch has a center within SPLOTCH_RADIUS of it (strict '<' +// on the squared distance, cell size = SPLOTCH_DISPLAY_SIZE so only the 3x3 cell +// neighborhood needs checking). +// +// coveragePct is ABSOLUTE canvas-area coverage (visibleCount * splotch-area / canvas-area, +// clamped to 100) — NOT a share of painted area — so a solo match doesn't trivially score +// 100%. Splotches whose ownerCxId doesn't match any current member (a not-yet-ported +// legacy sender, or a departed player) fall back to a colorIndex match against a live +// member; if that also fails, the splotch counts toward no one. +// +// Every current member gets a seeded zero-coverage entry, so painters-of-nothing still +// appear on the board, ranked last. Result is sorted best-first (coveragePct desc, +// visibleCount desc, cxId asc for determinism); ties share a rank and don't "beat" each +// other in CoverageEntry::beaten. +std::vector computeCoverage(const std::vector &splotches, + const std::vector &members); diff --git a/relaytestapp/src/game.cpp b/relaytestapp/src/game.cpp index 0ebf59f..e88a722 100644 --- a/relaytestapp/src/game.cpp +++ b/relaytestapp/src/game.cpp @@ -25,139 +25,163 @@ #include "globals.h" // C/C++ includes +#include #include #include #include +// Urgency thresholds for the match timer color (BCLOUD-14490 item 14). +static constexpr long long TIMER_URGENT_SEC = 10; +static constexpr long long TIMER_WARN_SEC = 30; -// Draws a game dialog and update its logic -void game_update() -{ - const auto& style = ImGui::GetStyle(); +// How long a rank-swap highlight/arrow stays visible after a player's rank changes +// (BCLOUD-14490 item 13). Driven by CoverageEntry::rankChangedAtMs, set once per +// recompute in app_tickMatch() — never per-frame, so it can't strobe. +static constexpr long long RANK_FLASH_MS = 600; + +// Fixed width of the docked scoreboard sidebar (matches the reference mockup's layout: +// a full-height left sidebar, canvas + timer/ping/exit-match to its right). +static constexpr float SIDEBAR_WIDTH = 220.0f; + +static const ImVec4 COLOR_ME(0.35f, 1.0f, 0.45f, 1.0f); - // Send options + +// The RANK / PLAYER / COVERAGE sidebar — driven by state.coverage, which app_tickMatch() +// keeps live-sorted best-first (BCLOUD-14490 items 6-10, 13). Docked full-height on the +// left, matching the reference mockup. +static void drawScoreboardSidebar(long long nowMs) +{ + ImGui::SetNextWindowPos(ImVec2(0.0f, ImGui::GetFrameHeight()), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(SIDEBAR_WIDTH, (float)height - ImGui::GetFrameHeight()), ImGuiCond_Always); + ImGui::Begin("##scoreboard", nullptr, + ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | + ImGuiWindowFlags_NoSavedSettings); + + if (ImGui::BeginTable("scoreboard_table", 2, + ImGuiTableFlags_SizingStretchProp | ImGuiTableFlags_RowBg)) { - ImGui::Begin("Settings", 0, - ImGuiWindowFlags_NoCollapse | - ImGuiWindowFlags_NoResize | - ImGuiWindowFlags_AlwaysAutoResize); + ImGui::TableSetupColumn("RANK / PLAYER", ImGuiTableColumnFlags_WidthStretch, 0.68f); + ImGui::TableSetupColumn("COVERAGE", ImGuiTableColumnFlags_WidthStretch | ImGuiTableColumnFlags_IndentDisable, 0.32f); + ImGui::TableHeadersRow(); - ImGui::Text("Players"); + for (const auto& entry : state.coverage) { - ImGui::Indent(); - if (!state.lobby.regionId.empty()) + const User* pMember = nullptr; + for (const auto& m : state.lobby.members) { - bool isActual = !regionFromLobbyId(state.lobby.lobbyId).empty(); - ImGui::TextDisabled("%s: %s", - isActual ? "Region" : "Est. region", - state.lobby.regionId.c_str()); + if (m.cxId == entry.cxId) { pMember = &m; break; } } - ImGui::TextDisabled("Mask = shockwave targets only"); - for (auto& user : state.lobby.members) + if (!pMember) continue; + + bool isMe = (entry.cxId == state.user.cxId); + bool flashing = entry.rankChangedAtMs > 0 && (nowMs - entry.rankChangedAtMs) < RANK_FLASH_MS; + bool improved = entry.rank < entry.prevRank; + + ImGui::TableNextRow(); + if (isMe) + ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0, ImColor(ImVec4(1.0f, 1.0f, 1.0f, 0.08f))); + else if (flashing) + ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0, + ImColor(improved ? ImVec4(0.3f, 1.0f, 0.3f, 0.20f) : ImVec4(1.0f, 0.3f, 0.3f, 0.20f))); + + // RANK / PLAYER — "#N" (gold/silver/bronze, item 7) + color dot + name + // (green for "me", item 8) + a "YOU" bubble + rank-swap arrow (item 13). + ImGui::TableNextColumn(); { - auto color = getColor(user.colorIndex % colorCount()); - ImGui::PushStyleColor(ImGuiCol_Text, color); - std::string label = user.name; - if (user.cxId == state.lobby.ownerCxId) label += " [H]"; - if (user.cxId == state.user.cxId) label += " (me)"; - ImGui::Checkbox(label.c_str(), &user.allowSendTo); - ImGui::PopStyleColor(); + ImVec4 rankColor(1.0f, 1.0f, 1.0f, 1.0f); // plain white for 4th place and below (item 7) + if (entry.rank == 1) rankColor = ImVec4(1.00f, 0.84f, 0.00f, 1.0f); // gold + else if (entry.rank == 2) rankColor = ImVec4(0.75f, 0.75f, 0.75f, 1.0f); // silver + else if (entry.rank == 3) rankColor = ImVec4(0.80f, 0.50f, 0.20f, 1.0f); // bronze + + const char* arrow = !flashing ? "" : (improved ? "^" : "v"); + ImGui::TextColored(rankColor, "%s#%d", arrow, entry.rank); ImGui::SameLine(); - char pingBuf[16]; - if (user.activePing < 0) - ImGui::TextDisabled("..."); - else if (user.activePing >= 999) - ImGui::TextDisabled("T/O"); - else + ImGui::TextColored(getColor(pMember->colorIndex % colorCount()), "\xE2\x97\x8F"); // "●" color dot + ImGui::SameLine(); + ImGui::TextColored(isMe ? COLOR_ME : ImVec4(1, 1, 1, 1), "%s", pMember->name.c_str()); + if (isMe) { - snprintf(pingBuf, sizeof(pingBuf), "%d ms", user.activePing); - ImGui::TextDisabled("%s", pingBuf); + ImGui::SameLine(); + ImVec2 textSize = ImGui::CalcTextSize("YOU"); + ImVec2 p0 = ImGui::GetCursorScreenPos(); + ImVec2 p1 = ImVec2(p0.x + textSize.x + 8.0f, p0.y + textSize.y + 2.0f); + ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, ImColor(ImVec4(1.0f, 1.0f, 1.0f, 0.15f)), 4.0f); + ImGui::SetCursorScreenPos(ImVec2(p0.x + 4.0f, p0.y + 1.0f)); + ImGui::TextUnformatted("YOU"); } } - ImGui::Unindent(); - } - ImGui::Separator(); - ImGui::Text("Reliable options"); - { - ImGui::Indent(); - ImGui::TextDisabled("Only affect position"); - ImGui::Text("Channel"); - { - ImGui::Indent(); - ImGui::BeginGroup(); - for (int i = 0; i < 4; ++i) - { - bool active = settings.sendChannel == i; - if (ImGui::RadioButton(std::to_string(i).c_str(), active)) - { - settings.sendChannel = i; - } - if (i % 2 == 0) ImGui::SameLine(); - } - ImGui::EndGroup(); - ImGui::Unindent(); - } - ImGui::Checkbox("Reliable", &settings.sendReliable); - ImGui::Checkbox("Ordered", &settings.sendOrdered); - ImGui::Unindent(); + // COVERAGE — as a % (item 9), green for "me" to match the rank/player color + ImGui::TableNextColumn(); + ImGui::TextColored(isMe ? COLOR_ME : ImVec4(1, 1, 1, 1), "%.0f%%", entry.coveragePct); } + ImGui::EndTable(); + } - ImGui::Separator(); - - // Game session info - bool isHost = state.user.cxId == state.lobby.ownerCxId; - if (isHost) - { - ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "HOST"); - ImGui::SameLine(); - if (ImGui::SmallButton("Clear Splotches")) - app_clearSplotches(); - } + ImGui::End(); +} - if (state.gameStartTime != 0) - { - auto nowMs = std::chrono::duration_cast( - std::chrono::system_clock::now().time_since_epoch()).count(); - long long elapsedMs = nowMs - state.gameStartTime; - long long elapsedSec = elapsedMs / 1000; - int minutes = (int)(elapsedSec / 60); - int seconds = (int)(elapsedSec % 60); - ImGui::Text("Game Time: %d:%02d", minutes, seconds); - - // CursorParty: 1:30 round with 10-second countdown then auto end-match - if (isCursorPartyLobby(settings.lobbyType)) - { - static const long long MATCH_DURATION_MS = 90000LL; - static const long long COUNTDOWN_FROM_MS = 80000LL; - static int matchEndRound = -1; // tracks which round end was already sent +// Debug/connection tooling — kept (per the CLAUDE.md RTA checklist's documented +// Reliable/Ordered/Channel controls) but out of the way of the scoreboard sidebar, +// which the reference mockup keeps clean. +static void drawDebugPanel() +{ + ImGui::SetNextWindowPos(ImVec2((float)width - 8.0f, (float)height - 8.0f), ImGuiCond_FirstUseEver, ImVec2(1.0f, 1.0f)); + ImGui::Begin("Debug", nullptr, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize); - if (elapsedMs >= MATCH_DURATION_MS) - { - if (isHost && matchEndRound != state.roundNumber) - { - matchEndRound = state.roundNumber; - app_endMatch(); - } - } - else if (elapsedMs >= COUNTDOWN_FROM_MS) - { - long long remaining = (MATCH_DURATION_MS - elapsedMs + 999LL) / 1000LL; - ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), - "Ending in %lld...", remaining); - } - } - } - ImGui::Text("Round: %d", state.roundNumber); - ImGui::Text("Lobby: %s", state.lobby.lobbyId.c_str()); + if (!state.lobby.regionId.empty()) + { + bool isActual = !regionFromLobbyId(state.lobby.lobbyId).empty(); + ImGui::TextDisabled("%s: %s", isActual ? "Region" : "Est. region", state.lobby.regionId.c_str()); + } - ImGui::End(); + ImGui::Text("Reliable options"); + ImGui::Indent(); + ImGui::TextDisabled("Only affect position"); + ImGui::Text("Channel"); + ImGui::Indent(); + ImGui::BeginGroup(); + for (int i = 0; i < 4; ++i) + { + bool active = settings.sendChannel == i; + if (ImGui::RadioButton(std::to_string(i).c_str(), active)) + settings.sendChannel = i; + if (i % 2 == 0) ImGui::SameLine(); } + ImGui::EndGroup(); + ImGui::Unindent(); + ImGui::Checkbox("Reliable", &settings.sendReliable); + ImGui::Checkbox("Ordered", &settings.sendOrdered); + ImGui::Unindent(); + + ImGui::Separator(); + ImGui::Text("Round: %d", state.roundNumber); + ImGui::Text("Lobby: %s", state.lobby.lobbyId.c_str()); + + ImGui::End(); +} + +// Draws a game dialog and update its logic +void game_update() +{ + // Drive the shared coverage/ranking recompute and the host-authoritative + // match-end + leaderboard-post flow. Safe to call every frame — internally + // throttled (COVERAGE_RECOMPUTE_MS) and phase-guarded. + app_tickMatch(); - // Main menu window, centered + auto nowMs = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); + + drawScoreboardSidebar(nowMs); + drawDebugPanel(); + + // Main game window, centered in the area to the right of the sidebar { - float gameWidth = 800; - float gameHeight = 600; + float gameWidth = CANVAS_W; + float gameHeight = CANVAS_H; float scale = 1.0f; if (settings.gameUIIScale == 0) { @@ -169,13 +193,62 @@ void game_update() } gameWidth *= scale; gameHeight *= scale; + float rightAreaX = SIDEBAR_WIDTH; + float rightAreaW = (float)width - SIDEBAR_WIDTH; ImGui::SetNextWindowPos(ImVec2( - (float)width / 5.0f * 3.0f - gameWidth / 2.0f, + rightAreaX + rightAreaW / 2.0f - gameWidth / 2.0f, (float)height / 2.0f - gameHeight / 2.0f)); ImGui::Begin("Game", nullptr, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize | - ImGuiWindowFlags_AlwaysAutoResize); + ImGuiWindowFlags_AlwaysAutoResize | + ImGuiWindowFlags_NoTitleBar); // item 1: remove game title from gameplay view + + // Timer (left, colored by urgency) + "Exit Match" (right) — above the canvas + // (item 12), replacing the old menu-bar "Leave"/"End Match" items (item 3). + { + long long remainingSec = 0; + ImVec4 timerColor(1.0f, 1.0f, 1.0f, 1.0f); + if (state.gameStartTime != 0 && isCursorPartyLobby(settings.lobbyType)) + { + long long elapsedMs = nowMs - state.gameStartTime; + long long remainingMs = MATCH_DURATION_MS - elapsedMs; + if (remainingMs < 0) remainingMs = 0; + remainingSec = (remainingMs + 999) / 1000; + + if (remainingSec <= TIMER_URGENT_SEC) + timerColor = ImVec4(1.0f, 0.3f, 0.3f, 1.0f); + else if (remainingSec <= TIMER_WARN_SEC) + timerColor = ImVec4(1.0f, 0.75f, 0.2f, 1.0f); + + ImGui::TextColored(timerColor, "%lld:%02lld", remainingSec / 60, remainingSec % 60); + } + + ImGui::SameLine(gameWidth - 110.0f); + if (ImGui::Button("\xE2\x86\xA9 Exit Match")) // "↩ Exit Match" + { + app_closeGame(); + } + + // Single self-ping readout (item 11 — ping lives outside the scoreboard's + // main column entirely now, rather than a per-row column). + if (pBCWrapper) + { + int ping = pBCWrapper->getRelayService()->getPing(); + ImVec4 pingColor = ping < 0 ? ImVec4(0.6f, 0.6f, 0.6f, 1.0f) + : ping < 100 ? ImVec4(0.4f, 0.9f, 0.5f, 1.0f) + : ping < 200 ? ImVec4(0.95f, 0.8f, 0.3f, 1.0f) + : ImVec4(1.0f, 0.4f, 0.4f, 1.0f); + ImGui::TextColored(pingColor, "\xE2\x97\x8F"); // "●" + ImGui::SameLine(); + if (ping < 0) + ImGui::TextDisabled("Ping: ..."); + else if (ping >= 999) + ImGui::TextDisabled("Ping: T/O"); + else + ImGui::TextDisabled("Ping: %d ms", ping); + } + } // Play area ImGui::BeginChildFrame(1, ImVec2(gameWidth, gameHeight)); @@ -208,15 +281,32 @@ void game_update() auto mouseDown = ImGui::IsMouseDown(0); if (mouseDown && !lastMouseDown) { - if (mousePos.x >= 0.0f && mousePos.x <= 800.0f && - mousePos.y >= 0.0f && mousePos.y <= 600.0f) + if (mousePos.x >= 0.0f && mousePos.x <= CANVAS_W && + mousePos.y >= 0.0f && mousePos.y <= CANVAS_H) { app_shockwave({ (int)(mousePos.x / scale), (int)(mousePos.y / scale) }); } } lastMouseDown = mouseDown; - // Splotches — persistent marks left by shockwaves, drawn under the transient rings + // Splotches — persistent marks left by shockwaves, drawn under the transient rings. + // Expiry is pruned in one remove_if pass (was an O(N^2) per-frame erase loop) + // before the draw pass, bumping splotchGeneration exactly once when it changes + // so app_tickMatch()'s coverage recompute notices. + if (state.splotchDurationSec >= 0) + { + size_t before = state.splotches.size(); + state.splotches.erase( + std::remove_if(state.splotches.begin(), state.splotches.end(), + [&](const Splotch& s) { + long long ageSec = (nowMs - s.startTimeMs) / 1000LL; + return ageSec >= state.splotchDurationSec; + }), + state.splotches.end()); + if (state.splotches.size() != before) + ++state.splotchGeneration; + } + if (SPLOTCH_TEX) { // Spring-overshoot pop: 0→~1.4 peak→1.0 settle over 0.3s (matches Unity AnimateSplatter) @@ -229,19 +319,10 @@ void game_update() return std::max(0.0f, std::min(1.0f + b, std::min(grow, shrink))); }; - auto nowMs = std::chrono::duration_cast( - std::chrono::system_clock::now().time_since_epoch()).count(); - for (auto it = state.splotches.begin(); it != state.splotches.end();) + for (const auto &splotch : state.splotches) { - auto &splotch = *it; long long ageSec = (nowMs - splotch.startTimeMs) / 1000LL; - if (state.splotchDurationSec >= 0 && ageSec >= state.splotchDurationSec) - { - it = state.splotches.erase(it); - continue; - } - float alpha = 0.55f; if (state.splotchDurationSec > 0) { @@ -273,8 +354,6 @@ void game_update() pDrawList->AddImageQuad(SPLOTCH_TEX, p1, p2, p3, p4, {0,0}, {1,0}, {1,1}, {0,1}, ImColor(tint)); - - ++it; } } @@ -325,6 +404,19 @@ void game_update() ImU32 col = ImColor(getColor(member.colorIndex % colorCount())); ImU32 shadow = IM_COL32(0, 0, 0, 160); + // Live coverage % next to the cursor, in-canvas (in addition to the + // sidebar scoreboard) — drawn before the arrow so the arrow renders on top. + for (const auto& covEntry : state.coverage) + { + if (covEntry.cxId != member.cxId) continue; + char buf[16]; + snprintf(buf, sizeof(buf), "%.0f%%", covEntry.coveragePct); + ImVec2 textPos(p.x + s * 0.9f, p.y - 16.0f * scale); + pDrawList->AddText(ImVec2(textPos.x + 1, textPos.y + 1), shadow, buf); + pDrawList->AddText(textPos, col, buf); + break; + } + // NW-pointing cursor arrow built from three triangles: // - main body (tip → shaft) // - corner fill (shaft → shoulder) diff --git a/relaytestapp/src/game.h b/relaytestapp/src/game.h index a842410..a4d2513 100644 --- a/relaytestapp/src/game.h +++ b/relaytestapp/src/game.h @@ -17,6 +17,7 @@ // Desc: Interface for displaying the game screen and updating its logic // Author: David St-Louis //----------------------------------------------------------------------------- +#pragma once // Draws the game dialog void game_update(); diff --git a/relaytestapp/src/globals.cpp b/relaytestapp/src/globals.cpp index d83d999..b446ff3 100644 --- a/relaytestapp/src/globals.cpp +++ b/relaytestapp/src/globals.cpp @@ -40,9 +40,61 @@ #define STB_IMAGE_IMPLEMENTATION #include "stb/stb_image.h" +// For resolving the running executable's own directory — see assetPath() below. +#if defined(_WIN32) +#include +#elif defined(__APPLE__) +#include +#else +#include +#endif + // Runtime color palette (populated from braincloud "Colours" property after login) std::vector g_colors; +// Directory containing the running executable — cached after the first call. +// The CMake build copies assets/ next to the binary on every platform (Contents/ +// MacOS/assets inside the .app bundle on mac, alongside the .exe on Windows), so +// resolving asset paths relative to THIS instead of the process's current working +// directory works both for `bccm run` (which sets a deliberate cwd) and for a +// distributed build launched by double-clicking it (Finder/Explorer set some +// unrelated cwd, which is what silently broke splotch/arrow textures there). +static const std::string &exeDir() +{ + static std::string dir; + static bool resolved = false; + if (!resolved) + { + resolved = true; + char buf[4096]; +#if defined(_WIN32) + DWORD len = GetModuleFileNameA(nullptr, buf, sizeof(buf)); + std::string exePath(buf, len); +#elif defined(__APPLE__) + uint32_t size = sizeof(buf); + std::string exePath; + if (_NSGetExecutablePath(buf, &size) == 0) + exePath.assign(buf); +#else + ssize_t len = readlink("/proc/self/exe", buf, sizeof(buf) - 1); + std::string exePath(buf, len > 0 ? (size_t)len : 0); +#endif + auto pos = exePath.find_last_of("/\\"); + if (pos != std::string::npos) + dir = exePath.substr(0, pos); + } + return dir; +} + +// Resolves relPath (e.g. "assets/PaintSplatter1.png") relative to the executable's +// own directory. Falls back to the plain relative path (old cwd-relative behavior) +// if the executable path couldn't be resolved for some reason. +static std::string assetPath(const std::string &relPath) +{ + const std::string &dir = exeDir(); + return dir.empty() ? relPath : (dir + "/" + relPath); +} + // Main application state instance State state; @@ -139,14 +191,58 @@ bool loadConfigs() // Load arrow textures for (int i = 0; i < 8; ++i) - ARROWS[i] = loadTexture("assets/arrow" + std::to_string(i) + ".png"); + ARROWS[i] = loadTexture(assetPath("assets/arrow" + std::to_string(i) + ".png")); // Load splotch texture - SPLOTCH_TEX = loadTexture("assets/PaintSplatter1.png"); + SPLOTCH_TEX = loadTexture(assetPath("assets/PaintSplatter1.png")); return perInstanceLoaded; } +// Dark-navy, rounded-panel theme, applied once at startup so every screen (main +// menu, lobby, game HUD) reads as one consistent design rather than default ImGui +// gray. Values chosen to match the reference main-menu/HUD mockups. +void applyTheme() +{ + ImGuiStyle &style = ImGui::GetStyle(); + style.WindowRounding = 10.0f; + style.ChildRounding = 8.0f; + style.FrameRounding = 6.0f; + style.PopupRounding = 8.0f; + style.GrabRounding = 6.0f; + style.TabRounding = 6.0f; + style.ScrollbarRounding = 8.0f; + style.WindowBorderSize = 1.0f; + style.FrameBorderSize = 0.0f; + style.WindowPadding = ImVec2(16.0f, 16.0f); + style.ItemSpacing = ImVec2(8.0f, 8.0f); + + ImVec4 *colors = style.Colors; + colors[ImGuiCol_WindowBg] = ImVec4(0.10f, 0.10f, 0.15f, 1.00f); + colors[ImGuiCol_ChildBg] = ImVec4(0.00f, 0.00f, 0.00f, 0.00f); + colors[ImGuiCol_PopupBg] = ImVec4(0.10f, 0.10f, 0.15f, 0.98f); + colors[ImGuiCol_Border] = ImVec4(0.30f, 0.32f, 0.42f, 0.55f); + colors[ImGuiCol_FrameBg] = ImVec4(0.16f, 0.17f, 0.23f, 1.00f); + colors[ImGuiCol_FrameBgHovered] = ImVec4(0.20f, 0.22f, 0.30f, 1.00f); + colors[ImGuiCol_FrameBgActive] = ImVec4(0.24f, 0.26f, 0.35f, 1.00f); + colors[ImGuiCol_TitleBg] = ImVec4(0.10f, 0.10f, 0.15f, 1.00f); + colors[ImGuiCol_TitleBgActive] = ImVec4(0.14f, 0.15f, 0.21f, 1.00f); + colors[ImGuiCol_Button] = ImVec4(0.20f, 0.21f, 0.29f, 1.00f); + colors[ImGuiCol_ButtonHovered] = ImVec4(0.27f, 0.29f, 0.40f, 1.00f); + colors[ImGuiCol_ButtonActive] = ImVec4(0.30f, 0.45f, 0.90f, 1.00f); + colors[ImGuiCol_Header] = colors[ImGuiCol_Button]; + colors[ImGuiCol_HeaderHovered] = colors[ImGuiCol_ButtonHovered]; + colors[ImGuiCol_HeaderActive] = colors[ImGuiCol_ButtonActive]; + colors[ImGuiCol_TableHeaderBg] = ImVec4(0.14f, 0.15f, 0.21f, 1.00f); + colors[ImGuiCol_TableBorderLight] = ImVec4(0.26f, 0.28f, 0.36f, 0.60f); + colors[ImGuiCol_TableBorderStrong]= ImVec4(0.30f, 0.32f, 0.42f, 0.80f); + colors[ImGuiCol_TableRowBg] = ImVec4(1.00f, 1.00f, 1.00f, 0.00f); + colors[ImGuiCol_TableRowBgAlt] = ImVec4(1.00f, 1.00f, 1.00f, 0.03f); + colors[ImGuiCol_ScrollbarBg] = ImVec4(0.08f, 0.08f, 0.12f, 1.00f); + colors[ImGuiCol_Text] = ImVec4(0.92f, 0.93f, 0.96f, 1.00f); + colors[ImGuiCol_Separator] = colors[ImGuiCol_Border]; +} + // Save configuration file to disk. // Instance 0 saves to configs.txt; instance N saves to configs_N.txt. void saveConfigs() diff --git a/relaytestapp/src/globals.h b/relaytestapp/src/globals.h index caa3534..8d36323 100644 --- a/relaytestapp/src/globals.h +++ b/relaytestapp/src/globals.h @@ -17,6 +17,7 @@ // Desc: Defines global application state, data and constants // Author: David St-Louis //----------------------------------------------------------------------------- +#pragma once // Imgui #include @@ -42,6 +43,11 @@ // Total number of distinct player colors (supports up to this many simultaneous players) #define NUM_COLORS 40 +// Matches the vendored brainCloud C++ SDK's RelayComms::MAX_PLAYERS / INVALID_NET_ID (40) — +// a netId in [0, MAX_LOBBY_MEMBERS) is valid; MAX_LOBBY_MEMBERS itself is the SDK's +// "not found" sentinel. Used when packing a netId onto the wire (e.g. splotch ownership). +static constexpr int MAX_LOBBY_MEMBERS = 40; + // Color palette matching JS (#RRGGBB) and Java (Color.decode) exactly. // Uses IM_COL32(r,g,b,a) which correctly packs into ImGui's ABGR uint32 format. // Palette is spread across the hue wheel in four tonal rows: @@ -108,6 +114,18 @@ inline ImVec4 getColor(int i) { // Display diameter of the splotch sprite in game-world units — matches _DISPLAY_SIZE in Splotch.gd static constexpr float SPLOTCH_DISPLAY_SIZE = 64.0f; +// Canvas / coverage-scoring geometry. All clients must agree on these exact values — +// they define the wire-normalized 0..1 coordinate space AND the coverage % denominator. +static constexpr float CANVAS_W = 800.0f; +static constexpr float CANVAS_H = 600.0f; +static constexpr float SPLOTCH_RADIUS = SPLOTCH_DISPLAY_SIZE * 0.5f; // 32 — obscure radius for coverage visibility + +// Match timing (moved here from game.cpp so app_tickMatch() and the HUD can both see them +// regardless of where the timer widget is drawn). +static constexpr long long MATCH_DURATION_MS = 90000LL; +static constexpr long long RESULT_GRACE_MS = 1000LL; // delay between match_result broadcast and endMatch() +static constexpr long long COVERAGE_RECOMPUTE_MS = 250LL; // live-board recompute throttle + // Screen state enum. enum class ScreenState : int { @@ -134,7 +152,6 @@ struct User int colorIndex = 7; bool isReady = false; bool isAlive = false; - bool allowSendTo = true; Point pos = {0, 0}; std::map pings; /* per-region ping data shared via lobby extra */ int activePing = -1; /* live relay RTT broadcast during gameplay (ms); -1 = not yet received */ @@ -173,14 +190,58 @@ struct Shockwave // Permanent color splotch left behind by a shockwave struct Splotch { - Point pos; - int colorIndex; - long long startTimeMs; /* ms since epoch — used for expiry and JIP sync */ - float rotation; /* radians — transmitted in relay message so all clients match */ + Point pos; + int colorIndex; + long long startTimeMs; /* ms since epoch — used for expiry and JIP sync */ + float rotation; /* radians — transmitted in relay message so all clients match */ + std::string ownerCxId; /* match-scoped owner key for coverage attribution; empty = unattributed (legacy sender) */ }; static constexpr float SPLOTCH_TAU = 6.28318530f; // 2π — upper bound for random rotation +// One player's live/final standing from computeCoverage() (see coverage.h). +// coveragePct is an ABSOLUTE canvas-area percentage (not a share of painted area) — +// this keeps the "highest coverage ever" leaderboard meaningful for solo matches too. +struct CoverageEntry +{ + std::string cxId; + int colorIndex = -1; + int visibleCount = 0; /* splotches whose center isn't obscured by a later one */ + float coveragePct = 0.0f; /* clamped [0,100] */ + int rank = 1; /* 1-based; ties share a rank */ + int prevRank = 1; /* previous recompute's rank, for the rank-swap flash */ + long long rankChangedAtMs = 0; /* set when rank last differed from prevRank */ + int beaten = 0; /* players strictly below this one (ties don't count) */ +}; + +// One player's entry in a host-broadcast, authoritative match_result. +struct MatchResultEntry +{ + std::string cxId; + int rank = 0; + float coveragePct = 0.0f; + int beaten = 0; +}; + +// Authoritative snapshot of a finished match's standings, broadcast by the (possibly +// migrated) host. Every client applies this once per round — see State::leaderboardPostedRound. +struct MatchResult +{ + bool valid = false; + int round = -1; + std::vector entries; +}; + +// Host-side match lifecycle, driven by app_tickMatch(). Reset to Running on every new +// round (onRelayConnected) — non-host clients just sit in Running the whole match; only +// whichever client currently satisfies isHost drives the ResultsBroadcast/Ended steps. +enum class MatchPhase : int +{ + Running, /* match clock ticking */ + ResultsBroadcast, /* match_result sent; waiting RESULT_GRACE_MS before endMatch() */ + Ended /* endMatch() called for this round */ +}; + // Main application state. This contain all of the "live" data. struct State { @@ -204,10 +265,29 @@ struct State std::chrono::steady_clock::time_point geoTestLobbyArrivalTime; /* When we entered Lobby state during a geo test (for 1.5s auto-start delay) */ std::chrono::steady_clock::time_point geoTestRelayConnectTime; /* When relay connected during a geo test (for 2.5s soak before disconnect) */ int splotchDurationSec = -1; /* -1 = forever; from SplotchDuration global property */ + + // Coverage scoring / leaderboards (BCLOUD-14472 / BCLOUD-14490) + unsigned long long splotchGeneration = 0; /* bumped on every splotch add/clear/expiry-prune */ + unsigned long long coverageComputedGen = (unsigned long long)-1; /* generation coverage[] was last computed at */ + long long coverageComputedAtMs = 0; /* wall-clock of last recompute, for the throttle */ + std::vector coverage; /* live, sorted rank board */ + + MatchPhase matchPhase = MatchPhase::Running; + long long resultsSentAtMs = 0; /* when match_result was broadcast, for the grace period */ + MatchResult matchResult; /* authoritative result once applied (host or non-host) */ + int leaderboardPostedRound = -1; /* guards against double-posting the CUMULATIVE points board */ + + // Leaderboard ids — read from brainCloud global properties in applyLobbyTypes(), same + // mechanism as AllLobbyTypes/Colours/SplotchDuration. Defaults let the app run before + // the boards exist in the portal (posts will just fail server-side until configured). + std::string coverageLeaderboardId = "CursorParty_HighestCoverage"; + std::string coverageLeaderboardIdQuarterly = "CursorParty_HighestCoverage_Quarterly"; + std::string pointsLeaderboardId = "CursorParty_Points"; + std::string pointsLeaderboardIdQuarterly = "CursorParty_Points_Quarterly"; }; // Change this one line to switch the default lobby type everywhere. -static const std::string DEFAULT_LOBBY_TYPE = "CursorPartyGameLift"; +static const std::string DEFAULT_LOBBY_TYPE = "CursorPartyV2"; struct Settings { @@ -236,6 +316,10 @@ extern Settings settings; bool loadConfigs(); void saveConfigs(); +// Shared dark theme (rounded panels, navy palette) — call once after +// ImGui::StyleColorsDark(), from every entry point (mainSDL.cpp, mainUWP.cpp). +void applyTheme(); + // True for any lobby type in the CursorParty family (name starts with "CursorParty"). // Add new CursorParty variants without touching any other file. inline bool isCursorPartyLobby(const std::string &lobbyType) diff --git a/relaytestapp/src/loading.h b/relaytestapp/src/loading.h index 6756802..9907cda 100644 --- a/relaytestapp/src/loading.h +++ b/relaytestapp/src/loading.h @@ -17,6 +17,7 @@ // Desc: Interface for displaying and updating a loading screen // Author: David St-Louis //----------------------------------------------------------------------------- +#pragma once // C/C++ headers #include diff --git a/relaytestapp/src/lobby.cpp b/relaytestapp/src/lobby.cpp index 47f879e..762525e 100644 --- a/relaytestapp/src/lobby.cpp +++ b/relaytestapp/src/lobby.cpp @@ -147,6 +147,34 @@ void lobby_update() } ImGui::Columns(); + // Last match results — shown once a round finishes (state.matchResult, set by + // app_tickMatch()/applyMatchResult()) until the next round starts (cleared in + // onRelayConnected()). Entries are already in rank order from computeCoverage(). + if (state.matchResult.valid) + { + ImGui::Separator(); + centerText("Last Match Results"); + for (const auto& entry : state.matchResult.entries) + { + const User* pMember = nullptr; + for (const auto& m : state.lobby.members) + { + if (m.cxId == entry.cxId) { pMember = &m; break; } + } + std::string name = pMember ? pMember->name : "?"; + std::string line = std::to_string(entry.rank) + ". " + name + + " " + std::to_string((int)(entry.coveragePct + 0.5f)) + "%" + + " (+" + std::to_string(entry.beaten + 1) + " pts)"; + bool isMe = entry.cxId == state.user.cxId; + float tw = ImGui::CalcTextSize(line.c_str()).x; + ImGui::SetCursorPosX((ImGui::GetWindowSize().x - tw) * 0.5f); + if (isMe) + ImGui::TextColored(ImVec4(0.35f, 1.0f, 0.45f, 1.0f), "%s", line.c_str()); + else + ImGui::TextDisabled("%s", line.c_str()); + } + } + // Ping data section — only shown when ping region data is enabled if (settings.usePingData) { diff --git a/relaytestapp/src/lobby.h b/relaytestapp/src/lobby.h index a503db0..bddacde 100644 --- a/relaytestapp/src/lobby.h +++ b/relaytestapp/src/lobby.h @@ -17,6 +17,7 @@ // Desc: Interface for displaying a lobby screen and updating its logic // Author: David St-Louis //----------------------------------------------------------------------------- +#pragma once // Draws the lobby dialog void lobby_update(); diff --git a/relaytestapp/src/login.h b/relaytestapp/src/login.h index 4a7916a..14a2e5a 100644 --- a/relaytestapp/src/login.h +++ b/relaytestapp/src/login.h @@ -17,6 +17,7 @@ // Desc: Interface for displaying a login screen and updating its logic // Author: David St-Louis //----------------------------------------------------------------------------- +#pragma once // Draws a login dialog and update its logic void login_update(); diff --git a/relaytestapp/src/mainMenu.cpp b/relaytestapp/src/mainMenu.cpp index 6b4cf1a..bd613bf 100644 --- a/relaytestapp/src/mainMenu.cpp +++ b/relaytestapp/src/mainMenu.cpp @@ -21,235 +21,668 @@ // App includes #include "app.h" #include "globals.h" +#include "BCCallback.h" // C/C++ includes +#include +#include #include #include #include "mainMenu.h" -// Main menu dialog width (height auto-sizes to content) -#define DIALOG_WIDTH 400.0f +// Main menu card widths (height auto-sizes to content) +#define LOBBY_CARD_WIDTH 400.0f +#define LEADERBOARD_CARD_WIDTH 380.0f +#define CARD_GAP 32.0f +#define RIGHT_TAB_HEIGHT 44.0f +#define CHAT_CARD_HEIGHT 460.0f -// Draws a login dialog and update its logic -void mainMenu_update() +// 0 = Leaderboard tab active, 1 = Chat tab active. +static int s_activeRightTab = 0; + +//----------------------------------------------------------------------------- +// Leaderboard viewer (BCLOUD-14472 follow-up) — top 5 + "you" row, toggleable +// between the two boards this app posts to (points / coverage) and Lifetime vs +// Quarterly. File-static since this is pure main-menu display state, not +// something any other screen or the relay wire protocol needs. +//----------------------------------------------------------------------------- + +struct LeaderboardRow { - // Main menu window, horizontally centered, near vertical center. - // AlwaysAutoResize lets it grow when the geo test panel is visible. - { - ImGui::SetNextWindowPos(ImVec2( - (float)width / 2.0f - DIALOG_WIDTH / 2.0f, - (float)height / 2.0f - 100.0f), // anchor ~100px above center; window grows down - ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(DIALOG_WIDTH, 0)); // 0 height = auto - ImGui::Begin("Main Menu", nullptr, - ImGuiWindowFlags_NoCollapse | - ImGuiWindowFlags_NoMove | - ImGuiWindowFlags_AlwaysAutoResize); - - // Protocol choice - if (ImGui::Combo("Protocol", (int *)&settings.protocol, "UDP\0TCP\0WS\0WSS\0")) - { - saveConfigs(); - } + std::string name; + int64_t score = 0; + int rank = 0; +}; - // Lobby type — populated dynamically from AllLobbyTypes global property. - // Always shown so it remains visible alongside the Play button even on error. - if (ImGui::BeginCombo("Lobby Type", settings.lobbyType.c_str())) - { - for (const auto &lobbyType : state.appLobbies) +static std::vector s_lbTop; +static LeaderboardRow s_lbSelf; +static bool s_lbHasSelf = false; +static int s_lbBoardType = 0; // 0 = Most Opponents Beaten (points), 1 = Highest Coverage % +static int s_lbPeriod = 0; // 0 = Lifetime, 1 = Quarterly +static int s_lbFetchedKey = -1; // -1 = never fetched; otherwise boardType*2+period already requested this session + +static std::string currentLeaderboardId() +{ + bool coverage = (s_lbBoardType == 1); + bool quarterly = (s_lbPeriod == 1); + if (coverage) + return quarterly ? state.coverageLeaderboardIdQuarterly : state.coverageLeaderboardId; + return quarterly ? state.pointsLeaderboardIdQuarterly : state.pointsLeaderboardId; +} + +// The score's user-defined "data" carries the display name (postMatchScores in +// app.cpp sets it) — GetGlobalLeaderboardPage/View don't otherwise return a usable +// name for arbitrary (non-friend) entries. +static LeaderboardRow parseLeaderboardEntry(const Json::Value &entry) +{ + LeaderboardRow row; + row.score = entry["score"].asInt64(); + row.rank = entry["rank"].asInt(); + row.name = entry["data"]["name"].asString(); + if (row.name.empty()) + row.name = "Player"; + return row; +} + +// Fetches the top 5 + the local player's own rank for the currently-selected board/ +// period combo. Guarded by s_lbFetchedKey so it only ever fires once per combo per +// session — switching tabs back and forth re-shows cached results, not a re-fetch. +static void fetchLeaderboardIfNeeded() +{ + int key = s_lbBoardType * 2 + s_lbPeriod; + if (s_lbFetchedKey == key || !pBCWrapper) return; + s_lbFetchedKey = key; + + std::string leaderboardId = currentLeaderboardId(); + + pBCWrapper->getSocialLeaderboardService()->getGlobalLeaderboardPage( + leaderboardId.c_str(), BrainCloud::HIGH_TO_LOW, 0, 4, + new BCCallback( + [key](const Json::Value &result) { - bool selected = (lobbyType == settings.lobbyType); - if (ImGui::Selectable(lobbyType.c_str(), selected)) - { - settings.lobbyType = lobbyType; - if (settings.lobbyType.find("Team") == 0) - { - if (settings.teamCode == "all") - settings.teamCode = "alpha"; - } - else - { - settings.teamCode = "all"; - } - saveConfigs(); - } - if (selected) - ImGui::SetItemDefaultFocus(); - } - ImGui::EndCombo(); - } + if (s_lbFetchedKey != key) return; // stale — user switched tabs since this was requested + s_lbTop.clear(); + for (const auto &entry : result["data"]["leaderboard"]) + s_lbTop.push_back(parseLeaderboardEntry(entry)); + }, + [key](const std::string &) + { + if (s_lbFetchedKey != key) return; + s_lbTop.clear(); + })); - // Team selection (only for Team lobby types) - if (settings.lobbyType.find("Team") == 0) - { - int teamChoice = (settings.teamCode == "beta") ? 1 : 0; - if (ImGui::Combo("Team", &teamChoice, "Alpha\0Beta\0\0")) + // Pro-tip from the brainCloud docs: beforeCount=0/afterCount=0 on + // GetGlobalLeaderboardView returns just the current player's own entry. + pBCWrapper->getSocialLeaderboardService()->getGlobalLeaderboardView( + leaderboardId.c_str(), BrainCloud::HIGH_TO_LOW, 0, 0, + new BCCallback( + [key](const Json::Value &result) { - settings.teamCode = (teamChoice == 0) ? "alpha" : "beta"; - saveConfigs(); - } + if (s_lbFetchedKey != key) return; + const auto &arr = result["data"]["leaderboard"]; + s_lbHasSelf = !arr.empty(); + if (s_lbHasSelf) + s_lbSelf = parseLeaderboardEntry(arr[0]); + }, + [key](const std::string &) + { + if (s_lbFetchedKey != key) return; + s_lbHasSelf = false; + })); +} + +// "4,821" style thousands separator — scores can be into the thousands for the +// cumulative points board. +static std::string formatScore(int64_t v) +{ + std::string s = std::to_string(v); + for (int i = (int)s.size() - 3; i > 0; i -= 3) + s.insert(i, ","); + return s; +} + +// The coverage board's raw score is basis points (postMatchScores in app.cpp posts +// coveragePct*100 as an int, since brainCloud leaderboard scores are int64 — there's +// no float score type) — divide back down to a percentage for display. The points +// board's raw score is already the real value (players beaten + completion bonus). +static std::string formatBoardScore(int64_t v) +{ + if (s_lbBoardType == 1) + { + char buf[16]; + snprintf(buf, sizeof(buf), "%.1f%%", v / 100.0); + return buf; + } + return formatScore(v); +} + +static ImVec4 rankColorFor(int rank) +{ + if (rank == 1) return ImVec4(1.00f, 0.84f, 0.00f, 1.0f); // gold + if (rank == 2) return ImVec4(0.75f, 0.75f, 0.75f, 1.0f); // silver + if (rank == 3) return ImVec4(0.80f, 0.50f, 0.20f, 1.0f); // bronze + return ImVec4(1.0f, 1.0f, 1.0f, 1.0f); +} + +static void drawLeaderboardCard(float x, float y) +{ + fetchLeaderboardIfNeeded(); + + ImGui::SetNextWindowPos(ImVec2(x, y), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(LEADERBOARD_CARD_WIDTH, CHAT_CARD_HEIGHT), ImGuiCond_Always); + ImGui::Begin("Leaderboard", nullptr, + ImGuiWindowFlags_NoCollapse | + ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoResize); + + // Board-type toggle: Most Opponents Beaten <-> Highest Coverage % + { + bool pointsActive = (s_lbBoardType == 0); + if (pointsActive) ImGui::PushStyleColor(ImGuiCol_Button, ImGui::GetStyle().Colors[ImGuiCol_ButtonActive]); + if (ImGui::Button("Most Opponents Beaten")) s_lbBoardType = 0; + if (pointsActive) ImGui::PopStyleColor(); + ImGui::SameLine(); + bool coverageActive = (s_lbBoardType == 1); + if (coverageActive) ImGui::PushStyleColor(ImGuiCol_Button, ImGui::GetStyle().Colors[ImGuiCol_ButtonActive]); + if (ImGui::Button("Highest Coverage %")) s_lbBoardType = 1; + if (coverageActive) ImGui::PopStyleColor(); + } + + // Period toggle: Lifetime <-> Quarterly + { + bool lifetimeActive = (s_lbPeriod == 0); + if (lifetimeActive) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.25f, 0.55f, 0.35f, 1.0f)); + if (ImGui::Button("Lifetime")) s_lbPeriod = 0; + if (lifetimeActive) ImGui::PopStyleColor(); + ImGui::SameLine(); + bool quarterlyActive = (s_lbPeriod == 1); + if (quarterlyActive) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.25f, 0.55f, 0.35f, 1.0f)); + if (ImGui::Button("Quarterly")) s_lbPeriod = 1; + if (quarterlyActive) ImGui::PopStyleColor(); + } + + ImGui::Separator(); + + if (s_lbTop.empty()) + { + ImGui::TextDisabled("No scores yet — be the first!"); + } + else + { + for (const auto &row : s_lbTop) + { + ImGui::TextColored(rankColorFor(row.rank), "#%d", row.rank); + ImGui::SameLine(50.0f); + ImGui::TextUnformatted(row.name.c_str()); + std::string scoreStr = formatBoardScore(row.score); + float tw = ImGui::CalcTextSize(scoreStr.c_str()).x; + ImGui::SameLine(LEADERBOARD_CARD_WIDTH - tw - 32.0f); + ImGui::Text("%s", scoreStr.c_str()); } + } - // Use ping region data toggle - if (ImGui::Checkbox("With Ping Region Data", &settings.usePingData)) + // "You" row — only when it's not already visible in the top 5, mirroring the + // "top N + you" pattern from the reference design. + if (s_lbHasSelf) + { + bool alreadyShown = false; + for (const auto &row : s_lbTop) + if (row.rank == s_lbSelf.rank) { alreadyShown = true; break; } + + if (!alreadyShown) { - saveConfigs(); + if (!s_lbTop.empty()) + ImGui::TextDisabled("..."); + ImGui::TextColored(rankColorFor(s_lbSelf.rank), "#%d", s_lbSelf.rank); + ImGui::SameLine(50.0f); + std::string label = s_lbSelf.name + " (You)"; + ImGui::TextColored(ImVec4(0.35f, 1.0f, 0.45f, 1.0f), "%s", label.c_str()); + std::string scoreStr = formatBoardScore(s_lbSelf.score); + float tw = ImGui::CalcTextSize(scoreStr.c_str()).x; + ImGui::SameLine(LEADERBOARD_CARD_WIDTH - tw - 32.0f); + ImGui::TextColored(ImVec4(0.35f, 1.0f, 0.45f, 1.0f), "%s", scoreStr.c_str()); } + } - // Auto geo test: EdgeGap/GameLift cycle through all regions (client-side routing); - // V2/others connect once and record whichever region the server chose. - if (ImGui::Checkbox("Auto Geo Test", &settings.autoGeoTest)) - saveConfigs(); - if (settings.autoGeoTest) + ImGui::End(); +} + +// Small floating tab strip sitting above the LEADERBOARD/CHAT panel — matches the +// reference mockup's outlined-toggle look, active tab highlighted. +static void drawRightTabs(float panelX, float panelY) +{ + ImGui::SetNextWindowPos(ImVec2(panelX, panelY - RIGHT_TAB_HEIGHT), ImGuiCond_Always); + ImGui::SetNextWindowBgAlpha(0.0f); + ImGui::Begin("##right_tabs", nullptr, + ImGuiWindowFlags_NoDecoration | + ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoSavedSettings | + ImGuiWindowFlags_AlwaysAutoResize); + + bool lbActive = (s_activeRightTab == 0); + if (lbActive) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.30f, 0.45f, 0.90f, 1.0f)); + if (ImGui::Button("LEADERBOARD", ImVec2(150.0f, 32.0f))) s_activeRightTab = 0; + if (lbActive) ImGui::PopStyleColor(); + + ImGui::SameLine(); + bool chatActive = (s_activeRightTab == 1); + if (chatActive) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.30f, 0.45f, 0.90f, 1.0f)); + if (ImGui::Button("CHAT", ImVec2(100.0f, 32.0f))) s_activeRightTab = 1; + if (chatActive) ImGui::PopStyleColor(); + + ImGui::End(); +} + +//----------------------------------------------------------------------------- +// Chat (main menu) — a single app-wide global channel. brainCloud's chat calls all +// require RTT to be enabled (RTT_NOT_ENABLED otherwise); app_enableChatRTT() (app.cpp) +// keeps RTT connected on every path that reaches this screen specifically so this +// works. This is poll-based (explicit fetch after send / on opening the tab), not +// live RTT push — see the summary for what a live-push version would need +// (registerRTTChatCallback + handling the Chat service in the RTT dispatch). +//----------------------------------------------------------------------------- + +// 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). +static const char *CHAT_CHANNEL_SUB_ID = "gl"; + +struct ChatMessage +{ + std::string fromName; + std::string text; +}; + +static std::string s_chatChannelId; +static bool s_chatChannelResolving = false; +static bool s_chatChannelReady = false; +static std::vector s_chatMessages; +static bool s_chatFetchInFlight = false; +static bool s_chatFetchedOnce = false; +static char s_chatInputBuf[240] = {0}; +static bool s_chatSending = false; + +static ChatMessage parseChatMessage(const Json::Value &m) +{ + ChatMessage msg; + msg.fromName = m["from"]["name"].asString(); + if (msg.fromName.empty()) + msg.fromName = "Player"; + msg.text = m["content"]["text"].asString(); + return msg; +} + +static void fetchChatMessages() +{ + if (s_chatChannelId.empty() || s_chatFetchInFlight) return; + s_chatFetchInFlight = true; + pBCWrapper->getChatService()->getRecentChatMessages( + s_chatChannelId.c_str(), 30, + new BCCallback( + [](const Json::Value &result) + { + s_chatFetchInFlight = false; + s_chatFetchedOnce = true; + s_chatMessages.clear(); + for (const auto &m : result["data"]["messages"]) + s_chatMessages.push_back(parseChatMessage(m)); + // Server returns newest-first; flip to oldest-first for natural + // top-to-bottom reading order. + std::reverse(s_chatMessages.begin(), s_chatMessages.end()); + }, + [](const std::string &) { s_chatFetchInFlight = false; })); +} + +// Backoff after a failed getChannelId, so a persistent failure (bad channel code, +// network hiccup) can't turn into a same-call-every-frame loop — brainCloud's abuse +// detection disables the client after enough repeated failures on one API call +// (reason_code 90200), which is exactly what happened here without this guard. +static long long s_chatChannelRetryAtMs = 0; + +// Resolves the shared global channel once RTT is up, then fetches history. +// Safe to call every frame the Chat tab is open — no-ops once resolved, in flight, +// or backing off after a recent failure. +static void ensureChatChannel() +{ + if (s_chatChannelReady || s_chatChannelResolving || !pBCWrapper) return; + if (!pBCWrapper->getRTTService()->getRTTEnabled()) + { + app_enableChatRTT(); // should already be on by the time MainMenu is reached; just in case + return; + } + + auto nowMs = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); + if (nowMs < s_chatChannelRetryAtMs) return; + + s_chatChannelResolving = true; + pBCWrapper->getChatService()->getChannelId( + "gl", CHAT_CHANNEL_SUB_ID, + new BCCallback( + [](const Json::Value &result) + { + s_chatChannelResolving = false; + s_chatChannelId = result["data"]["channelId"].asString(); + s_chatChannelReady = !s_chatChannelId.empty(); + if (s_chatChannelReady) + fetchChatMessages(); + }, + [](const std::string &) + { + s_chatChannelResolving = false; + auto now = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); + s_chatChannelRetryAtMs = now + 5000; // back off 5s before retrying + })); +} + +static void sendChatMessage() +{ + if (s_chatChannelId.empty() || s_chatInputBuf[0] == '\0' || s_chatSending) return; + s_chatSending = true; + pBCWrapper->getChatService()->postChatMessageSimple( + s_chatChannelId.c_str(), s_chatInputBuf, true, + new BCCallback( + [](const Json::Value &) { s_chatSending = false; fetchChatMessages(); }, + [](const std::string &) { s_chatSending = false; })); + s_chatInputBuf[0] = '\0'; +} + +static void drawChatCard(float x, float y) +{ + ensureChatChannel(); + + ImGui::SetNextWindowPos(ImVec2(x, y), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(LEADERBOARD_CARD_WIDTH, CHAT_CARD_HEIGHT), ImGuiCond_Always); + ImGui::Begin("Chat", nullptr, + ImGuiWindowFlags_NoCollapse | + ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoResize); + + if (!s_chatChannelReady) + { + ImGui::TextDisabled(s_chatChannelResolving || !s_chatFetchedOnce ? "Connecting..." : "Chat unavailable."); + } + else + { + ImGui::BeginChild("chat_scroll", ImVec2(0.0f, -32.0f), true); + for (const auto &m : s_chatMessages) { + ImGui::TextColored(ImVec4(0.6f, 0.75f, 1.0f, 1.0f), "%s:", m.fromName.c_str()); ImGui::SameLine(); - if (isRegionalCyclingLobby(settings.lobbyType)) - ImGui::TextDisabled("(cycles all regions)"); - else - ImGui::TextDisabled("(records server-chosen region)"); + ImGui::TextWrapped("%s", m.text.c_str()); } + if (ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 5.0f) + ImGui::SetScrollHereY(1.0f); // stick to bottom as new messages arrive + ImGui::EndChild(); + + ImGui::PushItemWidth(-70.0f); + bool enterPressed = ImGui::InputText("##chatInput", s_chatInputBuf, sizeof(s_chatInputBuf), + ImGuiInputTextFlags_EnterReturnsTrue); + ImGui::PopItemWidth(); + ImGui::SameLine(); + bool sendClicked = ImGui::Button("Send", ImVec2(60.0f, 0.0f)); + if ((enterPressed || sendClicked) && !s_chatSending) + sendChatMessage(); + } + + ImGui::End(); +} - // Stop condition differs by type: - // EdgeGap/GameLift — every region that has a defined specific lobby type has been tested - // V2/others — at least one region confirmed (server always picks the same fastest) - bool geoTestComplete = false; - if (settings.autoGeoTest && !state.pingData.empty()) +//----------------------------------------------------------------------------- +// Lobby card — protocol/lobby-type/ping-data setup, unchanged functionality, +// relabeled/reordered to match the reference layout (title + win-condition +// tagline up top, captioned dropdowns, "Find / Create Lobby" as the primary CTA). +//----------------------------------------------------------------------------- + +static void drawLobbyCard(float x, float y) +{ + ImGui::SetNextWindowPos(ImVec2(x, y), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(LOBBY_CARD_WIDTH, 0)); // 0 height = auto + ImGui::Begin("Cursor Party", nullptr, + ImGuiWindowFlags_NoCollapse | + ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_AlwaysAutoResize); + + // Win-condition tagline (BCLOUD-14472) — sets expectations before Play is clicked. + { + const char *tagline = "Paint more of the board than anyone else -- cover it, and you win the party."; + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + LOBBY_CARD_WIDTH - 20.0f); + ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.55f, 1.0f), "%s", tagline); + ImGui::PopTextWrapPos(); + } + ImGui::Separator(); + + // Lobby type — captioned like the reference (label above, not beside, the combo). + ImGui::TextDisabled("LOBBY TYPE"); + if (ImGui::BeginCombo("##LobbyType", settings.lobbyType.c_str())) + { + for (const auto &lobbyType : state.appLobbies) { - if (isRegionalCyclingLobby(settings.lobbyType)) - { - int mappable = 0; - for (const auto &kv : state.pingData) - if (!regionToSpecificLobbyType(settings.lobbyType, kv.first).empty()) - ++mappable; - geoTestComplete = mappable > 0 && (int)state.geoTestedRegions.size() >= mappable; - } - else + bool selected = (lobbyType == settings.lobbyType); + if (ImGui::Selectable(lobbyType.c_str(), selected)) { - geoTestComplete = !state.geoTestedRegions.empty(); + settings.lobbyType = lobbyType; + if (settings.lobbyType.find("Team") == 0) + { + if (settings.teamCode == "all") + settings.teamCode = "alpha"; + } + else + { + settings.teamCode = "all"; + } + saveConfigs(); } + if (selected) + ImGui::SetItemDefaultFocus(); } + ImGui::EndCombo(); + } - // Join a game - bool autoPlay = settings.autoJoin || (settings.autoGeoTest && !geoTestComplete); - if (ImGui::Button("Play") || autoPlay) + // Team selection (only for Team lobby types) + if (settings.lobbyType.find("Team") == 0) + { + ImGui::TextDisabled("TEAM"); + int teamChoice = (settings.teamCode == "beta") ? 1 : 0; + if (ImGui::Combo("##Team", &teamChoice, "Alpha\0Beta\0\0")) { - app_play(settings.protocol); + settings.teamCode = (teamChoice == 0) ? "alpha" : "beta"; + saveConfigs(); } + } + + // Network protocol + ImGui::TextDisabled("NETWORK PROTOCOL"); + if (ImGui::Combo("##Protocol", (int *)&settings.protocol, "UDP\0TCP\0WS\0WSS\0")) + { + saveConfigs(); + } + + // Use ping region data toggle + if (ImGui::Checkbox("With Ping Region Data", &settings.usePingData)) + { + saveConfigs(); + } - if (geoTestComplete) - ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "Geo test complete!"); + // Auto geo test: EdgeGap/GameLift cycle through all regions (client-side routing); + // V2/others connect once and record whichever region the server chose. + if (ImGui::Checkbox("Auto Geo Test", &settings.autoGeoTest)) + saveConfigs(); + if (settings.autoGeoTest) + { + ImGui::SameLine(); + if (isRegionalCyclingLobby(settings.lobbyType)) + ImGui::TextDisabled("(cycles all regions)"); + else + ImGui::TextDisabled("(records server-chosen region)"); + } - // ---- Geo Region Test Panel (visible once ping data is available) ---------- - if (!state.pingData.empty()) + // Stop condition differs by type: + // EdgeGap/GameLift — every region that has a defined specific lobby type has been tested + // V2/others — at least one region confirmed (server always picks the same fastest) + bool geoTestComplete = false; + if (settings.autoGeoTest && !state.pingData.empty()) + { + if (isRegionalCyclingLobby(settings.lobbyType)) { - ImGui::Separator(); - if (isRegionalCyclingLobby(settings.lobbyType)) - ImGui::TextDisabled("Geo Region Test (cycles all regions)"); - else - ImGui::TextDisabled("Geo Region Test (records server-chosen region)"); - - // Sort all known regions by ping - std::vector> sorted; + int mappable = 0; for (const auto &kv : state.pingData) - sorted.push_back({kv.second, kv.first}); - std::sort(sorted.begin(), sorted.end()); + if (!regionToSpecificLobbyType(settings.lobbyType, kv.first).empty()) + ++mappable; + geoTestComplete = mappable > 0 && (int)state.geoTestedRegions.size() >= mappable; + } + else + { + geoTestComplete = !state.geoTestedRegions.empty(); + } + } - const auto &tested = state.geoTestedRegions; + ImGui::Separator(); + ImGui::TextDisabled("Not sure what any of this means? Just tap below."); - if (isRegionalCyclingLobby(settings.lobbyType)) - { - // Regional cycling: only show regions that have a defined specific lobby type - std::vector> mapped; - for (const auto &p : sorted) - if (!regionToSpecificLobbyType(settings.lobbyType, p.second).empty()) - mapped.push_back(p); - - bool allTested = !mapped.empty(); - for (const auto &p : mapped) - if (std::find(tested.begin(), tested.end(), p.second) == tested.end()) - { - allTested = false; - break; - } - if (allTested && !tested.empty()) - ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "All lobbies tested — will wrap around"); + // Join a game — primary call to action + bool autoPlay = settings.autoJoin || (settings.autoGeoTest && !geoTestComplete); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.40f, 0.45f, 0.95f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.48f, 0.53f, 1.0f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.35f, 0.40f, 0.85f, 1.0f)); + bool clicked = ImGui::Button("Find / Create Lobby", ImVec2(LOBBY_CARD_WIDTH - 20.0f, 40.0f)); + ImGui::PopStyleColor(3); + if (clicked || autoPlay) + { + app_play(settings.protocol); + } - std::string nextRegion; - for (const auto &p : mapped) - if (std::find(tested.begin(), tested.end(), p.second) == tested.end()) - { - nextRegion = p.second; - break; - } + if (geoTestComplete) + ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "Geo test complete!"); + + // ---- Geo Region Test Panel (visible once ping data is available) ---------- + if (!state.pingData.empty()) + { + ImGui::Separator(); + if (isRegionalCyclingLobby(settings.lobbyType)) + ImGui::TextDisabled("Geo Region Test (cycles all regions)"); + else + ImGui::TextDisabled("Geo Region Test (records server-chosen region)"); + + // Sort all known regions by ping + std::vector> sorted; + for (const auto &kv : state.pingData) + sorted.push_back({kv.second, kv.first}); + std::sort(sorted.begin(), sorted.end()); + + const auto &tested = state.geoTestedRegions; - for (const auto &p : mapped) + if (isRegionalCyclingLobby(settings.lobbyType)) + { + // Regional cycling: only show regions that have a defined specific lobby type + std::vector> mapped; + for (const auto &p : sorted) + if (!regionToSpecificLobbyType(settings.lobbyType, p.second).empty()) + mapped.push_back(p); + + bool allTested = !mapped.empty(); + for (const auto &p : mapped) + if (std::find(tested.begin(), tested.end(), p.second) == tested.end()) + { + allTested = false; + break; + } + if (allTested && !tested.empty()) + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "All lobbies tested — will wrap around"); + + std::string nextRegion; + for (const auto &p : mapped) + if (std::find(tested.begin(), tested.end(), p.second) == tested.end()) { - bool wasTested = std::find(tested.begin(), tested.end(), p.second) != tested.end(); - if (wasTested) + nextRegion = p.second; + break; + } + + for (const auto &p : mapped) + { + bool wasTested = std::find(tested.begin(), tested.end(), p.second) != tested.end(); + if (wasTested) + { + auto resIt = state.geoTestResults.find(p.second); + if (resIt != state.geoTestResults.end() && resIt->second > 0) { - auto resIt = state.geoTestResults.find(p.second); - if (resIt != state.geoTestResults.end() && resIt->second > 0) - { - int relayMs = resIt->second; - bool pass = relayMs <= p.first + 100; - if (pass) - ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), - "[done] %s beacon:%dms relay:%dms PASS", p.second.c_str(), p.first, relayMs); - else - ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), - "[done] %s beacon:%dms relay:%dms FAIL", p.second.c_str(), p.first, relayMs); - } + int relayMs = resIt->second; + bool pass = relayMs <= p.first + 100; + if (pass) + ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), + "[done] %s beacon:%dms relay:%dms PASS", p.second.c_str(), p.first, relayMs); else - ImGui::TextDisabled("[done] %s (%dms)", p.second.c_str(), p.first); + ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), + "[done] %s beacon:%dms relay:%dms FAIL", p.second.c_str(), p.first, relayMs); } - else if (p.second == nextRegion) - ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), ">>> %s (%dms)", p.second.c_str(), p.first); else - ImGui::Text("[ ] %s (%dms)", p.second.c_str(), p.first); + ImGui::TextDisabled("[done] %s (%dms)", p.second.c_str(), p.first); } + else if (p.second == nextRegion) + ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), ">>> %s (%dms)", p.second.c_str(), p.first); + else + ImGui::Text("[ ] %s (%dms)", p.second.c_str(), p.first); } - else + } + else + { + // V2 / specific regional: show ping table; highlight which region the server confirmed + if (tested.empty()) + ImGui::TextDisabled("Run test to confirm server-chosen region"); + for (const auto &p : sorted) { - // V2 / specific regional: show ping table; highlight which region the server confirmed - if (tested.empty()) - ImGui::TextDisabled("Run test to confirm server-chosen region"); - for (const auto &p : sorted) + bool confirmed = std::find(tested.begin(), tested.end(), p.second) != tested.end(); + if (confirmed) { - bool confirmed = std::find(tested.begin(), tested.end(), p.second) != tested.end(); - if (confirmed) + auto resIt = state.geoTestResults.find(p.second); + if (resIt != state.geoTestResults.end() && resIt->second > 0) { - auto resIt = state.geoTestResults.find(p.second); - if (resIt != state.geoTestResults.end() && resIt->second > 0) - { - int relayMs = resIt->second; - bool pass = relayMs <= p.first + 100; - if (pass) - ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), - "[confirmed] %s beacon:%dms relay:%dms PASS", p.second.c_str(), p.first, relayMs); - else - ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), - "[confirmed] %s beacon:%dms relay:%dms FAIL", p.second.c_str(), p.first, relayMs); - } + int relayMs = resIt->second; + bool pass = relayMs <= p.first + 100; + if (pass) + ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), + "[confirmed] %s beacon:%dms relay:%dms PASS", p.second.c_str(), p.first, relayMs); else - ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "[confirmed] %s (%dms)", p.second.c_str(), p.first); + ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), + "[confirmed] %s beacon:%dms relay:%dms FAIL", p.second.c_str(), p.first, relayMs); } else - ImGui::TextDisabled("[ ? ] %s (%dms)", p.second.c_str(), p.first); + ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "[confirmed] %s (%dms)", p.second.c_str(), p.first); } + else + ImGui::TextDisabled("[ ? ] %s (%dms)", p.second.c_str(), p.first); } + } - if (!tested.empty()) + if (!tested.empty()) + { + if (ImGui::Button("Reset Geo Test")) { - if (ImGui::Button("Reset Geo Test")) - { - state.geoTestedRegions.clear(); - state.geoTestResults.clear(); - } + state.geoTestedRegions.clear(); + state.geoTestResults.clear(); } } - // ------------------------------------------------------------------------- - - ImGui::End(); } + // ------------------------------------------------------------------------- + + ImGui::End(); +} + +// Draws the main menu screen: the lobby-setup card and, beside it, the leaderboard +// viewer for the boards this app posts to (BCLOUD-14472). +void mainMenu_update() +{ + float totalWidth = LOBBY_CARD_WIDTH + CARD_GAP + LEADERBOARD_CARD_WIDTH; + float startX = (float)width / 2.0f - totalWidth / 2.0f; + float y = (float)height / 2.0f - 100.0f; // anchor ~100px above center; cards grow down + float rightX = startX + LOBBY_CARD_WIDTH + CARD_GAP; + + drawLobbyCard(startX, y); + drawRightTabs(rightX, y); + if (s_activeRightTab == 0) + drawLeaderboardCard(rightX, y); + else + drawChatCard(rightX, y); } diff --git a/relaytestapp/src/mainMenu.h b/relaytestapp/src/mainMenu.h index 58c7f0f..1ab9922 100644 --- a/relaytestapp/src/mainMenu.h +++ b/relaytestapp/src/mainMenu.h @@ -17,6 +17,7 @@ // Desc: Interface for displaying a main menu screen and updating its logic // Author: David St-Louis //----------------------------------------------------------------------------- +#pragma once // Draws the main menu dialog void mainMenu_update(); diff --git a/relaytestapp/src/mainSDL.cpp b/relaytestapp/src/mainSDL.cpp index bfb0166..df32977 100644 --- a/relaytestapp/src/mainSDL.cpp +++ b/relaytestapp/src/mainSDL.cpp @@ -144,6 +144,7 @@ int main(int argc, char *argv[]) // ImGui::StyleColorsClassic(); ImGui::StyleColorsDark(); // looks more aligned with the other examples + applyTheme(); // Load app related stuff auto instanceConfigLoaded = loadConfigs(); if (settings.multiInstance) diff --git a/relaytestapp/src/mainUWP.cpp b/relaytestapp/src/mainUWP.cpp index 4e2f280..2dae44e 100644 --- a/relaytestapp/src/mainUWP.cpp +++ b/relaytestapp/src/mainUWP.cpp @@ -224,6 +224,7 @@ namespace RelayTestApp io.MouseDrawCursor = true; ImGui_ImplDX11_Init(m_d3dDevice.Get(), m_d3dContext.Get()); ImGui::StyleColorsDark(); + applyTheme(); } // Called when the CoreWindow object is created (or re-created). From 8edf2a7f2ca303c004693c1c8eeeb958a0e8a7f9 Mon Sep 17 00:00:00 2001 From: Steve Jones Date: Fri, 7 Aug 2026 12:19:01 -0400 Subject: [PATCH 2/8] BCLOUD-14488 Refactoring the leaderboard, chat ui, reusing it in the lobby display, colour selector etc. --- relaytestapp/CMakeLists.txt | 4 + relaytestapp/src/app.cpp | 129 +++++- relaytestapp/src/app.h | 7 + relaytestapp/src/game.cpp | 60 ++- relaytestapp/src/globalChat.cpp | 173 +++++++++ relaytestapp/src/globalChat.h | 21 + relaytestapp/src/globals.h | 11 + relaytestapp/src/leaderboardPanel.cpp | 214 ++++++++++ relaytestapp/src/leaderboardPanel.h | 15 + relaytestapp/src/lobby.cpp | 540 +++++++++++++++++--------- relaytestapp/src/mainMenu.cpp | 381 +----------------- 11 files changed, 976 insertions(+), 579 deletions(-) create mode 100644 relaytestapp/src/globalChat.cpp create mode 100644 relaytestapp/src/globalChat.h create mode 100644 relaytestapp/src/leaderboardPanel.cpp create mode 100644 relaytestapp/src/leaderboardPanel.h diff --git a/relaytestapp/CMakeLists.txt b/relaytestapp/CMakeLists.txt index f010cbc..296c5a6 100644 --- a/relaytestapp/CMakeLists.txt +++ b/relaytestapp/CMakeLists.txt @@ -71,6 +71,10 @@ list(APPEND src_files src/coverage.h src/game.cpp src/game.h + src/globalChat.cpp + src/globalChat.h + src/leaderboardPanel.cpp + src/leaderboardPanel.h src/lobby.cpp src/lobby.h src/login.cpp diff --git a/relaytestapp/src/app.cpp b/relaytestapp/src/app.cpp index 767e58e..eba0718 100644 --- a/relaytestapp/src/app.cpp +++ b/relaytestapp/src/app.cpp @@ -89,6 +89,16 @@ static int s_playGeneration = 0; // onRTTConnected() so reaching the main menu doesn't silently auto-join a lobby. static bool s_wantsLobbySearch = false; +// True from the moment enableRTT() is called until rttConnectSuccess/Failure fires. +// getRTTEnabled() alone isn't enough to guard re-entry: it only flips true once the +// connection actually completes, so anything that calls enableRTT() every frame while +// disconnected (e.g. ensureChatChannel()'s "just in case" retry) would otherwise fire +// enableRTT() again on every frame of that connecting window — the SDK's RTTComms:: +// connect() then runs concurrently on more than one background thread against the same +// unsynchronized internal Json::Value state, which is what was crashing with a SIGSEGV +// deep in JsonCpp's tree code. Checked/set at both enableRTT() call sites below. +static bool s_rttConnecting = false; + // Tracks the region chosen for the current geo test lobby attempt. // Set when we pick the best un-tested region; recorded to geoTestedRegions on ROOM_READY. static std::string s_geoTestRegion; @@ -99,11 +109,13 @@ class RTTConnectCallback final : public BrainCloud::IRTTConnectCallback public: void rttConnectSuccess() override { + s_rttConnecting = false; onRTTConnected(); } void rttConnectFailure(const std::string &errorMessage) override { + s_rttConnecting = false; // Ignore failure if we intentionally disconnected (avoids re-entrant loop) if (isDisconnecting) return; @@ -373,6 +385,7 @@ static std::string buildExtraJson() { Json::Value extra; extra["colorIndex"] = state.user.colorIndex; + extra["rank"] = state.user.worldwideRank; if (!state.pingData.empty()) { Json::Value pings(Json::objectValue); @@ -516,13 +529,51 @@ void onRTTConnected() // already turned it on). Called whenever the app reaches the MainMenu screen. void app_enableChatRTT() { - if (pBCWrapper->getRTTService()->getRTTEnabled()) + // Called at every MainMenu arrival — piggyback the rank re-fetch here too + // rather than touching every one of those call sites separately. Cheap and + // idempotent (no-ops while a request is already in flight). + app_fetchWorldwideRank(); + + if (pBCWrapper->getRTTService()->getRTTEnabled() || s_rttConnecting) return; s_wantsLobbySearch = false; + s_rttConnecting = true; pBCWrapper->getRTTService()->registerRTTLobbyCallback(&bcRTTCallback); pBCWrapper->getRTTService()->enableRTT(&bcRTTConnectCallback, true); } +// Fetches this player's own rank on the coverage leaderboard, for the lobby member +// list's "Worldwide Rank" display. There's no client API to look up an ARBITRARY +// other player's rank (GetPlayersSocialLeaderboard/GetPlayerScore return score, not +// rank; GetGlobalLeaderboardView's rank is self-centric only) — so each player +// fetches their own and shares it via the lobby's "extra" field, the same mechanism +// already used for colorIndex/pings. -1 = unknown or no score posted yet. +// Idempotent-ish: safe to call repeatedly (e.g. every MainMenu arrival); a request +// already in flight is not re-issued. +static bool s_rankFetchInFlight = false; +void app_fetchWorldwideRank() +{ + if (s_rankFetchInFlight || !pBCWrapper || state.coverageLeaderboardId.empty()) return; + s_rankFetchInFlight = true; + pBCWrapper->getSocialLeaderboardService()->getGlobalLeaderboardView( + state.coverageLeaderboardId.c_str(), BrainCloud::HIGH_TO_LOW, 0, 0, + new BCCallback( + [](const Json::Value &result) + { + s_rankFetchInFlight = false; + const auto &arr = result["data"]["leaderboard"]; + int rank = (!arr.empty()) ? arr[0]["rank"].asInt() : -1; + if (rank == state.user.worldwideRank) return; + state.user.worldwideRank = rank; + // If already in a lobby, push the freshly-known rank to lobby-mates + // right away instead of waiting for some other reason to re-send extra. + if (!state.lobby.lobbyId.empty()) + pBCWrapper->getLobbyService()->updateReady( + state.lobby.lobbyId, state.user.isReady, buildExtraJson()); + }, + [](const std::string &) { s_rankFetchInFlight = false; })); +} + // Show error and go back to MainMenu without logging out. // Use this for relay/lobby errors where the user is still authenticated. static void errorAndReturnToMenu(const std::string &message) @@ -533,6 +584,7 @@ static void errorAndReturnToMenu(const std::string &message) pBCWrapper->getRelayService()->disconnect(); pBCWrapper->getRTTService()->deregisterAllRTTCallbacks(); pBCWrapper->getRTTService()->disableRTT(); + s_rttConnecting = false; // callbacks just deregistered — nothing will clear this otherwise // Reset state but keep user, app config, and geo test results User user = state.user; @@ -566,6 +618,7 @@ static void dieWithMessage(const std::string &message) pBCWrapper->getRelayService()->disconnect(); pBCWrapper->getRTTService()->deregisterAllRTTCallbacks(); pBCWrapper->getRTTService()->disableRTT(); + s_rttConnecting = false; // callbacks just deregistered — nothing will clear this otherwise pBCWrapper->logout(false, nullptr); @@ -1173,6 +1226,7 @@ void app_update() pBCWrapper->getRelayService()->disconnect(); pBCWrapper->getRTTService()->deregisterAllRTTCallbacks(); pBCWrapper->getRTTService()->disableRTT(); + s_rttConnecting = false; // callbacks just deregistered — nothing will clear this otherwise User user = state.user; auto appLobbies = state.appLobbies; int splotchDurationSec = state.splotchDurationSec; @@ -1512,8 +1566,13 @@ void app_play(BrainCloud::eRelayConnectionType in_protocol) // reconnecting, so this is the only way to pick the search flow back up. startLobbySearchFlow(); } - else + else if (!s_rttConnecting) { + // If chat already kicked off a connect (s_rttConnecting true), don't issue a + // second concurrent enableRTT() — s_wantsLobbySearch is already set above, so + // whichever caller's connect succeeds will pick up the lobby search from + // onRTTConnected() regardless of who initiated it. + s_rttConnecting = true; pBCWrapper->getRTTService()->registerRTTLobbyCallback(&bcRTTCallback); pBCWrapper->getRTTService()->enableRTT(&bcRTTConnectCallback, true); } @@ -1533,6 +1592,11 @@ static Lobby parseLobby(const Json::Value &lobbyJson, const std::string &lobbyId user.cxId = jsonMember["cxId"].asString(); user.name = jsonMember["name"].asString(); user.colorIndex = jsonMember["extra"]["colorIndex"].asInt(); + // Worldwide rank — each player fetches their OWN rank (self-centric API, + // getGlobalLeaderboardView has no "rank for an arbitrary other player" call) + // and shares it here, the same way colorIndex/pings already propagate. + const auto &rankJson = jsonMember["extra"]["rank"]; + user.worldwideRank = rankJson.isNull() ? -1 : rankJson.asInt(); // Ping data shared via the member's extra field const auto &pingsJson = jsonMember["extra"]["pings"]; if (pingsJson.isObject()) @@ -1577,10 +1641,17 @@ static void onLobbyEvent(const Json::Value &eventJson) const auto &jsonData = eventJson["data"]; // If there is a lobby object present in the message, update our lobby - // state with it. + // state with it. This fires on every lobby-update event (member join/leave, + // ready-state changes, etc.), not just the first one — parseLobby() returns a + // fresh Lobby each time, so chatMessages/arrivalTime must be explicitly carried + // forward or every routine update would silently wipe the chat history. if (jsonData["lobby"].isObject()) { + auto savedChatMessages = state.lobby.chatMessages; + auto savedArrivalTime = state.lobby.arrivalTime; state.lobby = parseLobby(jsonData["lobby"], jsonData["lobbyId"].asString()); + state.lobby.chatMessages = savedChatMessages; + state.lobby.arrivalTime = savedArrivalTime; // If we were joining lobby, show the lobby screen. We have the information to // display now. @@ -1588,6 +1659,7 @@ static void onLobbyEvent(const Json::Value &eventJson) { state.screenState = ScreenState::Lobby; state.geoTestLobbyArrivalTime = std::chrono::steady_clock::now(); + state.lobby.arrivalTime = std::chrono::steady_clock::now(); // true first-arrival timestamp, for the INFO tab // Non-host users auto-ready when arriving at the lobby so the host can // start the round immediately without waiting for others to click Ready. @@ -1681,6 +1753,55 @@ static void onLobbyEvent(const Json::Value &eventJson) startGame(); } + else if (operation == "SIGNAL") + { + // This-lobby chat, per the user's direction: implemented via SendSignal + // (Lobby service), not the Chat service — rides the RTT connection the + // lobby already has, no separate channel/registration needed. + // + // Real wire shape, confirmed from a live capture (the docs describe this + // as "LOBBY_SIGNAL_DATA" in prose, but the actual RTT operation is + // "SIGNAL"): data: { lobbyId, from: {id,name,pic,cxId}, signalData: }. "from" is the server's authoritative sender info — more + // reliable than trusting whatever our own signalData payload claims. + const auto &fromCxId = jsonData["from"]["cxId"].asString(); + std::string fromName = jsonData["from"]["name"].asString(); + std::string text = jsonData["signalData"]["text"].asString(); + + // Skip echoes of our own signal — app_sendLobbySignal already appended it + // locally on send. Compared by cxId (not name) since two players could + // share a display name. + if (!text.empty() && fromCxId != state.user.cxId) + { + ChatMessage msg; + msg.fromName = fromName.empty() ? "Player" : fromName; + msg.text = text; + state.lobby.chatMessages.push_back(msg); + } + } +} + +// Sends a chat message to everyone currently in this lobby, via the Lobby +// service's SendSignal (not the Chat service — see the LOBBY_SIGNAL_DATA handler +// in onLobbyEvent for why). Appends locally right away — the receive handler +// skips the echo of our own signal, which the server does send back to us too. +void app_sendLobbySignal(const std::string &text) +{ + if (text.empty() || state.lobby.lobbyId.empty()) return; + + // No need to embed our own name — the server wraps every signal with + // authoritative sender info (data.from.name/cxId) that the receive handler + // uses instead. + Json::Value signal; + signal["text"] = text; + Json::FastWriter writer; + + pBCWrapper->getLobbyService()->sendSignal(state.lobby.lobbyId, writer.write(signal), nullptr); + + ChatMessage msg; + msg.fromName = state.user.name; + msg.text = text; + state.lobby.chatMessages.push_back(msg); } // Connect to the Relay server and start the game @@ -1750,6 +1871,7 @@ void app_cancelLobby() pBCWrapper->getRTTService()->deregisterAllRTTCallbacks(); pBCWrapper->getRTTService()->disableRTT(); + s_rttConnecting = false; // callbacks just deregistered — nothing will clear this otherwise // Reset state but keep user, app config, and geo test results User user = state.user; @@ -1780,6 +1902,7 @@ void app_closeGame() pBCWrapper->getRelayService()->disconnect(); pBCWrapper->getRTTService()->deregisterAllRTTCallbacks(); pBCWrapper->getRTTService()->disableRTT(); + s_rttConnecting = false; // callbacks just deregistered — nothing will clear this otherwise // Reset state but keep user, app config, and geo test results User user = state.user; diff --git a/relaytestapp/src/app.h b/relaytestapp/src/app.h index 5bb20e6..1fa34f7 100644 --- a/relaytestapp/src/app.h +++ b/relaytestapp/src/app.h @@ -51,6 +51,13 @@ void app_play(BrainCloud::eRelayConnectionType protocol); // MainMenu screen is reached; no-ops if RTT is already connected). void app_enableChatRTT(); +// Sends a chat message to everyone in the current lobby, via Lobby service signals. +void app_sendLobbySignal(const std::string &text); + +// Fetches this player's own worldwide rank (coverage leaderboard) and shares it via +// the lobby's extra field. Safe to call repeatedly. +void app_fetchWorldwideRank(); + // Cancel lobby search or leave lobby. Go back to main menu without logging out. void app_cancelLobby(); diff --git a/relaytestapp/src/game.cpp b/relaytestapp/src/game.cpp index e88a722..83db94d 100644 --- a/relaytestapp/src/game.cpp +++ b/relaytestapp/src/game.cpp @@ -60,9 +60,16 @@ static void drawScoreboardSidebar(long long nowMs) ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoSavedSettings); + // Bounded + ImGuiTableFlags_ScrollY so a full 40-player lobby scrolls the BODY + // only, with the RANK/PLAYER/COVERAGE header frozen at the top (TableSetup- + // ScrollFreeze below) — a plain unbounded table would instead scroll the whole + // sidebar window, taking the header out of view with it. + ImVec2 tableSize(0.0f, ImGui::GetContentRegionAvail().y); if (ImGui::BeginTable("scoreboard_table", 2, - ImGuiTableFlags_SizingStretchProp | ImGuiTableFlags_RowBg)) + ImGuiTableFlags_SizingStretchProp | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY, + tableSize)) { + ImGui::TableSetupScrollFreeze(0, 1); // keep the header row visible while the body scrolls ImGui::TableSetupColumn("RANK / PLAYER", ImGuiTableColumnFlags_WidthStretch, 0.68f); ImGui::TableSetupColumn("COVERAGE", ImGuiTableColumnFlags_WidthStretch | ImGuiTableColumnFlags_IndentDisable, 0.32f); ImGui::TableHeadersRow(); @@ -129,7 +136,11 @@ static void drawScoreboardSidebar(long long nowMs) // which the reference mockup keeps clean. static void drawDebugPanel() { - ImGui::SetNextWindowPos(ImVec2((float)width - 8.0f, (float)height - 8.0f), ImGuiCond_FirstUseEver, ImVec2(1.0f, 1.0f)); + // ImGuiCond_Always (not FirstUseEver) — otherwise this only snaps to the + // bottom-right corner the very first time it's shown, and just stays wherever + // it was on every subsequent frame, drifting out of place (or off-screen) if + // the window gets resized afterward. + ImGui::SetNextWindowPos(ImVec2((float)width - 8.0f, (float)height - 8.0f), ImGuiCond_Always, ImVec2(1.0f, 1.0f)); ImGui::Begin("Debug", nullptr, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize); if (!state.lobby.regionId.empty()) @@ -178,23 +189,33 @@ void game_update() drawScoreboardSidebar(nowMs); drawDebugPanel(); - // Main game window, centered in the area to the right of the sidebar + // Main game window, filling the area to the right of the sidebar — the canvas + // auto-fits to whatever space is actually available (preserving the CANVAS_W: + // CANVAS_H aspect ratio) instead of rendering at a fixed pixel size that floats + // in the middle of a larger window with dead space around it. The Scale menu + // (0.25x/0.5x/1x) is now a ceiling on top of that auto-fit, not the primary + // size driver — still useful for deliberately forcing a smaller view, but it + // can no longer make the canvas bigger than the window actually has room for. { - float gameWidth = CANVAS_W; - float gameHeight = CANVAS_H; - float scale = 1.0f; - if (settings.gameUIIScale == 0) - { - scale = 0.25f; - } - if (settings.gameUIIScale == 1) - { - scale = 0.5f; - } - gameWidth *= scale; - gameHeight *= scale; float rightAreaX = SIDEBAR_WIDTH; float rightAreaW = (float)width - SIDEBAR_WIDTH; + float rightAreaH = (float)height - ImGui::GetFrameHeight(); + + const float PAD = 24.0f; // window chrome + margin around the canvas + const float HEADER_H = 56.0f; // timer/ping row drawn above the canvas + float availW = std::max(100.0f, rightAreaW - PAD * 2.0f); + float availH = std::max(100.0f, rightAreaH - PAD * 2.0f - HEADER_H); + + float autoScale = std::min(availW / CANVAS_W, availH / CANVAS_H); + float scaleCap = 1.0f; + if (settings.gameUIIScale == 0) + scaleCap = 0.25f; + else if (settings.gameUIIScale == 1) + scaleCap = 0.5f; + float scale = std::min(autoScale, scaleCap); + + float gameWidth = CANVAS_W * scale; + float gameHeight = CANVAS_H * scale; ImGui::SetNextWindowPos(ImVec2( rightAreaX + rightAreaW / 2.0f - gameWidth / 2.0f, (float)height / 2.0f - gameHeight / 2.0f)); @@ -323,12 +344,15 @@ void game_update() { long long ageSec = (nowMs - splotch.startTimeMs) / 1000LL; - float alpha = 0.55f; + // Opaque — matches the cross-client standard documented for this shared + // splotch art (CLAUDE.md: "opaque, multiplied by the player colour"); + // CPP was the one client still rendering these semi-transparent. + float alpha = 1.0f; if (state.splotchDurationSec > 0) { long long remaining = (long long)state.splotchDurationSec - ageSec; if (remaining <= 3) - alpha *= (float)remaining / 3.0f; + alpha *= (float)remaining / 3.0f; // still fade out right before expiry } auto base = getColor(splotch.colorIndex % colorCount()); diff --git a/relaytestapp/src/globalChat.cpp b/relaytestapp/src/globalChat.cpp new file mode 100644 index 0000000..de8e0a8 --- /dev/null +++ b/relaytestapp/src/globalChat.cpp @@ -0,0 +1,173 @@ +//----------------------------------------------------------------------------- +// File: globalChat.cpp +// Desc: Shared app-wide "Global" chat channel — see globalChat.h. brainCloud's chat +// calls all require RTT to be enabled (RTT_NOT_ENABLED otherwise); +// app_enableChatRTT() (app.cpp) keeps RTT connected on every path that reaches +// the main menu, which covers both call sites (main menu itself, and the lobby, +// which is only reachable after passing through the main menu). Poll-based +// (explicit fetch after send / on opening the tab), not live RTT push — a +// live-push version would need registerRTTChatCallback + a Chat-service branch +// in the RTT dispatch. +//----------------------------------------------------------------------------- + +#include "globalChat.h" +#include "app.h" +#include "BCCallback.h" + +#include +#include +#include + +// 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). +static const char *CHAT_CHANNEL_SUB_ID = "gl"; + +static std::string s_chatChannelId; +static bool s_chatChannelResolving = false; +static bool s_chatChannelReady = false; +static std::vector s_chatMessages; +static bool s_chatFetchInFlight = false; +static bool s_chatFetchedOnce = false; +static char s_chatInputBuf[240] = {0}; +static bool s_chatSending = false; + +static ChatMessage parseChatMessage(const Json::Value &m) +{ + ChatMessage msg; + msg.fromName = m["from"]["name"].asString(); + if (msg.fromName.empty()) + msg.fromName = "Player"; + msg.text = m["content"]["text"].asString(); + return msg; +} + +static void fetchChatMessages() +{ + if (s_chatChannelId.empty() || s_chatFetchInFlight) return; + s_chatFetchInFlight = true; + pBCWrapper->getChatService()->getRecentChatMessages( + s_chatChannelId.c_str(), 30, + new BCCallback( + [](const Json::Value &result) + { + s_chatFetchInFlight = false; + s_chatFetchedOnce = true; + s_chatMessages.clear(); + for (const auto &m : result["data"]["messages"]) + s_chatMessages.push_back(parseChatMessage(m)); + // Server returns newest-first; flip to oldest-first for natural + // top-to-bottom reading order. + std::reverse(s_chatMessages.begin(), s_chatMessages.end()); + }, + [](const std::string &) { s_chatFetchInFlight = false; })); +} + +// Backoff after a failed getChannelId, so a persistent failure (bad channel code, +// network hiccup) can't turn into a same-call-every-frame loop — brainCloud's abuse +// detection disables the client after enough repeated failures on one API call +// (reason_code 90200), which is exactly what happened here without this guard. +static long long s_chatChannelRetryAtMs = 0; + +// Resolves the shared global channel once RTT is up, then fetches history. +// Safe to call every frame a Chat/Global tab is open — no-ops once resolved, in +// flight, or backing off after a recent failure. +static void ensureChatChannel() +{ + if (s_chatChannelReady || s_chatChannelResolving || !pBCWrapper) return; + if (!pBCWrapper->getRTTService()->getRTTEnabled()) + { + app_enableChatRTT(); // should already be on by the time the main menu is reached; just in case + return; + } + + auto nowMs = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); + if (nowMs < s_chatChannelRetryAtMs) return; + + s_chatChannelResolving = true; + pBCWrapper->getChatService()->getChannelId( + "gl", CHAT_CHANNEL_SUB_ID, + new BCCallback( + [](const Json::Value &result) + { + s_chatChannelResolving = false; + s_chatChannelId = result["data"]["channelId"].asString(); + s_chatChannelReady = !s_chatChannelId.empty(); + if (s_chatChannelReady) + fetchChatMessages(); + }, + [](const std::string &) + { + s_chatChannelResolving = false; + auto now = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); + s_chatChannelRetryAtMs = now + 5000; // back off 5s before retrying + })); +} + +static void sendChatMessage() +{ + if (s_chatChannelId.empty() || s_chatInputBuf[0] == '\0' || s_chatSending) return; + s_chatSending = true; + pBCWrapper->getChatService()->postChatMessageSimple( + s_chatChannelId.c_str(), s_chatInputBuf, true, + new BCCallback( + [](const Json::Value &) { s_chatSending = false; fetchChatMessages(); }, + [](const std::string &) { s_chatSending = false; })); + s_chatInputBuf[0] = '\0'; +} + +void drawGlobalChatContent() +{ + ensureChatChannel(); + + if (!s_chatChannelReady) + { + ImGui::TextDisabled(s_chatChannelResolving || !s_chatFetchedOnce ? "Connecting..." : "Chat unavailable."); + } + else + { + ImGui::BeginChild("chat_scroll", ImVec2(0.0f, -32.0f), true); + bool wasAtBottom = ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 5.0f; + for (const auto &m : s_chatMessages) + { + ImGui::TextColored(ImVec4(0.6f, 0.75f, 1.0f, 1.0f), "%s:", m.fromName.c_str()); + ImGui::SameLine(); + ImGui::TextWrapped("%s", m.text.c_str()); + } + // Jump to the newest message whenever the list just grew (first history + // fetch included) as well as the usual "stick to bottom" case — otherwise + // a fetch that lands while scrollY is still at its initial 0 leaves the + // view stuck on the oldest messages instead of the most recent ones. + static size_t s_lastCount = 0; + if (wasAtBottom || s_chatMessages.size() > s_lastCount) + ImGui::SetScrollHereY(1.0f); + s_lastCount = s_chatMessages.size(); + ImGui::EndChild(); + + ImGui::PushItemWidth(-70.0f); + bool enterPressed = ImGui::InputText("##chatInput", s_chatInputBuf, sizeof(s_chatInputBuf), + ImGuiInputTextFlags_EnterReturnsTrue); + ImGui::PopItemWidth(); + ImGui::SameLine(); + bool sendClicked = ImGui::Button("Send", ImVec2(60.0f, 0.0f)); + if ((enterPressed || sendClicked) && !s_chatSending) + sendChatMessage(); + } +} + +void drawGlobalChatPanel(const char *windowId, float x, float y, float w, float h) +{ + ImGui::SetNextWindowPos(ImVec2(x, y), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(w, h), ImGuiCond_Always); + ImGui::Begin(windowId, nullptr, + ImGuiWindowFlags_NoCollapse | + ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoTitleBar); + + drawGlobalChatContent(); + + ImGui::End(); +} diff --git a/relaytestapp/src/globalChat.h b/relaytestapp/src/globalChat.h new file mode 100644 index 0000000..dabec53 --- /dev/null +++ b/relaytestapp/src/globalChat.h @@ -0,0 +1,21 @@ +//----------------------------------------------------------------------------- +// File: globalChat.h +// Desc: Shared app-wide "Global" chat channel (brainCloud Chat service) — used by +// both the main menu's Chat tab and the lobby's Chat > Global sub-tab, so the +// channel resolution / message history / send logic lives in one place. +//----------------------------------------------------------------------------- +#pragma once + +#include "globals.h" + +// Draws the global-chat panel (message scroll + input box) at the given rect, as +// its own standalone window. windowId must be unique per call site (e.g. +// "##global_chat_mainmenu" vs "##global_chat_lobby") since ImGui windows are +// identified by their label. +void drawGlobalChatPanel(const char *windowId, float x, float y, float w, float h); + +// Same content (message scroll + input box), but assumes the caller has already +// opened an ImGui window/child — for embedding inside another tabbed panel (e.g. +// the lobby's Chat tab, which has its own "This Lobby / Global" sub-toggle above +// this content). +void drawGlobalChatContent(); diff --git a/relaytestapp/src/globals.h b/relaytestapp/src/globals.h index 8d36323..b6492b1 100644 --- a/relaytestapp/src/globals.h +++ b/relaytestapp/src/globals.h @@ -155,6 +155,15 @@ struct User Point pos = {0, 0}; std::map pings; /* per-region ping data shared via lobby extra */ int activePing = -1; /* live relay RTT broadcast during gameplay (ms); -1 = not yet received */ + int worldwideRank = -1; /* this player's own rank on the coverage leaderboard, shared via lobby extra; -1 = unknown/no score yet */ +}; + +// A chat message — used for both the global (brainCloud Chat service) channel and +// this-lobby (Lobby service SendSignal) chat. +struct ChatMessage +{ + std::string fromName; + std::string text; }; // Lobby @@ -164,6 +173,8 @@ struct Lobby std::string ownerCxId; std::string regionId; /* region extracted from lobbyId prefix (e.g. "na-east") */ std::vector members; + std::vector chatMessages; /* this-lobby chat, via SendSignal — resets whenever Lobby is reset (state.lobby = Lobby()) */ + std::chrono::steady_clock::time_point arrivalTime; /* when we entered this lobby, for the INFO tab's "time in lobby" */ }; // Server info diff --git a/relaytestapp/src/leaderboardPanel.cpp b/relaytestapp/src/leaderboardPanel.cpp new file mode 100644 index 0000000..a9eb21f --- /dev/null +++ b/relaytestapp/src/leaderboardPanel.cpp @@ -0,0 +1,214 @@ +//----------------------------------------------------------------------------- +// File: leaderboardPanel.cpp +// Desc: Shared leaderboard viewer — see leaderboardPanel.h. Top 5 + "you" row, +// toggleable between the two boards this app posts to (points / coverage) +// and Lifetime vs Quarterly. +//----------------------------------------------------------------------------- + +#include "leaderboardPanel.h" +#include "app.h" +#include "globals.h" +#include "BCCallback.h" + +#include + +struct LeaderboardRow +{ + std::string name; + int64_t score = 0; + int rank = 0; +}; + +static std::vector s_lbTop; +static LeaderboardRow s_lbSelf; +static bool s_lbHasSelf = false; +static int s_lbBoardType = 0; // 0 = Most Opponents Beaten (points), 1 = Highest Coverage % +static int s_lbPeriod = 0; // 0 = Lifetime, 1 = Quarterly +static int s_lbFetchedKey = -1; // -1 = never fetched; otherwise boardType*2+period already requested this session + +static std::string currentLeaderboardId() +{ + bool coverage = (s_lbBoardType == 1); + bool quarterly = (s_lbPeriod == 1); + if (coverage) + return quarterly ? state.coverageLeaderboardIdQuarterly : state.coverageLeaderboardId; + return quarterly ? state.pointsLeaderboardIdQuarterly : state.pointsLeaderboardId; +} + +// The score's user-defined "data" carries the display name (postMatchScores in +// app.cpp sets it) — GetGlobalLeaderboardPage/View don't otherwise return a usable +// name for arbitrary (non-friend) entries. +static LeaderboardRow parseLeaderboardEntry(const Json::Value &entry) +{ + LeaderboardRow row; + row.score = entry["score"].asInt64(); + row.rank = entry["rank"].asInt(); + row.name = entry["data"]["name"].asString(); + if (row.name.empty()) + row.name = "Player"; + return row; +} + +// Fetches the top 5 + the local player's own rank for the currently-selected board/ +// period combo. Guarded by s_lbFetchedKey so it only ever fires once per combo per +// session — switching tabs back and forth re-shows cached results, not a re-fetch. +static void fetchLeaderboardIfNeeded() +{ + int key = s_lbBoardType * 2 + s_lbPeriod; + if (s_lbFetchedKey == key || !pBCWrapper) return; + s_lbFetchedKey = key; + + std::string leaderboardId = currentLeaderboardId(); + + pBCWrapper->getSocialLeaderboardService()->getGlobalLeaderboardPage( + leaderboardId.c_str(), BrainCloud::HIGH_TO_LOW, 0, 4, + new BCCallback( + [key](const Json::Value &result) + { + if (s_lbFetchedKey != key) return; // stale — user switched tabs since this was requested + s_lbTop.clear(); + for (const auto &entry : result["data"]["leaderboard"]) + s_lbTop.push_back(parseLeaderboardEntry(entry)); + }, + [key](const std::string &) + { + if (s_lbFetchedKey != key) return; + s_lbTop.clear(); + })); + + // Pro-tip from the brainCloud docs: beforeCount=0/afterCount=0 on + // GetGlobalLeaderboardView returns just the current player's own entry. + pBCWrapper->getSocialLeaderboardService()->getGlobalLeaderboardView( + leaderboardId.c_str(), BrainCloud::HIGH_TO_LOW, 0, 0, + new BCCallback( + [key](const Json::Value &result) + { + if (s_lbFetchedKey != key) return; + const auto &arr = result["data"]["leaderboard"]; + s_lbHasSelf = !arr.empty(); + if (s_lbHasSelf) + s_lbSelf = parseLeaderboardEntry(arr[0]); + }, + [key](const std::string &) + { + if (s_lbFetchedKey != key) return; + s_lbHasSelf = false; + })); +} + +// "4,821" style thousands separator — scores can be into the thousands for the +// cumulative points board. +static std::string formatScore(int64_t v) +{ + std::string s = std::to_string(v); + for (int i = (int)s.size() - 3; i > 0; i -= 3) + s.insert(i, ","); + return s; +} + +// The coverage board's raw score is basis points (postMatchScores in app.cpp posts +// coveragePct*100 as an int, since brainCloud leaderboard scores are int64 — there's +// no float score type) — divide back down to a percentage for display. The points +// board's raw score is already the real value (players beaten + completion bonus). +static std::string formatBoardScore(int64_t v) +{ + if (s_lbBoardType == 1) + { + char buf[16]; + snprintf(buf, sizeof(buf), "%.1f%%", v / 100.0); + return buf; + } + return formatScore(v); +} + +ImVec4 rankColorFor(int rank) +{ + if (rank == 1) return ImVec4(1.00f, 0.84f, 0.00f, 1.0f); // gold + if (rank == 2) return ImVec4(0.75f, 0.75f, 0.75f, 1.0f); // silver + if (rank == 3) return ImVec4(0.80f, 0.50f, 0.20f, 1.0f); // bronze + return ImVec4(1.0f, 1.0f, 1.0f, 1.0f); +} + +void drawLeaderboardPanel(const char *windowId, float x, float y, float w, float h) +{ + fetchLeaderboardIfNeeded(); + + ImGui::SetNextWindowPos(ImVec2(x, y), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(w, h), ImGuiCond_Always); + ImGui::Begin(windowId, nullptr, + ImGuiWindowFlags_NoCollapse | + ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoTitleBar); + + // Board-type toggle: Most Opponents Beaten <-> Highest Coverage % + { + bool pointsActive = (s_lbBoardType == 0); + if (pointsActive) ImGui::PushStyleColor(ImGuiCol_Button, ImGui::GetStyle().Colors[ImGuiCol_ButtonActive]); + if (ImGui::Button("Most Opponents Beaten")) s_lbBoardType = 0; + if (pointsActive) ImGui::PopStyleColor(); + ImGui::SameLine(); + bool coverageActive = (s_lbBoardType == 1); + if (coverageActive) ImGui::PushStyleColor(ImGuiCol_Button, ImGui::GetStyle().Colors[ImGuiCol_ButtonActive]); + if (ImGui::Button("Highest Coverage %")) s_lbBoardType = 1; + if (coverageActive) ImGui::PopStyleColor(); + } + + // Period toggle: Lifetime <-> Quarterly + { + bool lifetimeActive = (s_lbPeriod == 0); + if (lifetimeActive) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.25f, 0.55f, 0.35f, 1.0f)); + if (ImGui::Button("Lifetime")) s_lbPeriod = 0; + if (lifetimeActive) ImGui::PopStyleColor(); + ImGui::SameLine(); + bool quarterlyActive = (s_lbPeriod == 1); + if (quarterlyActive) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.25f, 0.55f, 0.35f, 1.0f)); + if (ImGui::Button("Quarterly")) s_lbPeriod = 1; + if (quarterlyActive) ImGui::PopStyleColor(); + } + + ImGui::Separator(); + + if (s_lbTop.empty()) + { + ImGui::TextDisabled("No scores yet — be the first!"); + } + else + { + for (const auto &row : s_lbTop) + { + ImGui::TextColored(rankColorFor(row.rank), "#%d", row.rank); + ImGui::SameLine(50.0f); + ImGui::TextUnformatted(row.name.c_str()); + std::string scoreStr = formatBoardScore(row.score); + float tw = ImGui::CalcTextSize(scoreStr.c_str()).x; + ImGui::SameLine(w - tw - 32.0f); + ImGui::Text("%s", scoreStr.c_str()); + } + } + + // "You" row — only when it's not already visible in the top 5, mirroring the + // "top N + you" pattern from the reference design. + if (s_lbHasSelf) + { + bool alreadyShown = false; + for (const auto &row : s_lbTop) + if (row.rank == s_lbSelf.rank) { alreadyShown = true; break; } + + if (!alreadyShown) + { + if (!s_lbTop.empty()) + ImGui::TextDisabled("..."); + ImGui::TextColored(rankColorFor(s_lbSelf.rank), "#%d", s_lbSelf.rank); + ImGui::SameLine(50.0f); + std::string label = s_lbSelf.name + " (You)"; + ImGui::TextColored(ImVec4(0.35f, 1.0f, 0.45f, 1.0f), "%s", label.c_str()); + std::string scoreStr = formatBoardScore(s_lbSelf.score); + float tw = ImGui::CalcTextSize(scoreStr.c_str()).x; + ImGui::SameLine(w - tw - 32.0f); + ImGui::TextColored(ImVec4(0.35f, 1.0f, 0.45f, 1.0f), "%s", scoreStr.c_str()); + } + } + + ImGui::End(); +} diff --git a/relaytestapp/src/leaderboardPanel.h b/relaytestapp/src/leaderboardPanel.h new file mode 100644 index 0000000..3119ed0 --- /dev/null +++ b/relaytestapp/src/leaderboardPanel.h @@ -0,0 +1,15 @@ +//----------------------------------------------------------------------------- +// File: leaderboardPanel.h +// Desc: Shared leaderboard viewer (top 5 + "you" row, board/period toggles) — used +// by both the main menu and the lobby's Leaderboards tab. +//----------------------------------------------------------------------------- +#pragma once + +#include + +// Draws the leaderboard panel (board-type + period toggles, top-5 + "you" row) at +// the given rect. windowId must be unique per call site. +void drawLeaderboardPanel(const char *windowId, float x, float y, float w, float h); + +// Gold/silver/bronze/white — shared with the lobby member list's rank display. +ImVec4 rankColorFor(int rank); diff --git a/relaytestapp/src/lobby.cpp b/relaytestapp/src/lobby.cpp index 762525e..ec7baa9 100644 --- a/relaytestapp/src/lobby.cpp +++ b/relaytestapp/src/lobby.cpp @@ -21,230 +21,390 @@ // App includes #include "app.h" #include "globals.h" +#include "globalChat.h" +#include "leaderboardPanel.h" // C/C++ includes #include #include -// Draws a login dialog and update its logic -void lobby_update() +#define LOBBY_LEFT_WIDTH 420.0f +#define LOBBY_RIGHT_WIDTH 460.0f +#define LOBBY_PANEL_HEIGHT 560.0f +#define LOBBY_GAP 32.0f +#define LOBBY_TAB_HEIGHT 44.0f + +// 0 = Chat, 1 = Leaderboards, 2 = Info +static int s_lobbyRightTab = 0; +// 0 = This Lobby (signals), 1 = Global (Chat service) — only meaningful while +// s_lobbyRightTab == 0. +static int s_lobbyChatSubTab = 0; + +static const ImVec4 LOBBY_COLOR_ME(0.35f, 1.0f, 0.45f, 1.0f); + +//----------------------------------------------------------------------------- +// Left panel — member list (colour swatch, name, YOU/HOST badges, ready state, +// Worldwide Rank) + Leave/Start. Colour is changed via a popup on your own row now +// (BCLOUD-14490 follow-up), not an always-visible 40-swatch grid. +//----------------------------------------------------------------------------- + +static void drawColorPickerPopup() { - // Lobby window: auto-sized, centered via pivot + if (!ImGui::BeginPopup("lobby_color_picker")) return; + + ImGui::TextDisabled("Choose your colour"); + ImGui::Separator(); + for (int i = 0; i < colorCount(); ++i) { - ImGui::SetNextWindowPos( - ImVec2((float)width / 2.0f, (float)height / 2.0f + 10.0f), - ImGuiCond_Always, ImVec2(0.5f, 0.5f)); - ImGui::Begin("Lobby", nullptr, - ImGuiWindowFlags_NoCollapse | - ImGuiWindowFlags_NoMove | - ImGuiWindowFlags_AlwaysAutoResize | - ImGuiWindowFlags_NoTitleBar); - - // Leave lobby - if (ImGui::Button("Leave")) + if (i % 8 != 0) + ImGui::SameLine(); + if (ImGui::ColorButton(("Col" + std::to_string(i)).c_str(), getColor(i), + ImGuiColorEditFlags_NoTooltip, ImVec2(24.0f, 24.0f))) { - app_cancelLobby(); + app_changeUserColor(i); + ImGui::CloseCurrentPopup(); } + } + ImGui::EndPopup(); +} - // We're the boss, so we can start the game - if (state.user.cxId == state.lobby.ownerCxId) - { - if (settings.autoGeoTest) - { - // Auto-start after a 1.5s delay so the lobby state settles - auto elapsed = std::chrono::steady_clock::now() - state.geoTestLobbyArrivalTime; - if (elapsed >= std::chrono::milliseconds(1500)) - app_startGame(); - } - else - { - ImGui::SameLine(); - if (ImGui::Button("Start")) - app_startGame(); - } - } +static void drawMemberRow(const User &member) +{ + bool isMe = (member.cxId == state.user.cxId); + bool isHost = (member.cxId == state.lobby.ownerCxId); + + // Per-widget unique IDs (cxId embedded directly) instead of PushID/PopID — + // OpenPopup("lobby_color_picker") below must resolve to the SAME id as + // drawColorPickerPopup()'s BeginPopup("lobby_color_picker"), which is called + // outside any PushID scope. ImGui hashes popup ids together with whatever's on + // the id stack at the time, so wrapping this row in PushID(member.cxId) would + // silently make OpenPopup target a different id than BeginPopup ever looks for + // — the popup would never appear, with no error, exactly what happened here. + ImVec4 color = getColor(member.colorIndex % colorCount()); + if (isMe) + { + // My own swatch is clickable — opens the colour picker popup. + if (ImGui::ColorButton("##mycolor", color, ImGuiColorEditFlags_NoTooltip, ImVec2(20.0f, 20.0f))) + ImGui::OpenPopup("lobby_color_picker"); + } + else + { + std::string colId = "##col_" + member.cxId; + ImGui::ColorButton(colId.c_str(), color, + ImGuiColorEditFlags_NoTooltip | ImGuiColorEditFlags_NoBorder, ImVec2(20.0f, 20.0f)); + } + ImGui::SameLine(); + + ImGui::TextColored(isMe ? LOBBY_COLOR_ME : ImVec4(1, 1, 1, 1), "%s", member.name.c_str()); + + if (isMe) + { + ImGui::SameLine(); + ImVec2 p0 = ImGui::GetCursorScreenPos(); + ImVec2 sz = ImGui::CalcTextSize("YOU"); + ImGui::GetWindowDrawList()->AddRectFilled(p0, ImVec2(p0.x + sz.x + 8.0f, p0.y + sz.y + 2.0f), + ImColor(ImVec4(1, 1, 1, 0.15f)), 4.0f); + ImGui::SetCursorScreenPos(ImVec2(p0.x + 4.0f, p0.y + 1.0f)); + ImGui::TextUnformatted("YOU"); + } + if (isHost) + { + ImGui::SameLine(); + ImVec2 p0 = ImGui::GetCursorScreenPos(); + ImVec2 sz = ImGui::CalcTextSize("HOST"); + ImGui::GetWindowDrawList()->AddRectFilled(p0, ImVec2(p0.x + sz.x + 8.0f, p0.y + sz.y + 2.0f), + ImColor(ImVec4(1.0f, 0.84f, 0.0f, 0.25f)), 4.0f); + ImGui::SetCursorScreenPos(ImVec2(p0.x + 4.0f, p0.y + 1.0f)); + ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "HOST"); + } + + // Worldwide Rank — right-aligned. Each member fetched their OWN rank and shared + // it via the lobby's extra field (see app_fetchWorldwideRank in app.cpp); there's + // no client API to look up an arbitrary other player's rank directly. + std::string rankStr = (member.worldwideRank >= 0) ? ("#" + std::to_string(member.worldwideRank)) : "Unranked"; + float rankW = ImGui::CalcTextSize(rankStr.c_str()).x; + ImGui::SameLine(LOBBY_LEFT_WIDTH - rankW - 40.0f); + ImGui::TextColored(member.worldwideRank >= 0 ? rankColorFor(member.worldwideRank) : ImVec4(0.6f, 0.6f, 0.6f, 1.0f), + "%s", rankStr.c_str()); + + // Status line below the name (ready state). + ImGui::TextDisabled(member.isReady ? "Ready" : "Not ready"); + + ImGui::Separator(); +} + +static void drawLobbyMembersPanel(float x, float y) +{ + ImGui::SetNextWindowPos(ImVec2(x, y), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(LOBBY_LEFT_WIDTH, LOBBY_PANEL_HEIGHT), ImGuiCond_Always); + ImGui::Begin("Lobby", nullptr, + ImGuiWindowFlags_NoCollapse | + ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoResize); - // Color picker: 10 per row, centered in the window + ImGui::Text("Lobby"); + ImGui::Separator(); + + // Header row — "Members" on the left, "Worldwide Rank" right-aligned, matching + // the member rows' own column layout. + ImGui::TextDisabled("Members"); + { + const char *rankHeader = "WORLDWIDE RANK"; + float w = ImGui::CalcTextSize(rankHeader).x; + ImGui::SameLine(LOBBY_LEFT_WIDTH - w - 40.0f); + ImGui::TextDisabled("%s", rankHeader); + } + + // Scrollable member list — up to 40 players in a CursorParty lobby, so this + // must scroll rather than grow the (fixed-height) window. + ImGui::BeginChild("lobby_members_scroll", ImVec2(0.0f, -48.0f), true); + for (const auto &member : state.lobby.members) + drawMemberRow(member); + drawColorPickerPopup(); + ImGui::EndChild(); + + // Leave / Start — pinned at the bottom. + if (ImGui::Button("Leave")) + app_cancelLobby(); + + bool isHost = (state.user.cxId == state.lobby.ownerCxId); + if (isHost) + { + if (settings.autoGeoTest) { - float buttonSize = ImGui::GetFrameHeight(); - float spacing = ImGui::GetStyle().ItemSpacing.x; - float gridWidth = 10.0f * buttonSize + 9.0f * spacing; - float startX = (ImGui::GetWindowSize().x - gridWidth) * 0.5f; - for (int i = 0; i < colorCount(); ++i) - { - if (i % 10 == 0) - ImGui::SetCursorPosX(startX); - else - ImGui::SameLine(); - if (ImGui::ColorButton(("Col" + std::to_string(i)).c_str(), getColor(i))) - app_changeUserColor(i); - } + auto elapsed = std::chrono::steady_clock::now() - state.geoTestLobbyArrivalTime; + if (elapsed >= std::chrono::milliseconds(1500)) + app_startGame(); } - - // Column width — recomputed only when the member list changes - static float colWidth = 80.0f; - static size_t lastMemberCount = 0; - static std::string lastOwnerCxId; - if (state.lobby.members.size() != lastMemberCount || - state.lobby.ownerCxId != lastOwnerCxId) + else { - lastMemberCount = state.lobby.members.size(); - lastOwnerCxId = state.lobby.ownerCxId; - const float COL_PADDING = 24.0f; - colWidth = 80.0f; - for (const auto& member : state.lobby.members) - { - std::string label = member.name; - if (member.cxId == state.lobby.ownerCxId) label += " [Host]"; - float w = ImGui::CalcTextSize(label.c_str()).x + COL_PADDING; - if (w > colWidth) colWidth = w; - } + ImGui::SameLine(); + if (ImGui::Button("Start")) + app_startGame(); } + } - // Dummy forces AlwaysAutoResize to expand the window to fit all 3 columns - float totalColW = colWidth * 3.0f + ImGui::GetStyle().ItemSpacing.x * 2.0f; - ImGui::Dummy(ImVec2(totalColW, 0.0f)); + ImGui::End(); +} - // Helper: center a line of text in the current window - auto centerText = [](const std::string& s) { - float tw = ImGui::CalcTextSize(s.c_str()).x; - ImGui::SetCursorPosX((ImGui::GetWindowSize().x - tw) * 0.5f); - ImGui::TextUnformatted(s.c_str()); - }; - auto centerTextDisabled = [](const std::string& s) { - float tw = ImGui::CalcTextSize(s.c_str()).x; - ImGui::SetCursorPosX((ImGui::GetWindowSize().x - tw) * 0.5f); - ImGui::TextDisabled("%s", s.c_str()); - }; +//----------------------------------------------------------------------------- +// Right panel — CHAT (This Lobby / Global sub-toggle) | LEADERBOARDS | INFO. +//----------------------------------------------------------------------------- - // Lobby info — centered - ImGui::Separator(); - int maxMembers = maxLobbyMembers(settings.lobbyType); - centerText("Lobby: " + state.lobby.lobbyId); - centerTextDisabled("Players: " + std::to_string((int)state.lobby.members.size()) + - " / " + std::to_string(maxMembers)); - if (state.roundNumber > 0) - centerTextDisabled("Round: " + std::to_string(state.roundNumber)); - - // Member columns — each name centered within its cell - ImGui::Columns(3, 0, true); - for (int i = 0; i < 3; ++i) - ImGui::SetColumnWidth(i, colWidth); - - for (const auto& member : state.lobby.members) - { - std::string label = member.name; - if (member.cxId == state.lobby.ownerCxId) label += " [Host]"; - float textW = ImGui::CalcTextSize(label.c_str()).x; - float indent = (colWidth - textW) * 0.5f; - auto pos = ImGui::GetCursorPos(); - // Drop shadow offset by 1px - ImGui::SetCursorPos({pos.x + indent + 1, pos.y + 1}); - ImGui::TextColored(ImVec4(0, 0, 0, 0.75f), "%s", label.c_str()); - ImGui::SetCursorPos({pos.x + indent, pos.y}); - ImGui::TextColored(getColor(member.colorIndex % colorCount()), "%s", label.c_str()); - ImGui::NextColumn(); - } - ImGui::Columns(); +static void drawLobbyRightTabs(float panelX, float panelY) +{ + ImGui::SetNextWindowPos(ImVec2(panelX, panelY - LOBBY_TAB_HEIGHT), ImGuiCond_Always); + ImGui::SetNextWindowBgAlpha(0.0f); + ImGui::Begin("##lobby_right_tabs", nullptr, + ImGuiWindowFlags_NoDecoration | + ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoSavedSettings | + ImGuiWindowFlags_AlwaysAutoResize); + + auto tab = [](const char *label, int idx, float w) + { + bool active = (s_lobbyRightTab == idx); + if (active) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.30f, 0.45f, 0.90f, 1.0f)); + if (ImGui::Button(label, ImVec2(w, 32.0f))) s_lobbyRightTab = idx; + if (active) ImGui::PopStyleColor(); + }; + + tab("CHAT", 0, 90.0f); + ImGui::SameLine(); + tab("LEADERBOARDS", 1, 150.0f); + ImGui::SameLine(); + tab("INFO", 2, 90.0f); + + ImGui::End(); +} + +// This-lobby chat, via Lobby service signals (state.lobby.chatMessages is appended +// to by app_sendLobbySignal on send and the LOBBY_SIGNAL_DATA handler in +// onLobbyEvent on receive — see app.cpp for both). +static void drawLobbySignalChat() +{ + ImGui::BeginChild("lobby_signal_scroll", ImVec2(0.0f, -32.0f), true); + bool wasAtBottom = ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 5.0f; + for (const auto &m : state.lobby.chatMessages) + { + bool isMe = (m.fromName == state.user.name); + ImGui::TextColored(isMe ? LOBBY_COLOR_ME : ImVec4(0.6f, 0.75f, 1.0f, 1.0f), "%s:", m.fromName.c_str()); + ImGui::SameLine(); + ImGui::TextWrapped("%s", m.text.c_str()); + } + // Same "jump to bottom on growth" fix as the global chat — a lobby chat history + // can arrive all at once too (join-in-progress + backlog), not just one at a time. + static size_t s_lastCount = 0; + if (wasAtBottom || state.lobby.chatMessages.size() > s_lastCount) + ImGui::SetScrollHereY(1.0f); + s_lastCount = state.lobby.chatMessages.size(); + ImGui::EndChild(); + + static char buf[240] = {0}; + ImGui::PushItemWidth(-70.0f); + bool enterPressed = ImGui::InputText("##lobbyChatInput", buf, sizeof(buf), ImGuiInputTextFlags_EnterReturnsTrue); + ImGui::PopItemWidth(); + ImGui::SameLine(); + bool sendClicked = ImGui::Button("Send", ImVec2(60.0f, 0.0f)); + if ((enterPressed || sendClicked) && buf[0] != '\0') + { + app_sendLobbySignal(buf); + buf[0] = '\0'; + } +} + +static void drawLobbyChatTab(float x, float y, float w, float h) +{ + ImGui::SetNextWindowPos(ImVec2(x, y), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(w, h), ImGuiCond_Always); + ImGui::Begin("##lobby_chat_tab", nullptr, + ImGuiWindowFlags_NoCollapse | + ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoTitleBar); + + bool lobbyActive = (s_lobbyChatSubTab == 0); + if (lobbyActive) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.25f, 0.55f, 0.35f, 1.0f)); + if (ImGui::Button("THIS LOBBY")) s_lobbyChatSubTab = 0; + if (lobbyActive) ImGui::PopStyleColor(); + ImGui::SameLine(); + bool globalActive = (s_lobbyChatSubTab == 1); + if (globalActive) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.25f, 0.55f, 0.35f, 1.0f)); + if (ImGui::Button("GLOBAL")) s_lobbyChatSubTab = 1; + if (globalActive) ImGui::PopStyleColor(); + ImGui::Separator(); + + if (s_lobbyChatSubTab == 0) + drawLobbySignalChat(); + else + drawGlobalChatContent(); + + ImGui::End(); +} + +static void drawLobbyInfoTab(float x, float y, float w, float h) +{ + ImGui::SetNextWindowPos(ImVec2(x, y), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(w, h), ImGuiCond_Always); + ImGui::Begin("##lobby_info_tab", nullptr, + ImGuiWindowFlags_NoCollapse | + ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoTitleBar); + + int maxMembers = maxLobbyMembers(settings.lobbyType); + ImGui::Text("Lobby: %s", state.lobby.lobbyId.c_str()); + if (!state.lobby.regionId.empty()) + ImGui::Text("Region: %s", state.lobby.regionId.c_str()); + ImGui::Text("Players: %d / %d", (int)state.lobby.members.size(), maxMembers); + + auto secondsInLobby = std::chrono::duration_cast( + std::chrono::steady_clock::now() - state.lobby.arrivalTime).count(); + ImGui::Text("Time in lobby: %02lld:%02lld", (long long)(secondsInLobby / 60), (long long)(secondsInLobby % 60)); + + bool isHost = (state.user.cxId == state.lobby.ownerCxId); + ImGui::Spacing(); + if (isHost) + ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "Press Start when ready."); + else + ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "Waiting for the host to start..."); - // Last match results — shown once a round finishes (state.matchResult, set by - // app_tickMatch()/applyMatchResult()) until the next round starts (cleared in - // onRelayConnected()). Entries are already in rank order from computeCoverage(). - if (state.matchResult.valid) + // Last match results — shown once a round finishes (state.matchResult, set by + // app_tickMatch()/applyMatchResult()) until the next round starts (cleared in + // onRelayConnected()). Entries are already in rank order from computeCoverage(). + if (state.matchResult.valid) + { + ImGui::Separator(); + ImGui::TextDisabled("Last Match Results"); + for (const auto &entry : state.matchResult.entries) { - ImGui::Separator(); - centerText("Last Match Results"); - for (const auto& entry : state.matchResult.entries) - { - const User* pMember = nullptr; - for (const auto& m : state.lobby.members) - { - if (m.cxId == entry.cxId) { pMember = &m; break; } - } - std::string name = pMember ? pMember->name : "?"; - std::string line = std::to_string(entry.rank) + ". " + name + - " " + std::to_string((int)(entry.coveragePct + 0.5f)) + "%" + - " (+" + std::to_string(entry.beaten + 1) + " pts)"; - bool isMe = entry.cxId == state.user.cxId; - float tw = ImGui::CalcTextSize(line.c_str()).x; - ImGui::SetCursorPosX((ImGui::GetWindowSize().x - tw) * 0.5f); - if (isMe) - ImGui::TextColored(ImVec4(0.35f, 1.0f, 0.45f, 1.0f), "%s", line.c_str()); - else - ImGui::TextDisabled("%s", line.c_str()); - } + const User *pMember = nullptr; + for (const auto &m : state.lobby.members) + if (m.cxId == entry.cxId) { pMember = &m; break; } + std::string name = pMember ? pMember->name : "?"; + bool isMe = (entry.cxId == state.user.cxId); + ImVec4 color = isMe ? LOBBY_COLOR_ME : ImVec4(1, 1, 1, 1); + ImGui::TextColored(rankColorFor(entry.rank), "#%d", entry.rank); + ImGui::SameLine(50.0f); + ImGui::TextColored(color, "%s %.1f%% (+%d pts)", name.c_str(), entry.coveragePct, entry.beaten + 1); } + } - // Ping data section — only shown when ping region data is enabled - if (settings.usePingData) + // Ping data — only shown when ping region data is enabled. + if (settings.usePingData) + { + std::vector regions; + auto addRegion = [&](const std::string &r) { - // Collect all unique region names across all members + our own data - std::vector regions; - auto addRegion = [&](const std::string &r) - { - if (std::find(regions.begin(), regions.end(), r) == regions.end()) - regions.push_back(r); - }; - for (const auto &kv : state.pingData) + if (std::find(regions.begin(), regions.end(), r) == regions.end()) + regions.push_back(r); + }; + for (const auto &kv : state.pingData) + addRegion(kv.first); + for (const auto &m : state.lobby.members) + for (const auto &kv : m.pings) addRegion(kv.first); - for (const auto &m : state.lobby.members) - for (const auto &kv : m.pings) - addRegion(kv.first); - std::sort(regions.begin(), regions.end()); + std::sort(regions.begin(), regions.end()); - if (!regions.empty()) + if (!regions.empty()) + { + ImGui::Separator(); + ImGui::TextDisabled("Ping Data (ms)"); + for (const auto &member : state.lobby.members) { - ImGui::Separator(); - centerText("Ping Data (ms)"); - - // Header row: region names + const std::map *pPings = &member.pings; + std::map selfPings; + if (pPings->empty() && member.cxId == state.user.cxId && !state.pingData.empty()) { - std::string header = " "; // name column indent - for (const auto &r : regions) - header += " " + r; - centerTextDisabled(header); + selfPings = state.pingData; + pPings = &selfPings; } + if (pPings->empty()) + continue; - // One row per member who has ping data - for (const auto &member : state.lobby.members) + std::string label = member.name + ": "; + for (const auto &r : regions) { - // Prefer member.pings (shared via extra); fall back to state.pingData for self - const std::map *pPings = &member.pings; - std::map selfPings; - if (pPings->empty() && member.cxId == state.user.cxId && !state.pingData.empty()) - { - selfPings = state.pingData; - pPings = &selfPings; - } - if (pPings->empty()) - continue; - - std::string label = member.name; - if (member.cxId == state.lobby.ownerCxId) - label += " [Host]"; - // Pad name to fixed width for alignment - while ((int)label.size() < 16) - label += ' '; - label += ":"; - for (const auto &r : regions) - { - auto it = pPings->find(r); - char buf[16]; - if (it != pPings->end()) - snprintf(buf, sizeof(buf), it->second >= 999 ? " T/O" : " %5d", it->second); - else - snprintf(buf, sizeof(buf), " -"); - label += buf; - } - // Highlight the row for the local user - if (member.cxId == state.user.cxId) - ImGui::TextColored(getColor(member.colorIndex % colorCount()), "%s", label.c_str()); + auto it = pPings->find(r); + char buf[32]; + if (it != pPings->end()) + snprintf(buf, sizeof(buf), "%s %s ", r.c_str(), it->second >= 999 ? "T/O" : std::to_string(it->second).c_str()); else - ImGui::TextDisabled("%s", label.c_str()); + snprintf(buf, sizeof(buf), "%s - ", r.c_str()); + label += buf; } + if (member.cxId == state.user.cxId) + ImGui::TextColored(getColor(member.colorIndex % colorCount()), "%s", label.c_str()); + else + ImGui::TextDisabled("%s", label.c_str()); } } + } - ImGui::End(); + ImGui::End(); +} + +// Draws the lobby screen and updates its logic. +void lobby_update() +{ + float totalWidth = LOBBY_LEFT_WIDTH + LOBBY_GAP + LOBBY_RIGHT_WIDTH; + float startX = (float)width / 2.0f - totalWidth / 2.0f; + float y = (float)height / 2.0f - LOBBY_PANEL_HEIGHT / 2.0f; + float rightX = startX + LOBBY_LEFT_WIDTH + LOBBY_GAP; + + drawLobbyMembersPanel(startX, y); + drawLobbyRightTabs(rightX, y); + + switch (s_lobbyRightTab) + { + case 0: + drawLobbyChatTab(rightX, y, LOBBY_RIGHT_WIDTH, LOBBY_PANEL_HEIGHT); + break; + case 1: + drawLeaderboardPanel("##lobby_leaderboards", rightX, y, LOBBY_RIGHT_WIDTH, LOBBY_PANEL_HEIGHT); + break; + case 2: + drawLobbyInfoTab(rightX, y, LOBBY_RIGHT_WIDTH, LOBBY_PANEL_HEIGHT); + break; } } diff --git a/relaytestapp/src/mainMenu.cpp b/relaytestapp/src/mainMenu.cpp index bd613bf..16361ed 100644 --- a/relaytestapp/src/mainMenu.cpp +++ b/relaytestapp/src/mainMenu.cpp @@ -21,11 +21,12 @@ // App includes #include "app.h" #include "globals.h" +#include "globalChat.h" +#include "leaderboardPanel.h" #include "BCCallback.h" // C/C++ includes #include -#include #include #include @@ -41,213 +42,6 @@ // 0 = Leaderboard tab active, 1 = Chat tab active. static int s_activeRightTab = 0; -//----------------------------------------------------------------------------- -// Leaderboard viewer (BCLOUD-14472 follow-up) — top 5 + "you" row, toggleable -// between the two boards this app posts to (points / coverage) and Lifetime vs -// Quarterly. File-static since this is pure main-menu display state, not -// something any other screen or the relay wire protocol needs. -//----------------------------------------------------------------------------- - -struct LeaderboardRow -{ - std::string name; - int64_t score = 0; - int rank = 0; -}; - -static std::vector s_lbTop; -static LeaderboardRow s_lbSelf; -static bool s_lbHasSelf = false; -static int s_lbBoardType = 0; // 0 = Most Opponents Beaten (points), 1 = Highest Coverage % -static int s_lbPeriod = 0; // 0 = Lifetime, 1 = Quarterly -static int s_lbFetchedKey = -1; // -1 = never fetched; otherwise boardType*2+period already requested this session - -static std::string currentLeaderboardId() -{ - bool coverage = (s_lbBoardType == 1); - bool quarterly = (s_lbPeriod == 1); - if (coverage) - return quarterly ? state.coverageLeaderboardIdQuarterly : state.coverageLeaderboardId; - return quarterly ? state.pointsLeaderboardIdQuarterly : state.pointsLeaderboardId; -} - -// The score's user-defined "data" carries the display name (postMatchScores in -// app.cpp sets it) — GetGlobalLeaderboardPage/View don't otherwise return a usable -// name for arbitrary (non-friend) entries. -static LeaderboardRow parseLeaderboardEntry(const Json::Value &entry) -{ - LeaderboardRow row; - row.score = entry["score"].asInt64(); - row.rank = entry["rank"].asInt(); - row.name = entry["data"]["name"].asString(); - if (row.name.empty()) - row.name = "Player"; - return row; -} - -// Fetches the top 5 + the local player's own rank for the currently-selected board/ -// period combo. Guarded by s_lbFetchedKey so it only ever fires once per combo per -// session — switching tabs back and forth re-shows cached results, not a re-fetch. -static void fetchLeaderboardIfNeeded() -{ - int key = s_lbBoardType * 2 + s_lbPeriod; - if (s_lbFetchedKey == key || !pBCWrapper) return; - s_lbFetchedKey = key; - - std::string leaderboardId = currentLeaderboardId(); - - pBCWrapper->getSocialLeaderboardService()->getGlobalLeaderboardPage( - leaderboardId.c_str(), BrainCloud::HIGH_TO_LOW, 0, 4, - new BCCallback( - [key](const Json::Value &result) - { - if (s_lbFetchedKey != key) return; // stale — user switched tabs since this was requested - s_lbTop.clear(); - for (const auto &entry : result["data"]["leaderboard"]) - s_lbTop.push_back(parseLeaderboardEntry(entry)); - }, - [key](const std::string &) - { - if (s_lbFetchedKey != key) return; - s_lbTop.clear(); - })); - - // Pro-tip from the brainCloud docs: beforeCount=0/afterCount=0 on - // GetGlobalLeaderboardView returns just the current player's own entry. - pBCWrapper->getSocialLeaderboardService()->getGlobalLeaderboardView( - leaderboardId.c_str(), BrainCloud::HIGH_TO_LOW, 0, 0, - new BCCallback( - [key](const Json::Value &result) - { - if (s_lbFetchedKey != key) return; - const auto &arr = result["data"]["leaderboard"]; - s_lbHasSelf = !arr.empty(); - if (s_lbHasSelf) - s_lbSelf = parseLeaderboardEntry(arr[0]); - }, - [key](const std::string &) - { - if (s_lbFetchedKey != key) return; - s_lbHasSelf = false; - })); -} - -// "4,821" style thousands separator — scores can be into the thousands for the -// cumulative points board. -static std::string formatScore(int64_t v) -{ - std::string s = std::to_string(v); - for (int i = (int)s.size() - 3; i > 0; i -= 3) - s.insert(i, ","); - return s; -} - -// The coverage board's raw score is basis points (postMatchScores in app.cpp posts -// coveragePct*100 as an int, since brainCloud leaderboard scores are int64 — there's -// no float score type) — divide back down to a percentage for display. The points -// board's raw score is already the real value (players beaten + completion bonus). -static std::string formatBoardScore(int64_t v) -{ - if (s_lbBoardType == 1) - { - char buf[16]; - snprintf(buf, sizeof(buf), "%.1f%%", v / 100.0); - return buf; - } - return formatScore(v); -} - -static ImVec4 rankColorFor(int rank) -{ - if (rank == 1) return ImVec4(1.00f, 0.84f, 0.00f, 1.0f); // gold - if (rank == 2) return ImVec4(0.75f, 0.75f, 0.75f, 1.0f); // silver - if (rank == 3) return ImVec4(0.80f, 0.50f, 0.20f, 1.0f); // bronze - return ImVec4(1.0f, 1.0f, 1.0f, 1.0f); -} - -static void drawLeaderboardCard(float x, float y) -{ - fetchLeaderboardIfNeeded(); - - ImGui::SetNextWindowPos(ImVec2(x, y), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(LEADERBOARD_CARD_WIDTH, CHAT_CARD_HEIGHT), ImGuiCond_Always); - ImGui::Begin("Leaderboard", nullptr, - ImGuiWindowFlags_NoCollapse | - ImGuiWindowFlags_NoMove | - ImGuiWindowFlags_NoResize); - - // Board-type toggle: Most Opponents Beaten <-> Highest Coverage % - { - bool pointsActive = (s_lbBoardType == 0); - if (pointsActive) ImGui::PushStyleColor(ImGuiCol_Button, ImGui::GetStyle().Colors[ImGuiCol_ButtonActive]); - if (ImGui::Button("Most Opponents Beaten")) s_lbBoardType = 0; - if (pointsActive) ImGui::PopStyleColor(); - ImGui::SameLine(); - bool coverageActive = (s_lbBoardType == 1); - if (coverageActive) ImGui::PushStyleColor(ImGuiCol_Button, ImGui::GetStyle().Colors[ImGuiCol_ButtonActive]); - if (ImGui::Button("Highest Coverage %")) s_lbBoardType = 1; - if (coverageActive) ImGui::PopStyleColor(); - } - - // Period toggle: Lifetime <-> Quarterly - { - bool lifetimeActive = (s_lbPeriod == 0); - if (lifetimeActive) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.25f, 0.55f, 0.35f, 1.0f)); - if (ImGui::Button("Lifetime")) s_lbPeriod = 0; - if (lifetimeActive) ImGui::PopStyleColor(); - ImGui::SameLine(); - bool quarterlyActive = (s_lbPeriod == 1); - if (quarterlyActive) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.25f, 0.55f, 0.35f, 1.0f)); - if (ImGui::Button("Quarterly")) s_lbPeriod = 1; - if (quarterlyActive) ImGui::PopStyleColor(); - } - - ImGui::Separator(); - - if (s_lbTop.empty()) - { - ImGui::TextDisabled("No scores yet — be the first!"); - } - else - { - for (const auto &row : s_lbTop) - { - ImGui::TextColored(rankColorFor(row.rank), "#%d", row.rank); - ImGui::SameLine(50.0f); - ImGui::TextUnformatted(row.name.c_str()); - std::string scoreStr = formatBoardScore(row.score); - float tw = ImGui::CalcTextSize(scoreStr.c_str()).x; - ImGui::SameLine(LEADERBOARD_CARD_WIDTH - tw - 32.0f); - ImGui::Text("%s", scoreStr.c_str()); - } - } - - // "You" row — only when it's not already visible in the top 5, mirroring the - // "top N + you" pattern from the reference design. - if (s_lbHasSelf) - { - bool alreadyShown = false; - for (const auto &row : s_lbTop) - if (row.rank == s_lbSelf.rank) { alreadyShown = true; break; } - - if (!alreadyShown) - { - if (!s_lbTop.empty()) - ImGui::TextDisabled("..."); - ImGui::TextColored(rankColorFor(s_lbSelf.rank), "#%d", s_lbSelf.rank); - ImGui::SameLine(50.0f); - std::string label = s_lbSelf.name + " (You)"; - ImGui::TextColored(ImVec4(0.35f, 1.0f, 0.45f, 1.0f), "%s", label.c_str()); - std::string scoreStr = formatBoardScore(s_lbSelf.score); - float tw = ImGui::CalcTextSize(scoreStr.c_str()).x; - ImGui::SameLine(LEADERBOARD_CARD_WIDTH - tw - 32.0f); - ImGui::TextColored(ImVec4(0.35f, 1.0f, 0.45f, 1.0f), "%s", scoreStr.c_str()); - } - } - - ImGui::End(); -} - // Small floating tab strip sitting above the LEADERBOARD/CHAT panel — matches the // reference mockup's outlined-toggle look, active tab highlighted. static void drawRightTabs(float panelX, float panelY) @@ -274,162 +68,6 @@ static void drawRightTabs(float panelX, float panelY) ImGui::End(); } -//----------------------------------------------------------------------------- -// Chat (main menu) — a single app-wide global channel. brainCloud's chat calls all -// require RTT to be enabled (RTT_NOT_ENABLED otherwise); app_enableChatRTT() (app.cpp) -// keeps RTT connected on every path that reaches this screen specifically so this -// works. This is poll-based (explicit fetch after send / on opening the tab), not -// live RTT push — see the summary for what a live-push version would need -// (registerRTTChatCallback + handling the Chat service in the RTT dispatch). -//----------------------------------------------------------------------------- - -// 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). -static const char *CHAT_CHANNEL_SUB_ID = "gl"; - -struct ChatMessage -{ - std::string fromName; - std::string text; -}; - -static std::string s_chatChannelId; -static bool s_chatChannelResolving = false; -static bool s_chatChannelReady = false; -static std::vector s_chatMessages; -static bool s_chatFetchInFlight = false; -static bool s_chatFetchedOnce = false; -static char s_chatInputBuf[240] = {0}; -static bool s_chatSending = false; - -static ChatMessage parseChatMessage(const Json::Value &m) -{ - ChatMessage msg; - msg.fromName = m["from"]["name"].asString(); - if (msg.fromName.empty()) - msg.fromName = "Player"; - msg.text = m["content"]["text"].asString(); - return msg; -} - -static void fetchChatMessages() -{ - if (s_chatChannelId.empty() || s_chatFetchInFlight) return; - s_chatFetchInFlight = true; - pBCWrapper->getChatService()->getRecentChatMessages( - s_chatChannelId.c_str(), 30, - new BCCallback( - [](const Json::Value &result) - { - s_chatFetchInFlight = false; - s_chatFetchedOnce = true; - s_chatMessages.clear(); - for (const auto &m : result["data"]["messages"]) - s_chatMessages.push_back(parseChatMessage(m)); - // Server returns newest-first; flip to oldest-first for natural - // top-to-bottom reading order. - std::reverse(s_chatMessages.begin(), s_chatMessages.end()); - }, - [](const std::string &) { s_chatFetchInFlight = false; })); -} - -// Backoff after a failed getChannelId, so a persistent failure (bad channel code, -// network hiccup) can't turn into a same-call-every-frame loop — brainCloud's abuse -// detection disables the client after enough repeated failures on one API call -// (reason_code 90200), which is exactly what happened here without this guard. -static long long s_chatChannelRetryAtMs = 0; - -// Resolves the shared global channel once RTT is up, then fetches history. -// Safe to call every frame the Chat tab is open — no-ops once resolved, in flight, -// or backing off after a recent failure. -static void ensureChatChannel() -{ - if (s_chatChannelReady || s_chatChannelResolving || !pBCWrapper) return; - if (!pBCWrapper->getRTTService()->getRTTEnabled()) - { - app_enableChatRTT(); // should already be on by the time MainMenu is reached; just in case - return; - } - - auto nowMs = std::chrono::duration_cast( - std::chrono::system_clock::now().time_since_epoch()).count(); - if (nowMs < s_chatChannelRetryAtMs) return; - - s_chatChannelResolving = true; - pBCWrapper->getChatService()->getChannelId( - "gl", CHAT_CHANNEL_SUB_ID, - new BCCallback( - [](const Json::Value &result) - { - s_chatChannelResolving = false; - s_chatChannelId = result["data"]["channelId"].asString(); - s_chatChannelReady = !s_chatChannelId.empty(); - if (s_chatChannelReady) - fetchChatMessages(); - }, - [](const std::string &) - { - s_chatChannelResolving = false; - auto now = std::chrono::duration_cast( - std::chrono::system_clock::now().time_since_epoch()).count(); - s_chatChannelRetryAtMs = now + 5000; // back off 5s before retrying - })); -} - -static void sendChatMessage() -{ - if (s_chatChannelId.empty() || s_chatInputBuf[0] == '\0' || s_chatSending) return; - s_chatSending = true; - pBCWrapper->getChatService()->postChatMessageSimple( - s_chatChannelId.c_str(), s_chatInputBuf, true, - new BCCallback( - [](const Json::Value &) { s_chatSending = false; fetchChatMessages(); }, - [](const std::string &) { s_chatSending = false; })); - s_chatInputBuf[0] = '\0'; -} - -static void drawChatCard(float x, float y) -{ - ensureChatChannel(); - - ImGui::SetNextWindowPos(ImVec2(x, y), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(LEADERBOARD_CARD_WIDTH, CHAT_CARD_HEIGHT), ImGuiCond_Always); - ImGui::Begin("Chat", nullptr, - ImGuiWindowFlags_NoCollapse | - ImGuiWindowFlags_NoMove | - ImGuiWindowFlags_NoResize); - - if (!s_chatChannelReady) - { - ImGui::TextDisabled(s_chatChannelResolving || !s_chatFetchedOnce ? "Connecting..." : "Chat unavailable."); - } - else - { - ImGui::BeginChild("chat_scroll", ImVec2(0.0f, -32.0f), true); - for (const auto &m : s_chatMessages) - { - ImGui::TextColored(ImVec4(0.6f, 0.75f, 1.0f, 1.0f), "%s:", m.fromName.c_str()); - ImGui::SameLine(); - ImGui::TextWrapped("%s", m.text.c_str()); - } - if (ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 5.0f) - ImGui::SetScrollHereY(1.0f); // stick to bottom as new messages arrive - ImGui::EndChild(); - - ImGui::PushItemWidth(-70.0f); - bool enterPressed = ImGui::InputText("##chatInput", s_chatInputBuf, sizeof(s_chatInputBuf), - ImGuiInputTextFlags_EnterReturnsTrue); - ImGui::PopItemWidth(); - ImGui::SameLine(); - bool sendClicked = ImGui::Button("Send", ImVec2(60.0f, 0.0f)); - if ((enterPressed || sendClicked) && !s_chatSending) - sendChatMessage(); - } - - ImGui::End(); -} - //----------------------------------------------------------------------------- // Lobby card — protocol/lobby-type/ping-data setup, unchanged functionality, // relabeled/reordered to match the reference layout (title + win-condition @@ -438,12 +76,18 @@ static void drawChatCard(float x, float y) static void drawLobbyCard(float x, float y) { + // Fixed size matching the right-side panel (CHAT_CARD_HEIGHT), so the two cards + // are always the same height. Content lives in a scrollable child so the + // variable-height geo-test panel can't grow the outer window — it just scrolls, + // and shorter content leaves the padded gap below it that a fixed-size window + // naturally gives for free. ImGui::SetNextWindowPos(ImVec2(x, y), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(LOBBY_CARD_WIDTH, 0)); // 0 height = auto + ImGui::SetNextWindowSize(ImVec2(LOBBY_CARD_WIDTH, CHAT_CARD_HEIGHT), ImGuiCond_Always); ImGui::Begin("Cursor Party", nullptr, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoMove | - ImGuiWindowFlags_AlwaysAutoResize); + ImGuiWindowFlags_NoResize); + ImGui::BeginChild("lobby_card_scroll", ImVec2(0.0f, 0.0f), false); // Win-condition tagline (BCLOUD-14472) — sets expectations before Play is clicked. { @@ -667,6 +311,7 @@ static void drawLobbyCard(float x, float y) } // ------------------------------------------------------------------------- + ImGui::EndChild(); ImGui::End(); } @@ -682,7 +327,7 @@ void mainMenu_update() drawLobbyCard(startX, y); drawRightTabs(rightX, y); if (s_activeRightTab == 0) - drawLeaderboardCard(rightX, y); + drawLeaderboardPanel("##leaderboard_mainmenu", rightX, y, LEADERBOARD_CARD_WIDTH, CHAT_CARD_HEIGHT); else - drawChatCard(rightX, y); + drawGlobalChatPanel("##global_chat_mainmenu", rightX, y, LEADERBOARD_CARD_WIDTH, CHAT_CARD_HEIGHT); } From 4b28e23e6c96686e402cb7aaa44edb5676cc51f3 Mon Sep 17 00:00:00 2001 From: Steve Jones Date: Fri, 7 Aug 2026 13:41:38 -0400 Subject: [PATCH 3/8] BCLOUD-14491 Summary Screen with members list and event displays --- relaytestapp/CMakeLists.txt | 2 + relaytestapp/src/app.cpp | 350 ++++++++++++++++++++++++++---- relaytestapp/src/app.h | 9 + relaytestapp/src/coverage.cpp | 101 ++++----- relaytestapp/src/coverage.h | 28 +-- relaytestapp/src/globals.h | 53 ++++- relaytestapp/src/lobby.cpp | 31 +++ relaytestapp/src/matchSummary.cpp | 291 +++++++++++++++++++++++++ relaytestapp/src/matchSummary.h | 10 + 9 files changed, 759 insertions(+), 116 deletions(-) create mode 100644 relaytestapp/src/matchSummary.cpp create mode 100644 relaytestapp/src/matchSummary.h diff --git a/relaytestapp/CMakeLists.txt b/relaytestapp/CMakeLists.txt index 296c5a6..3f2715f 100644 --- a/relaytestapp/CMakeLists.txt +++ b/relaytestapp/CMakeLists.txt @@ -77,6 +77,8 @@ list(APPEND src_files src/leaderboardPanel.h src/lobby.cpp src/lobby.h + src/matchSummary.cpp + src/matchSummary.h src/login.cpp src/login.h src/loading.cpp diff --git a/relaytestapp/src/app.cpp b/relaytestapp/src/app.cpp index eba0718..bc4bc85 100644 --- a/relaytestapp/src/app.cpp +++ b/relaytestapp/src/app.cpp @@ -25,6 +25,7 @@ #include "globals.h" #include "loading.h" #include "lobby.h" +#include "matchSummary.h" #include "login.h" #include "mainMenu.h" #include "BCCallback.h" @@ -69,9 +70,10 @@ static void sendGameStartToMask(uint64_t playerMask); static void sendSplotchSyncToMask(uint64_t mask); static void sendMatchResultToMask(uint64_t mask, int round, const std::vector &coverage); static std::vector toMatchResultEntries(const std::vector &coverage); -static void postMatchScores(const MatchResultEntry &mine); +static void postMatchScoresAndComputeDeltas(const MatchResultEntry &mine); static void applyMatchResult(int round, const std::vector &entries); static void onRelayConnected(); +static void sendLeaderboardDeltaToMask(uint64_t mask, const LeaderboardDelta &delta); static bool isDisconnecting = false; @@ -79,6 +81,12 @@ static bool isDisconnecting = false; // Reset on "first":true and whenever a new round starts (onRelayConnected). static std::vector s_pendingMatchResult; +// A player's "lb_result" can arrive before match_result has populated state.matchResult. +// entries for this round (they're broadcast by different senders on different channels, +// so relative ordering isn't guaranteed) — buffered here by cxId and drained into the +// matching entry as soon as applyMatchResult() sets entries. Reset every round. +static std::map s_pendingLbResults; + // Incremented on every app_play() call. Each ping-flow lambda captures this value and // checks it before acting — stale callbacks from a previous session are silently dropped. static int s_playGeneration = 0; @@ -148,7 +156,7 @@ class RelayConnectCallback final : public BrainCloud::IRelayConnectCallback void relayConnectSuccess(const std::string &jsonResponse) override { printf("[%d][DEBUG] Relay connect SUCCESS\n", settings.instanceIndex); - loading_status = ""; + state.isProvisioning = false; state.screenState = ScreenState::Game; onRelayConnected(); } @@ -767,13 +775,19 @@ static void sendMatchResultToMask(uint64_t mask, int round, const std::vector netId here and skip the entry entirely if that failed ("no longer + // connected") — but that resolution is unreliable enough in practice (root cause not + // fully nailed down) that it was silently dropping players who were still genuinely in + // the match, which is how a real 4-player match_result ended up being received as a + // single entry by everyone. match_result is a single small once-per-round broadcast, + // so the extra bytes of a full cxId per entry cost nothing — there's no reason to + // depend on netId resolution for this at all when we already know every member's cxId + // from state.lobby.members. for (const auto &c : coverage) { - int netId = pBCWrapper->getRelayService()->getNetIdForCxId(c.cxId); - if (netId < 0 || netId >= MAX_LOBBY_MEMBERS) continue; // no longer connected — skip - Json::Value entry; - entry["n"] = netId; + entry["cx"] = c.cxId; entry["r"] = c.rank; entry["c"] = (int)(c.coveragePct * 100.0f + 0.5f); // basis points, 0-10000 entry["b"] = c.beaten; @@ -804,14 +818,145 @@ static std::vector toMatchResultEntries(const std::vectorgetRelayService()->getNetIdForCxId(state.user.cxId); + if (netId < 0 || netId >= MAX_LOBBY_MEMBERS) return; + + Json::Value json; + json["op"] = "lb_result"; + json["data"]["n"] = netId; + + auto putPeriod = [&](const char *key, const LeaderboardPeriodDelta &pd) + { + if (!pd.improved) return; // absent key == "no change" on receipt + json["data"][key]["b"] = pd.rankBefore; + json["data"][key]["a"] = pd.rankAfter; + }; + putPeriod("pl", delta.pointsLifetime); + putPeriod("pq", delta.pointsQuarterly); + putPeriod("cl", delta.coverageLifetime); + putPeriod("cq", delta.coverageQuarterly); + + Json::FastWriter writer; + auto str = writer.write(json); + pBCWrapper->getRelayService()->sendToPlayers( + (const uint8_t *)str.data(), (int)str.length(), + mask, + true, // reliable + true, // ordered + (BrainCloud::eRelayChannel)0); +} + +// Writes this client's own finished delta into its match_result entry (no relay round +// trip needed for yourself) and shares it with everyone else in the match. +static void applyLeaderboardDeltaSelf(const LeaderboardDelta &delta) +{ + for (auto &e : state.matchResult.entries) + { + if (e.cxId == state.user.cxId) + { + e.lbDelta = delta; + break; + } + } + sendLeaderboardDeltaToMask(getPlayerMask(), delta); +} + +// One board's full before -> post -> after chain. Fetches the player's current rank/score +// on leaderboardId (GetGlobalLeaderboardView with before/afterCount 0 is the documented way +// to get just the caller's own entry), posts the new score, then re-fetches to see what +// actually stuck. isPersonalBestStyle selects what "improved" means: +// - coverage boards (kept-best score): improved = this score beat the previous best. +// Posting a LOWER coverage than your best is a no-op server-side, so comparing scores +// is the only reliable signal — rank alone could move for reasons unrelated to this +// round (other players posting later). +// - points boards (cumulative score): every post raises the score, so "did the score +// increase" is always true and useless; the real signal is whether RANK improved. +// Either way rankBefore/rankAfter are captured for display regardless of which one gates +// the badge — the summary screen's "Personal best" badge still shows a rank movement. +static void chainLeaderboardBoard( + const std::string &leaderboardId, int64_t score, const std::string &otherDataStr, + bool isPersonalBestStyle, + std::shared_ptr outDelta, + std::shared_ptr remaining, + std::function finalize) +{ + if (leaderboardId.empty()) + { + if (--(*remaining) == 0) finalize(); + return; + } + + auto onDone = [=]() { if (--(*remaining) == 0) finalize(); }; + + pBCWrapper->getSocialLeaderboardService()->getGlobalLeaderboardView( + leaderboardId.c_str(), BrainCloud::HIGH_TO_LOW, 0, 0, + new BCCallback( + [=](const Json::Value &beforeResult) + { + const auto &beforeArr = beforeResult["data"]["leaderboard"]; + int rankBefore = -1; + int64_t scoreBefore = -1; + if (!beforeArr.empty()) + { + rankBefore = beforeArr[0]["rank"].asInt(); + scoreBefore = beforeArr[0]["score"].asInt64(); + } + + pBCWrapper->getSocialLeaderboardService()->postScoreToLeaderboard( + leaderboardId.c_str(), score, otherDataStr, + new BCCallback( + [=](const Json::Value &) + { + pBCWrapper->getSocialLeaderboardService()->getGlobalLeaderboardView( + leaderboardId.c_str(), BrainCloud::HIGH_TO_LOW, 0, 0, + new BCCallback( + [=](const Json::Value &afterResult) + { + const auto &afterArr = afterResult["data"]["leaderboard"]; + int rankAfter = -1; + int64_t scoreAfter = -1; + if (!afterArr.empty()) + { + rankAfter = afterArr[0]["rank"].asInt(); + scoreAfter = afterArr[0]["score"].asInt64(); + } + outDelta->rankBefore = rankBefore; + outDelta->rankAfter = rankAfter; + outDelta->improved = isPersonalBestStyle + ? (scoreBefore < 0 || scoreAfter > scoreBefore) + : (rankAfter > 0 && (rankBefore < 0 || rankAfter < rankBefore)); + onDone(); + }, + [=](const std::string &) { outDelta->rankBefore = rankBefore; onDone(); })); + }, + [=](const std::string &) { onDone(); })); + }, + [=](const std::string &) + { + // Before-fetch failed — still try to post so the score isn't lost, just + // without a delta to show (rankBefore/After stay -1, improved stays false). + pBCWrapper->getSocialLeaderboardService()->postScoreToLeaderboard( + leaderboardId.c_str(), score, otherDataStr, + new BCCallback([=](const Json::Value &) { onDone(); }, + [=](const std::string &) { onDone(); })); + })); +} + +// Posts this client's own final standing to the four leaderboards, and computes/shares +// how that changed its own rank on each of them (BCLOUD-14489's Match Summary screen). +// Coverage score is basis points (0-10000) so the portal isn't stuck with float scores; +// points score is "players beaten" + a flat completion bonus (so a solo match — 0 beaten +// — still posts 1, per the ticket: "+1 bonus point for completing a game"). +static void postMatchScoresAndComputeDeltas(const MatchResultEntry &mine) +{ + int64_t basisPoints = (int64_t)(mine.coveragePct * 100.0f + 0.5f); + int64_t points = mine.beaten + 1; Json::Value otherData; otherData["round"] = state.roundNumber; @@ -823,18 +968,27 @@ static void postMatchScores(const MatchResultEntry &mine) Json::FastWriter writer; auto otherDataStr = writer.write(otherData); - auto postTo = [&](const std::string &leaderboardId, int64_t score) + auto pointsLifetime = std::make_shared(); + auto pointsQuarterly = std::make_shared(); + auto coverageLifetime = std::make_shared(); + auto coverageQuarterly = std::make_shared(); + auto remaining = std::make_shared(4); + + auto finalize = [pointsLifetime, pointsQuarterly, coverageLifetime, coverageQuarterly]() { - if (leaderboardId.empty()) return; - pBCWrapper->getSocialLeaderboardService()->postScoreToLeaderboard( - leaderboardId.c_str(), score, otherDataStr, - new BCCallback([](const Json::Value &) {}, [](const std::string &) {})); + LeaderboardDelta delta; + delta.ready = true; + delta.pointsLifetime = *pointsLifetime; + delta.pointsQuarterly = *pointsQuarterly; + delta.coverageLifetime = *coverageLifetime; + delta.coverageQuarterly = *coverageQuarterly; + applyLeaderboardDeltaSelf(delta); }; - postTo(state.coverageLeaderboardId, basisPoints); - postTo(state.coverageLeaderboardIdQuarterly, basisPoints); - postTo(state.pointsLeaderboardId, points); - postTo(state.pointsLeaderboardIdQuarterly, points); + chainLeaderboardBoard(state.pointsLeaderboardId, points, otherDataStr, false, pointsLifetime, remaining, finalize); + chainLeaderboardBoard(state.pointsLeaderboardIdQuarterly, points, otherDataStr, false, pointsQuarterly, remaining, finalize); + chainLeaderboardBoard(state.coverageLeaderboardId, basisPoints, otherDataStr, true, coverageLifetime, remaining, finalize); + chainLeaderboardBoard(state.coverageLeaderboardIdQuarterly, basisPoints, otherDataStr, true, coverageQuarterly, remaining, finalize); } // Applies an authoritative coverage snapshot for a round — either a locally-computed one @@ -853,6 +1007,18 @@ static void applyMatchResult(int round, const std::vector &ent state.matchResult.round = round; state.matchResult.entries = entries; + // Drain any "lb_result" broadcasts that arrived before this round's match_result did + // (different senders, no relative ordering guarantee between them — see s_pendingLbResults). + for (auto &e : state.matchResult.entries) + { + auto it = s_pendingLbResults.find(e.cxId); + if (it != s_pendingLbResults.end()) + { + e.lbDelta = it->second; + s_pendingLbResults.erase(it); + } + } + if (state.leaderboardPostedRound == round) return; state.leaderboardPostedRound = round; @@ -861,7 +1027,7 @@ static void applyMatchResult(int round, const std::vector &ent { if (e.cxId == state.user.cxId) { - postMatchScores(e); + postMatchScoresAndComputeDeltas(e); break; } } @@ -965,6 +1131,9 @@ static void onRelayConnected() state.coverageComputedGen = (unsigned long long)-1; state.resultsSentAtMs = 0; s_pendingMatchResult.clear(); + s_pendingLbResults.clear(); + state.awaitingRematch = false; + state.isProvisioning = false; // Auto geo test: relay connect confirms the region is reachable. // Record the connect time; app_update() disconnects after a 2.5s soak. @@ -1046,7 +1215,26 @@ static void onRelaySystemMessage(const Json::Value &json) state.splotches.clear(); ++state.splotchGeneration; state.gameStartTime = 0; - state.screenState = ScreenState::Lobby; + + // CursorParty rounds get the full Match Summary + rematch-queue screen + // (BCLOUD-14489); every other lobby type (geo test, RoomServer, etc.) keeps the + // old behavior of dropping straight back to the plain Lobby screen — they never + // populate state.matchResult with anything meaningful for this screen to show. + if (isCursorPartyLobby(settings.lobbyType) && !settings.autoGeoTest) + { + state.screenState = ScreenState::MatchSummary; + state.matchSummaryArrivalTime = std::chrono::steady_clock::now(); + state.awaitingRematch = true; + // 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, since nothing else resets it here. + pBCWrapper->getLobbyService()->updateReady( + state.lobby.lobbyId, false, buildExtraJson()); + } + else + { + state.screenState = ScreenState::Lobby; + } // Defer relay disconnect — cannot safely call deregister/disconnect from inside a relay callback state.pendingEndMatch = true; @@ -1133,9 +1321,8 @@ static void onRelayMessage(int netId, const Json::Value &json) for (const auto &entry : json["data"]["e"]) { - const auto &entryCxId = pBCWrapper->getRelayService()->getCxIdForNetId(entry["n"].asInt()); MatchResultEntry mre; - mre.cxId = entryCxId; + mre.cxId = entry["cx"].asString(); mre.rank = entry["r"].asInt(); mre.coveragePct = entry["c"].asInt() / 100.0f; // basis points -> % mre.beaten = entry["b"].asInt(); @@ -1149,6 +1336,39 @@ static void onRelayMessage(int netId, const Json::Value &json) } } } + else if (op == "lb_result") + { + // Each player's own leaderboard rank movement, broadcast once they've + // finished computing it (see postMatchScoresAndComputeDeltas). Can arrive + // before this round's match_result has populated state.matchResult.entries + // — buffer by cxId in that case (drained in applyMatchResult). + LeaderboardDelta delta; + delta.ready = true; + auto readPeriod = [&](const char *key, LeaderboardPeriodDelta &pd) + { + if (!json["data"].isMember(key)) return; // absent == "no change" for that period + pd.improved = true; + pd.rankBefore = json["data"][key]["b"].asInt(); + pd.rankAfter = json["data"][key]["a"].asInt(); + }; + readPeriod("pl", delta.pointsLifetime); + readPeriod("pq", delta.pointsQuarterly); + readPeriod("cl", delta.coverageLifetime); + readPeriod("cq", delta.coverageQuarterly); + + bool applied = false; + for (auto &e : state.matchResult.entries) + { + if (e.cxId == member.cxId) + { + e.lbDelta = delta; + applied = true; + break; + } + } + if (!applied) + s_pendingLbResults[member.cxId] = delta; + } else if (op == "game_start") { // Owner's authoritative start time — sync for non-owners and JIP players @@ -1399,6 +1619,9 @@ void app_update() case ScreenState::Game: game_update(); break; + case ScreenState::MatchSummary: + matchSummary_update(); + break; } // Version overlay — bottom-left, always visible on every screen @@ -1707,11 +1930,13 @@ static void onLobbyEvent(const Json::Value &eventJson) settings.colorIndex = state.user.colorIndex; saveConfigs(); - // Go to loading screen; reset timer so it counts from provisioning start - state.screenState = ScreenState::Starting; - loading_text = "Starting..."; - loading_reset_timer(); - loading_status = "Provisioning server..."; + // 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/cancel screen. isProvisioning just drives a + // small inline status line (see lobby.cpp); the actual screen change to Game only + // happens once relay truly connects (RelayConnectCallback::relayConnectSuccess). + state.isProvisioning = true; + state.provisioningStatus = "Provisioning server..."; } else if (operation == "ROOM_PROGRESS") { @@ -1720,15 +1945,15 @@ static void onLobbyEvent(const Json::Value &eventJson) const auto &msg = jsonData["msg"].asString(); char buf[128]; snprintf(buf, sizeof(buf), "%d/%d: %s", curStep, ofStep, msg.c_str()); - loading_status = buf; + state.provisioningStatus = buf; } else if (operation == "ROOM_ASSIGNED") { - loading_status = "Server assigned..."; + state.provisioningStatus = "Server assigned..."; } else if (operation == "ROOM_READY") { - loading_status = "Connecting..."; + state.provisioningStatus = "Connecting..."; state.server = parseServer(jsonData); // Record which region was actually launched for the geo test. @@ -1807,8 +2032,8 @@ void app_sendLobbySignal(const std::string &text) // Connect to the Relay server and start the game static void startGame() { - state.screenState = ScreenState::Starting; - + // No screenState change here — we're already sitting on Lobby (or wherever the STARTING + // event's isProvisioning banner started rendering) the whole way through to Game. pBCWrapper->getRelayService()->registerRelayCallback(&bcRelayCallback); pBCWrapper->getRelayService()->registerSystemCallback(&bcRelaySystemCallback); @@ -1924,19 +2149,64 @@ void app_closeGame() app_enableChatRTT(); // RTT was just disabled above — re-enable it for main-menu chat } -// Ready up and signals RTT service we can start the game +// Ready up and signals RTT service we can start the game. Stays on whatever screen the +// caller is already on (Lobby) — the STARTING lobby event that follows drives the +// non-blocking provisioning banner, not a screen change (see onLobbyEvent). void app_startGame() { state.user.isReady = true; - state.screenState = ScreenState::Starting; - loading_text = "Starting..."; - loading_reset_timer(); + state.awaitingRematch = false; // in case this was called by the rematch gate below pBCWrapper->getLobbyService()->updateReady( state.lobby.lobbyId, state.user.isReady, buildExtraJson()); } +// Marks this player as queued for a rematch AND takes them back to the Lobby screen — +// called both from the Match Summary screen's "Queue for Rematch" button and from its own +// per-player 15s auto-timeout (matchSummary_update()), so either path looks identical from +// here on: the player sits in the Lobby (chatting, etc.) waiting for app_tickRematchGate() +// below to actually start the next round. +void app_setRematchReady(bool ready) +{ + state.user.isReady = ready; + if (ready) + state.screenState = ScreenState::Lobby; + pBCWrapper->getLobbyService()->updateReady( + state.lobby.lobbyId, ready, buildExtraJson()); +} + +// Host-only gate on starting the next round: waits until every current lobby member has +// queued for a rematch (each auto-queues themselves within MATCH_SUMMARY_REMATCH_MS at the +// latest — see matchSummary.cpp — so this is mostly a safety net against clock skew between +// clients) OR that same deadline elapses regardless, whichever comes first. Once satisfied, +// calls the exact app_startGame() that already starts every round — no separate "begin +// round 2" mechanism needed. Non-host clients just display the shared countdown/count and +// wait for the resulting STARTING lobby event like they already do for the very first +// round. isHost is re-evaluated every call, so a host migration while some players are +// still on the Match Summary screen is picked up for free. Called once per frame from both +// lobby_update() and matchSummary_update() — whichever screen the host itself happens to be +// on, this still needs to keep evaluating for the other players who haven't returned yet. +void app_tickRematchGate() +{ + if (!state.awaitingRematch) return; + + bool isHost = !state.lobby.ownerCxId.empty() && state.user.cxId == state.lobby.ownerCxId; + if (!isHost) return; + + bool allReady = !state.lobby.members.empty(); + for (const auto &m : state.lobby.members) + { + if (!m.isReady) { allReady = false; break; } + } + + auto elapsedMs = std::chrono::duration_cast( + std::chrono::steady_clock::now() - state.matchSummaryArrivalTime).count(); + + if (allReady || elapsedMs >= MATCH_SUMMARY_REMATCH_MS) + app_startGame(); +} + // User changes his player color void app_changeUserColor(int colorIndex) { diff --git a/relaytestapp/src/app.h b/relaytestapp/src/app.h index 1fa34f7..77e2510 100644 --- a/relaytestapp/src/app.h +++ b/relaytestapp/src/app.h @@ -70,6 +70,15 @@ void app_endMatch(); // Ready up and signals RTT service we can start the game void app_startGame(); +// Marks this player queued for a rematch and takes them back to the Lobby screen — +// used by both the Match Summary screen's button and its own auto-timeout. +void app_setRematchReady(bool ready); + +// Host-only: starts the next round once everyone has queued for a rematch, or the +// 15s auto-rematch timer elapses. Called once per frame from both lobby_update() and +// matchSummary_update() (whichever screen the host is currently on). +void app_tickRematchGate(); + // User changes his player color void app_changeUserColor(int colorIndex); diff --git a/relaytestapp/src/coverage.cpp b/relaytestapp/src/coverage.cpp index d8c6901..ef11420 100644 --- a/relaytestapp/src/coverage.cpp +++ b/relaytestapp/src/coverage.cpp @@ -42,66 +42,25 @@ std::vector computeCoverage(const std::vector &splotches const int N = (int)splotches.size(); if (N > 0) { - // Uniform grid over the canvas, cell size = obscure-check neighborhood unit. - // Only a 3x3 cell neighborhood can contain a splotch within SPLOTCH_RADIUS, - // since SPLOTCH_RADIUS == cell size / 2. - const float CELL = SPLOTCH_DISPLAY_SIZE; - const int gridW = (int)(CANVAS_W / CELL) + 2; - const int gridH = (int)(CANVAS_H / CELL) + 2; - std::vector> grid(gridW * gridH); - - auto cellX = [&](int x) { - int cx = (int)(x / CELL); - return std::max(0, std::min(gridW - 1, cx)); - }; - auto cellY = [&](int y) { - int cy = (int)(y / CELL); - return std::max(0, std::min(gridH - 1, cy)); - }; - - const int R2 = (int)(SPLOTCH_RADIUS * SPLOTCH_RADIUS); // strict '<' obscure radius, squared - std::vector visible(N, true); - - // Sweep last-painted -> first. The grid at step i contains only splotches - // painted AFTER i (i.e. "on top" of it), which is exactly what "visible on - // the top layer" needs to check against. - for (int i = N - 1; i >= 0; --i) - { - const Splotch &s = splotches[i]; - int cx = cellX(s.pos.x); - int cy = cellY(s.pos.y); - bool obscured = false; - - for (int dy = -1; dy <= 1 && !obscured; ++dy) - { - int ny = cy + dy; - if (ny < 0 || ny >= gridH) continue; - for (int dx = -1; dx <= 1; ++dx) - { - int nx = cx + dx; - if (nx < 0 || nx >= gridW) continue; - const auto &cell = grid[nx + ny * gridW]; - for (int j : cell) - { - int ddx = splotches[j].pos.x - s.pos.x; - int ddy = splotches[j].pos.y - s.pos.y; - if (ddx * ddx + ddy * ddy < R2) - { - obscured = true; - break; - } - } - if (obscured) break; - } - } - - visible[i] = !obscured; - grid[cx + cy * gridW].push_back(i); - } + // Ownership grid: each cell records which player (by result[] index) most + // recently painted over it. Splotches are stamped in paint order (a filled circle + // of radius SPLOTCH_RADIUS), so a later splotch always overwrites an earlier one + // wherever they overlap — exactly matching what's rendered on screen (paint order + // = draw order = "last one wins"). -1 = unpainted, or painted by something that + // couldn't be attributed to any current member (still overwrites the grid, so it + // correctly obscures whoever was there before, it just credits no one). + const float CELL = COVERAGE_GRID_CELL_SIZE; + const int gridW = (int)(CANVAS_W / CELL); + const int gridH = (int)(CANVAS_H / CELL); + + static std::vector owner; // reused across calls (this runs every ~250ms mid-match) + owner.assign((size_t)gridW * gridH, -1); + + const float R2 = SPLOTCH_RADIUS * SPLOTCH_RADIUS; + const int cellRadius = (int)std::ceil(SPLOTCH_RADIUS / CELL); for (int i = 0; i < N; ++i) { - if (!visible[i]) continue; const Splotch &s = splotches[i]; int idx = -1; @@ -124,15 +83,39 @@ std::vector computeCoverage(const std::vector &splotches } } } + + int cx = (int)(s.pos.x / CELL); + int cy = (int)(s.pos.y / CELL); + for (int dy = -cellRadius; dy <= cellRadius; ++dy) + { + int gy = cy + dy; + if (gy < 0 || gy >= gridH) continue; + float py = (gy + 0.5f) * CELL; + float ddy = py - (float)s.pos.y; + for (int dx = -cellRadius; dx <= cellRadius; ++dx) + { + int gx = cx + dx; + if (gx < 0 || gx >= gridW) continue; + float px = (gx + 0.5f) * CELL; + float ddx = px - (float)s.pos.x; + if (ddx * ddx + ddy * ddy <= R2) + owner[gx + gy * gridW] = idx; // may be -1 — still overwrites, credits no one + } + } + } + + for (int c = 0, count = gridW * gridH; c < count; ++c) + { + int idx = owner[c]; if (idx >= 0) result[idx].visibleCount++; } } - const float splotchArea = 3.14159265f * SPLOTCH_RADIUS * SPLOTCH_RADIUS; + const float cellArea = COVERAGE_GRID_CELL_SIZE * COVERAGE_GRID_CELL_SIZE; const float canvasArea = CANVAS_W * CANVAS_H; for (auto &e : result) - e.coveragePct = std::min(100.0f, e.visibleCount * splotchArea / canvasArea * 100.0f); + e.coveragePct = std::min(100.0f, e.visibleCount * cellArea / canvasArea * 100.0f); std::sort(result.begin(), result.end(), [](const CoverageEntry &a, const CoverageEntry &b) { if (a.coveragePct != b.coveragePct) return a.coveragePct > b.coveragePct; diff --git a/relaytestapp/src/coverage.h b/relaytestapp/src/coverage.h index 72feb76..6cc68cd 100644 --- a/relaytestapp/src/coverage.h +++ b/relaytestapp/src/coverage.h @@ -24,23 +24,25 @@ // Computes each member's canvas coverage and ranks them. // -// Algorithm (matches the ticket's own rule — "check all splotches whose centers are -// visible on the top layer" — grid-accelerated so it doesn't cost O(N^2)): -// Sweep splotches from last-painted to first, maintaining a uniform spatial grid of -// already-swept (i.e. later-painted / "on top") splotches. A splotch's center is -// "visible" iff no later splotch has a center within SPLOTCH_RADIUS of it (strict '<' -// on the squared distance, cell size = SPLOTCH_DISPLAY_SIZE so only the 3x3 cell -// neighborhood needs checking). +// 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 — it replaced an earlier center-point/ +// obscure-radius heuristic that could diverge sharply from the actual visible area once +// splotches were dense/overlapping (e.g. a long stress-test match). // -// coveragePct is ABSOLUTE canvas-area coverage (visibleCount * splotch-area / canvas-area, -// clamped to 100) — NOT a share of painted area — so a solo match doesn't trivially score -// 100%. Splotches whose ownerCxId doesn't match any current member (a not-yet-ported -// legacy sender, or a departed player) fall back to a colorIndex match against a live -// member; if that also fails, the splotch counts toward no one. +// 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%. Splotches whose ownerCxId doesn't match any current member (a +// not-yet-ported legacy sender, or a departed player) fall back to a colorIndex match +// against a live member; if that also fails, the splotch still overwrites the grid +// (correctly obscuring whatever was under it) but credits no one. // // Every current member gets a seeded zero-coverage entry, so painters-of-nothing still // appear on the board, ranked last. Result is sorted best-first (coveragePct desc, // visibleCount desc, cxId asc for determinism); ties share a rank and don't "beat" each -// other in CoverageEntry::beaten. +// other in CoverageEntry::beaten. (visibleCount is now an owned-cell count, not a splotch +// count — the field wasn't renamed since nothing outside this file inspects it directly.) std::vector computeCoverage(const std::vector &splotches, const std::vector &members); diff --git a/relaytestapp/src/globals.h b/relaytestapp/src/globals.h index b6492b1..3b7a05a 100644 --- a/relaytestapp/src/globals.h +++ b/relaytestapp/src/globals.h @@ -118,14 +118,24 @@ static constexpr float SPLOTCH_DISPLAY_SIZE = 64.0f; // they define the wire-normalized 0..1 coordinate space AND the coverage % denominator. static constexpr float CANVAS_W = 800.0f; static constexpr float CANVAS_H = 600.0f; -static constexpr float SPLOTCH_RADIUS = SPLOTCH_DISPLAY_SIZE * 0.5f; // 32 — obscure radius for coverage visibility +static constexpr float SPLOTCH_RADIUS = SPLOTCH_DISPLAY_SIZE * 0.5f; // 32 — splotch stamp radius for coverage scoring + +// computeCoverage()'s ownership-grid cell size in canvas pixels — the resolution at which +// "who currently owns this bit of the board" is tracked. Small enough (relative to +// SPLOTCH_RADIUS) that the per-player % is a close read of the actual painted area, not a +// coarse approximation. All ports should use the same value so scores/behavior match. +static constexpr float COVERAGE_GRID_CELL_SIZE = 2.0f; // Match timing (moved here from game.cpp so app_tickMatch() and the HUD can both see them // regardless of where the timer widget is drawn). -static constexpr long long MATCH_DURATION_MS = 90000LL; +static constexpr long long MATCH_DURATION_MS = 35000LL; static constexpr long long RESULT_GRACE_MS = 1000LL; // delay between match_result broadcast and endMatch() static constexpr long long COVERAGE_RECOMPUTE_MS = 250LL; // live-board recompute throttle +// How long the post-match summary screen waits for everyone to queue for a rematch +// before the host starts the next round anyway (BCLOUD-14489). +static constexpr long long MATCH_SUMMARY_REMATCH_MS = 45000LL; + // Screen state enum. enum class ScreenState : int { @@ -135,7 +145,8 @@ enum class ScreenState : int JoiningLobby, Lobby, /* Lobby screen */ Starting, - Game /* Game screen */ + Game, /* Game screen */ + MatchSummary /* Post-match results + rematch-queue screen (BCLOUD-14489) */ }; // A point in 2D space @@ -217,7 +228,7 @@ struct CoverageEntry { std::string cxId; int colorIndex = -1; - int visibleCount = 0; /* splotches whose center isn't obscured by a later one */ + int visibleCount = 0; /* ownership-grid cells currently painted by this player (see coverage.h) */ float coveragePct = 0.0f; /* clamped [0,100] */ int rank = 1; /* 1-based; ties share a rank */ int prevRank = 1; /* previous recompute's rank, for the rank-swap flash */ @@ -225,6 +236,30 @@ struct CoverageEntry int beaten = 0; /* players strictly below this one (ties don't count) */ }; +// One leaderboard period's (Lifetime or Quarterly) rank-before/after for a single board. +// "improved" is the trigger for whether the summary screen shows a badge at all — for +// the points boards that means the rank got numerically better; for the coverage boards +// it means this round's score replaced a lower personal best (see postMatchScoresAndComputeDeltas). +struct LeaderboardPeriodDelta +{ + bool improved = false; + int rankBefore = -1; /* -1 = unranked/no score yet before this round */ + int rankAfter = -1; +}; + +// Personal leaderboard-rank movement from posting this round's score, across all four +// boards. Computed by each client for ITSELF only (there's no API to fetch an arbitrary +// other player's before/after rank) and broadcast to the rest of the match via the +// "lb_result" relay op — see postMatchScoresAndComputeDeltas / sendLeaderboardDeltaToMask. +struct LeaderboardDelta +{ + bool ready = false; /* true once this player's own delta has been computed (self) or received (others) */ + LeaderboardPeriodDelta pointsLifetime; + LeaderboardPeriodDelta pointsQuarterly; + LeaderboardPeriodDelta coverageLifetime; /* "improved" = new coverage personal best */ + LeaderboardPeriodDelta coverageQuarterly; +}; + // One player's entry in a host-broadcast, authoritative match_result. struct MatchResultEntry { @@ -232,6 +267,7 @@ struct MatchResultEntry int rank = 0; float coveragePct = 0.0f; int beaten = 0; + LeaderboardDelta lbDelta; /* filled in asynchronously — see State::matchResult */ }; // Authoritative snapshot of a finished match's standings, broadcast by the (possibly @@ -287,6 +323,15 @@ struct State long long resultsSentAtMs = 0; /* when match_result was broadcast, for the grace period */ MatchResult matchResult; /* authoritative result once applied (host or non-host) */ int leaderboardPostedRound = -1; /* guards against double-posting the CUMULATIVE points board */ + std::chrono::steady_clock::time_point matchSummaryArrivalTime; /* when the MatchSummary screen appeared, for the 15s auto-rematch countdown */ + bool awaitingRematch = false; /* true from END_MATCH until the next round actually starts — gates app_tickRematchGate() */ + + // Non-blocking "starting the next round" feedback (BCLOUD-14489 follow-up): the Lobby + // screen stays up (chat/etc. still usable) through the whole STARTING->ROOM_READY + // provisioning sequence instead of jumping to a blocking loading/cancel screen; this + // is what the Lobby screen renders as a small inline status line while it's true. + bool isProvisioning = false; + std::string provisioningStatus; // Leaderboard ids — read from brainCloud global properties in applyLobbyTypes(), same // mechanism as AllLobbyTypes/Colours/SplotchDuration. Defaults let the app run before diff --git a/relaytestapp/src/lobby.cpp b/relaytestapp/src/lobby.cpp index ec7baa9..b2082a3 100644 --- a/relaytestapp/src/lobby.cpp +++ b/relaytestapp/src/lobby.cpp @@ -176,6 +176,14 @@ static void drawLobbyMembersPanel(float x, float y) if (elapsed >= std::chrono::milliseconds(1500)) app_startGame(); } + else if (state.awaitingRematch) + { + // Rematch flow is fully automatic (app_tickRematchGate, ticked above) — no + // manual override here, so a host who returns early can't skip the "wait for + // stragglers or 15s" window the user asked for. + ImGui::SameLine(); + ImGui::TextDisabled("Waiting for other players to return..."); + } else { ImGui::SameLine(); @@ -384,9 +392,31 @@ static void drawLobbyInfoTab(float x, float y, float w, float h) ImGui::End(); } +// Small non-blocking status line shown while a round is being provisioned (STARTING -> +// ROOM_READY) — the Lobby screen (chat, member list, etc.) stays fully usable underneath +// it instead of being replaced by a blocking loading/cancel screen (BCLOUD-14489 follow-up). +static void drawProvisioningBanner() +{ + if (!state.isProvisioning) return; + + ImGui::SetNextWindowPos(ImVec2((float)width / 2.0f, ImGui::GetFrameHeight() + 8.0f), ImGuiCond_Always, ImVec2(0.5f, 0.0f)); + ImGui::SetNextWindowBgAlpha(0.75f); + ImGui::Begin("##provisioning_banner", nullptr, + ImGuiWindowFlags_NoDecoration | + ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoSavedSettings | + ImGuiWindowFlags_AlwaysAutoResize); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.4f, 1.0f), "Starting round... %s", state.provisioningStatus.c_str()); + ImGui::End(); +} + // Draws the lobby screen and updates its logic. void lobby_update() { + // Keeps the host's auto-rematch decision progressing even after the host itself has + // already returned here from the Match Summary screen while other players haven't yet. + app_tickRematchGate(); + float totalWidth = LOBBY_LEFT_WIDTH + LOBBY_GAP + LOBBY_RIGHT_WIDTH; float startX = (float)width / 2.0f - totalWidth / 2.0f; float y = (float)height / 2.0f - LOBBY_PANEL_HEIGHT / 2.0f; @@ -394,6 +424,7 @@ void lobby_update() drawLobbyMembersPanel(startX, y); drawLobbyRightTabs(rightX, y); + drawProvisioningBanner(); switch (s_lobbyRightTab) { diff --git a/relaytestapp/src/matchSummary.cpp b/relaytestapp/src/matchSummary.cpp new file mode 100644 index 0000000..0a13dee --- /dev/null +++ b/relaytestapp/src/matchSummary.cpp @@ -0,0 +1,291 @@ +//----------------------------------------------------------------------------- +// Copyright 2021 bitHeads inc. +//----------------------------------------------------------------------------- +// File: matchSummary.cpp +// Desc: 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. +//----------------------------------------------------------------------------- + +#include "matchSummary.h" +#include "app.h" +#include "globals.h" +#include "leaderboardPanel.h" + +#include +#include +#include + +static constexpr float PANEL_WIDTH = 940.0f; +static constexpr float PANEL_HEIGHT = 640.0f; + +static const ImVec4 COLOR_ME(0.35f, 1.0f, 0.45f, 1.0f); +static const ImVec4 COLOR_GOOD(0.45f, 0.95f, 0.55f, 1.0f); +static const ImVec4 COLOR_BEST(1.00f, 0.84f, 0.30f, 1.0f); +static const ImVec4 COLOR_DIM(0.55f, 0.58f, 0.65f, 1.0f); + +// A small rounded-rect "pill" with text inside, drawn at the current cursor position and +// advancing it — the same AddRectFilled-behind-text trick used for the "YOU"/"HOST" +// badges elsewhere (lobby.cpp/game.cpp), just wrapped into a helper since this screen +// needs several distinct pill styles side by side. +static void drawPill(const char *text, const ImVec4 &bg, const ImVec4 &fg) +{ + ImVec2 textSize = ImGui::CalcTextSize(text); + ImVec2 p0 = ImGui::GetCursorScreenPos(); + ImVec2 p1 = ImVec2(p0.x + textSize.x + 16.0f, p0.y + textSize.y + 8.0f); + ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, ImColor(bg), 6.0f); + ImGui::SetCursorScreenPos(ImVec2(p0.x + 8.0f, p0.y + 4.0f)); + ImGui::TextColored(fg, "%s", text); + ImGui::SetCursorScreenPos(ImVec2(p1.x + 6.0f, p0.y)); +} + +// Builds the "Lifetime #before->#after · Quarterly #before->#after" tail for whichever +// periods actually improved on one board (points OR coverage) — periods that didn't +// improve are simply omitted, matching the reference mockup (e.g. one player's badge +// only mentions Quarterly because their Lifetime rank didn't move that round). +static std::string periodsText(const LeaderboardPeriodDelta &lifetime, const LeaderboardPeriodDelta &quarterly) +{ + std::string out; + char buf[64]; + // Plain ASCII only — ImGui's default font atlas doesn't cover the Unicode arrow/dot + // glyphs this used to use (they rendered as "?" — a missing-glyph fallback, not a bug + // in the badge logic itself). + if (lifetime.improved) + { + snprintf(buf, sizeof(buf), "Lifetime #%d->#%d", lifetime.rankBefore, lifetime.rankAfter); + out += buf; + } + if (quarterly.improved) + { + if (!out.empty()) out += " | "; + snprintf(buf, sizeof(buf), "Quarterly #%d->#%d", quarterly.rankBefore, quarterly.rankAfter); + out += buf; + } + return out; +} + +// One player's row: rank/color/name/coverage header line, then a badges line for +// points earned + any leaderboard movement (BCLOUD-14489's "player cards"). +static void drawPlayerCard(const MatchResultEntry &entry, float width) +{ + const User *pMember = nullptr; + for (const auto &m : state.lobby.members) + { + if (m.cxId == entry.cxId) { pMember = &m; break; } + } + std::string name = pMember ? pMember->name : "?"; + int colorIndex = pMember ? pMember->colorIndex : 0; + bool isMe = (entry.cxId == state.user.cxId); + + // How many badge lines this card needs, so it gets an explicit height instead of + // BeginChild's height=0 — inside a scrolling parent that means "fill ALL remaining + // space", not "auto-fit to content", which is what was making every card after the + // first one collapse to zero height (present in state.matchResult.entries, just + // invisible). + int badgeLines = 1; // "+N pts ..." always shows + if (!entry.lbDelta.ready) + badgeLines += 1; // "Updating leaderboards..." + else + { + bool anyPointsUp = entry.lbDelta.pointsLifetime.improved || entry.lbDelta.pointsQuarterly.improved; + bool anyCoverageBest = entry.lbDelta.coverageLifetime.improved || entry.lbDelta.coverageQuarterly.improved; + badgeLines += anyPointsUp ? 1 : 0; + badgeLines += anyCoverageBest ? 1 : 0; + if (!anyPointsUp && !anyCoverageBest) + badgeLines += 1; // "No leaderboard rank change this round" + } + float lineH = ImGui::GetTextLineHeightWithSpacing(); + float cardHeight = lineH * (2 /* header + coverage caption */ + badgeLines) + 28.0f /* spacing + child padding */; + + ImGui::BeginChild(("##card_" + entry.cxId).c_str(), ImVec2(width, cardHeight), true, + ImGuiWindowFlags_NoScrollbar); + if (isMe) + ImGui::GetWindowDrawList()->AddRect(ImGui::GetWindowPos(), + ImVec2(ImGui::GetWindowPos().x + ImGui::GetWindowSize().x, ImGui::GetWindowPos().y + ImGui::GetWindowSize().y), + ImColor(COLOR_ME), 6.0f, 0, 1.5f); + + // Header: #rank, color dot, name (+YOU), coverage % right-aligned. The dot is drawn + // directly (not a text glyph) — font-independent, unlike a Unicode "●" character. + ImGui::TextColored(rankColorFor(entry.rank), "#%d", entry.rank); + ImGui::SameLine(); + { + float r = ImGui::GetTextLineHeight() * 0.3f; + ImVec2 p = ImGui::GetCursorScreenPos(); + ImVec2 center(p.x + r, p.y + ImGui::GetTextLineHeight() * 0.5f); + ImGui::GetWindowDrawList()->AddCircleFilled(center, r, ImColor(getColor(colorIndex % colorCount()))); + ImGui::Dummy(ImVec2(r * 2.0f + 4.0f, ImGui::GetTextLineHeight())); + } + ImGui::SameLine(); + ImGui::TextColored(isMe ? COLOR_ME : ImVec4(1, 1, 1, 1), "%s", name.c_str()); + if (isMe) + { + ImGui::SameLine(); + ImGui::TextDisabled("(YOU)"); + } + + // Coverage %, with a small "COVERAGE" caption stacked underneath — both right- + // aligned to the same X, matching the mockup's stacked "41% / COVERAGE" block. + { + char covBuf[16]; + snprintf(covBuf, sizeof(covBuf), "%.0f%%", entry.coveragePct); + float blockW = std::max(ImGui::CalcTextSize(covBuf).x, ImGui::CalcTextSize("COVERAGE").x); + float rightX = width - blockW - 20.0f; + float headerY = ImGui::GetCursorPosY() - ImGui::GetTextLineHeightWithSpacing(); + + ImGui::SetCursorPos(ImVec2(rightX, headerY)); + ImGui::TextColored(isMe ? COLOR_ME : ImVec4(1, 1, 1, 1), "%s", covBuf); + ImGui::SetCursorPos(ImVec2(rightX, headerY + ImGui::GetTextLineHeightWithSpacing())); + ImGui::TextDisabled("COVERAGE"); + } + + ImGui::Spacing(); + + // Badges row: points earned, then leaderboard movement (or "no change"). + char ptsBuf[64]; + snprintf(ptsBuf, sizeof(ptsBuf), "+%d pts", entry.beaten + 1); + drawPill(ptsBuf, ImVec4(0.20f, 0.24f, 0.34f, 1.0f), ImVec4(0.75f, 0.82f, 1.0f, 1.0f)); + char subBuf[64]; + snprintf(subBuf, sizeof(subBuf), "%d beaten + 1 for playing", entry.beaten); + ImGui::TextDisabled("%s", subBuf); + + if (!entry.lbDelta.ready) + { + ImGui::TextDisabled("Updating leaderboards..."); + } + else + { + bool anyPointsUp = entry.lbDelta.pointsLifetime.improved || entry.lbDelta.pointsQuarterly.improved; + bool anyCoverageBest = entry.lbDelta.coverageLifetime.improved || entry.lbDelta.coverageQuarterly.improved; + + if (anyPointsUp) + { + std::string tail = periodsText(entry.lbDelta.pointsLifetime, entry.lbDelta.pointsQuarterly); + std::string label = "^ Rank up - Opponents Beaten " + tail; + drawPill(label.c_str(), ImVec4(0.16f, 0.32f, 0.20f, 1.0f), COLOR_GOOD); + ImGui::NewLine(); + } + if (anyCoverageBest) + { + std::string tail = periodsText(entry.lbDelta.coverageLifetime, entry.lbDelta.coverageQuarterly); + std::string label = "* Personal best - Coverage % " + tail; + drawPill(label.c_str(), ImVec4(0.34f, 0.28f, 0.10f, 1.0f), COLOR_BEST); + ImGui::NewLine(); + } + if (!anyPointsUp && !anyCoverageBest) + drawPill("No leaderboard rank change this round", ImVec4(0.20f, 0.20f, 0.24f, 1.0f), COLOR_DIM); + } + + ImGui::EndChild(); +} + +void matchSummary_update() +{ + app_tickRematchGate(); + + // Per-player auto-queue: if this player hasn't clicked "Queue for Rematch" themselves + // by MATCH_SUMMARY_REMATCH_MS, queue them automatically and send them back to the + // Lobby (app_setRematchReady handles the screen transition) — matches the "if players + // do nothing, auto rematch" requirement without waiting on anyone else. + if (isCursorPartyLobby(settings.lobbyType) && !state.user.isReady) + { + auto elapsedMs = std::chrono::duration_cast( + std::chrono::steady_clock::now() - state.matchSummaryArrivalTime).count(); + if (elapsedMs >= MATCH_SUMMARY_REMATCH_MS) + app_setRematchReady(true); + } + + // PANEL_WIDTH/HEIGHT are a ceiling, not a fixed size — shrink to fit whenever the + // window is smaller than that (e.g. multiple instances tiled on one screen), floored + // so the layout doesn't collapse into something unreadable. + const float MARGIN = 24.0f; + float panelW = std::min(PANEL_WIDTH, std::max(480.0f, (float)width - MARGIN * 2.0f)); + float panelH = std::min(PANEL_HEIGHT, std::max(360.0f, (float)height - ImGui::GetFrameHeight() - MARGIN * 2.0f)); + + ImGui::SetNextWindowPos(ImVec2((float)width / 2.0f, (float)height / 2.0f), ImGuiCond_Always, ImVec2(0.5f, 0.5f)); + ImGui::SetNextWindowSize(ImVec2(panelW, panelH), ImGuiCond_Always); + ImGui::Begin("##match_summary", nullptr, + ImGuiWindowFlags_NoCollapse | + ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoTitleBar); + + ImGui::SetWindowFontScale(1.4f); + ImGui::Text("Match Summary"); + ImGui::SetWindowFontScale(1.0f); + ImGui::TextDisabled("Lobby %s - %d Players", state.lobby.lobbyId.c_str(), (int)state.lobby.members.size()); + ImGui::Spacing(); + + const auto &entries = state.matchResult.entries; + const MatchResultEntry *winner = entries.empty() ? nullptr : &entries.front(); + + // Winner banner + if (winner) + { + const User *pWinner = nullptr; + for (const auto &m : state.lobby.members) + if (m.cxId == winner->cxId) { pWinner = &m; break; } + std::string winnerName = pWinner ? pWinner->name : "?"; + + ImVec2 p0 = ImGui::GetCursorScreenPos(); + ImVec2 p1 = ImVec2(p0.x + ImGui::GetContentRegionAvail().x, p0.y + 40.0f); + ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, ImColor(ImVec4(0.30f, 0.24f, 0.08f, 1.0f)), 6.0f); + ImGui::GetWindowDrawList()->AddRect(p0, p1, ImColor(COLOR_BEST), 6.0f, 0, 1.5f); + ImGui::SetCursorScreenPos(ImVec2(p0.x + 14.0f, p0.y + 10.0f)); + ImGui::TextColored(COLOR_BEST, "%s wins the round, covering %.0f%% of the board.", + winnerName.c_str(), winner->coveragePct); + ImGui::SetCursorScreenPos(ImVec2(p0.x, p1.y + 12.0f)); + } + else + { + ImGui::TextDisabled("Waiting for results..."); + } + + ImGui::TextDisabled("RANK / PLAYER"); + ImGui::SameLine(panelW - 200.0f); + ImGui::TextDisabled("LEADERBOARD RESULT"); + ImGui::Separator(); + + float listHeight = panelH - ImGui::GetCursorPosY() - 90.0f; + ImGui::BeginChild("##summary_scroll", ImVec2(0.0f, std::max(80.0f, listHeight)), false); + for (const auto &entry : entries) + { + drawPlayerCard(entry, ImGui::GetContentRegionAvail().x - 4.0f); + ImGui::Spacing(); + } + ImGui::EndChild(); + + ImGui::Separator(); + + // Countdown + actions + long long remainingSec = 0; + if (isCursorPartyLobby(settings.lobbyType)) + { + auto elapsedMs = std::chrono::duration_cast( + std::chrono::steady_clock::now() - state.matchSummaryArrivalTime).count(); + long long remainingMs = MATCH_SUMMARY_REMATCH_MS - elapsedMs; + if (remainingMs < 0) remainingMs = 0; + remainingSec = (remainingMs + 999) / 1000; + } + ImGui::TextDisabled("Next Round: %lld:%02lld", remainingSec / 60, remainingSec % 60); + + int readyCount = 0; + for (const auto &m : state.lobby.members) + if (m.isReady) ++readyCount; + + bool iAmReady = state.user.isReady; + char rematchLabel[64]; + snprintf(rematchLabel, sizeof(rematchLabel), "%s %d/%d", + iAmReady ? "Queued for Rematch" : "Queue for Rematch", + readyCount, (int)state.lobby.members.size()); + + if (iAmReady) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.20f, 0.55f, 0.30f, 1.0f)); + if (ImGui::Button(rematchLabel, ImVec2(panelW * 0.6f, 36.0f))) + app_setRematchReady(!iAmReady); + if (iAmReady) ImGui::PopStyleColor(); + + ImGui::SameLine(); + if (ImGui::Button("Main Menu", ImVec2(-1.0f, 36.0f))) + app_cancelLobby(); + + ImGui::End(); +} diff --git a/relaytestapp/src/matchSummary.h b/relaytestapp/src/matchSummary.h new file mode 100644 index 0000000..3fcc252 --- /dev/null +++ b/relaytestapp/src/matchSummary.h @@ -0,0 +1,10 @@ +//----------------------------------------------------------------------------- +// Copyright 2021 bitHeads inc. +//----------------------------------------------------------------------------- +// File: matchSummary.h +// Desc: Post-match results + rematch-queue screen (BCLOUD-14489) +//----------------------------------------------------------------------------- +#pragma once + +// Draws the Match Summary screen and updates its logic. +void matchSummary_update(); From 6e916a7bffbd18a469c71893f07620f98e98af78 Mon Sep 17 00:00:00 2001 From: Steve Jones Date: Fri, 7 Aug 2026 13:59:37 -0400 Subject: [PATCH 4/8] send it over cloud scripts --- relaytestapp/src/app.cpp | 381 +++++++++++++++++++------------------ relaytestapp/src/globals.h | 1 + 2 files changed, 199 insertions(+), 183 deletions(-) diff --git a/relaytestapp/src/app.cpp b/relaytestapp/src/app.cpp index bc4bc85..a8ba5c7 100644 --- a/relaytestapp/src/app.cpp +++ b/relaytestapp/src/app.cpp @@ -70,10 +70,11 @@ static void sendGameStartToMask(uint64_t playerMask); static void sendSplotchSyncToMask(uint64_t mask); static void sendMatchResultToMask(uint64_t mask, int round, const std::vector &coverage); static std::vector toMatchResultEntries(const std::vector &coverage); -static void postMatchScoresAndComputeDeltas(const MatchResultEntry &mine); +static void hostPostMatchResultsToCloud(int round, const std::vector &entries); static void applyMatchResult(int round, const std::vector &entries); static void onRelayConnected(); -static void sendLeaderboardDeltaToMask(uint64_t mask, const LeaderboardDelta &delta); +static void sendLeaderboardResultsToMask(uint64_t mask, int round, const std::vector &entries); +static void applyLeaderboardResultsFromCloud(int round, const Json::Value &resultsArr); static bool isDisconnecting = false; @@ -81,12 +82,17 @@ static bool isDisconnecting = false; // Reset on "first":true and whenever a new round starts (onRelayConnected). static std::vector s_pendingMatchResult; -// A player's "lb_result" can arrive before match_result has populated state.matchResult. -// entries for this round (they're broadcast by different senders on different channels, -// so relative ordering isn't guaranteed) — buffered here by cxId and drained into the -// matching entry as soon as applyMatchResult() sets entries. Reset every round. +// The host's "lb_result" broadcast can arrive before match_result has populated +// state.matchResult.entries for this round (different senders — host for match_result, +// possibly a migrated host for lb_result — no relative ordering guarantee between them) — +// buffered here by cxId and drained into the matching entry as soon as applyMatchResult() +// sets entries. Reset every round. static std::map s_pendingLbResults; +// Chunk accumulator for the in-progress "lb_result" reassembly, same pattern as +// s_pendingMatchResult. Reset on "first":true and whenever a new round starts. +static std::vector> s_pendingLbChunk; + // Incremented on every app_play() call. Each ping-flow lambda captures this value and // checks it before acting — stale callbacks from a previous session are silently dropped. static int s_playGeneration = 0; @@ -818,179 +824,167 @@ static std::vector toMatchResultEntries(const std::vector &entries) { if (mask == 0) return; - int netId = pBCWrapper->getRelayService()->getNetIdForCxId(state.user.cxId); - if (netId < 0 || netId >= MAX_LOBBY_MEMBERS) return; - Json::Value json; - json["op"] = "lb_result"; - json["data"]["n"] = netId; + static const int MAX_RELAY_BYTES = 900; + static const int ENVELOPE_OVERHEAD = 80; - auto putPeriod = [&](const char *key, const LeaderboardPeriodDelta &pd) + Json::FastWriter writer; + bool isFirst = true; + std::vector chunk; + int currentSize = ENVELOPE_OVERHEAD; + + auto flushChunk = [&](bool isLast) { - if (!pd.improved) return; // absent key == "no change" on receipt - json["data"][key]["b"] = pd.rankBefore; - json["data"][key]["a"] = pd.rankAfter; + if (chunk.empty() && !isLast) return; + Json::Value json; + json["op"] = "lb_result"; + json["data"]["round"] = round; + json["data"]["first"] = isFirst; + json["data"]["last"] = isLast; + Json::Value arr(Json::arrayValue); + for (const auto &entry : chunk) + arr.append(entry); + json["data"]["e"] = arr; + auto str = writer.write(json); + pBCWrapper->getRelayService()->sendToPlayers( + (const uint8_t *)str.data(), (int)str.length(), + mask, true, true, (BrainCloud::eRelayChannel)0); + isFirst = false; + chunk.clear(); + currentSize = ENVELOPE_OVERHEAD; }; - putPeriod("pl", delta.pointsLifetime); - putPeriod("pq", delta.pointsQuarterly); - putPeriod("cl", delta.coverageLifetime); - putPeriod("cq", delta.coverageQuarterly); - Json::FastWriter writer; - auto str = writer.write(json); - pBCWrapper->getRelayService()->sendToPlayers( - (const uint8_t *)str.data(), (int)str.length(), - mask, - true, // reliable - true, // ordered - (BrainCloud::eRelayChannel)0); + auto putPeriod = [](Json::Value &parent, const char *key, const LeaderboardPeriodDelta &pd) + { + if (!pd.improved) return; + parent[key]["b"] = pd.rankBefore; + parent[key]["a"] = pd.rankAfter; + }; + + for (const auto &e : entries) + { + if (!e.lbDelta.ready) continue; + + Json::Value je; + 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); + + int entrySize = (int)writer.write(je).size() + 1; + if (currentSize + entrySize > MAX_RELAY_BYTES && !chunk.empty()) + flushChunk(false); + + chunk.push_back(std::move(je)); + currentSize += entrySize; + } + flushChunk(true); } -// Writes this client's own finished delta into its match_result entry (no relay round -// trip needed for yourself) and shares it with everyone else in the match. -static void applyLeaderboardDeltaSelf(const LeaderboardDelta &delta) +// Applies the PostMatchResults.js 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. +static void applyLeaderboardResultsFromCloud(int round, const Json::Value &resultsArr) { - for (auto &e : state.matchResult.entries) + if (!(state.matchResult.valid && state.matchResult.round == round)) + return; // a newer round has already started — this response is stale + + std::map profileIdToCxId; + for (const auto &m : state.lobby.members) + if (!m.profileId.empty()) + profileIdToCxId[m.profileId] = m.cxId; + + auto readPeriod = [](const Json::Value &j) + { + LeaderboardPeriodDelta pd; + pd.rankBefore = j["before"].asInt(); + pd.rankAfter = j["after"].asInt(); + pd.improved = j["improved"].asBool(); + return pd; + }; + + for (const auto &r : resultsArr) { - if (e.cxId == state.user.cxId) + auto it = profileIdToCxId.find(r["profileId"].asString()); + if (it == profileIdToCxId.end()) continue; + + LeaderboardDelta delta; + delta.ready = true; + delta.pointsLifetime = readPeriod(r["pointsLifetime"]); + delta.pointsQuarterly = readPeriod(r["pointsQuarterly"]); + delta.coverageLifetime = readPeriod(r["coverageLifetime"]); + delta.coverageQuarterly = readPeriod(r["coverageQuarterly"]); + + for (auto &e : state.matchResult.entries) { - e.lbDelta = delta; - break; + if (e.cxId == it->second) { e.lbDelta = delta; break; } } } - sendLeaderboardDeltaToMask(getPlayerMask(), delta); + + sendLeaderboardResultsToMask(getPlayerMask(), round, state.matchResult.entries); } -// One board's full before -> post -> after chain. Fetches the player's current rank/score -// on leaderboardId (GetGlobalLeaderboardView with before/afterCount 0 is the documented way -// to get just the caller's own entry), posts the new score, then re-fetches to see what -// actually stuck. isPersonalBestStyle selects what "improved" means: -// - coverage boards (kept-best score): improved = this score beat the previous best. -// Posting a LOWER coverage than your best is a no-op server-side, so comparing scores -// is the only reliable signal — rank alone could move for reasons unrelated to this -// round (other players posting later). -// - points boards (cumulative score): every post raises the score, so "did the score -// increase" is always true and useless; the real signal is whether RANK improved. -// Either way rankBefore/rankAfter are captured for display regardless of which one gates -// the badge — the summary screen's "Personal best" badge still shows a rank movement. -static void chainLeaderboardBoard( - const std::string &leaderboardId, int64_t score, const std::string &otherDataStr, - bool isPersonalBestStyle, - std::shared_ptr outDelta, - std::shared_ptr remaining, - std::function finalize) +// Host-only: posts the WHOLE round's results to the four leaderboards in one trusted +// server-side call (BCLOUD-14489 cloud-code migration — see PostMatchResults.js). This +// replaces what used to be up to 12 direct client API calls PER PLAYER (before-fetch + +// post + after-fetch x 4 boards, run independently by every client) with a single +// runScript call from the host; the script itself uses postScoreToLeaderboardOnBehalfOf, +// which is Cloud-Code-only, so individual clients can no longer post to these boards at +// all — closing the "any client can post any score for itself" hole client-side posting +// had. Coverage score is basis points (0-10000) so the portal isn't stuck with float +// scores; points score is "players beaten" + a flat completion bonus (so a solo match — +// 0 beaten — still posts 1, per the ticket: "+1 bonus point for completing a game"). +static void hostPostMatchResultsToCloud(int round, const std::vector &entries) { - if (leaderboardId.empty()) + Json::Value payload; + payload["round"] = round; + payload["pointsLeaderboardId"] = state.pointsLeaderboardId; + payload["pointsLeaderboardIdQuarterly"] = state.pointsLeaderboardIdQuarterly; + payload["coverageLeaderboardId"] = state.coverageLeaderboardId; + payload["coverageLeaderboardIdQuarterly"] = state.coverageLeaderboardIdQuarterly; + + Json::Value entriesArr(Json::arrayValue); + for (const auto &e : entries) { - if (--(*remaining) == 0) finalize(); - return; + const User *pMember = nullptr; + for (const auto &m : state.lobby.members) + if (m.cxId == e.cxId) { pMember = &m; break; } + if (!pMember || pMember->profileId.empty()) continue; // can't post server-side without a profileId + + Json::Value je; + je["profileId"] = pMember->profileId; + je["name"] = pMember->name; + je["points"] = e.beaten + 1; + je["coverageBasisPoints"] = (int)(e.coveragePct * 100.0f + 0.5f); + entriesArr.append(je); } + payload["entries"] = entriesArr; - auto onDone = [=]() { if (--(*remaining) == 0) finalize(); }; + Json::FastWriter writer; + auto payloadStr = writer.write(payload); - pBCWrapper->getSocialLeaderboardService()->getGlobalLeaderboardView( - leaderboardId.c_str(), BrainCloud::HIGH_TO_LOW, 0, 0, + pBCWrapper->getScriptService()->runScript("PostMatchResults", payloadStr, new BCCallback( - [=](const Json::Value &beforeResult) + [round](const Json::Value &result) { - const auto &beforeArr = beforeResult["data"]["leaderboard"]; - int rankBefore = -1; - int64_t scoreBefore = -1; - if (!beforeArr.empty()) - { - rankBefore = beforeArr[0]["rank"].asInt(); - scoreBefore = beforeArr[0]["score"].asInt64(); - } - - pBCWrapper->getSocialLeaderboardService()->postScoreToLeaderboard( - leaderboardId.c_str(), score, otherDataStr, - new BCCallback( - [=](const Json::Value &) - { - pBCWrapper->getSocialLeaderboardService()->getGlobalLeaderboardView( - leaderboardId.c_str(), BrainCloud::HIGH_TO_LOW, 0, 0, - new BCCallback( - [=](const Json::Value &afterResult) - { - const auto &afterArr = afterResult["data"]["leaderboard"]; - int rankAfter = -1; - int64_t scoreAfter = -1; - if (!afterArr.empty()) - { - rankAfter = afterArr[0]["rank"].asInt(); - scoreAfter = afterArr[0]["score"].asInt64(); - } - outDelta->rankBefore = rankBefore; - outDelta->rankAfter = rankAfter; - outDelta->improved = isPersonalBestStyle - ? (scoreBefore < 0 || scoreAfter > scoreBefore) - : (rankAfter > 0 && (rankBefore < 0 || rankAfter < rankBefore)); - onDone(); - }, - [=](const std::string &) { outDelta->rankBefore = rankBefore; onDone(); })); - }, - [=](const std::string &) { onDone(); })); + applyLeaderboardResultsFromCloud(round, result["data"]["results"]); }, - [=](const std::string &) + [round](const std::string &msg) { - // Before-fetch failed — still try to post so the score isn't lost, just - // without a delta to show (rankBefore/After stay -1, improved stays false). - pBCWrapper->getSocialLeaderboardService()->postScoreToLeaderboard( - leaderboardId.c_str(), score, otherDataStr, - new BCCallback([=](const Json::Value &) { onDone(); }, - [=](const std::string &) { onDone(); })); + printf("[DEBUG] PostMatchResults script call failed for round %d: %s\n", round, msg.c_str()); })); } -// Posts this client's own final standing to the four leaderboards, and computes/shares -// how that changed its own rank on each of them (BCLOUD-14489's Match Summary screen). -// Coverage score is basis points (0-10000) so the portal isn't stuck with float scores; -// points score is "players beaten" + a flat completion bonus (so a solo match — 0 beaten -// — still posts 1, per the ticket: "+1 bonus point for completing a game"). -static void postMatchScoresAndComputeDeltas(const MatchResultEntry &mine) -{ - int64_t basisPoints = (int64_t)(mine.coveragePct * 100.0f + 0.5f); - int64_t points = mine.beaten + 1; - - Json::Value otherData; - otherData["round"] = state.roundNumber; - otherData["rank"] = mine.rank; - // GetGlobalLeaderboardPage/View don't return a usable "name" field for arbitrary - // (non-friend) entries — embedding it in the score's user-defined data is the - // standard way to show a display name in a leaderboard viewer (see mainMenu.cpp). - otherData["name"] = state.user.name; - Json::FastWriter writer; - auto otherDataStr = writer.write(otherData); - - auto pointsLifetime = std::make_shared(); - auto pointsQuarterly = std::make_shared(); - auto coverageLifetime = std::make_shared(); - auto coverageQuarterly = std::make_shared(); - auto remaining = std::make_shared(4); - - auto finalize = [pointsLifetime, pointsQuarterly, coverageLifetime, coverageQuarterly]() - { - LeaderboardDelta delta; - delta.ready = true; - delta.pointsLifetime = *pointsLifetime; - delta.pointsQuarterly = *pointsQuarterly; - delta.coverageLifetime = *coverageLifetime; - delta.coverageQuarterly = *coverageQuarterly; - applyLeaderboardDeltaSelf(delta); - }; - - chainLeaderboardBoard(state.pointsLeaderboardId, points, otherDataStr, false, pointsLifetime, remaining, finalize); - chainLeaderboardBoard(state.pointsLeaderboardIdQuarterly, points, otherDataStr, false, pointsQuarterly, remaining, finalize); - chainLeaderboardBoard(state.coverageLeaderboardId, basisPoints, otherDataStr, true, coverageLifetime, remaining, finalize); - chainLeaderboardBoard(state.coverageLeaderboardIdQuarterly, basisPoints, otherDataStr, true, coverageQuarterly, remaining, finalize); -} - // Applies an authoritative coverage snapshot for a round — either a locally-computed one // (host, or a no-result fallback) or one just reassembled from a "match_result" broadcast. // Idempotent per round: a migrated host's broadcast racing the original host's (or the @@ -1023,14 +1017,14 @@ static void applyMatchResult(int round, const std::vector &ent return; state.leaderboardPostedRound = round; - for (const auto &e : entries) - { - if (e.cxId == state.user.cxId) - { - postMatchScoresAndComputeDeltas(e); - break; - } - } + // Only the host posts — hostPostMatchResultsToCloud (via PostMatchResults.js) covers + // every player in one call, so every OTHER client posting its own would just be a + // redundant (and no-longer-even-possible, since postScoreToLeaderboardOnBehalfOf is + // Cloud-Code-only) duplicate. Non-host clients just wait for the "lb_result" broadcast + // that call produces. + bool isHost = !state.lobby.ownerCxId.empty() && state.user.cxId == state.lobby.ownerCxId; + if (isHost) + hostPostMatchResultsToCloud(round, entries); } // Drives the shared coverage/ranking calculation and the host-authoritative match-end @@ -1132,6 +1126,7 @@ static void onRelayConnected() state.resultsSentAtMs = 0; s_pendingMatchResult.clear(); s_pendingLbResults.clear(); + s_pendingLbChunk.clear(); state.awaitingRematch = false; state.isProvisioning = false; @@ -1338,36 +1333,55 @@ static void onRelayMessage(int netId, const Json::Value &json) } else if (op == "lb_result") { - // Each player's own leaderboard rank movement, broadcast once they've - // finished computing it (see postMatchScoresAndComputeDeltas). Can arrive + // Host-computed leaderboard results for the WHOLE round (see + // hostPostMatchResultsToCloud/PostMatchResults.js), chunked like + // match_result since a 40-player lobby could exceed one packet. Can arrive // before this round's match_result has populated state.matchResult.entries // — buffer by cxId in that case (drained in applyMatchResult). - LeaderboardDelta delta; - delta.ready = true; - auto readPeriod = [&](const char *key, LeaderboardPeriodDelta &pd) - { - if (!json["data"].isMember(key)) return; // absent == "no change" for that period - pd.improved = true; - pd.rankBefore = json["data"][key]["b"].asInt(); - pd.rankAfter = json["data"][key]["a"].asInt(); - }; - readPeriod("pl", delta.pointsLifetime); - readPeriod("pq", delta.pointsQuarterly); - readPeriod("cl", delta.coverageLifetime); - readPeriod("cq", delta.coverageQuarterly); - - bool applied = false; - for (auto &e : state.matchResult.entries) + int round = json["data"]["round"].asInt(); + if (!(state.matchResult.valid && state.matchResult.round == round)) { - if (e.cxId == member.cxId) + if (json["data"]["first"].asBool()) + s_pendingLbChunk.clear(); + + for (const auto &entry : json["data"]["e"]) + { + LeaderboardDelta delta; + delta.ready = true; + auto readPeriod = [&](const char *key, LeaderboardPeriodDelta &pd) + { + if (!entry.isMember(key)) return; // absent == "no change" for that period + pd.improved = true; + pd.rankBefore = entry[key]["b"].asInt(); + pd.rankAfter = entry[key]["a"].asInt(); + }; + readPeriod("pl", delta.pointsLifetime); + readPeriod("pq", delta.pointsQuarterly); + readPeriod("cl", delta.coverageLifetime); + readPeriod("cq", delta.coverageQuarterly); + s_pendingLbChunk.push_back(std::make_pair(entry["cx"].asString(), delta)); + } + + if (json["data"]["last"].asBool()) { - e.lbDelta = delta; - applied = true; - break; + for (const auto &kv : s_pendingLbChunk) + { + bool applied = false; + for (auto &e : state.matchResult.entries) + { + if (e.cxId == kv.first) + { + e.lbDelta = kv.second; + applied = true; + break; + } + } + if (!applied) + s_pendingLbResults[kv.first] = kv.second; + } + s_pendingLbChunk.clear(); } } - if (!applied) - s_pendingLbResults[member.cxId] = delta; } else if (op == "game_start") { @@ -1813,6 +1827,7 @@ static Lobby parseLobby(const Json::Value &lobbyJson, const std::string &lobbyId { User user; user.cxId = jsonMember["cxId"].asString(); + user.profileId = jsonMember["profileId"].asString(); user.name = jsonMember["name"].asString(); user.colorIndex = jsonMember["extra"]["colorIndex"].asInt(); // Worldwide rank — each player fetches their OWN rank (self-centric API, diff --git a/relaytestapp/src/globals.h b/relaytestapp/src/globals.h index 3b7a05a..7881d32 100644 --- a/relaytestapp/src/globals.h +++ b/relaytestapp/src/globals.h @@ -159,6 +159,7 @@ struct Point struct User { std::string cxId; /* RTT Connection Id */ + std::string profileId; /* brainCloud profileId — needed server-side (postScoreToLeaderboardOnBehalfOf takes a profileId, not a cxId) */ std::string name; /* User name */ int colorIndex = 7; bool isReady = false; From 638f61c990b22c29f82001cb18cc0cf0122800a3 Mon Sep 17 00:00:00 2001 From: Steve Jones Date: Fri, 7 Aug 2026 14:22:41 -0400 Subject: [PATCH 5/8] 90 s --- relaytestapp/src/globals.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/relaytestapp/src/globals.h b/relaytestapp/src/globals.h index 7881d32..5460da6 100644 --- a/relaytestapp/src/globals.h +++ b/relaytestapp/src/globals.h @@ -128,7 +128,7 @@ static constexpr float COVERAGE_GRID_CELL_SIZE = 2.0f; // Match timing (moved here from game.cpp so app_tickMatch() and the HUD can both see them // regardless of where the timer widget is drawn). -static constexpr long long MATCH_DURATION_MS = 35000LL; +static constexpr long long MATCH_DURATION_MS = 90000LL; static constexpr long long RESULT_GRACE_MS = 1000LL; // delay between match_result broadcast and endMatch() static constexpr long long COVERAGE_RECOMPUTE_MS = 250LL; // live-board recompute throttle From 1ca18919db8f149df0a2cbaaedecf4373bd5ac5e Mon Sep 17 00:00:00 2001 From: Steve Jones Date: Mon, 17 Aug 2026 11:56:33 -0400 Subject: [PATCH 6/8] update, dleete, chat messages, ready up signals --- relaytestapp/src/app.cpp | 30 +++++--- relaytestapp/src/app.h | 5 ++ relaytestapp/src/globalChat.cpp | 117 ++++++++++++++++++++++---------- relaytestapp/src/globalChat.h | 6 ++ relaytestapp/src/globals.h | 1 + relaytestapp/src/lobby.cpp | 10 +++ 6 files changed, 121 insertions(+), 48 deletions(-) diff --git a/relaytestapp/src/app.cpp b/relaytestapp/src/app.cpp index a8ba5c7..18cef95 100644 --- a/relaytestapp/src/app.cpp +++ b/relaytestapp/src/app.cpp @@ -22,6 +22,7 @@ #include "app.h" #include "coverage.h" #include "game.h" +#include "globalChat.h" #include "globals.h" #include "loading.h" #include "lobby.h" @@ -152,6 +153,10 @@ class RTTCallback final : public BrainCloud::IRTTCallback { onLobbyEvent(eventJson); } + else if (service == BrainCloud::ServiceName::Chat.getValue()) + { + chat_onRTTChatEvent(eventJson); + } } }; @@ -538,9 +543,9 @@ void onRTTConnected() } // Enables RTT so main-menu chat works — brainCloud's chat calls (getChannelId, -// getRecentChatMessages, postChatMessageSimple) all fail with RTT_NOT_ENABLED -// otherwise. Idempotent: no-ops if RTT is already connected (e.g. a lobby search -// already turned it on). Called whenever the app reaches the MainMenu screen. +// channelConnect, postChatMessageSimple) all fail with RTT_NOT_ENABLED otherwise. +// Idempotent: no-ops if RTT is already connected (e.g. a lobby search already turned +// it on). Called whenever the app reaches the MainMenu screen. void app_enableChatRTT() { // Called at every MainMenu arrival — piggyback the rank re-fetch here too @@ -553,6 +558,7 @@ void app_enableChatRTT() s_wantsLobbySearch = false; s_rttConnecting = true; pBCWrapper->getRTTService()->registerRTTLobbyCallback(&bcRTTCallback); + pBCWrapper->getRTTService()->registerRTTChatCallback(&bcRTTCallback); pBCWrapper->getRTTService()->enableRTT(&bcRTTConnectCallback, true); } @@ -1574,14 +1580,6 @@ void app_update() ImGui::EndMenu(); } ImGui::Separator(); - if (state.lobby.ownerCxId == state.user.cxId) - { - if (ImGui::MenuItem("End Match")) - { - app_endMatch(); - } - ImGui::Separator(); - } if (ImGui::MenuItem("Leave")) { app_closeGame(); @@ -1811,6 +1809,7 @@ void app_play(BrainCloud::eRelayConnectionType in_protocol) // onRTTConnected() regardless of who initiated it. s_rttConnecting = true; pBCWrapper->getRTTService()->registerRTTLobbyCallback(&bcRTTCallback); + pBCWrapper->getRTTService()->registerRTTChatCallback(&bcRTTCallback); pBCWrapper->getRTTService()->enableRTT(&bcRTTConnectCallback, true); } } @@ -2177,6 +2176,15 @@ void app_startGame() buildExtraJson()); } +void app_toggleReady() +{ + state.user.isReady = !state.user.isReady; + pBCWrapper->getLobbyService()->updateReady( + state.lobby.lobbyId, + state.user.isReady, + buildExtraJson()); +} + // Marks this player as queued for a rematch AND takes them back to the Lobby screen — // called both from the Match Summary screen's "Queue for Rematch" button and from its own // per-player 15s auto-timeout (matchSummary_update()), so either path looks identical from diff --git a/relaytestapp/src/app.h b/relaytestapp/src/app.h index 77e2510..69ba307 100644 --- a/relaytestapp/src/app.h +++ b/relaytestapp/src/app.h @@ -70,6 +70,11 @@ void app_endMatch(); // Ready up and signals RTT service we can start the game void app_startGame(); +// Non-host lobby members have no "Start" button (only the host can start the round), +// but still need a way to signal they're ready before the host starts — toggles this +// player's own ready state. Not used during the rematch flow (see app_setRematchReady). +void app_toggleReady(); + // Marks this player queued for a rematch and takes them back to the Lobby screen — // used by both the Match Summary screen's button and its own auto-timeout. void app_setRematchReady(bool ready); diff --git a/relaytestapp/src/globalChat.cpp b/relaytestapp/src/globalChat.cpp index de8e0a8..2ee8dee 100644 --- a/relaytestapp/src/globalChat.cpp +++ b/relaytestapp/src/globalChat.cpp @@ -4,10 +4,11 @@ // calls all require RTT to be enabled (RTT_NOT_ENABLED otherwise); // app_enableChatRTT() (app.cpp) keeps RTT connected on every path that reaches // the main menu, which covers both call sites (main menu itself, and the lobby, -// which is only reachable after passing through the main menu). Poll-based -// (explicit fetch after send / on opening the tab), not live RTT push — a -// live-push version would need registerRTTChatCallback + a Chat-service branch -// in the RTT dispatch. +// which is only reachable after passing through the main menu). 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 the RTT "chat" event dispatched to chat_onRTTChatEvent — see +// knowledge-articles/01-chat.md. No re-fetch after posting. //----------------------------------------------------------------------------- #include "globalChat.h" @@ -27,14 +28,13 @@ static std::string s_chatChannelId; static bool s_chatChannelResolving = false; static bool s_chatChannelReady = false; static std::vector s_chatMessages; -static bool s_chatFetchInFlight = false; -static bool s_chatFetchedOnce = false; static char s_chatInputBuf[240] = {0}; static bool s_chatSending = false; static ChatMessage parseChatMessage(const Json::Value &m) { ChatMessage msg; + msg.msgId = m["msgId"].asString(); msg.fromName = m["from"]["name"].asString(); if (msg.fromName.empty()) msg.fromName = "Player"; @@ -42,36 +42,16 @@ static ChatMessage parseChatMessage(const Json::Value &m) return msg; } -static void fetchChatMessages() -{ - if (s_chatChannelId.empty() || s_chatFetchInFlight) return; - s_chatFetchInFlight = true; - pBCWrapper->getChatService()->getRecentChatMessages( - s_chatChannelId.c_str(), 30, - new BCCallback( - [](const Json::Value &result) - { - s_chatFetchInFlight = false; - s_chatFetchedOnce = true; - s_chatMessages.clear(); - for (const auto &m : result["data"]["messages"]) - s_chatMessages.push_back(parseChatMessage(m)); - // Server returns newest-first; flip to oldest-first for natural - // top-to-bottom reading order. - std::reverse(s_chatMessages.begin(), s_chatMessages.end()); - }, - [](const std::string &) { s_chatFetchInFlight = false; })); -} - // Backoff after a failed getChannelId, so a persistent failure (bad channel code, // network hiccup) can't turn into a same-call-every-frame loop — brainCloud's abuse // detection disables the client after enough repeated failures on one API call // (reason_code 90200), which is exactly what happened here without this guard. static long long s_chatChannelRetryAtMs = 0; -// Resolves the shared global channel once RTT is up, then fetches history. -// Safe to call every frame a Chat/Global tab is open — no-ops once resolved, in -// flight, or backing off after a recent failure. +// 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 +// every frame a Chat/Global tab is open — no-ops once resolved, in flight, or backing +// off after a recent failure. static void ensureChatChannel() { if (s_chatChannelReady || s_chatChannelResolving || !pBCWrapper) return; @@ -91,11 +71,33 @@ static void ensureChatChannel() new BCCallback( [](const Json::Value &result) { - s_chatChannelResolving = false; - s_chatChannelId = result["data"]["channelId"].asString(); - s_chatChannelReady = !s_chatChannelId.empty(); - if (s_chatChannelReady) - fetchChatMessages(); + std::string channelId = result["data"]["channelId"].asString(); + if (channelId.empty()) + { + s_chatChannelResolving = false; + return; + } + + pBCWrapper->getChatService()->channelConnect( + channelId, 30, + new BCCallback( + [channelId](const Json::Value &connectResult) + { + s_chatChannelResolving = false; + s_chatChannelId = channelId; + s_chatChannelReady = true; + + s_chatMessages.clear(); + for (const auto &m : connectResult["data"]["messages"]) + s_chatMessages.push_back(parseChatMessage(m)); + }, + [](const std::string &) + { + s_chatChannelResolving = false; + auto now = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); + s_chatChannelRetryAtMs = now + 5000; + })); }, [](const std::string &) { @@ -106,6 +108,8 @@ static void ensureChatChannel() })); } +// Delivered to every connected member, including the sender — so sending never needs +// a follow-up fetch (see chat_onRTTChatEvent below). static void sendChatMessage() { if (s_chatChannelId.empty() || s_chatInputBuf[0] == '\0' || s_chatSending) return; @@ -113,18 +117,57 @@ static void sendChatMessage() pBCWrapper->getChatService()->postChatMessageSimple( s_chatChannelId.c_str(), s_chatInputBuf, true, new BCCallback( - [](const Json::Value &) { s_chatSending = false; fetchChatMessages(); }, + [](const Json::Value &) { s_chatSending = false; }, [](const std::string &) { s_chatSending = false; })); s_chatInputBuf[0] = '\0'; } +// New messages, edits, and deletes all arrive on the same "chat" RTT event, keyed by +// msgId — a message updated or deleted after it's scrolled out of the visible/fetched +// window is simply not found below and the event is a no-op, which is correct (there's +// nothing on screen to change). +void chat_onRTTChatEvent(const Json::Value &eventJson) +{ + const std::string operation = eventJson["operation"].asString(); + const Json::Value &data = eventJson["data"]; + + if (operation == "INCOMING") + { + s_chatMessages.push_back(parseChatMessage(data)); + } + else if (operation == "UPDATE") + { + // Same shape as INCOMING (full message, edited content) — find by msgId and + // replace in place so it doesn't jump to the bottom of the scroll. + ChatMessage updated = parseChatMessage(data); + for (auto &m : s_chatMessages) + { + if (m.msgId == updated.msgId) + { + m = updated; + break; + } + } + } + else if (operation == "DELETE") + { + // DELETE's payload is just {chId, msgId} — no content/from — so only msgId is + // usable here. + std::string msgId = data["msgId"].asString(); + s_chatMessages.erase( + std::remove_if(s_chatMessages.begin(), s_chatMessages.end(), + [&msgId](const ChatMessage &m) { return m.msgId == msgId; }), + s_chatMessages.end()); + } +} + void drawGlobalChatContent() { ensureChatChannel(); if (!s_chatChannelReady) { - ImGui::TextDisabled(s_chatChannelResolving || !s_chatFetchedOnce ? "Connecting..." : "Chat unavailable."); + ImGui::TextDisabled(s_chatChannelResolving ? "Connecting..." : "Chat unavailable."); } else { diff --git a/relaytestapp/src/globalChat.h b/relaytestapp/src/globalChat.h index dabec53..4a6ddd6 100644 --- a/relaytestapp/src/globalChat.h +++ b/relaytestapp/src/globalChat.h @@ -7,6 +7,7 @@ #pragma once #include "globals.h" +#include // Draws the global-chat panel (message scroll + input box) at the given rect, as // its own standalone window. windowId must be unique per call site (e.g. @@ -19,3 +20,8 @@ void drawGlobalChatPanel(const char *windowId, float x, float y, float w, float // the lobby's Chat tab, which has its own "This Lobby / Global" sub-toggle above // this content). void drawGlobalChatContent(); + +// Dispatches an RTT "chat" event to the global chat channel. Called from the app's +// central RTT callback whenever eventJson["service"] == "chat" — see +// knowledge-articles/01-chat.md for why this replaces polling after every send. +void chat_onRTTChatEvent(const Json::Value &eventJson); diff --git a/relaytestapp/src/globals.h b/relaytestapp/src/globals.h index 5460da6..b62d9cd 100644 --- a/relaytestapp/src/globals.h +++ b/relaytestapp/src/globals.h @@ -174,6 +174,7 @@ struct User // this-lobby (Lobby service SendSignal) chat. struct ChatMessage { + std::string msgId; std::string fromName; std::string text; }; diff --git a/relaytestapp/src/lobby.cpp b/relaytestapp/src/lobby.cpp index b2082a3..db53f07 100644 --- a/relaytestapp/src/lobby.cpp +++ b/relaytestapp/src/lobby.cpp @@ -191,6 +191,16 @@ static void drawLobbyMembersPanel(float x, float y) app_startGame(); } } + else if (!state.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 toggle. + // Once awaiting a rematch, the Match Summary screen's "Queue for Rematch" + // button already handles readying up instead. + ImGui::SameLine(); + if (ImGui::Button(state.user.isReady ? "Not Ready" : "Ready Up")) + app_toggleReady(); + } ImGui::End(); } From 6355a113e7f07e7e3ff24e86191da7c301c8c9a9 Mon Sep 17 00:00:00 2001 From: Steve Jones Date: Tue, 18 Aug 2026 15:35:08 -0400 Subject: [PATCH 7/8] join in progress updated coverage on join in progress --- relaytestapp/src/app.cpp | 99 +++++++++++++++++++------------ relaytestapp/src/game.cpp | 27 +++++++-- relaytestapp/src/globals.h | 8 ++- relaytestapp/src/lobby.cpp | 18 ++---- relaytestapp/src/matchSummary.cpp | 18 ++++-- 5 files changed, 112 insertions(+), 58 deletions(-) diff --git a/relaytestapp/src/app.cpp b/relaytestapp/src/app.cpp index 18cef95..ffde1dc 100644 --- a/relaytestapp/src/app.cpp +++ b/relaytestapp/src/app.cpp @@ -983,7 +983,9 @@ static void hostPostMatchResultsToCloud(int round, const std::vectorgetRelayService()->getNetIdForCxId(cxId); uint64_t mask = (uint64_t)1 << (uint64_t)netId; sendGameStartToMask(mask); @@ -1344,49 +1366,45 @@ static void onRelayMessage(int netId, const Json::Value &json) // match_result since a 40-player lobby could exceed one packet. Can arrive // before this round's match_result has populated state.matchResult.entries // — buffer by cxId in that case (drained in applyMatchResult). - int round = json["data"]["round"].asInt(); - if (!(state.matchResult.valid && state.matchResult.round == round)) - { - if (json["data"]["first"].asBool()) - s_pendingLbChunk.clear(); + if (json["data"]["first"].asBool()) + s_pendingLbChunk.clear(); - for (const auto &entry : json["data"]["e"]) + for (const auto &entry : json["data"]["e"]) + { + LeaderboardDelta delta; + delta.ready = true; + auto readPeriod = [&](const char *key, LeaderboardPeriodDelta &pd) { - LeaderboardDelta delta; - delta.ready = true; - auto readPeriod = [&](const char *key, LeaderboardPeriodDelta &pd) - { - if (!entry.isMember(key)) return; // absent == "no change" for that period - pd.improved = true; - pd.rankBefore = entry[key]["b"].asInt(); - pd.rankAfter = entry[key]["a"].asInt(); - }; - readPeriod("pl", delta.pointsLifetime); - readPeriod("pq", delta.pointsQuarterly); - readPeriod("cl", delta.coverageLifetime); - readPeriod("cq", delta.coverageQuarterly); - s_pendingLbChunk.push_back(std::make_pair(entry["cx"].asString(), delta)); - } + if (!entry.isMember(key)) return; // absent == "no change" for that period + pd.improved = true; + pd.rankBefore = entry[key]["b"].asInt(); + pd.rankAfter = entry[key]["a"].asInt(); + }; + readPeriod("pl", delta.pointsLifetime); + readPeriod("pq", delta.pointsQuarterly); + readPeriod("cl", delta.coverageLifetime); + readPeriod("cq", delta.coverageQuarterly); + s_pendingLbChunk.push_back(std::make_pair(entry["cx"].asString(), delta)); + } - if (json["data"]["last"].asBool()) + if (json["data"]["last"].asBool()) + { + for (const auto &kv : s_pendingLbChunk) { - for (const auto &kv : s_pendingLbChunk) + bool applied = false; + for (auto &e : state.matchResult.entries) { - bool applied = false; - for (auto &e : state.matchResult.entries) + if (e.cxId == kv.first) { - if (e.cxId == kv.first) - { - e.lbDelta = kv.second; - applied = true; - break; - } + e.lbDelta = kv.second; + applied = true; + break; } - if (!applied) - s_pendingLbResults[kv.first] = kv.second; } - s_pendingLbChunk.clear(); + if (!applied) + s_pendingLbResults[kv.first] = kv.second; } + s_pendingLbChunk.clear(); } } else if (op == "game_start") @@ -1502,7 +1520,13 @@ void app_update() // Non-host users re-ready for the next round now that we're back in the lobby. // The host does NOT auto-ready — the host controls when the next match starts. - if (state.user.cxId != state.lobby.ownerCxId) + // Only for lobby types with no Match Summary screen (geo test, RoomServer, etc.) — + // CursorParty lobbies already cleared isReady in the END_MATCH handler above so the + // Match Summary screen's per-player "Queue for Rematch" gate (BCLOUD-14489) controls + // it; auto-readying here would silently defeat that gate and every player's 45s + // opt-in window. + if (state.user.cxId != state.lobby.ownerCxId && + !(isCursorPartyLobby(settings.lobbyType) && !settings.autoGeoTest)) { state.user.isReady = true; pBCWrapper->getLobbyService()->updateReady( @@ -1829,6 +1853,7 @@ static Lobby parseLobby(const Json::Value &lobbyJson, const std::string &lobbyId user.profileId = jsonMember["profileId"].asString(); user.name = jsonMember["name"].asString(); user.colorIndex = jsonMember["extra"]["colorIndex"].asInt(); + user.isReady = jsonMember["isReady"].asBool(); // Worldwide rank — each player fetches their OWN rank (self-centric API, // getGlobalLeaderboardView has no "rank for an arbitrary other player" call) // and shares it here, the same way colorIndex/pings already propagate. diff --git a/relaytestapp/src/game.cpp b/relaytestapp/src/game.cpp index 83db94d..2112ee1 100644 --- a/relaytestapp/src/game.cpp +++ b/relaytestapp/src/game.cpp @@ -297,17 +297,36 @@ void game_update() } lastMousePos = mousePos; - // Check if clicked + // Check if clicked — holding the button auto-repeats a splotch every + // AUTO_PAINT_INTERVAL_SEC (initial click still paints immediately). Same interval + // as js/Godot so hold-to-paint feels consistent for everyone in a shared match. + const float AUTO_PAINT_INTERVAL_SEC = 0.15f; static bool lastMouseDown = false; + static float autoPaintAccum = 0.0f; auto mouseDown = ImGui::IsMouseDown(0); + bool inBounds = mousePos.x >= 0.0f && mousePos.x <= CANVAS_W && + mousePos.y >= 0.0f && mousePos.y <= CANVAS_H; + if (mouseDown && !lastMouseDown) { - if (mousePos.x >= 0.0f && mousePos.x <= CANVAS_W && - mousePos.y >= 0.0f && mousePos.y <= CANVAS_H) - { + autoPaintAccum = 0.0f; + if (inBounds) app_shockwave({ (int)(mousePos.x / scale), (int)(mousePos.y / scale) }); + } + else if (mouseDown) + { + autoPaintAccum += ImGui::GetIO().DeltaTime; + if (autoPaintAccum >= AUTO_PAINT_INTERVAL_SEC) + { + autoPaintAccum = 0.0f; + if (inBounds) + app_shockwave({ (int)(mousePos.x / scale), (int)(mousePos.y / scale) }); } } + else + { + autoPaintAccum = 0.0f; + } lastMouseDown = mouseDown; // Splotches — persistent marks left by shockwaves, drawn under the transient rings. diff --git a/relaytestapp/src/globals.h b/relaytestapp/src/globals.h index b62d9cd..2923113 100644 --- a/relaytestapp/src/globals.h +++ b/relaytestapp/src/globals.h @@ -129,13 +129,19 @@ static constexpr float COVERAGE_GRID_CELL_SIZE = 2.0f; // Match timing (moved here from game.cpp so app_tickMatch() and the HUD can both see them // regardless of where the timer widget is drawn). static constexpr long long MATCH_DURATION_MS = 90000LL; -static constexpr long long RESULT_GRACE_MS = 1000LL; // delay between match_result broadcast and endMatch() +static constexpr long long RESULT_GRACE_MS = 3000LL; // delay between match_result broadcast and endMatch() static constexpr long long COVERAGE_RECOMPUTE_MS = 250LL; // live-board recompute throttle // How long the post-match summary screen waits for everyone to queue for a rematch // before the host starts the next round anyway (BCLOUD-14489). static constexpr long long MATCH_SUMMARY_REMATCH_MS = 45000LL; +// How long a player card waits for its "lb_result" leaderboard delta before giving up and +// showing "Leaderboard unavailable" instead of "Updating leaderboards..." forever — the +// cloud script call/broadcast is best-effort (see RESULT_GRACE_MS), so this is the backstop +// that keeps the summary screen from looking permanently stuck when it's lost that race. +static constexpr long long LEADERBOARD_RESULT_TIMEOUT_MS = 8000LL; + // Screen state enum. enum class ScreenState : int { diff --git a/relaytestapp/src/lobby.cpp b/relaytestapp/src/lobby.cpp index db53f07..bd055e7 100644 --- a/relaytestapp/src/lobby.cpp +++ b/relaytestapp/src/lobby.cpp @@ -176,27 +176,21 @@ static void drawLobbyMembersPanel(float x, float y) if (elapsed >= std::chrono::milliseconds(1500)) app_startGame(); } - else if (state.awaitingRematch) - { - // Rematch flow is fully automatic (app_tickRematchGate, ticked above) — no - // manual override here, so a host who returns early can't skip the "wait for - // stragglers or 15s" window the user asked for. - ImGui::SameLine(); - ImGui::TextDisabled("Waiting for other players to return..."); - } else { + // 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. ImGui::SameLine(); if (ImGui::Button("Start")) app_startGame(); } } - else if (!state.awaitingRematch) + else { // 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 toggle. - // Once awaiting a rematch, the Match Summary screen's "Queue for Rematch" - // button already handles readying up instead. + // every other member still needs a way to signal they're ready — this toggle + // stays available in every round, not just the first. ImGui::SameLine(); if (ImGui::Button(state.user.isReady ? "Not Ready" : "Ready Up")) app_toggleReady(); diff --git a/relaytestapp/src/matchSummary.cpp b/relaytestapp/src/matchSummary.cpp index 0a13dee..ccc0539 100644 --- a/relaytestapp/src/matchSummary.cpp +++ b/relaytestapp/src/matchSummary.cpp @@ -77,6 +77,10 @@ static void drawPlayerCard(const MatchResultEntry &entry, float width) int colorIndex = pMember ? pMember->colorIndex : 0; bool isMe = (entry.cxId == state.user.cxId); + auto arrivalElapsedMs = std::chrono::duration_cast( + std::chrono::steady_clock::now() - state.matchSummaryArrivalTime).count(); + bool leaderboardTimedOut = arrivalElapsedMs >= LEADERBOARD_RESULT_TIMEOUT_MS; + // How many badge lines this card needs, so it gets an explicit height instead of // BeginChild's height=0 — inside a scrolling parent that means "fill ALL remaining // space", not "auto-fit to content", which is what was making every card after the @@ -150,7 +154,7 @@ static void drawPlayerCard(const MatchResultEntry &entry, float width) if (!entry.lbDelta.ready) { - ImGui::TextDisabled("Updating leaderboards..."); + ImGui::TextDisabled(leaderboardTimedOut ? "Leaderboard unavailable" : "Updating leaderboards..."); } else { @@ -268,11 +272,17 @@ void matchSummary_update() } ImGui::TextDisabled("Next Round: %lld:%02lld", remainingSec / 60, remainingSec % 60); + // Your own row uses the immediate local isReady, not the lobby snapshot — the snapshot + // for your own entry is briefly stale right after the server echo confirming a + // ready-toggle lags a few seconds behind, which used to show a count like "1/1" before + // dropping back to the correct "0/1". + bool iAmReady = state.user.isReady; int readyCount = 0; for (const auto &m : state.lobby.members) - if (m.isReady) ++readyCount; - - bool iAmReady = state.user.isReady; + { + bool ready = (m.cxId == state.user.cxId) ? iAmReady : m.isReady; + if (ready) ++readyCount; + } char rematchLabel[64]; snprintf(rematchLabel, sizeof(rematchLabel), "%s %d/%d", iAmReady ? "Queued for Rematch" : "Queue for Rematch", From 2057c8cd980fabee273754ba8ce2238046fdcfbd Mon Sep 17 00:00:00 2001 From: Steve Jones Date: Wed, 19 Aug 2026 11:38:11 -0400 Subject: [PATCH 8/8] Send match results are host, confirmed on server, and requested by clients for the summary screen --- relaytestapp/src/app.cpp | 214 ++++++++++-------------------- relaytestapp/src/app.h | 6 + relaytestapp/src/globals.h | 15 ++- relaytestapp/src/matchSummary.cpp | 1 + 4 files changed, 83 insertions(+), 153 deletions(-) diff --git a/relaytestapp/src/app.cpp b/relaytestapp/src/app.cpp index ffde1dc..25c8176 100644 --- a/relaytestapp/src/app.cpp +++ b/relaytestapp/src/app.cpp @@ -74,7 +74,6 @@ static std::vector toMatchResultEntries(const std::vector &entries); static void applyMatchResult(int round, const std::vector &entries); static void onRelayConnected(); -static void sendLeaderboardResultsToMask(uint64_t mask, int round, const std::vector &entries); static void applyLeaderboardResultsFromCloud(int round, const Json::Value &resultsArr); static bool isDisconnecting = false; @@ -83,16 +82,13 @@ static bool isDisconnecting = false; // Reset on "first":true and whenever a new round starts (onRelayConnected). static std::vector s_pendingMatchResult; -// The host's "lb_result" broadcast can arrive before match_result has populated -// state.matchResult.entries for this round (different senders — host for match_result, -// possibly a migrated host for lb_result — no relative ordering guarantee between them) — -// buffered here by cxId and drained into the matching entry as soon as applyMatchResult() -// sets entries. Reset every round. -static std::map s_pendingLbResults; - -// Chunk accumulator for the in-progress "lb_result" reassembly, same pattern as -// s_pendingMatchResult. Reset on "first":true and whenever a new round starts. -static std::vector> s_pendingLbChunk; +// Non-host: throttle state for polling the GlobalEntity that PostMatchResults.js writes +// (indexed by ":") instead of waiting on a host relay broadcast — see +// app_tickMatchResultsPoll(). Reset whenever a new round's matchResult shows up. +static int s_resultsPollRound = -1; +static long long s_lastResultsPollMs = 0; +static bool s_resultsPollInFlight = false; +static const long long RESULTS_POLL_INTERVAL_MS = 1000; // Incremented on every app_play() call. Each ping-flow lambda captures this value and // checks it before acting — stale callbacks from a previous session are silently dropped. @@ -830,76 +826,12 @@ static std::vector toMatchResultEntries(const std::vector &entries) -{ - if (mask == 0) return; - - static const int MAX_RELAY_BYTES = 900; - static const int ENVELOPE_OVERHEAD = 80; - - Json::FastWriter writer; - bool isFirst = true; - std::vector chunk; - int currentSize = ENVELOPE_OVERHEAD; - - auto flushChunk = [&](bool isLast) - { - if (chunk.empty() && !isLast) return; - Json::Value json; - json["op"] = "lb_result"; - json["data"]["round"] = round; - json["data"]["first"] = isFirst; - json["data"]["last"] = isLast; - Json::Value arr(Json::arrayValue); - for (const auto &entry : chunk) - arr.append(entry); - json["data"]["e"] = arr; - auto str = writer.write(json); - pBCWrapper->getRelayService()->sendToPlayers( - (const uint8_t *)str.data(), (int)str.length(), - mask, true, true, (BrainCloud::eRelayChannel)0); - isFirst = false; - chunk.clear(); - currentSize = ENVELOPE_OVERHEAD; - }; - - auto putPeriod = [](Json::Value &parent, const char *key, const LeaderboardPeriodDelta &pd) - { - if (!pd.improved) return; - parent[key]["b"] = pd.rankBefore; - parent[key]["a"] = pd.rankAfter; - }; - - for (const auto &e : entries) - { - if (!e.lbDelta.ready) continue; - - Json::Value je; - 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); - - int entrySize = (int)writer.write(je).size() + 1; - if (currentSize + entrySize > MAX_RELAY_BYTES && !chunk.empty()) - flushChunk(false); - - chunk.push_back(std::move(je)); - currentSize += entrySize; - } - flushChunk(true); -} - -// Applies the PostMatchResults.js response (keyed by profileId) onto state.matchResult. +// Applies a PostMatchResults.js 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. +// known together). Called on the host directly from hostPostMatchResultsToCloud's own +// script response, and on every other client from app_tickMatchResultsPoll() once the +// GlobalEntity that call wrote shows up — both feed it the exact same "results" array +// shape, so there's only one place that parses it. static void applyLeaderboardResultsFromCloud(int round, const Json::Value &resultsArr) { if (!(state.matchResult.valid && state.matchResult.round == round)) @@ -936,8 +868,6 @@ static void applyLeaderboardResultsFromCloud(int round, const Json::Value &resul if (e.cxId == it->second) { e.lbDelta = delta; break; } } } - - sendLeaderboardResultsToMask(getPlayerMask(), round, state.matchResult.entries); } // Host-only: posts the WHOLE round's results to the four leaderboards in one trusted @@ -954,6 +884,7 @@ static void hostPostMatchResultsToCloud(int round, const std::vector:"-indexed GlobalEntity for non-host clients to poll (see app_tickMatchResultsPoll) payload["pointsLeaderboardId"] = state.pointsLeaderboardId; payload["pointsLeaderboardIdQuarterly"] = state.pointsLeaderboardIdQuarterly; payload["coverageLeaderboardId"] = state.coverageLeaderboardId; @@ -1009,18 +940,6 @@ static void applyMatchResult(int round, const std::vector &ent state.matchResult.round = round; state.matchResult.entries = entries; - // Drain any "lb_result" broadcasts that arrived before this round's match_result did - // (different senders, no relative ordering guarantee between them — see s_pendingLbResults). - for (auto &e : state.matchResult.entries) - { - auto it = s_pendingLbResults.find(e.cxId); - if (it != s_pendingLbResults.end()) - { - e.lbDelta = it->second; - s_pendingLbResults.erase(it); - } - } - if (state.leaderboardPostedRound == round) return; state.leaderboardPostedRound = round; @@ -1028,13 +947,63 @@ static void applyMatchResult(int round, const std::vector &ent // Only the host posts — hostPostMatchResultsToCloud (via PostMatchResults.js) covers // every player in one call, so every OTHER client posting its own would just be a // redundant (and no-longer-even-possible, since postScoreToLeaderboardOnBehalfOf is - // Cloud-Code-only) duplicate. Non-host clients just wait for the "lb_result" broadcast - // that call produces. + // Cloud-Code-only) duplicate. Non-host clients pick the result up on their own via + // app_tickMatchResultsPoll() instead of waiting on the host to relay it — see there. bool isHost = !state.lobby.ownerCxId.empty() && state.user.cxId == state.lobby.ownerCxId; if (isHost) hostPostMatchResultsToCloud(round, entries); } +// Non-host: polls the GlobalEntity PostMatchResults.js writes (indexed by +// ":") until it shows up, instead of waiting on the host to relay its own +// script response over the relay connection — a host that disconnects right after posting +// (or mid-broadcast) used to leave everyone else stuck at the "Leaderboard unavailable" +// timeout even though the leaderboard post itself had already succeeded. Safe to call every +// frame: it no-ops until there's a valid, not-yet-resolved matchResult for a non-host +// client, then self-throttles to RESULTS_POLL_INTERVAL_MS. Called from matchSummary_update(). +void app_tickMatchResultsPoll() +{ + if (!state.matchResult.valid) return; + + bool isHost = !state.lobby.ownerCxId.empty() && state.user.cxId == state.lobby.ownerCxId; + if (isHost) return; // host already has its results from its own PostMatchResults call + + bool anyReady = false; + for (const auto &e : state.matchResult.entries) + if (e.lbDelta.ready) { anyReady = true; break; } + if (anyReady) return; // already applied + + if (s_resultsPollRound != state.matchResult.round) + { + s_resultsPollRound = state.matchResult.round; + s_lastResultsPollMs = 0; + s_resultsPollInFlight = false; + } + if (s_resultsPollInFlight) return; + + long long nowMs = std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count(); + if (nowMs - s_lastResultsPollMs < RESULTS_POLL_INTERVAL_MS) return; + s_lastResultsPollMs = nowMs; + s_resultsPollInFlight = true; + + int round = state.matchResult.round; + std::string indexedId = state.lobby.lobbyId + ":" + std::to_string(round); + pBCWrapper->getGlobalEntityService()->getListByIndexedId(indexedId, 1, + new BCCallback( + [round](const Json::Value &result) + { + s_resultsPollInFlight = false; + const Json::Value &list = result["data"]["entityList"]; + if (!list.isArray() || list.empty()) return; // not written yet — next tick retries + applyLeaderboardResultsFromCloud(round, list[0]["data"]["results"]); + }, + [](const std::string &) + { + s_resultsPollInFlight = false; // next tick retries + })); +} + // Drives the shared coverage/ranking calculation and the host-authoritative match-end // flow. Called once per frame from game_update() while state.screenState == Game. // @@ -1133,8 +1102,9 @@ static void onRelayConnected() state.coverageComputedGen = (unsigned long long)-1; state.resultsSentAtMs = 0; s_pendingMatchResult.clear(); - s_pendingLbResults.clear(); - s_pendingLbChunk.clear(); + s_resultsPollRound = -1; + s_lastResultsPollMs = 0; + s_resultsPollInFlight = false; state.awaitingRematch = false; state.isProvisioning = false; @@ -1359,54 +1329,6 @@ static void onRelayMessage(int netId, const Json::Value &json) } } } - else if (op == "lb_result") - { - // Host-computed leaderboard results for the WHOLE round (see - // hostPostMatchResultsToCloud/PostMatchResults.js), chunked like - // match_result since a 40-player lobby could exceed one packet. Can arrive - // before this round's match_result has populated state.matchResult.entries - // — buffer by cxId in that case (drained in applyMatchResult). - if (json["data"]["first"].asBool()) - s_pendingLbChunk.clear(); - - for (const auto &entry : json["data"]["e"]) - { - LeaderboardDelta delta; - delta.ready = true; - auto readPeriod = [&](const char *key, LeaderboardPeriodDelta &pd) - { - if (!entry.isMember(key)) return; // absent == "no change" for that period - pd.improved = true; - pd.rankBefore = entry[key]["b"].asInt(); - pd.rankAfter = entry[key]["a"].asInt(); - }; - readPeriod("pl", delta.pointsLifetime); - readPeriod("pq", delta.pointsQuarterly); - readPeriod("cl", delta.coverageLifetime); - readPeriod("cq", delta.coverageQuarterly); - s_pendingLbChunk.push_back(std::make_pair(entry["cx"].asString(), delta)); - } - - if (json["data"]["last"].asBool()) - { - for (const auto &kv : s_pendingLbChunk) - { - bool applied = false; - for (auto &e : state.matchResult.entries) - { - if (e.cxId == kv.first) - { - e.lbDelta = kv.second; - applied = true; - break; - } - } - if (!applied) - s_pendingLbResults[kv.first] = kv.second; - } - s_pendingLbChunk.clear(); - } - } else if (op == "game_start") { // Owner's authoritative start time — sync for non-owners and JIP players diff --git a/relaytestapp/src/app.h b/relaytestapp/src/app.h index 69ba307..de6fc83 100644 --- a/relaytestapp/src/app.h +++ b/relaytestapp/src/app.h @@ -96,3 +96,9 @@ void app_shockwave(const Point& pos); // Drives coverage/ranking recompute + the host-authoritative match-end + leaderboard-post // flow. Called once per frame from game_update() while on the Game screen. void app_tickMatch(); + +// Non-host: polls for the host's PostMatchResults results (a GlobalEntity, not a relay +// broadcast — see the function definition in app.cpp) once a round's matchResult is valid. +// Safe/cheap to call every frame; self-throttles. Called once per frame from +// matchSummary_update() while on the Match Summary screen. +void app_tickMatchResultsPoll(); diff --git a/relaytestapp/src/globals.h b/relaytestapp/src/globals.h index 2923113..bc0a035 100644 --- a/relaytestapp/src/globals.h +++ b/relaytestapp/src/globals.h @@ -136,10 +136,11 @@ static constexpr long long COVERAGE_RECOMPUTE_MS = 250LL; // live-board recomput // before the host starts the next round anyway (BCLOUD-14489). static constexpr long long MATCH_SUMMARY_REMATCH_MS = 45000LL; -// How long a player card waits for its "lb_result" leaderboard delta before giving up and -// showing "Leaderboard unavailable" instead of "Updating leaderboards..." forever — the -// cloud script call/broadcast is best-effort (see RESULT_GRACE_MS), so this is the backstop -// that keeps the summary screen from looking permanently stuck when it's lost that race. +// How long a player card waits for its leaderboard delta (polled from the GlobalEntity +// PostMatchResults.js writes — see app_tickMatchResultsPoll) before giving up and showing +// "Leaderboard unavailable" instead of "Updating leaderboards..." forever — the cloud +// script call is best-effort, so this is the backstop that keeps the summary screen from +// looking permanently stuck if it never shows up. static constexpr long long LEADERBOARD_RESULT_TIMEOUT_MS = 8000LL; // Screen state enum. @@ -256,9 +257,9 @@ struct LeaderboardPeriodDelta }; // Personal leaderboard-rank movement from posting this round's score, across all four -// boards. Computed by each client for ITSELF only (there's no API to fetch an arbitrary -// other player's before/after rank) and broadcast to the rest of the match via the -// "lb_result" relay op — see postMatchScoresAndComputeDeltas / sendLeaderboardDeltaToMask. +// boards. Computed server-side for everyone in one PostMatchResults.js call made by the +// host — see hostPostMatchResultsToCloud (host, applied directly from the script response) +// and app_tickMatchResultsPoll (everyone else, polled from the GlobalEntity that call writes). struct LeaderboardDelta { bool ready = false; /* true once this player's own delta has been computed (self) or received (others) */ diff --git a/relaytestapp/src/matchSummary.cpp b/relaytestapp/src/matchSummary.cpp index ccc0539..793599b 100644 --- a/relaytestapp/src/matchSummary.cpp +++ b/relaytestapp/src/matchSummary.cpp @@ -185,6 +185,7 @@ static void drawPlayerCard(const MatchResultEntry &entry, float width) void matchSummary_update() { app_tickRematchGate(); + app_tickMatchResultsPoll(); // Per-player auto-queue: if this player hasn't clicked "Queue for Rematch" themselves // by MATCH_SUMMARY_REMATCH_MS, queue them automatically and send them back to the