From fb82ac06d0ec8f7177a9f3a70ee77ff8b8ecde4e Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Sun, 21 Dec 2025 21:17:58 +0100 Subject: [PATCH] fixed highscores --- src/app/TetrisApp.cpp | 12 +- src/core/application/ApplicationManager.cpp | 5 +- src/persistence/Scores.cpp | 40 +++- src/persistence/Scores.h | 2 + src/states/MenuState.cpp | 19 ++ supabe_integrate.md | 213 ++++++++++++++++++++ 6 files changed, 281 insertions(+), 10 deletions(-) create mode 100644 supabe_integrate.md diff --git a/src/app/TetrisApp.cpp b/src/app/TetrisApp.cpp index cbb0e90..9a60d4f 100644 --- a/src/app/TetrisApp.cpp +++ b/src/app/TetrisApp.cpp @@ -899,7 +899,7 @@ void TetrisApp::Impl::runLoop() int rightScore = coopGame->score(CoopGame::PlayerSide::Right); int combinedScore = leftScore + rightScore; ensureScoresLoaded(); - scores.submit(combinedScore, coopGame->lines(), coopGame->level(), coopGame->elapsed(), combined); + scores.submit(combinedScore, coopGame->lines(), coopGame->level(), coopGame->elapsed(), combined, "cooperate"); Settings::instance().setPlayerName(playerName); isNewHighScore = false; SDL_StopTextInput(window); @@ -911,7 +911,8 @@ void TetrisApp::Impl::runLoop() } else if (e.key.scancode == SDL_SCANCODE_RETURN || e.key.scancode == SDL_SCANCODE_KP_ENTER) { if (playerName.empty()) playerName = "PLAYER"; ensureScoresLoaded(); - scores.submit(game->score(), game->lines(), game->level(), game->elapsed(), playerName); + std::string gt = (game->getMode() == GameMode::Challenge) ? "challenge" : "classic"; + scores.submit(game->score(), game->lines(), game->level(), game->elapsed(), playerName, gt); Settings::instance().setPlayerName(playerName); isNewHighScore = false; SDL_StopTextInput(window); @@ -1302,7 +1303,7 @@ void TetrisApp::Impl::runLoop() } else { isNewHighScore = false; ensureScoresLoaded(); - scores.submit(combinedScore, coopGame->lines(), coopGame->level(), coopGame->elapsed()); + scores.submit(combinedScore, coopGame->lines(), coopGame->level(), coopGame->elapsed(), "P1 & P2", "cooperate"); } state = AppState::GameOver; stateMgr->setState(state); @@ -1328,7 +1329,10 @@ void TetrisApp::Impl::runLoop() } else { isNewHighScore = false; ensureScoresLoaded(); - scores.submit(game->score(), game->lines(), game->level(), game->elapsed()); + { + std::string gt = (game->getMode() == GameMode::Challenge) ? "challenge" : "classic"; + scores.submit(game->score(), game->lines(), game->level(), game->elapsed(), "PLAYER", gt); + } } state = AppState::GameOver; stateMgr->setState(state); diff --git a/src/core/application/ApplicationManager.cpp b/src/core/application/ApplicationManager.cpp index 5a3c766..f919208 100644 --- a/src/core/application/ApplicationManager.cpp +++ b/src/core/application/ApplicationManager.cpp @@ -1406,11 +1406,14 @@ void ApplicationManager::setupStateHandlers() { if (m_stateContext.game->isGameOver()) { // Submit score before transitioning if (m_stateContext.scores) { + std::string gt = (m_stateContext.game->getMode() == GameMode::Challenge) ? "challenge" : "classic"; m_stateContext.scores->submit( m_stateContext.game->score(), m_stateContext.game->lines(), m_stateContext.game->level(), - m_stateContext.game->elapsed() + m_stateContext.game->elapsed(), + std::string("PLAYER"), + gt ); } m_stateManager->setState(AppState::GameOver); diff --git a/src/persistence/Scores.cpp b/src/persistence/Scores.cpp index b92178e..04602d9 100644 --- a/src/persistence/Scores.cpp +++ b/src/persistence/Scores.cpp @@ -54,14 +54,33 @@ void ScoreManager::load() { ScoreEntry e; iss >> e.score >> e.lines >> e.level >> e.timeSec; if (iss) { - // Try to read name (rest of line after timeSec) + // Try to read name (rest of line after timeSec). We may also have a trailing gameType token. std::string remaining; std::getline(iss, remaining); - if (!remaining.empty() && remaining[0] == ' ') { - e.name = remaining.substr(1); // Remove leading space + if (!remaining.empty() && remaining[0] == ' ') remaining = remaining.substr(1); + if (!remaining.empty()) { + static const std::vector known = {"classic","cooperate","challenge","versus"}; + while (!remaining.empty() && (remaining.back() == '\n' || remaining.back() == '\r' || remaining.back() == ' ')) remaining.pop_back(); + size_t lastSpace = remaining.find_last_of(' '); + std::string lastToken = (lastSpace == std::string::npos) ? remaining : remaining.substr(lastSpace + 1); + bool matched = false; + for (const auto &k : known) { + if (lastToken == k) { + matched = true; + e.gameType = k; + if (lastSpace == std::string::npos) e.name = "PLAYER"; + else e.name = remaining.substr(0, lastSpace); + break; + } + } + if (!matched) { + e.name = remaining; + e.gameType = "classic"; + } + } else { + e.name = "PLAYER"; + e.gameType = "classic"; } - // For backward compatibility local files may not include gameType; default is 'classic' - e.gameType = "classic"; scores.push_back(e); } if (scores.size() >= maxEntries) break; @@ -91,6 +110,8 @@ void ScoreManager::submit(int score, int lines, int level, double timeSec, const newEntry.level = level; newEntry.timeSec = timeSec; newEntry.name = name; + // preserve the game type locally so menu filtering works immediately + newEntry.gameType = gameType; scores.push_back(newEntry); std::sort(scores.begin(), scores.end(), [](auto&a,auto&b){return a.score>b.score;}); if (scores.size()>maxEntries) scores.resize(maxEntries); @@ -105,6 +126,15 @@ bool ScoreManager::isHighScore(int score) const { return score > scores.back().score; } +void ScoreManager::replaceAll(const std::vector& newScores) { + scores = newScores; + // Ensure ordering and trimming to our configured maxEntries + std::sort(scores.begin(), scores.end(), [](auto&a,auto&b){return a.score>b.score;}); + if (scores.size() > maxEntries) scores.resize(maxEntries); + // Persist new set to local file for next launch + try { save(); } catch (...) { /* swallow */ } +} + void ScoreManager::createSampleScores() { scores = { {159840, 189, 14, 972.0, "GREGOR"}, diff --git a/src/persistence/Scores.h b/src/persistence/Scores.h index e08bfee..1f11e0b 100644 --- a/src/persistence/Scores.h +++ b/src/persistence/Scores.h @@ -10,6 +10,8 @@ public: explicit ScoreManager(size_t maxScores = 12); void load(); void save() const; + // Replace the in-memory scores (thread-safe caller should ensure non-blocking) + void replaceAll(const std::vector& newScores); // New optional `gameType` parameter will be sent as `game_type`. // Allowed values: "classic", "versus", "cooperate", "challenge". void submit(int score, int lines, int level, double timeSec, const std::string& name = "PLAYER", const std::string& gameType = "classic"); diff --git a/src/states/MenuState.cpp b/src/states/MenuState.cpp index 7ba7338..c486b84 100644 --- a/src/states/MenuState.cpp +++ b/src/states/MenuState.cpp @@ -1,5 +1,6 @@ #include "MenuState.h" #include "persistence/Scores.h" +#include "../network/supabase_client.h" #include "graphics/Font.h" #include "../graphics/ui/HelpOverlay.h" #include "../core/GlobalState.h" @@ -169,6 +170,24 @@ void MenuState::onEnter() { if (ctx.exitPopupSelectedButton) { *ctx.exitPopupSelectedButton = 1; } + // Refresh highscores for classic/cooperate/challenge asynchronously + try { + std::thread([this]() { + try { + auto c_classic = supabase::FetchHighscores("classic", 12); + auto c_coop = supabase::FetchHighscores("cooperate", 12); + auto c_challenge = supabase::FetchHighscores("challenge", 12); + std::vector combined; + combined.reserve(c_classic.size() + c_coop.size() + c_challenge.size()); + combined.insert(combined.end(), c_classic.begin(), c_classic.end()); + combined.insert(combined.end(), c_coop.begin(), c_coop.end()); + combined.insert(combined.end(), c_challenge.begin(), c_challenge.end()); + if (this->ctx.scores) this->ctx.scores->replaceAll(combined); + } catch (...) { + // swallow network errors - keep existing scores + } + }).detach(); + } catch (...) {} } void MenuState::renderMainButtonTop(SDL_Renderer* renderer, float logicalScale, SDL_Rect logicalVP) { diff --git a/supabe_integrate.md b/supabe_integrate.md new file mode 100644 index 0000000..3540c98 --- /dev/null +++ b/supabe_integrate.md @@ -0,0 +1,213 @@ +# Spacetris — Supabase Highscore Integration +## VS Code Copilot AI Agent Prompt + +You are integrating Supabase highscores into a native C++ SDL3 game called **Spacetris**. + +This is a REST-only integration using Supabase PostgREST. +Do NOT use any Supabase JS SDKs. + +--- + +## 1. Goal + +Implement a highscore backend using Supabase for these game modes: +- classic +- challenge +- cooperate +- versus + +Highscores must be: +- Submitted asynchronously on game over +- Fetched asynchronously for leaderboard screens +- Non-blocking (never stall render loop) +- Offline-safe (fail silently) + +--- + +## 2. Supabase Configuration + +The following constants are provided at build time: + +```cpp +const std::string SUPABASE_URL = "https://xzxpmvyamjvtxpwnjpad.supabase.co"; +const std::string SUPABASE_ANON_KEY = "sb_publishable_GqQx844xYDizO9-ytlBXfA_MVT6N7yA"; +```` + +All requests go to: + +``` +{SUPABASE_URL}/rest/v1/highscores +``` + +--- + +## 3. Database Schema (Already Exists) + +The Supabase table `highscores` has the following fields: + +* score (integer) +* lines (integer) +* level (integer) +* time_sec (integer) +* name (string) +* game_type ("classic", "versus", "cooperate", "challenge") +* timestamp (integer, UNIX epoch seconds) + +--- + +## 4. Data Model in C++ + +Create a struct matching the database schema: + +```cpp +struct HighscoreEntry { + int score; + int lines; + int level; + int timeSec; + std::string name; + std::string gameType; + int timestamp; +}; +``` + +--- + +## 5. HTTP Layer Requirements + +* Use **libcurl** +* Use **JSON** (nlohmann::json or equivalent) +* All network calls must run in a worker thread +* Never block the SDL main loop + +Required HTTP headers: + +``` +apikey: SUPABASE_ANON_KEY +Authorization: Bearer SUPABASE_ANON_KEY +Content-Type: application/json +``` + +--- + +## 6. Submit Highscore (POST) + +Implement: + +```cpp +void SubmitHighscoreAsync(const HighscoreEntry& entry); +``` + +Behavior: + +* Convert entry to JSON +* POST to `/rest/v1/highscores` +* On failure: + + * Log error + * Do NOT crash + * Optionally store JSON locally for retry + +Example JSON payload: + +```json +{ + "score": 123456, + "lines": 240, + "level": 37, + "time_sec": 1820, + "name": "P1 & P2", + "game_type": "cooperate", + "timestamp": 1710000000 +} +``` + +--- + +## 7. Fetch Leaderboard (GET) + +Implement: + +```cpp +std::vector FetchHighscores( + const std::string& gameType, + int limit +); +``` + +REST query examples: + +Classic: + +``` +?game_type=eq.classic&order=score.desc&limit=20 +``` + +Challenge: + +``` +?game_type=eq.challenge&order=level.desc,time_sec.asc&limit=20 +``` + +Cooperate: + +``` +?game_type=eq.cooperate&order=score.desc&limit=20 +``` + +--- + +## 8. Threading Model + +* Use `std::thread` or a simple job queue +* Network calls must not run on the render thread +* Use mutex or lock-free queue to pass results back to UI + +--- + +## 9. Error Handling Rules + +* If Supabase is unreachable: + + * Game continues normally + * Leaderboard screen shows "Offline" +* Never block gameplay +* Never show raw network errors to player + +--- + +## 10. Security Constraints + +* API key is public (acceptable for highscores) +* Obfuscate key in binary if possible +* Do NOT trust client-side data (future server validation planned) + +--- + +## 11. File Structure Suggestion + +``` +/network + supabase_client.h + supabase_client.cpp + +/highscores + highscore_submit.cpp + highscore_fetch.cpp +``` + +--- + +## 12. Acceptance Criteria + +* Highscores are submitted after game over +* Leaderboards load without blocking gameplay +* Works for all four game types +* Offline mode does not crash or freeze +* Code is clean, modular, and SDL3-safe + +--- + +## 13. Summary for the Agent + +Integrate Supabase highscores into Spacetris using REST calls from C++ with libcurl. Use async submission and fetching. Support classic, challenge, cooperate, and versus modes. Ensure non-blocking behavior and graceful offline handling.