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:
@@ -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
173
backend/src/api/plugin.rs
Normal 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)
|
||||
})),
|
||||
)),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user