diff --git a/src/game/analytics.c b/src/game/analytics.c index 9ab6cd7..9c0333b 100644 --- a/src/game/analytics.c +++ b/src/game/analytics.c @@ -18,6 +18,8 @@ EM_JS(void, js_analytics_init, (), { * -D defines, falling back to sensible defaults. */ Module._analyticsClientId = localStorage.getItem('jnr_client_id'); Module._analyticsSessionId = null; + Module._analyticsStartPending = null; /* Promise while start is in-flight */ + Module._analyticsLastStats = null; /* stashed for beforeunload fallback */ /* Runtime config: check for data attributes on the canvas container * first, then fall back to compiled-in defaults. */ @@ -36,7 +38,8 @@ EM_JS(void, js_analytics_init, (), { } }); -/* Start a new session. Sends POST /api/analytics/session/start/. */ +/* Start a new session. Sends POST /api/analytics/session/start/. + * Stores the in-flight promise so session_end can wait for it. */ EM_JS(void, js_analytics_session_start, (), { if (!Module._analyticsUrl) return; @@ -64,7 +67,7 @@ EM_JS(void, js_analytics_session_start, (), { } }); - fetch(Module._analyticsUrl + '/api/analytics/session/start/', { + Module._analyticsStartPending = fetch(Module._analyticsUrl + '/api/analytics/session/start/', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -75,50 +78,77 @@ EM_JS(void, js_analytics_session_start, (), { .then(function(r) { return r.json(); }) .then(function(data) { Module._analyticsSessionId = data.session_id || null; + Module._analyticsStartPending = null; console.log('[analytics] Session started: ' + Module._analyticsSessionId); }) .catch(function(err) { + Module._analyticsStartPending = null; console.error('[analytics] Session start failed:', err); }); }); -/* End the current session with gameplay stats. - * Parameters are passed from C as integers/string. */ -EM_JS(void, js_analytics_session_end, (int score, int level_reached, - int lives_used, int duration_secs, - const char *end_reason_ptr), { - if (!Module._analyticsUrl || !Module._analyticsSessionId) return; +/* Internal helper: send the session-end POST (used by both the C wrapper + * and the beforeunload fallback). */ +EM_JS(void, js_analytics_send_end, (int score, int level_reached, + int lives_used, int duration_secs, + const char *end_reason_ptr), { + /* Helper that performs the actual end request given a session id. */ + function doEnd(sid, endReason, score, levelReached, livesUsed, durationSecs) { + var body = JSON.stringify({ + score: score, + level_reached: levelReached > 0 ? levelReached : 1, + lives_used: livesUsed, + duration_seconds: durationSecs, + end_reason: endReason + }); + + /* Stash stats for the beforeunload fallback */ + Module._analyticsLastStats = body; + + return fetch(Module._analyticsUrl + '/api/analytics/session/' + sid + '/end/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': Module._analyticsKey + }, + body: body, + keepalive: true + }) + .then(function(r) { return r.json(); }) + .then(function(data) { + console.log('[analytics] Session ended: ' + sid + + (data.new_high_score ? ' (NEW HIGH SCORE!)' : '')); + }) + .catch(function(err) { + console.error('[analytics] Session end failed:', err); + }); + } + + if (!Module._analyticsUrl) return; var endReason = UTF8ToString(end_reason_ptr); + + /* If session start is still in-flight, wait for it before ending. */ + if (Module._analyticsStartPending) { + var pending = Module._analyticsStartPending; + /* Clear session synchronously so duplicate end calls are harmless */ + Module._analyticsStartPending = null; + pending.then(function() { + var sid = Module._analyticsSessionId; + if (sid) { + Module._analyticsSessionId = null; + doEnd(sid, endReason, score, level_reached, lives_used, duration_secs); + } + }); + return; + } + + if (!Module._analyticsSessionId) return; + var sid = Module._analyticsSessionId; - - var body = JSON.stringify({ - score: score, - level_reached: level_reached > 0 ? level_reached : 1, - lives_used: lives_used, - duration_seconds: duration_secs, - end_reason: endReason - }); - - fetch(Module._analyticsUrl + '/api/analytics/session/' + sid + '/end/', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-API-Key': Module._analyticsKey - }, - body: body - }) - .then(function(r) { return r.json(); }) - .then(function(data) { - console.log('[analytics] Session ended: ' + sid + - (data.new_high_score ? ' (NEW HIGH SCORE!)' : '')); - }) - .catch(function(err) { - console.error('[analytics] Session end failed:', err); - }); - - /* Clear session so duplicate end calls are harmless */ + /* Clear synchronously before the async request to prevent races */ Module._analyticsSessionId = null; + doEnd(sid, endReason, score, level_reached, lives_used, duration_secs); }); /* ── C wrappers ─────────────────────────────────────────────────── */ @@ -131,9 +161,9 @@ void analytics_session_start(void) { js_analytics_session_start(); } -void analytics_session_end(const GameStats *stats, const char *end_reason) { - stats_update_score((GameStats *)stats); - js_analytics_session_end( +void analytics_session_end(GameStats *stats, const char *end_reason) { + stats_update_score(stats); + js_analytics_send_end( stats->score, stats->levels_completed > 0 ? stats->levels_completed : 1, stats->deaths, @@ -151,7 +181,7 @@ void analytics_init(void) { void analytics_session_start(void) {} -void analytics_session_end(const GameStats *stats, const char *end_reason) { +void analytics_session_end(GameStats *stats, const char *end_reason) { (void)stats; (void)end_reason; } diff --git a/src/game/analytics.h b/src/game/analytics.h index 1d514f8..cc55822 100644 --- a/src/game/analytics.h +++ b/src/game/analytics.h @@ -11,7 +11,8 @@ void analytics_init(void); void analytics_session_start(void); /* End the current analytics session with final stats. + * Computes the composite score before sending. * end_reason: "death", "quit", "timeout", or "completed". */ -void analytics_session_end(const GameStats *stats, const char *end_reason); +void analytics_session_end(GameStats *stats, const char *end_reason); #endif /* JNR_ANALYTICS_H */ diff --git a/src/game/level.c b/src/game/level.c index 7a12d5c..cb5ba85 100644 --- a/src/game/level.c +++ b/src/game/level.c @@ -175,11 +175,9 @@ bool level_load_generated(Level *level, Tilemap *gen_map) { static Camera *s_active_camera = NULL; static void damage_entity(Entity *target, int damage) { - stats_record_damage_dealt(damage); target->health -= damage; if (target->health <= 0) { target->flags |= ENTITY_DEAD; - stats_record_kill(); /* Death particles — centered on entity */ Vec2 center = vec2( @@ -215,6 +213,8 @@ static void damage_player(Entity *player, int damage, Entity *source) { PlayerData *ppd = (PlayerData *)player->data; stats_record_damage_taken(damage); damage_entity(player, damage); + /* Note: damage_taken is recorded here; damage_dealt and kills are + * recorded at the specific call sites that deal player-sourced damage. */ /* Screen shake on player hit (stronger) */ if (s_active_camera) { @@ -267,8 +267,10 @@ static void handle_collisions(EntityManager *em) { /* Player bullet hits enemies */ if (from_player && entity_is_enemy(b)) { if (physics_overlap(&a->body, &b->body)) { + stats_record_damage_dealt(a->damage); damage_entity(b, a->damage); stats_record_shot_hit(); + if (b->flags & ENTITY_DEAD) stats_record_kill(); hit = true; } } @@ -300,7 +302,9 @@ static void handle_collisions(EntityManager *em) { a->body.pos.y + a->body.size.y * 0.5f); if (stomping) { + stats_record_damage_dealt(2); damage_entity(a, 2); + if (a->flags & ENTITY_DEAD) stats_record_kill(); player->body.vel.y = -PLAYER_JUMP_FORCE * 0.7f; } else { damage_player(player, a->damage, a); diff --git a/src/game/stats.h b/src/game/stats.h index b1d8cbd..d00f8d6 100644 --- a/src/game/stats.h +++ b/src/game/stats.h @@ -1,8 +1,6 @@ #ifndef JNR_STATS_H #define JNR_STATS_H -#include - /* Per-session gameplay statistics, accumulated during play and * submitted to the analytics backend when the session ends. */ typedef struct GameStats { diff --git a/web/shell.html b/web/shell.html index ddfd862..fea2750 100644 --- a/web/shell.html +++ b/web/shell.html @@ -286,23 +286,33 @@ } /* ── Analytics: end session on tab close ────────────── */ + /* Fallback for when the WASM shutdown path didn't get a chance to + * run (e.g. user closes tab mid-game). Uses fetch with keepalive + * so the browser can send the request after the page is gone, and + * includes the X-API-Key header that sendBeacon can't carry. */ window.addEventListener('beforeunload', function() { if (typeof Module !== 'undefined' && Module._analyticsSessionId && Module._analyticsUrl) { - /* Use sendBeacon for reliability during page unload */ var sid = Module._analyticsSessionId; - var body = JSON.stringify({ + Module._analyticsSessionId = null; + /* Use stashed stats from the last C-side update if available, + * otherwise send minimal data so the session isn't left open. */ + var body = Module._analyticsLastStats || JSON.stringify({ score: 0, level_reached: 1, lives_used: 0, duration_seconds: 0, end_reason: 'quit' }); - var blob = new Blob([body], { type: 'application/json' }); - navigator.sendBeacon( - Module._analyticsUrl + '/api/analytics/session/' + sid + '/end/', - blob - ); + fetch(Module._analyticsUrl + '/api/analytics/session/' + sid + '/end/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': Module._analyticsKey + }, + body: body, + keepalive: true + }); } });