Add global leaderboard client (dormant by default)

End-of-game scores are POSTed to a configurable backend URL; the main menu
gains a "Global Leaderboard" entry that fetches and renders the top N for
the current board. Default `global_leaderboard=0` ships the code dormant
until the backend at leaderboard_url is live.

Architecture:
- httpclient.h: thin async interface. http_post_json / http_get fire and
  forget; the user callback runs from http_pump() (native) or the
  Emscripten runtime (web). No blocking calls.
- httpclient_native.cpp: libcurl-multi, polled per frame.
- httpclient_web.cpp: emscripten_fetch (-sFETCH=1 link option).
- leaderboard.{h,cpp}: portable game-side module. Hand-rolled JSON
  build/parse for the tiny fixed-shape wire format; no JSON dep.

Identity: player name + a player-typed "board name" (shared room code).
A per-install RFC-4122-v4 UUID `client_id` is auto-generated on first run
via SDL_rand_bits and stored in the config — server uses it for rate
limiting / dedup, not as a public identity.

Wire format (informational, server is out-of-tree):
  POST /api/scores  body: {board, name, client_id, score, skill,
                           gametype, end_scene, end_lives, end_health,
                           playtime, start_date, end_date}
                    resp: {rank, total}
  GET  /api/scores?board=&limit=50
                    resp: {board, total, scores:[{name,score,end_scene}]}

Hook points:
- manage.cpp game_start: populate hi.start_date; reset_submission().
- manage.cpp game_stop: populate hi.end_date and the profile name on hi,
  then leaderboard::submit_score after scorefile.record.
- kobo.cpp main: http_init/http_shutdown around the run; generate
  client_id on first run when empty.
- kobo.cpp kobo_gfxengine_t::frame: http_pump() before gsm.frame().
- states.cpp st_game_over post_render: renders "Submitting…" / "Rank #N of M"
  / "(offline)" under "GAME OVER".
- states.cpp main_menu: "Global Leaderboard" button (only when enabled
  + board name set).
- states.cpp st_global_leaderboard_t: new browser state listing top 16.
- options.cpp game_options: onoff toggle.

Out of scope for this commit (left as follow-ups):
- In-game text editor for board name / URL (form toolkit has no free-text
  widget; users edit via config file or CLI flags for now).
- Retry queue across sessions.
- Server implementation. tools/mock-leaderboard.py is a 100-line Python
  scratch server for local testing only.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ville Lindholm 2026-06-05 11:10:20 +03:00
parent 7ac9875d11
commit 9238ce61c6
No known key found for this signature in database
GPG Key ID: 89AE9EAA3B6FDE7C
14 changed files with 988 additions and 1 deletions

View File

@ -57,6 +57,7 @@ if(EMSCRIPTEN)
else() else()
find_package(SDL3 CONFIG REQUIRED) find_package(SDL3 CONFIG REQUIRED)
find_package(SDL3_image CONFIG REQUIRED) find_package(SDL3_image CONFIG REQUIRED)
find_package(CURL REQUIRED)
endif() endif()
if(KOBO_ENABLE_OPENGL) if(KOBO_ENABLE_OPENGL)
@ -199,14 +200,26 @@ set(KOBO_SOURCES
myship.cpp radar.cpp random.cpp scenes.cpp score.cpp screen.cpp myship.cpp radar.cpp random.cpp scenes.cpp score.cpp screen.cpp
filemap.cpp prefs.cpp cfgform.cpp options.cpp gamestate.cpp filemap.cpp prefs.cpp cfgform.cpp options.cpp gamestate.cpp
states.cpp form.cpp cfgparse.cpp game.cpp kobo.cpp logger.c states.cpp form.cpp cfgparse.cpp game.cpp kobo.cpp logger.c
dashboard.cpp sound.cpp prof.cpp bench.cpp dashboard.cpp sound.cpp prof.cpp bench.cpp leaderboard.cpp
) )
if(EMSCRIPTEN)
list(APPEND KOBO_SOURCES httpclient_web.cpp)
else()
list(APPEND KOBO_SOURCES httpclient_native.cpp)
endif()
add_executable(kobodl ${KOBO_SOURCES}) add_executable(kobodl ${KOBO_SOURCES})
target_include_directories(kobodl PRIVATE ${CMAKE_SOURCE_DIR}/data/sfx) target_include_directories(kobodl PRIVATE ${CMAKE_SOURCE_DIR}/data/sfx)
target_link_libraries(kobodl PRIVATE graphics sound eel kobo_deps) target_link_libraries(kobodl PRIVATE graphics sound eel kobo_deps)
set_target_properties(kobodl PROPERTIES OUTPUT_NAME ${KOBO_EXEFILE}) set_target_properties(kobodl PROPERTIES OUTPUT_NAME ${KOBO_EXEFILE})
if(EMSCRIPTEN)
target_link_options(kobodl PRIVATE -sFETCH=1)
else()
target_link_libraries(kobodl PRIVATE CURL::libcurl)
endif()
if(EMSCRIPTEN) if(EMSCRIPTEN)
# ASYNCIFY lets the existing blocking main loop (gengine->run(), SDL_Delay) # ASYNCIFY lets the existing blocking main loop (gengine->run(), SDL_Delay)
# yield to the browser without rewriting it around emscripten_set_main_loop. # yield to the browser without rewriting it around emscripten_set_main_loop.

