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>
176 lines
4.9 KiB
C++
176 lines
4.9 KiB
C++
/*
|
|
* 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;
|
|
}
|
|
}
|