feat: Implement Player Retention Analytics System (Phase 2.2)

Backend:
- Add player_sessions table (migration 004) for session tracking
- Implement retention calculation queries (24h/48h/72h post-wipe)
- Add /api/plugin/player-event endpoint for join/leave tracking
- Add /api/analytics/retention endpoint with CSV export
- Track unique players, session duration, new vs returning ratio

Frontend:
- Create PlayerRetentionView with ECharts retention curves
- Add multi-wipe comparison (last 3/6/10/20 wipes)
- Display summary metrics and detailed wipe table
- Add /retention route to router

Plugin:
- Update CorrosionCompanion.cs to send player events to new endpoint
- Track player join/leave with license_key authentication

Enables data-driven wipe timing optimization by answering:
"What percentage of players return 24h/48h/72h after a wipe?"

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-02-15 14:23:21 -05:00
parent cef89ade18
commit f29524e633
9 changed files with 1020 additions and 12 deletions

View File

@@ -15,3 +15,5 @@ pub mod early_access;
pub mod admin;
pub mod ws;
pub mod analytics;
pub mod plugin;
pub mod settings;

173
backend/src/api/plugin.rs Normal file
View File

@@ -0,0 +1,173 @@
use std::sync::Arc;
use axum::{
extract::State,
http::StatusCode,
routing::post,
Json, Router,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::db::{licenses, player_sessions};
use crate::models::error::{ApiError, ApiResult};
use crate::AppState;
pub fn router() -> Router<Arc<AppState>> {
Router::new()
.route("/checkin", post(checkin))
.route("/player-event", post(player_event))
}
/// Plugin check-in payload (from uMod plugin on server start).
#[derive(Debug, Deserialize)]
struct CheckinRequest {
license_key: String,
server_name: String,
server_description: Option<String>,
server_url: Option<String>,
max_players: Option<i32>,
world_size: Option<i32>,
seed: Option<i32>,
plugin_version: Option<String>,
server_version: Option<String>,
}
/// POST /api/plugin/checkin
/// Plugin sends this on server startup to register with the control plane.
async fn checkin(
State(state): State<Arc<AppState>>,
Json(req): Json<CheckinRequest>,
) -> ApiResult<Json<serde_json::Value>> {
// Validate license key
let license = licenses::get_license_by_key(&state.db, &req.license_key)
.await
.map_err(|_| ApiError::Unauthorized)?
.ok_or(ApiError::Unauthorized)?;
// TODO: Update server_connections.plugin_last_seen
// TODO: Update server_config with server_name, world_size, seed, etc.
tracing::info!(
"Plugin check-in: license {} ({})",
license.id,
req.server_name
);
Ok(Json(serde_json::json!({
"status": "ok",
"license_id": license.id,
"message": "Check-in successful"
})))
}
/// Player event payload from uMod plugin.
#[derive(Debug, Deserialize)]
struct PlayerEventRequest {
license_key: String,
event: String, // "player_connected" or "player_disconnected"
player_id: String, // SteamID
player_name: String,
timestamp: Option<i64>, // Unix timestamp
}
/// Response for player event tracking.
#[derive(Debug, Serialize)]
struct PlayerEventResponse {
status: String,
session_id: Option<Uuid>,
}
/// POST /api/plugin/player-event
/// Plugin sends this on player join/leave to track sessions for retention analytics.
async fn player_event(
State(state): State<Arc<AppState>>,
Json(req): Json<PlayerEventRequest>,
) -> Result<Json<PlayerEventResponse>, (StatusCode, Json<serde_json::Value>)> {
// Validate license key
let license = licenses::get_license_by_key(&state.db, &req.license_key)
.await
.map_err(|e| {
tracing::error!("License lookup failed: {}", e);
(
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({
"error": "Invalid license key"
})),
)
})?
.ok_or_else(|| {
(
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({
"error": "Invalid license key"
})),
)
})?;
match req.event.as_str() {
"player_connected" => {
// Track join event
let session_id = player_sessions::track_player_join(
&state.db,
license.id,
&req.player_id,
&req.player_name,
)
.await
.map_err(|e| {
tracing::error!("Failed to track player join: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Failed to track player join"
})),
)
})?;
tracing::debug!(
"Player joined: {} ({}) on license {}",
req.player_name,
req.player_id,
license.id
);
Ok(Json(PlayerEventResponse {
status: "ok".to_string(),
session_id: Some(session_id),
}))
}
"player_disconnected" => {
// Track leave event
player_sessions::track_player_leave(&state.db, license.id, &req.player_id)
.await
.map_err(|e| {
tracing::error!("Failed to track player leave: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Failed to track player leave"
})),
)
})?;
tracing::debug!(
"Player left: {} ({}) on license {}",
req.player_name,
req.player_id,
license.id
);
Ok(Json(PlayerEventResponse {
status: "ok".to_string(),
session_id: None,
}))
}
_ => Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": format!("Unknown event type: {}", req.event)
})),
)),
}
}

