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:
34
CHANGELOG.md
34
CHANGELOG.md
@@ -4,6 +4,40 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added (Phase 2.2 — Player Retention Analytics)
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
- Migration `004_player_sessions.sql` — Player session tracking table with indexes for retention queries
|
||||||
|
- `backend/src/db/player_sessions.rs` — Complete player session tracking and retention analysis:
|
||||||
|
- `track_player_join()` / `track_player_leave()` — Record individual player sessions
|
||||||
|
- `calculate_retention_after_wipe()` — Calculate 24h/48h/72h return rates per wipe
|
||||||
|
- `get_unique_player_count()` / `get_avg_session_duration()` — Session metrics
|
||||||
|
- `get_new_vs_returning_ratio()` — New vs returning player analysis
|
||||||
|
- `get_recent_wipe_retention_metrics()` — Multi-wipe retention trends
|
||||||
|
- `cleanup_old_player_sessions()` — 90-day retention cleanup
|
||||||
|
- `backend/src/api/plugin.rs` — Plugin event endpoints:
|
||||||
|
- `POST /api/plugin/player-event` — Track player join/leave events
|
||||||
|
- `POST /api/plugin/checkin` — Plugin registration on server start
|
||||||
|
- Extended `backend/src/api/analytics.rs` with retention endpoints:
|
||||||
|
- `GET /api/analytics/retention?wipe_count=6` — Multi-wipe retention metrics
|
||||||
|
- `GET /api/analytics/retention/export` — CSV export of retention data
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- `PlayerRetentionView.vue` — Complete retention analytics dashboard:
|
||||||
|
- ECharts retention curve (24h/48h/72h lines across multiple wipes)
|
||||||
|
- Summary cards: unique players, avg session duration, new vs returning ratio
|
||||||
|
- Wipe selector (last 3/6/10/20 wipes)
|
||||||
|
- Detailed wipe table with retention percentages
|
||||||
|
- CSV export functionality
|
||||||
|
- Added route `/retention` to router
|
||||||
|
- TypeScript interfaces: `WipeRetentionMetric`, `SessionSummary`, `RetentionResponse`
|
||||||
|
|
||||||
|
**Plugin:**
|
||||||
|
- Updated `CorrosionCompanion.cs` to track player events via `/api/plugin/player-event`
|
||||||
|
- Modified `OnPlayerConnected` / `OnPlayerDisconnected` hooks with license_key authentication
|
||||||
|
|
||||||
|
**Purpose:** Answers critical question: "What percentage of players return 24h/48h/72h after a wipe?" Enables data-driven wipe timing optimization and player retention analysis.
|
||||||
|
|
||||||
### Added (Phase 2.2 — Map Analytics System)
|
### Added (Phase 2.2 — Map Analytics System)
|
||||||
|
|
||||||
**Backend:**
|
**Backend:**
|
||||||
|
|||||||
26
backend/migrations/004_player_sessions.sql
Normal file
26
backend/migrations/004_player_sessions.sql
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
-- Player Session Tracking for Retention Analytics
|
||||||
|
-- Phase 2 Feature: Track individual player sessions to calculate post-wipe retention
|
||||||
|
|
||||||
|
CREATE TABLE player_sessions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
|
||||||
|
steam_id VARCHAR(20) NOT NULL,
|
||||||
|
player_name VARCHAR(100) NOT NULL,
|
||||||
|
session_start TIMESTAMPTZ NOT NULL,
|
||||||
|
session_end TIMESTAMPTZ,
|
||||||
|
duration_seconds INTEGER,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_player_sessions_license ON player_sessions(license_id);
|
||||||
|
CREATE INDEX idx_player_sessions_steam ON player_sessions(license_id, steam_id);
|
||||||
|
CREATE INDEX idx_player_sessions_start ON player_sessions(session_start DESC);
|
||||||
|
CREATE INDEX idx_player_sessions_steam_start ON player_sessions(license_id, steam_id, session_start DESC);
|
||||||
|
|
||||||
|
-- Index for retention queries (JOIN with wipe_history on date ranges)
|
||||||
|
CREATE INDEX idx_player_sessions_license_start_range ON player_sessions(license_id, session_start)
|
||||||
|
WHERE session_start IS NOT NULL;
|
||||||
|
|
||||||
|
COMMENT ON TABLE player_sessions IS 'Individual player join/leave sessions for retention analytics';
|
||||||
|
COMMENT ON COLUMN player_sessions.duration_seconds IS 'Calculated when session_end is set (session_end - session_start)';
|
||||||
|
COMMENT ON INDEX idx_player_sessions_steam_start IS 'Optimizes "last session for steam_id" queries when player leaves';
|
||||||
@@ -15,3 +15,5 @@ pub mod early_access;
|
|||||||
pub mod admin;
|
pub mod admin;
|
||||||
pub mod ws;
|
pub mod ws;
|
||||||
pub mod analytics;
|
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)
|
||||||
|
})),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,3 +12,5 @@ pub mod notifications;
|
|||||||
pub mod chat;
|
pub mod chat;
|
||||||
pub mod stats;
|
pub mod stats;
|
||||||
pub mod store;
|
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())
|
||||||
|
}
|
||||||
@@ -129,6 +129,8 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.nest("/api/admin", api::admin::router())
|
.nest("/api/admin", api::admin::router())
|
||||||
.nest("/api/ws", api::ws::router())
|
.nest("/api/ws", api::ws::router())
|
||||||
.nest("/api/analytics", api::analytics::router())
|
.nest("/api/analytics", api::analytics::router())
|
||||||
|
.nest("/api/plugin", api::plugin::router())
|
||||||
|
.nest("/api/settings", api::settings::router())
|
||||||
.layer(cors)
|
.layer(cors)
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|||||||
340
frontend/src/views/admin/PlayerRetentionView.vue
Normal file
340
frontend/src/views/admin/PlayerRetentionView.vue
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, watch, nextTick } from 'vue'
|
||||||
|
import { Users, TrendingUp, Clock, Download, BarChart3 } from 'lucide-vue-next'
|
||||||
|
import * as echarts from 'echarts'
|
||||||
|
import type { ECharts } from 'echarts'
|
||||||
|
import { useApi } from '@/composables/useApi'
|
||||||
|
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
interface WipeRetentionMetric {
|
||||||
|
wipe_id: string
|
||||||
|
wipe_date: string
|
||||||
|
total_players_before_wipe: number
|
||||||
|
returned_24h: number
|
||||||
|
returned_48h: number
|
||||||
|
returned_72h: number
|
||||||
|
retention_24h_percent: number
|
||||||
|
retention_48h_percent: number
|
||||||
|
retention_72h_percent: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionSummary {
|
||||||
|
unique_players: number
|
||||||
|
total_sessions: number
|
||||||
|
avg_session_duration_minutes: number
|
||||||
|
new_players: number
|
||||||
|
returning_players: number
|
||||||
|
new_vs_returning_ratio: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RetentionResponse {
|
||||||
|
wipe_metrics: WipeRetentionMetric[]
|
||||||
|
summary: SessionSummary
|
||||||
|
}
|
||||||
|
|
||||||
|
const wipeCount = ref<number>(6)
|
||||||
|
const loading = ref(true)
|
||||||
|
const retentionData = ref<RetentionResponse | null>(null)
|
||||||
|
|
||||||
|
const retentionChart = ref<HTMLElement | null>(null)
|
||||||
|
let retentionChartInstance: ECharts | null = null
|
||||||
|
|
||||||
|
const loadRetentionData = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await api.get<RetentionResponse>(
|
||||||
|
`/api/analytics/retention?wipe_count=${wipeCount.value}`
|
||||||
|
)
|
||||||
|
retentionData.value = response
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
renderCharts()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load retention data:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderCharts = () => {
|
||||||
|
if (!retentionData.value || !retentionData.value.wipe_metrics.length) return
|
||||||
|
|
||||||
|
// Retention curve chart (24h/48h/72h over multiple wipes)
|
||||||
|
if (retentionChart.value) {
|
||||||
|
if (retentionChartInstance) {
|
||||||
|
retentionChartInstance.dispose()
|
||||||
|
}
|
||||||
|
retentionChartInstance = echarts.init(retentionChart.value)
|
||||||
|
|
||||||
|
const wipeLabels = retentionData.value.wipe_metrics.map((w) =>
|
||||||
|
new Date(w.wipe_date).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const retention24h = retentionData.value.wipe_metrics.map((w) => w.retention_24h_percent)
|
||||||
|
const retention48h = retentionData.value.wipe_metrics.map((w) => w.retention_48h_percent)
|
||||||
|
const retention72h = retentionData.value.wipe_metrics.map((w) => w.retention_72h_percent)
|
||||||
|
|
||||||
|
retentionChartInstance.setOption({
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
backgroundColor: '#1a1a1a',
|
||||||
|
borderColor: '#2a2a2a',
|
||||||
|
textStyle: { color: '#e5e5e5' },
|
||||||
|
formatter: (params: any) => {
|
||||||
|
let tooltip = `<strong>${params[0].axisValue}</strong><br/>`
|
||||||
|
params.forEach((param: any) => {
|
||||||
|
tooltip += `${param.marker} ${param.seriesName}: ${param.value.toFixed(1)}%<br/>`
|
||||||
|
})
|
||||||
|
return tooltip
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
data: ['24h Return', '48h Return', '72h Return'],
|
||||||
|
textStyle: { color: '#a3a3a3' },
|
||||||
|
top: 0
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
left: '3%',
|
||||||
|
right: '4%',
|
||||||
|
bottom: '10%',
|
||||||
|
top: '15%',
|
||||||
|
containLabel: true
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: wipeLabels,
|
||||||
|
axisLine: { lineStyle: { color: '#404040' } },
|
||||||
|
axisLabel: { color: '#808080', rotate: 45 }
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
name: 'Retention %',
|
||||||
|
axisLine: { lineStyle: { color: '#404040' } },
|
||||||
|
splitLine: { lineStyle: { color: '#2a2a2a' } },
|
||||||
|
axisLabel: {
|
||||||
|
color: '#808080',
|
||||||
|
formatter: (value: number) => `${value}%`
|
||||||
|
},
|
||||||
|
max: 100
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '24h Return',
|
||||||
|
type: 'line',
|
||||||
|
data: retention24h,
|
||||||
|
smooth: true,
|
||||||
|
lineStyle: { color: '#CE422B', width: 3 },
|
||||||
|
itemStyle: { color: '#CE422B' },
|
||||||
|
symbolSize: 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '48h Return',
|
||||||
|
type: 'line',
|
||||||
|
data: retention48h,
|
||||||
|
smooth: true,
|
||||||
|
lineStyle: { color: '#f59e0b', width: 3 },
|
||||||
|
itemStyle: { color: '#f59e0b' },
|
||||||
|
symbolSize: 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '72h Return',
|
||||||
|
type: 'line',
|
||||||
|
data: retention72h,
|
||||||
|
smooth: true,
|
||||||
|
lineStyle: { color: '#10b981', width: 3 },
|
||||||
|
itemStyle: { color: '#10b981' },
|
||||||
|
symbolSize: 8
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadCSV = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/analytics/retention/export?wipe_count=${wipeCount.value}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('access_token')}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const blob = await response.blob()
|
||||||
|
const url = window.URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `retention_metrics_${wipeCount.value}_wipes.csv`
|
||||||
|
a.click()
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to download CSV:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(wipeCount, () => {
|
||||||
|
loadRetentionData()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadRetentionData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="p-6 space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Users class="w-5 h-5 text-oxide-500" />
|
||||||
|
<h1 class="text-2xl font-bold text-neutral-100">Player Retention</h1>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
@click="downloadCSV"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg hover:bg-neutral-700 transition-colors text-sm text-neutral-300"
|
||||||
|
>
|
||||||
|
<Download class="w-4 h-4" />
|
||||||
|
Export CSV
|
||||||
|
</button>
|
||||||
|
<div class="flex items-center gap-2 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2">
|
||||||
|
<label class="text-sm text-neutral-400">Wipes:</label>
|
||||||
|
<select
|
||||||
|
v-model.number="wipeCount"
|
||||||
|
class="bg-transparent text-neutral-100 text-sm focus:outline-none"
|
||||||
|
>
|
||||||
|
<option :value="3">Last 3</option>
|
||||||
|
<option :value="6">Last 6</option>
|
||||||
|
<option :value="10">Last 10</option>
|
||||||
|
<option :value="20">Last 20</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||||
|
<div class="text-neutral-500">Loading retention data...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else-if="retentionData && retentionData.wipe_metrics.length > 0">
|
||||||
|
<!-- Summary cards -->
|
||||||
|
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<Users class="w-4 h-4 text-neutral-500" />
|
||||||
|
<p class="text-sm text-neutral-400">Unique Players</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-2xl font-bold text-neutral-100">{{ retentionData.summary.unique_players }}</p>
|
||||||
|
<p class="text-xs text-neutral-600 mt-1">Last 30 days</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<Clock class="w-4 h-4 text-neutral-500" />
|
||||||
|
<p class="text-sm text-neutral-400">Avg Session</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-2xl font-bold text-neutral-100">
|
||||||
|
{{ retentionData.summary.avg_session_duration_minutes.toFixed(0) }}m
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-neutral-600 mt-1">Duration</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<TrendingUp class="w-4 h-4 text-neutral-500" />
|
||||||
|
<p class="text-sm text-neutral-400">New Players</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-2xl font-bold text-neutral-100">{{ retentionData.summary.new_players }}</p>
|
||||||
|
<p class="text-xs text-neutral-600 mt-1">Last 30 days</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<BarChart3 class="w-4 h-4 text-neutral-500" />
|
||||||
|
<p class="text-sm text-neutral-400">Returning</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-2xl font-bold text-neutral-100">{{ retentionData.summary.returning_players }}</p>
|
||||||
|
<p class="text-xs text-neutral-600 mt-1">Last 30 days</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Retention curve chart -->
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||||
|
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">
|
||||||
|
Retention Curve (Post-Wipe Return Rates)
|
||||||
|
</h2>
|
||||||
|
<div ref="retentionChart" class="h-96"></div>
|
||||||
|
<div class="mt-4 text-xs text-neutral-500">
|
||||||
|
<p>
|
||||||
|
<strong>How to read:</strong> Percentage of players who played in the 7 days before a wipe and
|
||||||
|
returned within 24h/48h/72h after the wipe.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Wipe details table -->
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||||
|
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">
|
||||||
|
Wipe Details
|
||||||
|
</h2>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-neutral-800">
|
||||||
|
<th class="text-left py-2 px-3 text-neutral-400 font-medium">Wipe Date</th>
|
||||||
|
<th class="text-right py-2 px-3 text-neutral-400 font-medium">Pre-Wipe Players</th>
|
||||||
|
<th class="text-right py-2 px-3 text-neutral-400 font-medium">24h Return</th>
|
||||||
|
<th class="text-right py-2 px-3 text-neutral-400 font-medium">48h Return</th>
|
||||||
|
<th class="text-right py-2 px-3 text-neutral-400 font-medium">72h Return</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="wipe in retentionData.wipe_metrics"
|
||||||
|
:key="wipe.wipe_id"
|
||||||
|
class="border-b border-neutral-800 hover:bg-neutral-800/50 transition-colors"
|
||||||
|
>
|
||||||
|
<td class="py-3 px-3 text-neutral-300">
|
||||||
|
{{ new Date(wipe.wipe_date).toLocaleString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
}) }}
|
||||||
|
</td>
|
||||||
|
<td class="py-3 px-3 text-right text-neutral-300">
|
||||||
|
{{ wipe.total_players_before_wipe }}
|
||||||
|
</td>
|
||||||
|
<td class="py-3 px-3 text-right">
|
||||||
|
<span class="text-neutral-100 font-medium">{{ wipe.returned_24h }}</span>
|
||||||
|
<span class="text-neutral-500 text-xs ml-1">({{ wipe.retention_24h_percent.toFixed(1) }}%)</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-3 px-3 text-right">
|
||||||
|
<span class="text-neutral-100 font-medium">{{ wipe.returned_48h }}</span>
|
||||||
|
<span class="text-neutral-500 text-xs ml-1">({{ wipe.retention_48h_percent.toFixed(1) }}%)</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-3 px-3 text-right">
|
||||||
|
<span class="text-neutral-100 font-medium">{{ wipe.returned_72h }}</span>
|
||||||
|
<span class="text-neutral-500 text-xs ml-1">({{ wipe.retention_72h_percent.toFixed(1) }}%)</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center"
|
||||||
|
>
|
||||||
|
<Users class="w-12 h-12 text-neutral-700 mx-auto mb-4" />
|
||||||
|
<p class="text-neutral-500 mb-2">No retention data available</p>
|
||||||
|
<p class="text-sm text-neutral-600">
|
||||||
|
Player retention metrics will appear here after wipes are tracked and players join/leave.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -107,19 +107,27 @@ namespace Oxide.Plugins
|
|||||||
|
|
||||||
var data = new Dictionary<string, object>
|
var data = new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
|
{ "license_key", config.LicenseKey },
|
||||||
{ "event", "player_connected" },
|
{ "event", "player_connected" },
|
||||||
{ "player_id", player.UserIDString },
|
{ "player_id", player.UserIDString },
|
||||||
{ "player_name", player.displayName },
|
{ "player_name", player.displayName },
|
||||||
{ "ip_address", player.net?.connection?.ipaddress ?? "unknown" },
|
|
||||||
{ "timestamp", DateTimeOffset.UtcNow.ToUnixTimeSeconds() }
|
{ "timestamp", DateTimeOffset.UtcNow.ToUnixTimeSeconds() }
|
||||||
};
|
};
|
||||||
|
|
||||||
SendEvent("player_connected", data);
|
SendApiRequest("/api/plugin/player-event", data, (code, response) =>
|
||||||
|
|
||||||
if (config.DebugMode)
|
|
||||||
{
|
{
|
||||||
Puts($"Player connected: {player.displayName} ({player.UserIDString})");
|
if (config.DebugMode)
|
||||||
}
|
{
|
||||||
|
if (code == 200)
|
||||||
|
{
|
||||||
|
Puts($"Player join tracked: {player.displayName} ({player.UserIDString})");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
PrintWarning($"Player join tracking failed: HTTP {code}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void OnPlayerDisconnected(BasePlayer player, string reason)
|
void OnPlayerDisconnected(BasePlayer player, string reason)
|
||||||
@@ -128,19 +136,27 @@ namespace Oxide.Plugins
|
|||||||
|
|
||||||
var data = new Dictionary<string, object>
|
var data = new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
|
{ "license_key", config.LicenseKey },
|
||||||
{ "event", "player_disconnected" },
|
{ "event", "player_disconnected" },
|
||||||
{ "player_id", player.UserIDString },
|
{ "player_id", player.UserIDString },
|
||||||
{ "player_name", player.displayName },
|
{ "player_name", player.displayName },
|
||||||
{ "reason", reason ?? "unknown" },
|
|
||||||
{ "timestamp", DateTimeOffset.UtcNow.ToUnixTimeSeconds() }
|
{ "timestamp", DateTimeOffset.UtcNow.ToUnixTimeSeconds() }
|
||||||
};
|
};
|
||||||
|
|
||||||
SendEvent("player_disconnected", data);
|
SendApiRequest("/api/plugin/player-event", data, (code, response) =>
|
||||||
|
|
||||||
if (config.DebugMode)
|
|
||||||
{
|
{
|
||||||
Puts($"Player disconnected: {player.displayName} (Reason: {reason})");
|
if (config.DebugMode)
|
||||||
}
|
{
|
||||||
|
if (code == 200)
|
||||||
|
{
|
||||||
|
Puts($"Player leave tracked: {player.displayName} (Reason: {reason})");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
PrintWarning($"Player leave tracking failed: HTTP {code}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
object OnPlayerChat(BasePlayer player, string message)
|
object OnPlayerChat(BasePlayer player, string message)
|
||||||
|
|||||||
Reference in New Issue
Block a user