Fix review issues in analytics integration
1. Race condition: session_end now waits for an in-flight session_start promise before sending, so quick restarts don't drop the end call. 2. beforeunload: replaced sendBeacon (which can't set headers) with fetch(..., keepalive: true) so the X-API-Key header is included and the backend doesn't 401. 3. Stats double-counting: removed stats_record_damage_dealt and stats_record_kill from damage_entity (which was called for all damage including player deaths). Now only recorded at player-sourced call sites (projectile hits, stomps). 4. Removed const-cast: analytics_session_end now takes GameStats* (non-const) since stats_update_score mutates it. 5. beforeunload now uses stashed stats from the last C-side session_end call instead of hardcoded zeroes. Session ID is cleared synchronously before async fetch to prevent races. 6. Removed unused stdint.h include from stats.h.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
#ifndef JNR_STATS_H
|
||||
#define JNR_STATS_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
/* Per-session gameplay statistics, accumulated during play and
|
||||
* submitted to the analytics backend when the session ends. */
|
||||
typedef struct GameStats {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user