/* * 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