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)
})),
)),
}
}