feat(server): update leaderboard scores from sync push
When a user pushes sync data and is opted in to the leaderboard, the server now updates their leaderboard row with the merged stats using MAX(best_score) and MIN(best_time_secs) — scores never regress even if the client sends stale data. Eliminates the need for a separate score-submission API call: the sync push already carries the full stats, so the leaderboard stays current after every push. Added two integration tests: - push_after_opt_in_updates_leaderboard_score - push_lower_score_does_not_overwrite_leaderboard_best Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+20
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT leaderboard_opt_in FROM users WHERE id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "leaderboard_opt_in",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "765c87463905b2edbf37f7416d5bc38e639c7e4c5feb0f5a9d8d6fb724e7a0a8"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "UPDATE leaderboard\n SET best_score = MAX(COALESCE(best_score, 0), ?),\n best_time_secs = CASE\n WHEN ? IS NULL THEN best_time_secs\n WHEN best_time_secs IS NULL THEN ?\n ELSE MIN(best_time_secs, ?)\n END,\n recorded_at = ?\n WHERE user_id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 6
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "a931c228c46dc06a5657d419cd5dfa5a810bbce9501845c92c6619d29806d70c"
|
||||||
|
}
|
||||||
@@ -129,6 +129,10 @@ pub async fn pull(
|
|||||||
|
|
||||||
/// `POST /api/sync/push` — merge the client's payload with the server's
|
/// `POST /api/sync/push` — merge the client's payload with the server's
|
||||||
/// stored payload, persist the result, and return it.
|
/// stored payload, persist the result, and return it.
|
||||||
|
///
|
||||||
|
/// If the user has opted in to the leaderboard, the leaderboard row is also
|
||||||
|
/// updated with the merged `best_single_score` and `fastest_win_seconds` so
|
||||||
|
/// scores stay in sync without a separate submission step.
|
||||||
pub async fn push(
|
pub async fn push(
|
||||||
State(pool): State<SqlitePool>,
|
State(pool): State<SqlitePool>,
|
||||||
user: AuthenticatedUser,
|
user: AuthenticatedUser,
|
||||||
@@ -144,6 +148,7 @@ pub async fn push(
|
|||||||
None => {
|
None => {
|
||||||
// First push — nothing to merge against; store directly.
|
// First push — nothing to merge against; store directly.
|
||||||
store_payload(&pool, &user.user_id, &client_payload).await?;
|
store_payload(&pool, &user.user_id, &client_payload).await?;
|
||||||
|
update_leaderboard_if_opted_in(&pool, &user.user_id, &client_payload).await?;
|
||||||
return Ok(Json(SyncResponse {
|
return Ok(Json(SyncResponse {
|
||||||
merged: client_payload,
|
merged: client_payload,
|
||||||
server_time: Utc::now(),
|
server_time: Utc::now(),
|
||||||
@@ -155,6 +160,7 @@ pub async fn push(
|
|||||||
let (merged, conflicts) = merge(&client_payload, &server_payload);
|
let (merged, conflicts) = merge(&client_payload, &server_payload);
|
||||||
|
|
||||||
store_payload(&pool, &user.user_id, &merged).await?;
|
store_payload(&pool, &user.user_id, &merged).await?;
|
||||||
|
update_leaderboard_if_opted_in(&pool, &user.user_id, &merged).await?;
|
||||||
|
|
||||||
Ok(Json(SyncResponse {
|
Ok(Json(SyncResponse {
|
||||||
merged,
|
merged,
|
||||||
@@ -162,3 +168,55 @@ pub async fn push(
|
|||||||
conflicts,
|
conflicts,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// If the user is opted in to the leaderboard, update their row with the
|
||||||
|
/// better of the stored and incoming `best_single_score` / `fastest_win_seconds`.
|
||||||
|
///
|
||||||
|
/// Uses SQLite `MIN`/`MAX` in the UPDATE so the database never regresses
|
||||||
|
/// a score even if the client sends stale data.
|
||||||
|
async fn update_leaderboard_if_opted_in(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
user_id: &str,
|
||||||
|
payload: &SyncPayload,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
// Only update if the user has opted in (leaderboard row exists).
|
||||||
|
let opted_in: Option<i64> = sqlx::query_scalar!(
|
||||||
|
"SELECT leaderboard_opt_in FROM users WHERE id = ?",
|
||||||
|
user_id
|
||||||
|
)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if opted_in != Some(1) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let best_score = payload.stats.best_single_score as i64;
|
||||||
|
let fastest = if payload.stats.fastest_win_seconds == u64::MAX {
|
||||||
|
// Sentinel "never won" value — don't store.
|
||||||
|
None::<i64>
|
||||||
|
} else {
|
||||||
|
Some(payload.stats.fastest_win_seconds as i64)
|
||||||
|
};
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
r#"UPDATE leaderboard
|
||||||
|
SET best_score = MAX(COALESCE(best_score, 0), ?),
|
||||||
|
best_time_secs = CASE
|
||||||
|
WHEN ? IS NULL THEN best_time_secs
|
||||||
|
WHEN best_time_secs IS NULL THEN ?
|
||||||
|
ELSE MIN(best_time_secs, ?)
|
||||||
|
END,
|
||||||
|
recorded_at = ?
|
||||||
|
WHERE user_id = ?"#,
|
||||||
|
best_score,
|
||||||
|
fastest, fastest, fastest,
|
||||||
|
now,
|
||||||
|
user_id
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -676,3 +676,103 @@ async fn opt_in_then_leaderboard_shows_entry() {
|
|||||||
.any(|e| e["display_name"] == "KarenTheGreat");
|
.any(|e| e["display_name"] == "KarenTheGreat");
|
||||||
assert!(found, "opted-in user must appear in leaderboard");
|
assert!(found, "opted-in user must appear in leaderboard");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pushing sync data after opting in updates the leaderboard best_score.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn push_after_opt_in_updates_leaderboard_score() {
|
||||||
|
set_jwt_secret();
|
||||||
|
let pool = test_pool().await;
|
||||||
|
let app = build_test_router(pool);
|
||||||
|
|
||||||
|
let (access, _) = register_user(app.clone(), "scorer", "scorepass").await;
|
||||||
|
let user_id = decode_sub(&access);
|
||||||
|
|
||||||
|
// Opt in.
|
||||||
|
post_authed(
|
||||||
|
app.clone(),
|
||||||
|
"/api/leaderboard/opt-in",
|
||||||
|
&access,
|
||||||
|
serde_json::json!({ "display_name": "Scorer" }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Build a payload with a known best_single_score.
|
||||||
|
let payload = SyncPayload {
|
||||||
|
user_id: uuid::Uuid::parse_str(&user_id).unwrap(),
|
||||||
|
stats: StatsSnapshot {
|
||||||
|
best_single_score: 3_500,
|
||||||
|
fastest_win_seconds: 180,
|
||||||
|
games_won: 1,
|
||||||
|
games_played: 1,
|
||||||
|
..StatsSnapshot::default()
|
||||||
|
},
|
||||||
|
achievements: vec![],
|
||||||
|
progress: PlayerProgress::default(),
|
||||||
|
last_modified: Utc::now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let push_resp = post_authed(
|
||||||
|
app.clone(),
|
||||||
|
"/api/sync/push",
|
||||||
|
&access,
|
||||||
|
serde_json::to_value(&payload).unwrap(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(push_resp.status(), StatusCode::OK, "push must return 200");
|
||||||
|
|
||||||
|
// Leaderboard should reflect the pushed score.
|
||||||
|
let lb_resp = get_authed(app, "/api/leaderboard", &access).await;
|
||||||
|
let body = body_json(lb_resp).await;
|
||||||
|
let entries = body.as_array().unwrap();
|
||||||
|
let entry = entries.iter().find(|e| e["display_name"] == "Scorer").expect("entry missing");
|
||||||
|
assert_eq!(entry["best_score"], 3_500, "best_score must be updated from push");
|
||||||
|
assert_eq!(entry["best_time_secs"], 180, "best_time_secs must be updated from push");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pushing a lower score after a higher one does not overwrite the best.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn push_lower_score_does_not_overwrite_leaderboard_best() {
|
||||||
|
set_jwt_secret();
|
||||||
|
let pool = test_pool().await;
|
||||||
|
let app = build_test_router(pool);
|
||||||
|
|
||||||
|
let (access, _) = register_user(app.clone(), "champ", "champpass").await;
|
||||||
|
let user_id = decode_sub(&access);
|
||||||
|
|
||||||
|
post_authed(
|
||||||
|
app.clone(),
|
||||||
|
"/api/leaderboard/opt-in",
|
||||||
|
&access,
|
||||||
|
serde_json::json!({ "display_name": "Champ" }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let make = |score: u32, time: u64| SyncPayload {
|
||||||
|
user_id: uuid::Uuid::parse_str(&user_id).unwrap(),
|
||||||
|
stats: StatsSnapshot {
|
||||||
|
best_single_score: score,
|
||||||
|
fastest_win_seconds: time,
|
||||||
|
games_won: 1,
|
||||||
|
games_played: 1,
|
||||||
|
..StatsSnapshot::default()
|
||||||
|
},
|
||||||
|
achievements: vec![],
|
||||||
|
progress: PlayerProgress::default(),
|
||||||
|
last_modified: Utc::now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// First push: high score.
|
||||||
|
post_authed(app.clone(), "/api/sync/push", &access,
|
||||||
|
serde_json::to_value(make(5_000, 120)).unwrap()).await;
|
||||||
|
|
||||||
|
// Second push: lower score and slower time.
|
||||||
|
post_authed(app.clone(), "/api/sync/push", &access,
|
||||||
|
serde_json::to_value(make(1_000, 600)).unwrap()).await;
|
||||||
|
|
||||||
|
let lb_resp = get_authed(app, "/api/leaderboard", &access).await;
|
||||||
|
let body = body_json(lb_resp).await;
|
||||||
|
let entries = body.as_array().unwrap();
|
||||||
|
let entry = entries.iter().find(|e| e["display_name"] == "Champ").unwrap();
|
||||||
|
assert_eq!(entry["best_score"], 5_000, "best_score must not regress");
|
||||||
|
assert_eq!(entry["best_time_secs"], 120, "best_time_secs must stay at fastest");
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user