44
httpclient.h Normal file
View File

@ -0,0 +1,44 @@
/*
* httpclient.h thin async HTTP client.
*
* Two impls share this header:
* httpclient_native.cpp libcurl-multi, polled from http_pump().
* httpclient_web.cpp emscripten_fetch; http_pump() is a no-op.
*
* Callbacks fire on the main thread: native impls dispatch from http_pump(),
* web from the Emscripten runtime (which is already main-thread).
*
* The `body` pointer in http_response_t is only valid for the duration of
* the callback copy what you need.
*/
#ifndef KOBO_HTTPCLIENT_H
#define KOBO_HTTPCLIENT_H
#include <stddef.h>
typedef struct http_response_t {
int status; // HTTP status code; 0 = network/transport failure
const char *body; // NUL-terminated; valid only during the callback
size_t len;
} http_response_t;
typedef void (*http_callback_t)(const http_response_t *resp, void *user);
#ifdef __cplusplus
extern "C" {
#endif
void http_init(void);
void http_shutdown(void);
void http_post_json(const char *url, const char *body,
http_callback_t cb, void *user);
void http_get(const char *url, http_callback_t cb, void *user);
void http_pump(void);
#ifdef __cplusplus
}
#endif
#endif /* KOBO_HTTPCLIENT_H */

175
httpclient_native.cpp Normal file
View File

