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:
@@ -12,3 +12,5 @@ pub mod notifications;
|
||||
pub mod chat;
|
||||
pub mod stats;
|
||||
pub mod store;
|
||||
pub mod player_sessions;
|
||||
pub mod public;
|
||||
|
||||
413
backend/src/db/player_sessions.rs
Normal file
413
backend/src/db/player_sessions.rs
Normal 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())
|
||||
}
|
||||
Reference in New Issue
Block a user