// VoxelRank player count for a Terraria (TShock) server. // // Every minute this sends the number of players online to VoxelRank, which // shows it on your listing instead of "Unknown". It sends nothing else: no // names, no IPs, no chat. // // Install // 1. Build (dotnet build -c Release) or take VoxelRank.dll from the download. // 2. Put VoxelRank.dll in the server's ServerPlugins folder and restart. // 3. The first start writes tshock/voxelrank.json. Put your API key from // your listing's edit page in it, then run /voxelrank reload (no // restart needed). // // Commands (permission "voxelrank.admin", always allowed from the console) // /voxelrank reload read tshock/voxelrank.json again // /voxelrank status show the config and the result of the last report // /voxelrank send send a report now // // The endpoint is POST {site_url}/api/v1/heartbeat, the same one the Palworld // program uses. VoxelRank only accepts it for listings of a game that cannot // be queried (Terraria, Palworld). using System; using System.IO; using System.Net.Http; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Terraria; using TerrariaApi.Server; using TShockAPI; namespace VoxelRank { [ApiVersion(2, 1)] public class VoxelRankPlugin : TerrariaPlugin { private const string Placeholder = "your-api-key"; public override string Name => "VoxelRank"; public override string Author => "VoxelRank"; public override string Description => "Reports the number of players online to VoxelRank."; public override Version Version => new Version(1, 0, 0); private static readonly HttpClient Http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; private static string ConfigPath => Path.Combine(TShock.SavePath, "voxelrank.json"); private Config _config = new Config(); private Timer _timer; private string _lastResult = "no report sent yet"; private int _sending; public VoxelRankPlugin(Main game) : base(game) { } public override void Initialize() { ServerApi.Hooks.GamePostInitialize.Register(this, OnPostInitialize); Commands.ChatCommands.Add(new Command("voxelrank.admin", OnCommand, "voxelrank")); } protected override void Dispose(bool disposing) { if (disposing) { ServerApi.Hooks.GamePostInitialize.Deregister(this, OnPostInitialize); Commands.ChatCommands.RemoveAll(c => c.CommandDelegate == (CommandDelegate)OnCommand); _timer?.Dispose(); } base.Dispose(disposing); } private void OnPostInitialize(EventArgs args) { LoadConfig(); // First report after 20 seconds (the world needs a moment), then on the interval. _timer = new Timer(_ => Tick(), null, TimeSpan.FromSeconds(20), Timeout.InfiniteTimeSpan); } private void Tick() { try { SendReport().GetAwaiter().GetResult(); } catch (Exception e) { _lastResult = "error: " + e.Message; } finally { // Re-armed after every report, so a slow site never stacks requests, // and an interval change in the config applies on the next round. _timer?.Change(TimeSpan.FromSeconds(Math.Max(30, _config.IntervalSeconds)), Timeout.InfiniteTimeSpan); } } private bool LoadConfig() { try { if (!File.Exists(ConfigPath)) { _config = new Config(); File.WriteAllText(ConfigPath, JsonSerializer.Serialize(_config, JsonOptions)); TShock.Log.ConsoleInfo("[VoxelRank] Wrote " + ConfigPath + ". Put your API key in it, then run /voxelrank reload."); return true; } _config = JsonSerializer.Deserialize(File.ReadAllText(ConfigPath), JsonOptions) ?? new Config(); return true; } catch (Exception e) { TShock.Log.ConsoleError("[VoxelRank] Could not read " + ConfigPath + ": " + e.Message); return false; } } private async Task SendReport() { if (Interlocked.Exchange(ref _sending, 1) == 1) return; try { if (string.IsNullOrWhiteSpace(_config.ApiKey) || _config.ApiKey == Placeholder) { _lastResult = "api_key is not set in " + ConfigPath; return; } int players = TShock.Utils.GetActivePlayerCount(); int max = Math.Max(1, TShock.Config.Settings.MaxSlots); string body = JsonSerializer.Serialize(new { players, max_players = max }); using var req = new HttpRequestMessage(HttpMethod.Post, _config.SiteUrl.TrimEnd('/') + "/api/v1/heartbeat") { Content = new StringContent(body, Encoding.UTF8, "application/json"), }; req.Headers.Add("X-Api-Key", _config.ApiKey); req.Headers.UserAgent.ParseAdd("VoxelRank-TShock/1.0"); using var res = await Http.SendAsync(req).ConfigureAwait(false); if (res.IsSuccessStatusCode) _lastResult = "sent " + players + "/" + max; else if ((int)res.StatusCode == 401) _lastResult = "VoxelRank refused the key (HTTP 401): check api_key"; else _lastResult = "VoxelRank answered HTTP " + (int)res.StatusCode; } catch (Exception e) { _lastResult = "error: " + e.Message; } finally { Interlocked.Exchange(ref _sending, 0); } } private void OnCommand(CommandArgs args) { string sub = args.Parameters.Count > 0 ? args.Parameters[0].ToLowerInvariant() : ""; switch (sub) { case "reload": if (LoadConfig()) args.Player.SendSuccessMessage("[VoxelRank] Config reloaded."); else args.Player.SendErrorMessage("[VoxelRank] Could not read the config, see the console."); break; case "send": Task.Run(async () => { await SendReport().ConfigureAwait(false); args.Player.SendInfoMessage("[VoxelRank] " + _lastResult); }); break; case "status": args.Player.SendInfoMessage("[VoxelRank] site: " + _config.SiteUrl + ", every " + Math.Max(30, _config.IntervalSeconds) + "s"); args.Player.SendInfoMessage("[VoxelRank] last report: " + _lastResult); break; default: args.Player.SendInfoMessage("[VoxelRank] /voxelrank "); break; } } private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions { WriteIndented = true }; private class Config { [System.Text.Json.Serialization.JsonPropertyName("api_key")] public string ApiKey { get; set; } = Placeholder; [System.Text.Json.Serialization.JsonPropertyName("site_url")] public string SiteUrl { get; set; } = "https://voxelrank.com"; [System.Text.Json.Serialization.JsonPropertyName("interval_seconds")] public int IntervalSeconds { get; set; } = 60; } } }