@ -0,0 +1,175 @@
/*
* httpclient_native.cpp libcurl-multi backend.
*
* Single CURLM handle owned by this module. http_post_json / http_get queue
* an easy handle; http_pump() runs curl_multi_perform and drains finished
* transfers, firing user callbacks exactly once.
*
* Failure modes (DNS, connect refused, timeout, etc.) are surfaced as
* status=0 in the response caller doesn't have to distinguish curl error
* codes from HTTP errors.
*/
#include "httpclient.h"
#include "kobolog.h"
#include <curl/curl.h>
#include <string>
#include <unordered_map>
namespace {
CURLM *g_multi = nullptr;
struct pending_t {
http_callback_t cb;
void *user;
std::string body;
curl_slist *headers = nullptr;
};
// Keyed by CURL* easy handle so curl_multi_info_read results can be routed.
std::unordered_map<CURL *, pending_t *> g_pending;
size_t write_cb(char *ptr, size_t size, size_t nmemb, void *userdata)
{
pending_t *p = static_cast<pending_t *>(userdata);
p->body.append(ptr, size * nmemb);
return size * nmemb;
}
void start_request(CURL *easy, pending_t *pending)
{
curl_easy_setopt(easy, CURLOPT_WRITEFUNCTION, write_cb);
curl_easy_setopt(easy, CURLOPT_WRITEDATA, pending);
curl_easy_setopt(easy, CURLOPT_TIMEOUT, 10L);
curl_easy_setopt(easy, CURLOPT_CONNECTTIMEOUT, 5L);
curl_easy_setopt(easy, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(easy, CURLOPT_USERAGENT, "kobodl/0.5.1");
g_pending[easy] = pending;
CURLMcode rc = curl_multi_add_handle(g_multi, easy);
if(rc != CURLM_OK)
{
log_printf(ELOG, "http: curl_multi_add_handle failed: %s\n",
curl_multi_strerror(rc));
// Synthesize a transport failure callback.
http_response_t resp{0, "", 0};
pending->cb(&resp, pending->user);
g_pending.erase(easy);
if(pending->headers) curl_slist_free_all(pending->headers);
curl_easy_cleanup(easy);
delete pending;
}
}
} // namespace
extern "C" void http_init(void)
{
if(g_multi) return;
curl_global_init(CURL_GLOBAL_DEFAULT);
g_multi = curl_multi_init();
}
extern "C" void http_shutdown(void)
{
if(!g_multi) return;
// Abandon any in-flight handles — we're shutting down, callbacks are
// moot. Free the resources to avoid leak reports.
for(auto &kv : g_pending)
{
curl_multi_remove_handle(g_multi, kv.first);
if(kv.second->headers) curl_slist_free_all(kv.second->headers);
curl_easy_cleanup(kv.first);
delete kv.second;
}
g_pending.clear();
curl_multi_cleanup(g_multi);
g_multi = nullptr;
curl_global_cleanup();
}
extern "C" void http_post_json(const char *url, const char *body,
http_callback_t cb, void *user)
{
if(!g_multi || !cb) return;
CURL *easy = curl_easy_init();
if(!easy)
{
http_response_t resp{0, "", 0};
cb(&resp, user);
return;
}
auto *pending = new pending_t;
pending->cb = cb;
pending->user = user;
pending->headers = curl_slist_append(nullptr, "Content-Type: application/json");
curl_easy_setopt(easy, CURLOPT_URL, url);
curl_easy_setopt(easy, CURLOPT_POST, 1L);
curl_easy_setopt(easy, CURLOPT_COPYPOSTFIELDS, body ? body : "");
curl_easy_setopt(easy, CURLOPT_HTTPHEADER, pending->headers);
start_request(easy, pending);
}
extern "C" void http_get(const char *url, http_callback_t cb, void *user)
{
if(!g_multi || !cb) return;
CURL *easy = curl_easy_init();
if(!easy)
{
http_response_t resp{0, "", 0};
cb(&resp, user);
return;
}
auto *pending = new pending_t;
pending->cb = cb;
pending->user = user;
curl_easy_setopt(easy, CURLOPT_URL, url);
curl_easy_setopt(easy, CURLOPT_HTTPGET, 1L);
start_request(easy, pending);
}
extern "C" void http_pump(void)
{
if(!g_multi) return;
int running = 0;
curl_multi_perform(g_multi, &running);
CURLMsg *msg;
int msgs_left = 0;
while((msg = curl_multi_info_read(g_multi, &msgs_left)) != nullptr)
{
if(msg->msg != CURLMSG_DONE) continue;
CURL *easy = msg->easy_handle;
auto it = g_pending.find(easy);
if(it == g_pending.end())
{
curl_multi_remove_handle(g_multi, easy);
curl_easy_cleanup(easy);
continue;
}
pending_t *p = it->second;
long status = 0;
if(msg->data.result == CURLE_OK)
curl_easy_getinfo(easy, CURLINFO_RESPONSE_CODE, &status);
else
log_printf(DLOG, "http: transport error: %s\n",
curl_easy_strerror(msg->data.result));
http_response_t resp;
resp.status = (int)status;
resp.body = p->body.c_str();
resp.len = p->body.size();
p->cb(&resp, p->user);
curl_multi_remove_handle(g_multi, easy);
if(p->headers) curl_slist_free_all(p->headers);
curl_easy_cleanup(easy);
g_pending.erase(it);
delete p;
}
}

95
httpclient_web.cpp Normal file
View File

@ -0,0 +1,95 @@
/*
* httpclient_web.cpp emscripten_fetch backend.
*
* emscripten_fetch is async and fires onsuccess/onerror on the main thread.
* We attach a small heap-owned context to userData; the trampolines invoke
* the user callback and free the context.
*
* http_pump() is a no-op because the runtime drives completion delivery.
*/
#ifdef __EMSCRIPTEN__
#include "httpclient.h"
#include <emscripten/fetch.h>
#include <stdlib.h>
#include <string.h>
namespace {
struct ctx_t {
http_callback_t cb;
void *user;
};
void deliver(emscripten_fetch_t *fetch, int status)
{
ctx_t *c = static_cast<ctx_t *>(fetch->userData);
http_response_t resp;
resp.status = status;
resp.body = fetch->data ? fetch->data : "";
resp.len = (size_t)fetch->numBytes;
c->cb(&resp, c->user);
emscripten_fetch_close(fetch);
delete c;
}
void on_success(emscripten_fetch_t *fetch)
{
deliver(fetch, (int)fetch->status);
}
void on_error(emscripten_fetch_t *fetch)
{
// Distinguish: a non-2xx with body (e.g. 500 + JSON) reports its status;
// a true transport failure (no connection, CORS reject) reports 0.
int status = (fetch->status >= 100 && fetch->status < 600)
? (int)fetch->status : 0;
deliver(fetch, status);
}
void issue(const char *method, const char *url, const char *body,
http_callback_t cb, void *user)
{
emscripten_fetch_attr_t attr;
emscripten_fetch_attr_init(&attr);
strncpy(attr.requestMethod, method, sizeof(attr.requestMethod) - 1);
attr.attributes = EMSCRIPTEN_FETCH_LOAD_TO_MEMORY;
attr.onsuccess = on_success;
attr.onerror = on_error;
static const char *headers[] = {
"Content-Type", "application/json", nullptr
};
if(body)
{
attr.requestHeaders = headers;
attr.requestData = body;
attr.requestDataSize = strlen(body);
}
auto *c = new ctx_t{cb, user};
attr.userData = c;
emscripten_fetch(&attr, url);
}
} // namespace
extern "C" void http_init(void) {}
extern "C" void http_shutdown(void) {}
extern "C" void http_pump(void) {}
extern "C" void http_post_json(const char *url, const char *body,
http_callback_t cb, void *user)
{
if(!cb) return;
issue("POST", url, body ? body : "", cb, user);
}
extern "C" void http_get(const char *url, http_callback_t cb, void *user)
{
if(!cb) return;
issue("GET", url, nullptr, cb, user);
}
#endif /* __EMSCRIPTEN__ */

View File

@ -63,6 +63,7 @@ extern "C" {
#include "enemies.h" #include "enemies.h"
#include "prof.h" #include "prof.h"
#include "bench.h" #include "bench.h"
#include "httpclient.h"
#define MAX_FPS_RESULTS 64 #define MAX_FPS_RESULTS 64
@ -1860,6 +1861,7 @@ void kobo_gfxengine_t::frame()
/* /*
* Run the current gamestate for one frame * Run the current gamestate for one frame
*/ */
http_pump();
gsm.frame(); gsm.frame();
if(prefs->cmd_autoshot && !manage.game_stopped()) if(prefs->cmd_autoshot && !manage.game_stopped())
@ -2088,6 +2090,7 @@ int main(int argc, char *argv[])
prof_init(); prof_init();
SDL_Init(0); SDL_Init(0);
http_init();
if(main_init()) if(main_init())
{ {
@ -2111,6 +2114,31 @@ int main(int argc, char *argv[])
return 1; return 1;
} }
// First-run client_id generation. Stable per-install identifier sent
// with leaderboard submissions for rate-limiting/dedup (not used as
// the public identity — that's name + board_name).
if(prefs->client_id[0] == '\0')
{
// Not cryptographically random — this is a rate-limit dedup
// token, not a secret. SDL_rand_bits is sufficient.
Uint8 b[16];
for(int i = 0; i < 16; i += 4)
{
Uint32 r = SDL_rand_bits();
b[i] = (Uint8)(r >> 24);
b[i+1] = (Uint8)(r >> 16);
b[i+2] = (Uint8)(r >> 8);
b[i+3] = (Uint8)r;
}
b[6] = (b[6] & 0x0F) | 0x40; // RFC 4122 v4
b[8] = (b[8] & 0x3F) | 0x80;
snprintf(prefs->client_id, sizeof(prefs->client_id),
"%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x",
b[0],b[1],b[2],b[3], b[4],b[5], b[6],b[7],
b[8],b[9], b[10],b[11],b[12],b[13],b[14],b[15]);
prefs->changed = 1;
}
if(prefs->cmd_options_man) if(prefs->cmd_options_man)
{ {
put_options_man(); put_options_man();
@ -2194,6 +2222,7 @@ int main(int argc, char *argv[])
km.print_fps_results(); km.print_fps_results();
km.close_logging(); km.close_logging();
http_shutdown();
main_cleanup(); main_cleanup();
return 0; return 0;
} }

260
leaderboard.cpp Normal file
View File

@ -0,0 +1,260 @@
/*
* leaderboard.cpp submission + browse over HTTP.
*
* Hand-rolled JSON build/parse. The wire schema is tiny and fixed-shape
* (see plan); no point in pulling in a JSON dep just to handle two
* response types.
*
* Threading: callbacks fire from http_pump() (native) or the Emscripten
* runtime (web) both single-threaded relative to the game loop, so
* mutating the global state structs from the callbacks is safe.
*/
// pfile.h (pulled in via score.h via leaderboard.h) uses FILE without
// including stdio.h itself; pull it in first.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
#include "leaderboard.h"
#include "httpclient.h"
#include "prefs.h"
#include "kobo.h"
#include "kobolog.h"
extern prefs_t *prefs;
namespace {
leaderboard_submission_t g_sub = { LB_IDLE, 0, 0 };
leaderboard_browse_t g_brw = { LB_IDLE, 0, 0, {}, "" };
bool enabled()
{
return prefs && prefs->global_leaderboard
&& prefs->leaderboard_url[0] != '\0'
&& prefs->leaderboard_board[0] != '\0';
}
// ---- JSON helpers ----------------------------------------------------------
//
// Build: snprintf with manual escaping for string fields.
// Parse: substring-search the known keys. Tolerates whitespace but assumes
// the server emits one occurrence of each key (which our server contract does).
void json_escape(char *out, size_t cap, const char *in)
{
size_t o = 0;
for(size_t i = 0; in[i] && o + 2 < cap; ++i)
{
char c = in[i];
if(c == '"' || c == '\\')
{
if(o + 3 >= cap) break;
out[o++] = '\\'; out[o++] = c;
}
else if((unsigned char)c < 0x20)
{
// Skip control bytes; the in-game name editor only emits ASCII
// letters anyway. Logging this would be noise.
continue;
}
else out[o++] = c;
}
out[o] = '\0';
}
// Find `"key":` and return pointer to first char after the colon, or NULL.
const char *find_key(const char *body, const char *key)
{
size_t klen = strlen(key);
for(const char *p = body; (p = strchr(p, '"')) != NULL; ++p)
{
++p;
if(strncmp(p, key, klen) != 0) continue;
if(p[klen] != '"') continue;
const char *q = p + klen + 1;
while(*q == ' ' || *q == '\t') ++q;
if(*q != ':') continue;
++q;
while(*q == ' ' || *q == '\t') ++q;
return q;
}
return NULL;
}
bool parse_int(const char *p, int *out)
{
if(!p) return false;
char *end = NULL;
long v = strtol(p, &end, 10);
if(end == p) return false;
*out = (int)v;
return true;
}
// Copies the next "string" into out, returns pointer past the closing quote.
const char *parse_string(const char *p, char *out, size_t cap)
{
if(!p || *p != '"') { if(cap) out[0] = '\0'; return NULL; }
++p;
size_t o = 0;
while(*p && *p != '"')
{
if(*p == '\\' && p[1])
{
if(o + 1 < cap) out[o++] = p[1];
p += 2;
}
else
{
if(o + 1 < cap) out[o++] = *p;
++p;
}
}
if(cap) out[o] = '\0';
return *p == '"' ? p + 1 : NULL;
}
// ---- callbacks --------------------------------------------------------------
void on_submit(const http_response_t *resp, void * /*user*/)
{
if(resp->status == 0)
{
g_sub.status = LB_OFFLINE;
log_printf(WLOG, "leaderboard: submission failed (network)\n");
return;
}
if(resp->status / 100 != 2)
{
g_sub.status = LB_FAILED;
log_printf(WLOG, "leaderboard: submission rejected (HTTP %d)\n",
resp->status);
return;
}
int rank = -1, total = -1;
parse_int(find_key(resp->body, "rank"), &rank);
parse_int(find_key(resp->body, "total"), &total);
g_sub.rank = rank;
g_sub.total = total;
g_sub.status = LB_DONE;
}
void on_browse(const http_response_t *resp, void * /*user*/)
{
if(resp->status == 0) { g_brw.status = LB_OFFLINE; return; }
if(resp->status / 100 != 2) { g_brw.status = LB_FAILED; return; }
g_brw.count = 0;
int total = -1;
parse_int(find_key(resp->body, "total"), &total);
g_brw.total = total;
// "scores": [ {name, score, end_scene}, ... ]
const char *p = find_key(resp->body, "scores");
if(!p || *p != '[') { g_brw.status = LB_FAILED; return; }
++p;
while(g_brw.count < LEADERBOARD_BROWSE_MAX)
{
while(*p && *p != '{' && *p != ']') ++p;
if(*p != '{') break;
// Each entry is a small object — scan it locally.
const char *end = strchr(p, '}');
if(!end) break;
leaderboard_entry_t &e = g_brw.entries[g_brw.count];
e.name[0] = '\0';
e.score = 0;
e.end_scene = 0;
const char *kp = find_key(p, "name");
if(kp && kp < end) parse_string(kp, e.name, sizeof(e.name));
kp = find_key(p, "score");
int sv = 0;
if(kp && kp < end && parse_int(kp, &sv) && sv >= 0)
e.score = (unsigned int)sv;
kp = find_key(p, "end_scene");
if(kp && kp < end) parse_int(kp, &e.end_scene);
++g_brw.count;
p = end + 1;
}
g_brw.status = LB_DONE;
}
} // namespace
namespace leaderboard {
void submit_score(const s_hiscore_t *hi)
{
g_sub.status = LB_IDLE;
g_sub.rank = -1;
g_sub.total = -1;
if(!enabled() || !hi) return;
char name_esc[LEADERBOARD_NAME_MAX * 2 + 1];
char board_esc[LEADERBOARD_NAME_MAX * 2 + 1];
char id_esc[128];
const char *player_name = (hi->profile && hi->profile->name[0])
? hi->profile->name : "ANON";
json_escape(name_esc, sizeof(name_esc), player_name);
json_escape(board_esc, sizeof(board_esc), prefs->leaderboard_board);
json_escape(id_esc, sizeof(id_esc), prefs->client_id);
char body[1024];
int n = snprintf(body, sizeof(body),
"{\"board\":\"%s\",\"name\":\"%s\",\"client_id\":\"%s\","
"\"client\":\"kobodl/0.5.1\","
"\"score\":%u,\"skill\":%d,\"gametype\":%d,"
"\"end_scene\":%d,\"end_lives\":%d,\"end_health\":%d,"
"\"playtime\":%u,\"start_date\":%u,\"end_date\":%u}",
board_esc, name_esc, id_esc,
hi->score, hi->skill, hi->gametype,
hi->end_scene, hi->end_lives, hi->end_health,
hi->playtime, hi->start_date, hi->end_date);
if(n <= 0 || n >= (int)sizeof(body)) return;
char url[512];
snprintf(url, sizeof(url), "%s/api/scores", prefs->leaderboard_url);
g_sub.status = LB_INFLIGHT;
http_post_json(url, body, on_submit, NULL);
}
void fetch_board()
{
g_brw.status = LB_IDLE;
g_brw.count = 0;
g_brw.total = 0;
if(!enabled()) return;
strncpy(g_brw.board, prefs->leaderboard_board, sizeof(g_brw.board) - 1);
g_brw.board[sizeof(g_brw.board) - 1] = '\0';
char board_esc[LEADERBOARD_NAME_MAX * 2 + 1];
json_escape(board_esc, sizeof(board_esc), prefs->leaderboard_board);
char url[768];
snprintf(url, sizeof(url),
"%s/api/scores?board=%s&limit=%d",
prefs->leaderboard_url, board_esc, LEADERBOARD_BROWSE_MAX);
g_brw.status = LB_INFLIGHT;
http_get(url, on_browse, NULL);
}
const leaderboard_submission_t *submission() { return &g_sub; }
const leaderboard_browse_t *browse() { return &g_brw; }
void reset_submission()
{
g_sub.status = LB_IDLE;
g_sub.rank = -1;
g_sub.total = -1;
}
} // namespace leaderboard

69
leaderboard.h Normal file
View File

@ -0,0 +1,69 @@
/*
* leaderboard.h global leaderboard client.
*
* Two flows:
*
* 1. submit_score() is called after each completed game (manage.cpp). It
* POSTs the run's s_hiscore_t to leaderboard_url + "/api/scores". The
* game-over screen polls submission() to render the in-flight UI.
*
* 2. fetch_board() is called when the in-game leaderboard browser opens.
* It GETs the top N for the current board. The browser state polls
* browse() to render the list.
*
* Both flows are no-ops when prefs->global_leaderboard is 0, or when the
* board name is empty, or when the URL is empty.
*/
#ifndef KOBO_LEADERBOARD_H
#define KOBO_LEADERBOARD_H
#include "score.h"
#define LEADERBOARD_BROWSE_MAX 50
#define LEADERBOARD_NAME_MAX 32
enum leaderboard_status_t {
LB_IDLE = 0,
LB_INFLIGHT,
LB_DONE,
LB_FAILED, // server error, network error, or feature disabled
LB_OFFLINE // transport failure (status 0)
};
struct leaderboard_submission_t {
leaderboard_status_t status;
int rank; // valid when status == LB_DONE
int total; // valid when status == LB_DONE
};
struct leaderboard_entry_t {
char name[LEADERBOARD_NAME_MAX];
unsigned int score;
int end_scene;
};
struct leaderboard_browse_t {
leaderboard_status_t status;
int count;
int total;
leaderboard_entry_t entries[LEADERBOARD_BROWSE_MAX];
char board[LEADERBOARD_NAME_MAX];
};
namespace leaderboard {
// Called from manage.cpp after scorefile.record(). Safe to call always;
// it self-checks prefs and silently no-ops if disabled.
void submit_score(const s_hiscore_t *hi);
// Called from the browser state on enter. Replaces previous results.
void fetch_board();
// State accessors — game UI reads these every frame.
const leaderboard_submission_t *submission();
const leaderboard_browse_t *browse();
// Reset submission state to LB_IDLE (e.g. when starting a new game).
void reset_submission();
}
#endif // KOBO_LEADERBOARD_H

View File

@ -52,6 +52,8 @@
#include "random.h" #include "random.h"
#include "prof.h" #include "prof.h"
#include "bench.h" #include "bench.h"
#include "leaderboard.h"
#include <time.h>
#define GIGA 1000000000 #define GIGA 1000000000
@ -213,6 +215,8 @@ void _manage::game_start()
{ {
_game_over = 0; _game_over = 0;
hi.clear(); hi.clear();
hi.start_date = (unsigned int)time(NULL);
leaderboard::reset_submission();
game.set(GAME_SINGLE, (skill_levels_t)scorefile.profile()->skill); game.set(GAME_SINGLE, (skill_levels_t)scorefile.profile()->skill);
@ -267,8 +271,20 @@ void _manage::game_stop()
hi.score = score; hi.score = score;
hi.end_scene = scene_num; hi.end_scene = scene_num;
hi.end_health = myship.health(); hi.end_health = myship.health();
hi.end_date = (unsigned int)time(NULL);
// Profile name isn't part of s_hiscore_t's serialized form, but
// leaderboard::submit_score wants it. Sync it here so the
// payload picks it up.
s_profile_t *p = scorefile.profile();
if(p)
{
hi.profile = p;
strncpy(hi.name, p->name, sizeof(hi.name) - 1);
hi.name[sizeof(hi.name) - 1] = '\0';
}
scorefile.record(&hi); scorefile.record(&hi);
leaderboard::submit_score(&hi);
} }
ships = 0; ships = 0;
ships_changed = 1; ships_changed = 1;

View File

@ -458,6 +458,14 @@ void game_options_t::build()
item("Off", 200); item("Off", 200);
item("Low", 100); item("Low", 100);
item("High", 50); item("High", 50);
// Leaderboard toggle. Board name and URL live in the config file
// (the form toolkit has no free-text widget). When this is on but
// the board name is empty, submission no-ops silently.
space();
xoffs = 0.6;
onoff("Global Leaderboard", &prf->global_leaderboard, 0);
big(); big();
space(); space();
xoffs = 0.5; xoffs = 0.5;

View File

@ -104,6 +104,18 @@ void prefs_t::init()
key("sfx", sfxdir, ""); desc("Sound Data Path"); key("sfx", sfxdir, ""); desc("Sound Data Path");
key("scores", scoredir, ""); desc("Score File Path"); key("scores", scoredir, ""); desc("Score File Path");
comment("--- Global leaderboard ---------------------");
// Default off: this code is shipped dormant until the backend at
// leaderboard_url exists. Flip on once the server is live.
yesno("global_leaderboard", global_leaderboard, 0);
desc("Submit Scores to Global Leaderboard");
key("leaderboard_board", leaderboard_board, "");
desc("Leaderboard Board Name (shared with friends)");
key("leaderboard_url", leaderboard_url,
"https://leaderboard.lindholm.dev");
desc("Leaderboard Server URL");
key("client_id", client_id, ""); desc("Anonymous Per-Install ID");
// Obsolete stuff (not written into new files) // Obsolete stuff (not written into new files)
key("size", o_size, 0, 0); desc("Screen Size (Obsolete)"); key("size", o_size, 0, 0); desc("Screen Size (Obsolete)");
key("wait", o_wait_msec, 30); desc("Game Speed (Obsolete)"); key("wait", o_wait_msec, 30); desc("Game Speed (Obsolete)");

View File

@ -95,6 +95,12 @@ class prefs_t : public config_parser_t
cfg_string_t sfxdir; //Path to sfx/ cfg_string_t sfxdir; //Path to sfx/
cfg_string_t scoredir; //Path to scores/ cfg_string_t scoredir; //Path to scores/
//Global leaderboard
int global_leaderboard; //Submit scores to the server
cfg_string_t leaderboard_board; //Shared "room code"
cfg_string_t leaderboard_url; //Server endpoint base URL
cfg_string_t client_id; //Per-install UUID (auto-gen)
//Obsolete stuff (compatibility) //Obsolete stuff (compatibility)
int o_size; //Screen scale int o_size; //Screen scale
int o_wait_msec; //ms per control system frame int o_wait_msec; //ms per control system frame

View File

@ -38,6 +38,9 @@
#include "sound.h" #include "sound.h"
#include "radar.h" #include "radar.h"
#include "random.h" #include "random.h"
#include "leaderboard.h"
#include "prefs.h"
#include <stdio.h>
gamestatemanager_t gsm; gamestatemanager_t gsm;
@ -664,6 +667,41 @@ void st_game_over_t::post_render()
int y = PIXEL2CS(100) + (int)floor(PIXEL2CS(15)*sin(ft * 6)); int y = PIXEL2CS(100) + (int)floor(PIXEL2CS(15)*sin(ft * 6));
wmain->center_fxp(y, "GAME OVER"); wmain->center_fxp(y, "GAME OVER");
const leaderboard_submission_t *sub = leaderboard::submission();
if(sub->status != LB_IDLE)
{
char buf[64];
const char *msg = NULL;
switch(sub->status)
{
case LB_INFLIGHT:
msg = "Submitting score…";
break;
case LB_DONE:
if(sub->rank > 0 && sub->total > 0)
{
snprintf(buf, sizeof(buf), "Rank #%d of %d",
sub->rank, sub->total);
msg = buf;
}
else
msg = "Score submitted";
break;
case LB_OFFLINE:
msg = "(offline)";
break;
case LB_FAILED:
msg = "(submission failed)";
break;
default: break;
}
if(msg)
{
wmain->font(B_NORMAL_FONT);
wmain->center_fxp(PIXEL2CS(140), msg);
}
}
wradar->frame(); wradar->frame();
} }
@ -1281,6 +1319,8 @@ void main_menu_t::build()
} }
space(); space();
button("Options", 2); button("Options", 2);
if(prefs->global_leaderboard && prefs->leaderboard_board[0])
button("Global Leaderboard", 200);
space(); space();
if(manage.game_stopped()) if(manage.game_stopped())
button("Return to Intro", 0); button("Return to Intro", 0);
@ -1367,6 +1407,9 @@ void st_main_menu_t::select(int tag)
case 3: case 3:
gsm.push(&st_new_player); gsm.push(&st_new_player);
break; break;
case 200:
gsm.push(&st_global_leaderboard);
break;
case 4: // Player: Inc/Dec case 4: // Player: Inc/Dec
sound.ui_tick(); sound.ui_tick();
prefs->changed = 1; prefs->changed = 1;
@ -1997,3 +2040,105 @@ st_profile_audio_t st_profile_audio;
#endif /*PROFILE_AUDIO*/ #endif /*PROFILE_AUDIO*/
/*----------------------------------------------------------
Global Leaderboard browser
----------------------------------------------------------*/
st_global_leaderboard_t::st_global_leaderboard_t()
{
name = "global_leaderboard";
}
void st_global_leaderboard_t::enter()
{
start_time = (int)SDL_GetTicks();
leaderboard::fetch_board();
}
void st_global_leaderboard_t::press(int button)
{
switch(button)
{
case BTN_EXIT:
case BTN_NO:
case BTN_FIRE:
case BTN_START:
case BTN_SELECT:
case BTN_YES:
sound.ui_ok();
pop();
break;
}
}
void st_global_leaderboard_t::frame()
{
manage.run_intro();
}
void st_global_leaderboard_t::post_render()
{
kobo_basestate_t::post_render();
const leaderboard_browse_t *b = leaderboard::browse();
wmain->font(B_BIG_FONT);
wmain->center(40, "GLOBAL LEADERBOARD");
char buf[96];
wmain->font(B_NORMAL_FONT);
if(b->board[0])
{
snprintf(buf, sizeof(buf), "Board: %s", b->board);
wmain->center(70, buf);
}
switch(b->status)
{
case LB_INFLIGHT:
wmain->center(120, "Loading…");
break;
case LB_OFFLINE:
wmain->center(120, "(offline)");
break;
case LB_FAILED:
wmain->center(120, "(server error)");
break;
case LB_IDLE:
wmain->center(120, "Leaderboard disabled.");
wmain->center(140, "Set leaderboard_board in config.");
break;
case LB_DONE:
{
if(b->count == 0)
{
wmain->center(120, "No scores yet.");
break;
}
// Two-column layout: rank + name on the left, score + stage
// on the right. 18px line height matches the existing intro
// high-score screen feel.
int y = 100;
int rows = b->count > 16 ? 16 : b->count;
for(int i = 0; i < rows; ++i)
{
const leaderboard_entry_t &e = b->entries[i];
snprintf(buf, sizeof(buf), "%2d. %-10s",
i + 1, e.name);
wmain->string(40, y, buf);
snprintf(buf, sizeof(buf), "%9u st.%2d",
e.score, e.end_scene + 1);
wmain->string(160, y, buf);
y += 12;
}
break;
}
default: break;
}
wmain->center(WSIZE - 30, "(Press any key to return)");
}
st_global_leaderboard_t st_global_leaderboard;