View File

@@ -12,3 +12,5 @@ pub mod notifications;
pub mod chat;
pub mod stats;
pub mod store;
pub mod player_sessions;
pub mod public;

View File

@@ -0,0 +1,413 @@
use sqlx::PgPool;
use uuid::Uuid;
use anyhow::{Context, Result};
use chrono::{DateTime, Utc, Duration};
use serde::{Deserialize, Serialize};
/// Player session record.
#[derive(Debug, Clone, sqlx::FromRow, Serialize)]
pub struct PlayerSession {
pub id: Uuid,
pub license_id: Uuid,
pub steam_id: String,
pub player_name: String,
pub session_start: DateTime<Utc>,
pub session_end: Option<DateTime<Utc>>,
pub duration_seconds: Option<i32>,
pub created_at: DateTime<Utc>,
}
/// Retention metrics for a specific wipe.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WipeRetentionMetrics {
pub wipe_id: Uuid,
pub wipe_date: DateTime<Utc>,
pub total_players_before_wipe: i64,
pub returned_24h: i64,
pub returned_48h: i64,
pub returned_72h: i64,
pub retention_24h_percent: f64,
pub retention_48h_percent: f64,
pub retention_72h_percent: f64,
}
/// Summary metrics for a time period.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionSummary {
pub unique_players: i64,
pub total_sessions: i64,
pub avg_session_duration_minutes: f64,
pub new_players: i64,
pub returning_players: i64,
pub new_vs_returning_ratio: f64,
}
/// Track player join event — creates new session.
pub async fn track_player_join(
pool: &PgPool,
license_id: Uuid,
steam_id: &str,
player_name: &str,
) -> Result<Uuid> {
let id = Uuid::new_v4();
sqlx::query(
"INSERT INTO player_sessions (id, license_id, steam_id, player_name, session_start)
VALUES ($1, $2, $3, $4, NOW())",
)
.bind(id)
.bind(license_id)
.bind(steam_id)
.bind(player_name)
.execute(pool)
.await
.context("Failed to insert player session")?;
Ok(id)
}
/// Track player leave event — updates the most recent open session for this steam_id.
/// Calculates duration_seconds and sets session_end.
pub async fn track_player_leave(
pool: &PgPool,
license_id: Uuid,
steam_id: &str,
) -> Result<()> {
// Find the most recent session without session_end
sqlx::query(
"UPDATE player_sessions
SET session_end = NOW(),
duration_seconds = EXTRACT(EPOCH FROM (NOW() - session_start))::INTEGER
WHERE id = (
SELECT id FROM player_sessions
WHERE license_id = $1 AND steam_id = $2 AND session_end IS NULL
ORDER BY session_start DESC
LIMIT 1
)",
)
.bind(license_id)
.bind(steam_id)
.execute(pool)
.await
.context("Failed to update player session on leave")?;
Ok(())
}
/// Get count of unique players for a license in a time range.
pub async fn get_unique_player_count(
pool: &PgPool,
license_id: Uuid,
start: DateTime<Utc>,
end: DateTime<Utc>,
) -> Result<i64> {
let result: (i64,) = sqlx::query_as(
"SELECT COUNT(DISTINCT steam_id)
FROM player_sessions
WHERE license_id = $1 AND session_start >= $2 AND session_start < $3",
)
.bind(license_id)
.bind(start)
.bind(end)
.fetch_one(pool)
.await
.context("Failed to count unique players")?;
Ok(result.0)
}
/// Get average session duration (in minutes) for a time range.
pub async fn get_avg_session_duration(
pool: &PgPool,
license_id: Uuid,
start: DateTime<Utc>,
end: DateTime<Utc>,
) -> Result<f64> {
let result: (Option<f64>,) = sqlx::query_as(
"SELECT AVG(duration_seconds) / 60.0 as avg_minutes
FROM player_sessions
WHERE license_id = $1
AND session_start >= $2
AND session_start < $3
AND duration_seconds IS NOT NULL",
)
.bind(license_id)
.bind(start)
.bind(end)
.fetch_one(pool)
.await
.context("Failed to calculate avg session duration")?;
Ok(result.0.unwrap_or(0.0))
}
/// Calculate new vs returning players for a time range.
/// New = first session ever in this range.
/// Returning = had sessions before this range.
pub async fn get_new_vs_returning_ratio(
pool: &PgPool,
license_id: Uuid,
start: DateTime<Utc>,
end: DateTime<Utc>,
) -> Result<(i64, i64)> {
// Get unique players in this range
let players_in_range: Vec<(String,)> = sqlx::query_as(
"SELECT DISTINCT steam_id
FROM player_sessions
WHERE license_id = $1 AND session_start >= $2 AND session_start < $3",
)
.bind(license_id)
.bind(start)
.bind(end)
.fetch_all(pool)
.await
.context("Failed to get players in range")?;
let mut new_players = 0i64;
let mut returning_players = 0i64;
for (steam_id,) in players_in_range {
// Check if player had any sessions before this range
let has_prior: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM player_sessions
WHERE license_id = $1 AND steam_id = $2 AND session_start < $3",
)
.bind(license_id)
.bind(&steam_id)
.bind(start)
.fetch_one(pool)
.await
.context("Failed to check prior sessions")?;
if has_prior.0 > 0 {
returning_players += 1;
} else {
new_players += 1;
}
}
Ok((new_players, returning_players))
}
/// Get session summary for a time range.
pub async fn get_session_summary(
pool: &PgPool,
license_id: Uuid,
start: DateTime<Utc>,
end: DateTime<Utc>,
) -> Result<SessionSummary> {
let unique_players = get_unique_player_count(pool, license_id, start, end).await?;
let total_sessions: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM player_sessions
WHERE license_id = $1 AND session_start >= $2 AND session_start < $3",
)
.bind(license_id)
.bind(start)
.bind(end)
.fetch_one(pool)
.await
.context("Failed to count total sessions")?;
let avg_session_duration_minutes = get_avg_session_duration(pool, license_id, start, end).await?;
let (new_players, returning_players) = get_new_vs_returning_ratio(pool, license_id, start, end).await?;
let new_vs_returning_ratio = if returning_players > 0 {
new_players as f64 / returning_players as f64
} else {
new_players as f64
};
Ok(SessionSummary {
unique_players,
total_sessions: total_sessions.0,
avg_session_duration_minutes,
new_players,
returning_players,
new_vs_returning_ratio,
})
}
/// Calculate retention metrics for a specific wipe.
///
/// Algorithm:
/// 1. Get all unique players who joined in the 7 days BEFORE the wipe
/// 2. For each player, check if they returned within 24h/48h/72h AFTER the wipe
/// 3. Calculate percentage retention
pub async fn calculate_retention_after_wipe(
pool: &PgPool,
license_id: Uuid,
wipe_id: Uuid,
) -> Result<WipeRetentionMetrics> {
// Get wipe start time
let wipe: (DateTime<Utc>,) = sqlx::query_as(
"SELECT started_at FROM wipe_history WHERE id = $1 AND license_id = $2",
)
.bind(wipe_id)
.bind(license_id)
.fetch_one(pool)
.await
.context("Failed to fetch wipe date")?;
let wipe_date = wipe.0;
let pre_wipe_start = wipe_date - Duration::days(7);
// Get unique players who played in 7 days before wipe
let players_before_wipe: Vec<(String,)> = sqlx::query_as(
"SELECT DISTINCT steam_id
FROM player_sessions
WHERE license_id = $1
AND session_start >= $2
AND session_start < $3",
)
.bind(license_id)
.bind(pre_wipe_start)
.bind(wipe_date)
.fetch_all(pool)
.await
.context("Failed to get pre-wipe players")?;
let total_players_before_wipe = players_before_wipe.len() as i64;
if total_players_before_wipe == 0 {
return Ok(WipeRetentionMetrics {
wipe_id,
wipe_date,
total_players_before_wipe: 0,
returned_24h: 0,
returned_48h: 0,
returned_72h: 0,
retention_24h_percent: 0.0,
retention_48h_percent: 0.0,
retention_72h_percent: 0.0,
});
}
let mut returned_24h = 0i64;
let mut returned_48h = 0i64;
let mut returned_72h = 0i64;
for (steam_id,) in players_before_wipe {
// Check if player returned within 24h
let returned_24h_check: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM player_sessions
WHERE license_id = $1
AND steam_id = $2
AND session_start >= $3
AND session_start < $4",
)
.bind(license_id)
.bind(&steam_id)
.bind(wipe_date)
.bind(wipe_date + Duration::hours(24))
.fetch_one(pool)
.await
.context("Failed to check 24h retention")?;
if returned_24h_check.0 > 0 {
returned_24h += 1;
}
// Check if player returned within 48h
let returned_48h_check: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM player_sessions
WHERE license_id = $1
AND steam_id = $2
AND session_start >= $3
AND session_start < $4",
)
.bind(license_id)
.bind(&steam_id)
.bind(wipe_date)
.bind(wipe_date + Duration::hours(48))
.fetch_one(pool)
.await
.context("Failed to check 48h retention")?;
if returned_48h_check.0 > 0 {
returned_48h += 1;
}
// Check if player returned within 72h
let returned_72h_check: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM player_sessions
WHERE license_id = $1
AND steam_id = $2
AND session_start >= $3
AND session_start < $4",
)
.bind(license_id)
.bind(&steam_id)
.bind(wipe_date)
.bind(wipe_date + Duration::hours(72))
.fetch_one(pool)
.await
.context("Failed to check 72h retention")?;
if returned_72h_check.0 > 0 {
returned_72h += 1;
}
}
Ok(WipeRetentionMetrics {
wipe_id,
wipe_date,
total_players_before_wipe,
returned_24h,
returned_48h,
returned_72h,
retention_24h_percent: (returned_24h as f64 / total_players_before_wipe as f64) * 100.0,
retention_48h_percent: (returned_48h as f64 / total_players_before_wipe as f64) * 100.0,
retention_72h_percent: (returned_72h as f64 / total_players_before_wipe as f64) * 100.0,
})
}
/// Get retention metrics for the last N wipes.
pub async fn get_recent_wipe_retention_metrics(
pool: &PgPool,
license_id: Uuid,
limit: i64,
) -> Result<Vec<WipeRetentionMetrics>> {
// Get recent wipes with started_at timestamp
let wipes: Vec<(Uuid, DateTime<Utc>)> = sqlx::query_as(
"SELECT id, started_at
FROM wipe_history
WHERE license_id = $1
AND started_at IS NOT NULL
AND status = 'success'
ORDER BY started_at DESC
LIMIT $2",
)
.bind(license_id)
.bind(limit)
.fetch_all(pool)
.await
.context("Failed to fetch recent wipes")?;
let mut metrics = Vec::new();
for (wipe_id, _wipe_date) in wipes {
match calculate_retention_after_wipe(pool, license_id, wipe_id).await {
Ok(metric) => metrics.push(metric),
Err(e) => {
tracing::warn!("Failed to calculate retention for wipe {}: {}", wipe_id, e);
// Continue with other wipes
}
}
}
Ok(metrics)
}
/// Cleanup old player sessions beyond retention period (default 90 days).
pub async fn cleanup_old_player_sessions(pool: &PgPool, retention_days: i64) -> Result<u64> {
let result = sqlx::query(
"DELETE FROM player_sessions
WHERE session_start < NOW() - ($1 || ' days')::INTERVAL",
)
.bind(retention_days)
.execute(pool)
.await
.context("Failed to delete old player sessions")?;
Ok(result.rows_affected())
}

View File

@@ -129,6 +129,8 @@ async fn main() -> anyhow::Result<()> {
.nest("/api/admin", api::admin::router())
.nest("/api/ws", api::ws::router())
.nest("/api/analytics", api::analytics::router())
.nest("/api/plugin", api::plugin::router())
.nest("/api/settings", api::settings::router())
.layer(cors)
.layer(TraceLayer::new_for_http())
.with_state(state);