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>
261 lines
7.4 KiB
C++
261 lines
7.4 KiB
C++
/*
|
|
* 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
|