View File

@ -473,6 +473,22 @@ class st_ask_abort_game_t : public st_yesno_base_t
}; };
/*----------------------------------------------------------
Global Leaderboard browser
----------------------------------------------------------*/
class st_global_leaderboard_t : public kobo_basestate_t
{
int start_time;
public:
st_global_leaderboard_t();
void enter();
void press(int button);
void frame();
void post_render();
};
/*---------------------------------------------------------- /*----------------------------------------------------------
Debug: Audio Engine Profiling Debug: Audio Engine Profiling
----------------------------------------------------------*/ ----------------------------------------------------------*/
@ -534,6 +550,7 @@ extern st_options_graphics_t st_options_graphics;
extern st_options_audio_t st_options_audio; extern st_options_audio_t st_options_audio;
extern st_options_control_t st_options_control; extern st_options_control_t st_options_control;
extern st_options_game_t st_options_game; extern st_options_game_t st_options_game;
extern st_global_leaderboard_t st_global_leaderboard;
extern st_error_t st_error; extern st_error_t st_error;
#ifdef PROFILE_AUDIO #ifdef PROFILE_AUDIO
extern st_profile_audio_t st_profile_audio; extern st_profile_audio_t st_profile_audio;

98
tools/mock-leaderboard.py Executable file
View File

