diff --git a/CMakeLists.txt b/CMakeLists.txt index 97cb40b..e20a849 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -57,6 +57,7 @@ if(EMSCRIPTEN) else() find_package(SDL3 CONFIG REQUIRED) find_package(SDL3_image CONFIG REQUIRED) + find_package(CURL REQUIRED) endif() if(KOBO_ENABLE_OPENGL) @@ -199,14 +200,26 @@ set(KOBO_SOURCES myship.cpp radar.cpp random.cpp scenes.cpp score.cpp screen.cpp filemap.cpp prefs.cpp cfgform.cpp options.cpp gamestate.cpp 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}) target_include_directories(kobodl PRIVATE ${CMAKE_SOURCE_DIR}/data/sfx) target_link_libraries(kobodl PRIVATE graphics sound eel kobo_deps) 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) # ASYNCIFY lets the existing blocking main loop (gengine->run(), SDL_Delay) # yield to the browser without rewriting it around emscripten_set_main_loop. diff --git a/httpclient.h b/httpclient.h new file mode 100644 index 0000000..364a221 --- /dev/null +++ b/httpclient.h @@ -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 + +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 */ diff --git a/httpclient_native.cpp b/httpclient_native.cpp new file mode 100644 index 0000000..cba6b82 --- /dev/null +++ b/httpclient_native.cpp @@ -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 +#include +#include + +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 g_pending; + +size_t write_cb(char *ptr, size_t size, size_t nmemb, void *userdata) +{ + pending_t *p = static_cast(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; + } +} diff --git a/httpclient_web.cpp b/httpclient_web.cpp new file mode 100644 index 0000000..434977f --- /dev/null +++ b/httpclient_web.cpp @@ -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 +#include +#include + +namespace { + +struct ctx_t { + http_callback_t cb; + void *user; +}; + +void deliver(emscripten_fetch_t *fetch, int status) +{ + ctx_t *c = static_cast(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__ */ diff --git a/kobo.cpp b/kobo.cpp index 0f06a28..1bc8c11 100644 --- a/kobo.cpp +++ b/kobo.cpp @@ -63,6 +63,7 @@ extern "C" { #include "enemies.h" #include "prof.h" #include "bench.h" +#include "httpclient.h" #define MAX_FPS_RESULTS 64 @@ -1860,6 +1861,7 @@ void kobo_gfxengine_t::frame() /* * Run the current gamestate for one frame */ + http_pump(); gsm.frame(); if(prefs->cmd_autoshot && !manage.game_stopped()) @@ -2088,6 +2090,7 @@ int main(int argc, char *argv[]) prof_init(); SDL_Init(0); + http_init(); if(main_init()) { @@ -2111,6 +2114,31 @@ int main(int argc, char *argv[]) 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) { put_options_man(); @@ -2194,6 +2222,7 @@ int main(int argc, char *argv[]) km.print_fps_results(); km.close_logging(); + http_shutdown(); main_cleanup(); return 0; } diff --git a/leaderboard.cpp b/leaderboard.cpp new file mode 100644 index 0000000..5e62320 --- /dev/null +++ b/leaderboard.cpp @@ -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 +#include +#include +#include + +#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 diff --git a/leaderboard.h b/leaderboard.h new file mode 100644 index 0000000..df56fbc --- /dev/null +++ b/leaderboard.h @@ -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 diff --git a/manage.cpp b/manage.cpp index 512b344..448f70e 100644 --- a/manage.cpp +++ b/manage.cpp @@ -52,6 +52,8 @@ #include "random.h" #include "prof.h" #include "bench.h" +#include "leaderboard.h" +#include #define GIGA 1000000000 @@ -213,6 +215,8 @@ void _manage::game_start() { _game_over = 0; hi.clear(); + hi.start_date = (unsigned int)time(NULL); + leaderboard::reset_submission(); game.set(GAME_SINGLE, (skill_levels_t)scorefile.profile()->skill); @@ -267,8 +271,20 @@ void _manage::game_stop() hi.score = score; hi.end_scene = scene_num; 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); + leaderboard::submit_score(&hi); } ships = 0; ships_changed = 1; diff --git a/options.cpp b/options.cpp index ed0cf26..13ad812 100644 --- a/options.cpp +++ b/options.cpp @@ -458,6 +458,14 @@ void game_options_t::build() item("Off", 200); item("Low", 100); 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(); space(); xoffs = 0.5; diff --git a/prefs.cpp b/prefs.cpp index d3a0adf..595d63b 100644 --- a/prefs.cpp +++ b/prefs.cpp @@ -104,6 +104,18 @@ void prefs_t::init() key("sfx", sfxdir, ""); desc("Sound Data 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) key("size", o_size, 0, 0); desc("Screen Size (Obsolete)"); key("wait", o_wait_msec, 30); desc("Game Speed (Obsolete)"); diff --git a/prefs.h b/prefs.h index 1f6cca1..dd8f3c5 100644 --- a/prefs.h +++ b/prefs.h @@ -95,6 +95,12 @@ class prefs_t : public config_parser_t cfg_string_t sfxdir; //Path to sfx/ 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) int o_size; //Screen scale int o_wait_msec; //ms per control system frame diff --git a/states.cpp b/states.cpp index 7910bd1..367f1bc 100644 --- a/states.cpp +++ b/states.cpp @@ -38,6 +38,9 @@ #include "sound.h" #include "radar.h" #include "random.h" +#include "leaderboard.h" +#include "prefs.h" +#include 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)); 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(); } @@ -1281,6 +1319,8 @@ void main_menu_t::build() } space(); button("Options", 2); + if(prefs->global_leaderboard && prefs->leaderboard_board[0]) + button("Global Leaderboard", 200); space(); if(manage.game_stopped()) button("Return to Intro", 0); @@ -1367,6 +1407,9 @@ void st_main_menu_t::select(int tag) case 3: gsm.push(&st_new_player); break; + case 200: + gsm.push(&st_global_leaderboard); + break; case 4: // Player: Inc/Dec sound.ui_tick(); prefs->changed = 1; @@ -1997,3 +2040,105 @@ st_profile_audio_t st_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; + diff --git a/states.h b/states.h index e30441d..def3136 100644 --- a/states.h +++ b/states.h @@ -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 ----------------------------------------------------------*/ @@ -534,6 +550,7 @@ extern st_options_graphics_t st_options_graphics; extern st_options_audio_t st_options_audio; extern st_options_control_t st_options_control; extern st_options_game_t st_options_game; +extern st_global_leaderboard_t st_global_leaderboard; extern st_error_t st_error; #ifdef PROFILE_AUDIO extern st_profile_audio_t st_profile_audio; diff --git a/tools/mock-leaderboard.py b/tools/mock-leaderboard.py new file mode 100755 index 0000000..08ee642 --- /dev/null +++ b/tools/mock-leaderboard.py @@ -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()