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>
326 lines
10 KiB
C#
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
|
|
}
|
|
}
|