@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""Mock leaderboard server for local testing.
Stores submitted scores in memory, returns rank/total on POST, and serves
the top-N on GET. Sends permissive CORS so the web build can talk to it
from a different port.
Usage:
./tools/mock-leaderboard.py # listens on :8765
./tools/mock-leaderboard.py 8080 # custom port
"""
import json
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse, parse_qs
# In-memory store: {board_name: [score_record, ...]}
SCORES = {}
def _cors(handler):
handler.send_header("Access-Control-Allow-Origin", "*")
handler.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
handler.send_header("Access-Control-Allow-Headers", "Content-Type")
class Handler(BaseHTTPRequestHandler):
def do_OPTIONS(self):
self.send_response(204)
_cors(self)
self.end_headers()
def do_GET(self):
url = urlparse(self.path)
if url.path != "/api/scores":
self.send_response(404); _cors(self); self.end_headers(); return
q = parse_qs(url.query)
board = (q.get("board") or [""])[0]
limit = int((q.get("limit") or ["50"])[0])
rows = sorted(SCORES.get(board, []),
key=lambda r: r["score"], reverse=True)[:limit]
body = json.dumps({
"board": board,
"total": len(SCORES.get(board, [])),
"scores": [
{"name": r["name"], "score": r["score"],
"end_scene": r["end_scene"]}
for r in rows
],
}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
_cors(self)
self.end_headers()
self.wfile.write(body)
def do_POST(self):
if urlparse(self.path).path != "/api/scores":
self.send_response(404); _cors(self); self.end_headers(); return
length = int(self.headers.get("Content-Length", "0"))
try:
payload = json.loads(self.rfile.read(length))
except Exception as e:
self.send_response(400); _cors(self); self.end_headers()
self.wfile.write(f"bad json: {e}".encode())
return
board = payload.get("board", "")
SCORES.setdefault(board, []).append(payload)
# Rank: 1 = best. Sort descending, find position.
ranked = sorted(SCORES[board], key=lambda r: r["score"], reverse=True)
# Identify *this* submission by reference equality.
rank = next((i + 1 for i, r in enumerate(ranked) if r is payload), -1)
body = json.dumps({"rank": rank, "total": len(SCORES[board])}).encode()
print(f"[POST] board={board} name={payload.get('name')} "
f"score={payload.get('score')} -> #{rank}/{len(SCORES[board])}")
self.send_response(200)
self.send_header("Content-Type", "application/json")
_cors(self)
self.end_headers()
self.wfile.write(body)
def log_message(self, fmt, *args):
# Keep the noise down; we print our own line on POST.
return
def main():
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8765
srv = HTTPServer(("127.0.0.1", port), Handler)
print(f"mock-leaderboard listening on http://127.0.0.1:{port}")
try:
srv.serve_forever()
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()