kobodl/httpclient_native.cpp
Ville Lindholm 9238ce61c6
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>
2026-06-05 11:10:20 +03:00

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;
}
}