Files
corrosion-admin-panel/plugin/CorrosionCompanion.cs
Vantz Stockwell f29524e633 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>
2026-02-15 14:23:21 -05:00

326 lines
10 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using Oxide.Core;
using Oxide.Core.Libraries.Covalence;
namespace Oxide.Plugins
{
[Info("Corrosion Companion", "Corrosion", "1.0.0")]
[Description("Connects Rust server to Corrosion admin panel via HTTP API")]
public class CorrosionCompanion : RustPlugin
{
#region Configuration
private Configuration config;
public class Configuration
{
[JsonProperty("API Base URL")]
public string ApiBaseUrl { get; set; } = "https://api.corrosion.example.com";
[JsonProperty("License Key")]
public string LicenseKey { get; set; } = "YOUR_LICENSE_KEY_HERE";
[JsonProperty("Heartbeat Interval (seconds)")]
public int HeartbeatInterval { get; set; } = 60;
[JsonProperty("Send Player Events")]
public bool SendPlayerEvents { get; set; } = true;
[JsonProperty("Send Chat Events")]
public bool SendChatEvents { get; set; } = false;
[JsonProperty("Debug Mode")]
public bool DebugMode { get; set; } = false;
}
protected override void LoadConfig()
{
base.LoadConfig();
try
{
config = Config.ReadObject<Configuration>();
if (config == null)
{
throw new Exception("Config is null");
}
}
catch
{
PrintWarning("Config file corrupt or missing, generating new one");
LoadDefaultConfig();
}
}
protected override void LoadDefaultConfig()
{
config = new Configuration();
SaveConfig();
}
protected override void SaveConfig() => Config.WriteObject(config);
#endregion
#region Lifecycle Hooks
private Timer heartbeatTimer;
void OnServerInitialized()
{
Puts("Corrosion Companion initialized");
// Validate configuration
if (string.IsNullOrEmpty(config.LicenseKey) || config.LicenseKey == "YOUR_LICENSE_KEY_HERE")
{
PrintError("License key not configured! Edit oxide/config/CorrosionCompanion.json");
return;
}
// Send initial check-in
CheckIn();
// Start heartbeat timer
heartbeatTimer = timer.Every(config.HeartbeatInterval, () =>
{
SendHeartbeat();
});
Puts($"Heartbeat started (every {config.HeartbeatInterval}s)");
}
void Unload()
{
heartbeatTimer?.Destroy();
Puts("Corrosion Companion unloaded");
}
#endregion
#region Player Event Hooks
void OnPlayerConnected(BasePlayer player)
{
if (!config.SendPlayerEvents) return;
var data = new Dictionary<string, object>
{
{ "license_key", config.LicenseKey },
{ "event", "player_connected" },
{ "player_id", player.UserIDString },
{ "player_name", player.displayName },
{ "timestamp", DateTimeOffset.UtcNow.ToUnixTimeSeconds() }
};
SendApiRequest("/api/plugin/player-event", data, (code, response) =>
{
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)
{
if (!config.SendPlayerEvents) return;
var data = new Dictionary<string, object>
{
{ "license_key", config.LicenseKey },
{ "event", "player_disconnected" },
{ "player_id", player.UserIDString },
{ "player_name", player.displayName },
{ "timestamp", DateTimeOffset.UtcNow.ToUnixTimeSeconds() }
};
SendApiRequest("/api/plugin/player-event", data, (code, response) =>
{
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)
{
if (!config.SendChatEvents) return null;
var data = new Dictionary<string, object>
{
{ "event", "player_chat" },
{ "player_id", player.UserIDString },
{ "player_name", player.displayName },
{ "message", message },
{ "timestamp", DateTimeOffset.UtcNow.ToUnixTimeSeconds() }
};
SendEvent("player_chat", data);
return null; // Don't block the message
}
#endregion
#region API Communication
private void CheckIn()
{
var data = new Dictionary<string, object>
{
{ "license_key", config.LicenseKey },
{ "server_name", ConVar.Server.hostname },
{ "server_description", ConVar.Server.description },
{ "server_url", ConVar.Server.url },
{ "max_players", ConVar.Server.maxplayers },
{ "world_size", ConVar.Server.worldsize },
{ "seed", ConVar.Server.seed },
{ "plugin_version", Version.ToString() },
{ "server_version", Rust.Protocol.network.ToString() }
};
SendApiRequest("/api/plugin/checkin", data, (code, response) =>
{
if (code == 200)
{
Puts("Check-in successful");
if (config.DebugMode)
{
Puts($"Response: {response}");
}
}
else
{
PrintWarning($"Check-in failed: HTTP {code}");
}
});
}
private void SendHeartbeat()
{
var data = new Dictionary<string, object>
{
{ "license_key", config.LicenseKey },
{ "player_count", BasePlayer.activePlayerList.Count },
{ "max_players", ConVar.Server.maxplayers },
{ "fps", Performance.current.frameRate },
{ "entity_count", BaseNetworkable.serverEntities.Count },
{ "uptime_seconds", Time.realtimeSinceStartup },
{ "timestamp", DateTimeOffset.UtcNow.ToUnixTimeSeconds() }
};
SendApiRequest("/api/plugin/heartbeat", data, (code, response) =>
{
if (config.DebugMode)
{
if (code == 200)
{
Puts($"Heartbeat sent (Players: {BasePlayer.activePlayerList.Count}, FPS: {Performance.current.frameRate:F1})");
}
else
{
PrintWarning($"Heartbeat failed: HTTP {code}");
}
}
});
}
private void SendEvent(string eventType, Dictionary<string, object> data)
{
data["license_key"] = config.LicenseKey;
SendApiRequest($"/api/plugin/events/{eventType}", data, (code, response) =>
{
if (config.DebugMode && code != 200)
{
PrintWarning($"Event {eventType} failed: HTTP {code}");
}
});
}
private void SendApiRequest(string endpoint, Dictionary<string, object> data, Action<int, string> callback)
{
string url = config.ApiBaseUrl.TrimEnd('/') + endpoint;
string json = JsonConvert.SerializeObject(data);
webrequest.Enqueue(url, json, (code, response) =>
{
callback?.Invoke(code, response ?? "");
}, this, RequestMethod.POST, new Dictionary<string, string>
{
{ "Content-Type", "application/json" }
});
}
#endregion
#region Console Commands
[Command("corrosion.status")]
private void StatusCommand(IPlayer player, string command, string[] args)
{
if (!player.IsAdmin)
{
player.Reply("You don't have permission to use this command");
return;
}
player.Reply("=== Corrosion Companion Status ===");
player.Reply($"Version: {Version}");
player.Reply($"License Key: {config.LicenseKey.Substring(0, Math.Min(8, config.LicenseKey.Length))}...");
player.Reply($"API URL: {config.ApiBaseUrl}");
player.Reply($"Heartbeat Interval: {config.HeartbeatInterval}s");
player.Reply($"Player Events: {(config.SendPlayerEvents ? "Enabled" : "Disabled")}");
player.Reply($"Chat Events: {(config.SendChatEvents ? "Enabled" : "Disabled")}");
player.Reply($"Debug Mode: {(config.DebugMode ? "Enabled" : "Disabled")}");
player.Reply($"Active Players: {BasePlayer.activePlayerList.Count}");
}
[Command("corrosion.checkin")]
private void CheckinCommand(IPlayer player, string command, string[] args)
{
if (!player.IsAdmin)
{
player.Reply("You don't have permission to use this command");
return;
}
player.Reply("Sending check-in to Corrosion API...");
CheckIn();
}
[Command("corrosion.heartbeat")]
private void HeartbeatCommand(IPlayer player, string command, string[] args)
{
if (!player.IsAdmin)
{
player.Reply("You don't have permission to use this command");
return;
}
player.Reply("Sending heartbeat to Corrosion API...");
SendHeartbeat();
}
#endregion
}
}