feat: Implement map analytics system with effectiveness tracking

Add complete map analytics pipeline to answer: "Which maps drive the most
players? Is my rotation working?"

Backend Changes:
- Migration 005: Add map_id FK to server_stats and wipe_history tables
- Stats consumer now captures current_map_id when persisting stats
- Map analytics queries: get_map_analytics() returns performance metrics,
  effectiveness scores, and rotation health
- API endpoint: GET /api/analytics/maps?range=90d returns summary with
  best performing map and rotation effectiveness percentage

Frontend Changes:
- MapAnalyticsView.vue: Complete dashboard with performance charts,
  sortable metrics table, actionable insights, and CSV export
- ECharts bar chart comparing avg vs peak players per map
- Color-coded effectiveness scoring (green ≥80%, yellow ≥60%, red <60%)
- Time range selector: 30d/90d/all

Purpose: Enables data-driven map selection for wipe day based on player
engagement metrics. Rotation effectiveness algorithm scores maps by
(avg_players / peak_players) * 100.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-02-15 14:22:55 -05:00
parent dc7f41b8c5
commit cef89ade18
9 changed files with 691 additions and 4 deletions

View File

@@ -1,10 +1,32 @@
use sqlx::PgPool;
use uuid::Uuid;
use anyhow::Result;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
// TODO: Define Map struct (id, server_id, name, file_path, size_bytes, uploaded_at)
// TODO: Define MapRotation struct (id, server_id, map_ids, current_index, auto_rotate)
/// Map performance metrics for analytics.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MapPerformanceMetrics {
pub map_id: Uuid,
pub map_name: String,
pub seed: Option<i32>,
pub times_used: i64,
pub avg_players: f64,
pub peak_players: i32,
pub unique_players: Option<i64>, // Phase 2.3 (requires player session tracking)
pub effectiveness_score: f64,
}
/// Map analytics summary response.
#[derive(Debug, Clone, Serialize)]
pub struct MapAnalyticsSummary {
pub maps: Vec<MapPerformanceMetrics>,
pub best_performing_map: Option<String>,
pub rotation_effectiveness: f64,
}
/// Upload/register a new custom map.
pub async fn create_map(pool: &PgPool, server_id: Uuid, name: &str, file_path: &str, size_bytes: i64) -> Result<Uuid> {
todo!()
@@ -34,3 +56,106 @@ pub async fn get_map_rotation(pool: &PgPool, server_id: Uuid) -> Result<()> {
pub async fn update_map_rotation(pool: &PgPool, server_id: Uuid, map_ids: &[Uuid], auto_rotate: bool) -> Result<()> {
todo!()
}
/// Get map analytics for a license over a time range (in days).
/// Returns effectiveness metrics: avg players, peak players, times used, effectiveness score.
pub async fn get_map_analytics(
pool: &PgPool,
license_id: Uuid,
days: i64,
) -> Result<MapAnalyticsSummary> {
// Query map performance: JOIN server_stats with map_library to get metrics per map
let map_metrics: Vec<MapPerformanceMetrics> = sqlx::query_as::<_, (Uuid, String, Option<i32>, i64, f64, i32)>(
"SELECT
m.id as map_id,
m.display_name as map_name,
m.seed,
COUNT(DISTINCT DATE_TRUNC('day', s.recorded_at)) as times_used,
AVG(s.player_count)::FLOAT8 as avg_players,
MAX(s.player_count) as peak_players
FROM map_library m
INNER JOIN server_stats s ON s.map_id = m.id
WHERE m.license_id = $1
AND s.recorded_at >= NOW() - ($2 || ' days')::INTERVAL
GROUP BY m.id, m.display_name, m.seed
ORDER BY avg_players DESC",
)
.bind(license_id)
.bind(days)
.fetch_all(pool)
.await
.context("Failed to query map analytics")?
.into_iter()
.map(|(map_id, map_name, seed, times_used, avg_players, peak_players)| {
// Calculate effectiveness score: weighted avg of player count and peak (0-100 scale)
// Formula: (avg_players / peak_players) * 70 + (peak_players / max_server_slots) * 30
// Simplified version: (avg_players / peak_players if > 0 else 0) * 100
let effectiveness_score = if peak_players > 0 {
((avg_players / peak_players as f64) * 100.0).min(100.0)
} else {
0.0
};
MapPerformanceMetrics {
map_id,
map_name,
seed,
times_used,
avg_players,
peak_players,
unique_players: None, // Phase 2.3
effectiveness_score,
}
})
.collect();
// Determine best performing map (highest avg players)
let best_performing_map = map_metrics
.iter()
.max_by(|a, b| a.avg_players.partial_cmp(&b.avg_players).unwrap())
.map(|m| m.map_name.clone());
// Calculate overall rotation effectiveness (avg of all map effectiveness scores)
let rotation_effectiveness = if !map_metrics.is_empty() {
map_metrics.iter().map(|m| m.effectiveness_score).sum::<f64>() / map_metrics.len() as f64
} else {
0.0
};
Ok(MapAnalyticsSummary {
maps: map_metrics,
best_performing_map,
rotation_effectiveness,
})
}
/// Get player count trends per map over wipe cycles.
/// Returns time-series data grouped by map and wipe.
pub async fn get_map_population_trends(
pool: &PgPool,
license_id: Uuid,
) -> Result<Vec<(Uuid, String, String, f64)>> {
// Query: JOIN wipe_history with server_stats to get avg player count per map per wipe
let trends: Vec<(Uuid, String, String, f64)> = sqlx::query_as(
"SELECT
m.id as map_id,
m.display_name as map_name,
w.started_at::TEXT as wipe_date,
AVG(s.player_count)::FLOAT8 as avg_players
FROM map_library m
INNER JOIN wipe_history w ON w.map_id = m.id
INNER JOIN server_stats s ON s.license_id = w.license_id
AND s.recorded_at >= w.started_at
AND s.recorded_at < COALESCE(w.completed_at, NOW())
WHERE m.license_id = $1
AND w.started_at IS NOT NULL
GROUP BY m.id, m.display_name, w.started_at
ORDER BY w.started_at ASC",
)
.bind(license_id)
.fetch_all(pool)
.await
.context("Failed to query map population trends")?;
Ok(trends)
}

View File

@@ -51,13 +51,14 @@ pub async fn insert_server_stats(
entity_count: i32,
uptime_seconds: i32,
memory_usage_mb: i32,
map_id: Option<Uuid>,
) -> Result<Uuid> {
let id = Uuid::new_v4();
sqlx::query(
"INSERT INTO server_stats
(id, license_id, player_count, max_players, fps, entity_count, uptime_seconds, memory_usage_mb, recorded_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())",
(id, license_id, player_count, max_players, fps, entity_count, uptime_seconds, memory_usage_mb, map_id, recorded_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW())",
)
.bind(id)
.bind(license_id)
@@ -67,6 +68,7 @@ pub async fn insert_server_stats(
.bind(entity_count)
.bind(uptime_seconds)
.bind(memory_usage_mb)
.bind(map_id)
.execute(pool)
.await
.context("Failed to insert server stats")?;
@@ -220,3 +222,16 @@ pub async fn cleanup_old_hourly_stats(pool: &PgPool, retention_days: i64) -> Res
Ok(result.rows_affected())
}
/// Get the current map_id for a license (for map analytics tracking).
pub async fn get_current_map_id(pool: &PgPool, license_id: Uuid) -> Result<Option<Uuid>> {
let result: Option<(Option<Uuid>,)> = sqlx::query_as(
"SELECT current_map_id FROM server_config WHERE license_id = $1",
)
.bind(license_id)
.fetch_optional(pool)
.await
.context("Failed to query current_map_id")?;
Ok(result.and_then(|r| r.0))
}